diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1c7cf727cb..571278c30c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2021.1.4", + "version": "2021.2.2", "commands": [ "jb" ] diff --git a/Build.ps1 b/Build.ps1 index ee1dd68cfb..0cca69c095 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=SolutionPersonal -dsl=ProjectPersonal CheckLastExitCode [xml]$xml = Get-Content "$outputPath" diff --git a/CSharpGuidelinesAnalyzer.config b/CSharpGuidelinesAnalyzer.config index acd0856299..89b568e155 100644 --- a/CSharpGuidelinesAnalyzer.config +++ b/CSharpGuidelinesAnalyzer.config @@ -1,5 +1,5 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index bec8ba912e..895d25aaf5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,10 +6,11 @@ 5.0.* $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 + enable - + @@ -24,9 +25,9 @@ 33.1.1 3.1.0 - 6.1.0 + 6.2.0 4.16.1 2.4.* - 16.11.0 + 17.0.0 diff --git a/README.md b/README.md index 39ea16b3be..8f6c6f3eb1 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,13 @@ 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 | +| .NET version | Entity Framework 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 | ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index b3eb75daa6..d96e015953 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ The need for breaking changes has blocked several efforts in the v4.x release, s - [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) - [x] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233) -- [ ] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029) +- [x] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029) Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. diff --git a/benchmarks/BenchmarkResource.cs b/benchmarks/BenchmarkResource.cs deleted file mode 100644 index ad18d999c7..0000000000 --- a/benchmarks/BenchmarkResource.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace Benchmarks -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BenchmarkResource : Identifiable - { - [Attr(PublicName = BenchmarkResourcePublicNames.NameAttr)] - public string Name { get; set; } - - [HasOne] - public SubResource Child { get; set; } - } -} diff --git a/benchmarks/BenchmarkResourcePublicNames.cs b/benchmarks/BenchmarkResourcePublicNames.cs deleted file mode 100644 index 84b63e7668..0000000000 --- a/benchmarks/BenchmarkResourcePublicNames.cs +++ /dev/null @@ -1,10 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static - -namespace Benchmarks -{ - internal static class BenchmarkResourcePublicNames - { - public const string NameAttr = "full-name"; - public const string Type = "simple-types"; - } -} diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 4b19516001..225c3a75d7 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs deleted file mode 100644 index 184ba5a082..0000000000 --- a/benchmarks/DependencyFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Benchmarks -{ - internal sealed class DependencyFactory - { - public IResourceGraph CreateResourceGraph(IJsonApiOptions options) - { - var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - - builder.Add(BenchmarkResourcePublicNames.Type); - builder.Add(); - - return builder.Build(); - } - } -} diff --git a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs index 5ceff85a2d..8b2deb98b9 100644 --- a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -21,7 +21,7 @@ public abstract class DeserializationBenchmarkBase protected DeserializationBenchmarkBase() { var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; @@ -30,7 +30,9 @@ protected DeserializationBenchmarkBase() var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); - serviceContainer.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); + + serviceContainer.AddService(typeof(IResourceDefinition), + new JsonApiResourceDefinition(resourceGraph)); // ReSharper disable once VirtualMemberCallInConstructor JsonApiRequest request = CreateJsonApiRequest(resourceGraph); @@ -56,7 +58,7 @@ protected DeserializationBenchmarkBase() protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceA : Identifiable + public sealed class IncomingResource : Identifiable { [Attr] public bool Attribute01 { get; set; } @@ -74,7 +76,7 @@ public sealed class ResourceA : Identifiable public float? Attribute05 { get; set; } [Attr] - public string Attribute06 { get; set; } + public string Attribute06 { get; set; } = null!; [Attr] public DateTime? Attribute07 { get; set; } @@ -89,34 +91,34 @@ public sealed class ResourceA : Identifiable public DayOfWeek Attribute10 { get; set; } [HasOne] - public ResourceA Single1 { get; set; } + public IncomingResource Single1 { get; set; } = null!; [HasOne] - public ResourceA Single2 { get; set; } + public IncomingResource Single2 { get; set; } = null!; [HasOne] - public ResourceA Single3 { get; set; } + public IncomingResource Single3 { get; set; } = null!; [HasOne] - public ResourceA Single4 { get; set; } + public IncomingResource Single4 { get; set; } = null!; [HasOne] - public ResourceA Single5 { get; set; } + public IncomingResource Single5 { get; set; } = null!; [HasMany] - public ISet Multi1 { get; set; } + public ISet Multi1 { get; set; } = null!; [HasMany] - public ISet Multi2 { get; set; } + public ISet Multi2 { get; set; } = null!; [HasMany] - public ISet Multi3 { get; set; } + public ISet Multi3 { get; set; } = null!; [HasMany] - public ISet Multi4 { get; set; } + public ISet Multi4 { get; set; } = null!; [HasMany] - public ISet Multi5 { get; set; } + public ISet Multi5 { get; set; } = null!; } } } diff --git a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs index c09b7c77c7..0181f4ccbc 100644 --- a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs +++ b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs @@ -20,7 +20,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase op = "add", data = new { - type = "resourceAs", + type = "incomingResources", lid = "a-1", attributes = new { @@ -41,7 +41,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "101" } }, @@ -49,7 +49,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "102" } }, @@ -57,7 +57,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "103" } }, @@ -65,7 +65,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "104" } }, @@ -73,7 +73,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "105" } }, @@ -83,7 +83,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "201" } } @@ -94,7 +94,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "202" } } @@ -105,7 +105,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "203" } } @@ -116,7 +116,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "204" } } @@ -127,7 +127,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "205" } } @@ -140,7 +140,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase op = "update", data = new { - type = "resourceAs", + type = "incomingResources", id = "1", attributes = new { @@ -161,7 +161,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "101" } }, @@ -169,7 +169,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "102" } }, @@ -177,7 +177,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "103" } }, @@ -185,7 +185,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "104" } }, @@ -193,7 +193,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "105" } }, @@ -203,7 +203,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "201" } } @@ -214,7 +214,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "202" } } @@ -225,7 +225,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "203" } } @@ -236,7 +236,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "204" } } @@ -247,7 +247,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "205" } } @@ -260,7 +260,7 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase op = "remove", @ref = new { - type = "resourceAs", + type = "incomingResources", lid = "a-1" } } @@ -268,15 +268,15 @@ public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase }).Replace("atomic__operations", "atomic:operations"); [Benchmark] - public object DeserializeOperationsRequest() + public object? DeserializeOperationsRequest() { - var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions); + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; return DocumentAdapter.Convert(document); } protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) { - return new() + return new JsonApiRequest { Kind = EndpointKind.AtomicOperations }; diff --git a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs index d3fe50ffa6..e154306819 100644 --- a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs +++ b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs @@ -15,7 +15,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", attributes = new { attribute01 = true, @@ -35,7 +35,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "101" } }, @@ -43,7 +43,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "102" } }, @@ -51,7 +51,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "103" } }, @@ -59,7 +59,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "104" } }, @@ -67,7 +67,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { data = new { - type = "resourceAs", + type = "incomingResources", id = "105" } }, @@ -77,7 +77,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "201" } } @@ -88,7 +88,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "202" } } @@ -99,7 +99,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "203" } } @@ -110,7 +110,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "204" } } @@ -121,7 +121,7 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { new { - type = "resourceAs", + type = "incomingResources", id = "205" } } @@ -131,19 +131,18 @@ public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase }); [Benchmark] - public object DeserializeResourceRequest() + public object? DeserializeResourceRequest() { - var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions); - + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; return DocumentAdapter.Convert(document); } protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) { - return new() + return new JsonApiRequest { Kind = EndpointKind.Primary, - PrimaryResourceType = resourceGraph.GetResourceType(), + PrimaryResourceType = resourceGraph.GetResourceType(), WriteOperation = WriteOperationKind.CreateResource }; } diff --git a/benchmarks/LinkBuilding/LinkBuilderGetNamespaceFromPathBenchmarks.cs b/benchmarks/LinkBuilding/LinkBuilderGetNamespaceFromPathBenchmarks.cs deleted file mode 100644 index 400fc0dbcf..0000000000 --- a/benchmarks/LinkBuilding/LinkBuilderGetNamespaceFromPathBenchmarks.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Text; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.LinkBuilding -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - [SimpleJob(3, 10, 20)] - [MemoryDiagnoser] - public class LinkBuilderGetNamespaceFromPathBenchmarks - { - private const string RequestPath = "/api/some-really-long-namespace-path/resources/current/articles/?some"; - private const string ResourceName = "articles"; - private const char PathDelimiter = '/'; - - [Benchmark] - public void UsingStringSplit() - { - GetNamespaceFromPathUsingStringSplit(RequestPath, ResourceName); - } - - [Benchmark] - public void UsingReadOnlySpan() - { - GetNamespaceFromPathUsingReadOnlySpan(RequestPath, ResourceName); - } - - private static void GetNamespaceFromPathUsingStringSplit(string path, string resourceName) - { - var namespaceBuilder = new StringBuilder(path.Length); - string[] segments = path.Split('/'); - - for (int index = 1; index < segments.Length; index++) - { - if (segments[index] == resourceName) - { - break; - } - - namespaceBuilder.Append(PathDelimiter); - namespaceBuilder.Append(segments[index]); - } - - _ = namespaceBuilder.ToString(); - } - - private static void GetNamespaceFromPathUsingReadOnlySpan(string path, string resourceName) - { - ReadOnlySpan resourceNameSpan = resourceName.AsSpan(); - ReadOnlySpan pathSpan = path.AsSpan(); - - for (int index = 0; index < pathSpan.Length; index++) - { - if (pathSpan[index].Equals(PathDelimiter)) - { - if (pathSpan.Length > index + resourceNameSpan.Length) - { - ReadOnlySpan possiblePathSegment = pathSpan.Slice(index + 1, resourceNameSpan.Length); - - if (resourceNameSpan.SequenceEqual(possiblePathSegment)) - { - int lastCharacterIndex = index + 1 + resourceNameSpan.Length; - - bool isAtEnd = lastCharacterIndex == pathSpan.Length; - bool hasDelimiterAfterSegment = pathSpan.Length >= lastCharacterIndex + 1 && pathSpan[lastCharacterIndex].Equals(PathDelimiter); - - if (isAtEnd || hasDelimiterAfterSegment) - { - _ = pathSpan[..index].ToString(); - } - } - } - } - } - } - } -} diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs index 995538eb76..45406133dd 100644 --- a/benchmarks/Program.cs +++ b/benchmarks/Program.cs @@ -1,7 +1,6 @@ using BenchmarkDotNet.Running; using Benchmarks.Deserialization; -using Benchmarks.LinkBuilding; -using Benchmarks.Query; +using Benchmarks.QueryString; using Benchmarks.Serialization; namespace Benchmarks @@ -16,8 +15,7 @@ private static void Main(string[] args) typeof(OperationsDeserializationBenchmarks), typeof(ResourceSerializationBenchmarks), typeof(OperationsSerializationBenchmarks), - typeof(QueryParserBenchmarks), - typeof(LinkBuilderGetNamespaceFromPathBenchmarks) + typeof(QueryStringParserBenchmarks) }); switcher.Run(args); diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs similarity index 54% rename from benchmarks/Query/QueryParserBenchmarks.cs rename to benchmarks/QueryString/QueryStringParserBenchmarks.cs index bb6cec20e8..6e576bc4a7 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,32 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging.Abstractions; -namespace Benchmarks.Query +namespace Benchmarks.QueryString { // ReSharper disable once ClassCanBeSealed.Global [MarkdownExporter] [SimpleJob(3, 10, 20)] [MemoryDiagnoser] - public class QueryParserBenchmarks + public class QueryStringParserBenchmarks { - private readonly DependencyFactory _dependencyFactory = new(); private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new(); - private readonly QueryStringReader _queryStringReaderForSort; - private readonly QueryStringReader _queryStringReaderForAll; + private readonly QueryStringReader _queryStringReader; - public QueryParserBenchmarks() + public QueryStringParserBenchmarks() { IJsonApiOptions options = new JsonApiOptions { EnableLegacyFilterNotation = true }; - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); var request = new JsonApiRequest { - PrimaryResourceType = resourceGraph.GetResourceType(typeof(BenchmarkResource)), + PrimaryResourceType = resourceGraph.GetResourceType(typeof(QueryableResource)), IsCollection = true }; - _queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, request, options, _queryStringAccessor); - _queryStringReaderForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, request, options, _queryStringAccessor); - } - - private static QueryStringReader CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, JsonApiRequest request, IJsonApiOptions options, - FakeRequestQueryStringAccessor queryStringAccessor) - { - var sortReader = new SortQueryStringParameterReader(request, resourceGraph); - - IEnumerable readers = sortReader.AsEnumerable(); - - return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); - } - - private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGraph resourceGraph, JsonApiRequest request, IJsonApiOptions options, - FakeRequestQueryStringAccessor queryStringAccessor) - { var resourceFactory = new ResourceFactory(new ServiceContainer()); var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); @@ -68,25 +48,25 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader); - return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); + _queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance); } [Benchmark] public void AscendingSort() { - string queryString = $"?sort={BenchmarkResourcePublicNames.NameAttr}"; + const string queryString = "?sort=alt-attr-name"; _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForSort.ReadAll(null); + _queryStringReader.ReadAll(null); } [Benchmark] public void DescendingSort() { - string queryString = $"?sort=-{BenchmarkResourcePublicNames.NameAttr}"; + const string queryString = "?sort=-alt-attr-name"; _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForSort.ReadAll(null); + _queryStringReader.ReadAll(null); } [Benchmark] @@ -94,13 +74,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 +92,7 @@ private void Run(int iterations, Action action) private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor { - public IQueryCollection Query { get; private set; } + public IQueryCollection Query { get; private set; } = new QueryCollection(); public void SetQueryString(string queryString) { diff --git a/benchmarks/QueryString/QueryableResource.cs b/benchmarks/QueryString/QueryableResource.cs new file mode 100644 index 0000000000..bcf0a5075a --- /dev/null +++ b/benchmarks/QueryString/QueryableResource.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace Benchmarks.QueryString +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class QueryableResource : Identifiable + { + [Attr(PublicName = "alt-attr-name")] + public string? Name { get; set; } + + [HasOne] + public QueryableResource? Child { get; set; } + } +} diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index fbcdf0b0a9..fef0d67a12 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -26,7 +26,7 @@ public OperationsSerializationBenchmarks() private static IEnumerable CreateResponseOperations(IJsonApiRequest request) { - var resource1 = new ResourceA + var resource1 = new OutgoingResource { Id = 1, Attribute01 = true, @@ -41,7 +41,7 @@ private static IEnumerable CreateResponseOperations(IJsonApi Attribute10 = DayOfWeek.Sunday }; - var resource2 = new ResourceA + var resource2 = new OutgoingResource { Id = 2, Attribute01 = false, @@ -56,7 +56,7 @@ private static IEnumerable CreateResponseOperations(IJsonApi Attribute10 = DayOfWeek.Monday }; - var resource3 = new ResourceA + var resource3 = new OutgoingResource { Id = 3, Attribute01 = true, @@ -71,7 +71,7 @@ private static IEnumerable CreateResponseOperations(IJsonApi Attribute10 = DayOfWeek.Tuesday }; - var resource4 = new ResourceA + var resource4 = new OutgoingResource { Id = 4, Attribute01 = false, @@ -86,7 +86,7 @@ private static IEnumerable CreateResponseOperations(IJsonApi Attribute10 = DayOfWeek.Wednesday }; - var resource5 = new ResourceA + var resource5 = new OutgoingResource { Id = 5, Attribute01 = true, @@ -122,10 +122,10 @@ public string SerializeOperationsResponse() protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) { - return new() + return new JsonApiRequest { Kind = EndpointKind.AtomicOperations, - PrimaryResourceType = resourceGraph.GetResourceType() + PrimaryResourceType = resourceGraph.GetResourceType() }; } diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index 8f538cc9a2..2877d96713 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -17,11 +17,11 @@ namespace Benchmarks.Serialization // ReSharper disable once ClassCanBeSealed.Global public class ResourceSerializationBenchmarks : SerializationBenchmarkBase { - private static readonly ResourceA ResponseResource = CreateResponseResource(); + private static readonly OutgoingResource ResponseResource = CreateResponseResource(); - private static ResourceA CreateResponseResource() + private static OutgoingResource CreateResponseResource() { - var resource1 = new ResourceA + var resource1 = new OutgoingResource { Id = 1, Attribute01 = true, @@ -36,7 +36,7 @@ private static ResourceA CreateResponseResource() Attribute10 = DayOfWeek.Sunday }; - var resource2 = new ResourceA + var resource2 = new OutgoingResource { Id = 2, Attribute01 = false, @@ -51,7 +51,7 @@ private static ResourceA CreateResponseResource() Attribute10 = DayOfWeek.Monday }; - var resource3 = new ResourceA + var resource3 = new OutgoingResource { Id = 3, Attribute01 = true, @@ -66,7 +66,7 @@ private static ResourceA CreateResponseResource() Attribute10 = DayOfWeek.Tuesday }; - var resource4 = new ResourceA + var resource4 = new OutgoingResource { Id = 4, Attribute01 = false, @@ -81,7 +81,7 @@ private static ResourceA CreateResponseResource() Attribute10 = DayOfWeek.Wednesday }; - var resource5 = new ResourceA + var resource5 = new OutgoingResource { Id = 5, Attribute01 = true, @@ -113,21 +113,21 @@ public string SerializeResourceResponse() protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) { - return new() + return new JsonApiRequest { Kind = EndpointKind.Primary, - PrimaryResourceType = resourceGraph.GetResourceType() + PrimaryResourceType = resourceGraph.GetResourceType() }; } protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - ResourceType resourceAType = resourceGraph.GetResourceType(); + ResourceType resourceAType = resourceGraph.GetResourceType(); - RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Single2)); - RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Single3)); - RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Multi4)); - RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Multi5)); + RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); ImmutableArray chain = ArrayFactory.Create(single2, single3, multi4, multi5).ToImmutableArray(); IEnumerable chains = new ResourceFieldChainExpression(chain).AsEnumerable(); diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index 41fa9776b6..2abde17e42 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -40,7 +40,7 @@ protected SerializationBenchmarkBase() } }; - ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions; // ReSharper disable VirtualMemberCallInConstructor @@ -64,7 +64,7 @@ protected SerializationBenchmarkBase() protected abstract IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph); [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceA : Identifiable + public sealed class OutgoingResource : Identifiable { [Attr] public bool Attribute01 { get; set; } @@ -82,7 +82,7 @@ public sealed class ResourceA : Identifiable public float? Attribute05 { get; set; } [Attr] - public string Attribute06 { get; set; } + public string Attribute06 { get; set; } = null!; [Attr] public DateTime? Attribute07 { get; set; } @@ -97,34 +97,34 @@ public sealed class ResourceA : Identifiable public DayOfWeek Attribute10 { get; set; } [HasOne] - public ResourceA Single1 { get; set; } + public OutgoingResource Single1 { get; set; } = null!; [HasOne] - public ResourceA Single2 { get; set; } + public OutgoingResource Single2 { get; set; } = null!; [HasOne] - public ResourceA Single3 { get; set; } + public OutgoingResource Single3 { get; set; } = null!; [HasOne] - public ResourceA Single4 { get; set; } + public OutgoingResource Single4 { get; set; } = null!; [HasOne] - public ResourceA Single5 { get; set; } + public OutgoingResource Single5 { get; set; } = null!; [HasMany] - public ISet Multi1 { get; set; } + public ISet Multi1 { get; set; } = null!; [HasMany] - public ISet Multi2 { get; set; } + public ISet Multi2 { get; set; } = null!; [HasMany] - public ISet Multi3 { get; set; } + public ISet Multi3 { get; set; } = null!; [HasMany] - public ISet Multi4 { get; set; } + public ISet Multi4 { get; set; } = null!; [HasMany] - public ISet Multi5 { get; set; } + public ISet Multi5 { get; set; } = null!; } private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor @@ -134,32 +134,32 @@ public IImmutableSet OnApplyIncludes(ResourceType reso return existingIncludes; } - public FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpression existingFilter) + public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) { return existingFilter; } - public SortExpression OnApplySort(ResourceType resourceType, SortExpression existingSort) + public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) { return existingSort; } - public PaginationExpression OnApplyPagination(ResourceType resourceType, PaginationExpression existingPagination) + public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) { return existingPagination; } - public SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression existingSparseFieldSet) + public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) { return existingSparseFieldSet; } - public object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) { return null; } - public IDictionary GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) { return null; } @@ -170,8 +170,8 @@ public Task OnPrepareWriteAsync(TResource resource, WriteOperationKin return Task.CompletedTask; } - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { return Task.FromResult(rightResourceId); @@ -223,7 +223,7 @@ private sealed class FakeLinkBuilder : ILinkBuilder { public TopLevelLinks GetTopLevelLinks() { - return new() + return new TopLevelLinks { Self = "TopLevel:Self" }; @@ -231,15 +231,15 @@ public TopLevelLinks GetTopLevelLinks() public ResourceLinks GetResourceLinks(ResourceType resourceType, string id) { - return new() + return new ResourceLinks { Self = "Resource:Self" }; } - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, string leftId) { - return new() + return new RelationshipLinks { Self = "Relationship:Self", Related = "Relationship:Related" @@ -249,11 +249,11 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship private sealed class FakeMetaBuilder : IMetaBuilder { - public void Add(IReadOnlyDictionary values) + public void Add(IReadOnlyDictionary values) { } - public IDictionary Build() + public IDictionary? Build() { return null; } diff --git a/benchmarks/SubResource.cs b/benchmarks/SubResource.cs deleted file mode 100644 index 9d0f95bbcf..0000000000 --- a/benchmarks/SubResource.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace Benchmarks -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SubResource : Identifiable - { - [Attr] - public string Value { get; set; } - } -} diff --git a/cleanupcode.ps1 b/cleanupcode.ps1 index 605ebff705..dccf70ebc1 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 diff --git a/docs/getting-started/step-by-step.md b/docs/getting-started/step-by-step.md index 75661780cc..adc9bdf8e4 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -69,9 +69,9 @@ where `TResource` is the model that inherits from `Identifiable`. ```c# public class PeopleController : JsonApiController { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } 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/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 2d5d24cec9..9fc9d380f1 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -5,9 +5,9 @@ You need to create controllers that inherit from `JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -24,9 +24,9 @@ This approach is ok, but introduces some boilerplate that can easily be avoided. ```c# public class ArticlesController : BaseJsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } @@ -61,9 +61,9 @@ An attempt to use one of the blacklisted methods will result in a HTTP 405 Metho [HttpReadOnly] public class ArticlesController : BaseJsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -80,9 +80,9 @@ For more information about resource service injection, see [Replacing injected s ```c# public class ReportsController : BaseJsonApiController { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService getAllService) - : base(options, loggerFactory, getAllService) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IGetAllService getAllService) + : base(options, resourceGraph, loggerFactory, getAllService) { } diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index f56442fcac..4c9eeeb8a6 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -223,7 +223,7 @@ _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# diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 83bb7e5ffa..1842a44606 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -156,9 +156,10 @@ Then in the controller, you should inherit from the base controller and pass the ```c# public class ArticlesController : BaseJsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create, IDeleteService delete) - : base(options, loggerFactory, create: create, delete: delete) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, ICreateService create, + IDeleteService delete) + : base(options, resourceGraph, loggerFactory, create: create, delete: delete) { } diff --git a/docs/usage/options.md b/docs/usage/options.md index 54eb7f0d3b..8b1ee5bae8 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -100,9 +100,12 @@ 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 -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. +[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. + +How nullability affects ModelState validation is described [here](~/usage/resources/nullability.md). ```c# options.ValidateModelState = true; @@ -115,5 +118,8 @@ public class Person : Identifiable [Required] [MinLength(3)] public string FirstName { get; set; } + + [Required] + public LoginAccount Account : get; set; } } ``` diff --git a/docs/usage/resources/nullability.md b/docs/usage/resources/nullability.md new file mode 100644 index 0000000000..c3ec59eab6 --- /dev/null +++ b/docs/usage/resources/nullability.md @@ -0,0 +1,85 @@ +# 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#enable-modelstate-validation). + +# Value types + +When ModelState validation is enabled, non-nullable value types will **not** trigger a validation error when omitted in the request body. +To make JsonApiDotNetCore return an error when such a property is missing on resource creation, declare it as nullable and annotate it with `[Required]`. + +Example: + +```c# +public sealed class User : Identifiable +{ + [Attr] + [Required] + public bool? IsAdministrator { get; set; } +} +``` + +This makes Entity Framework Core generate non-nullable columns. And model errors are returned when nullable fields are omitted. + +# Reference types + +When the [nullable reference types](https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references) (NRT) compiler feature is enabled, it affects both ASP.NET ModelState validation and Entity Framework Core. + +## NRT turned off + +When NRT is turned off, use `[Required]` on required attributes and relationships. This makes Entity Framework Core generate non-nullable columns. And model errors are returned when required fields are omitted. + +Example: + +```c# +public sealed class Label : Identifiable +{ + [Attr] + [Required] + public string Name { get; set; } + + [Attr] + public string RgbColor { get; set; } + + [HasOne] + [Required] + public Person Creator { get; set; } + + [HasOne] + public Label Parent { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } +} +``` + +## NRT turned on + +When NRT is turned on, use nullability annotations (?) on attributes and relationships. This makes Entity Framework Core generate non-nullable columns. And model errors are returned when non-nullable fields are omitted. + +The [Entity Framework Core guide on NRT](https://docs.microsoft.com/en-us/ef/core/miscellaneous/nullable-reference-types) recommends to use constructor binding to initialize non-nullable properties, but JsonApiDotNetCore does not support that. For required navigation properties, it suggests to use a non-nullable property with a nullable backing field. JsonApiDotNetCore does not support that either. In both cases, just use the null-forgiving operator (!). + +When ModelState validation is turned on, to-many relationships must be assigned an empty collection. Otherwise an error is returned when they don't occur in the request body. + +Example: + +```c# +public sealed class Label : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public string? RgbColor { get; set; } + + [HasOne] + public Person Creator { get; set; } = null!; + + [HasOne] + public Label? Parent { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} +``` diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 24d3e834ff..662e8a1a3d 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -20,6 +20,84 @@ public class TodoItem : Identifiable 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# +public sealed class Car : Identifiable +{ + [HasOne] + public Engine Engine { get; set; } +} + +public sealed class Engine : Identifiable +{ + [HasOne] + public Car Car { get; set; } +} + +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey() + .IsRequired(); + } +} +``` + +Which results in Entity Framework Core generating the next database objects: +```sql +CREATE TABLE "Engine" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") +); +CREATE TABLE "Cars" ( + "Id" integer NOT NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engine_Id" FOREIGN KEY ("Id") REFERENCES "Engine" ("Id") + ON DELETE CASCADE +); +``` + +That mechanism does not make sense for JSON:API, because patching a relationship would result in also +changing the identity of a resource. Naming the foreign key explicitly fixes the problem by forcing to +create a foreign key column. + +```c# +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey("EngineId") // Explicit foreign key name added + .IsRequired(); +} +``` + +Which generates the correct database objects: +```sql +CREATE TABLE "Engine" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") +); +CREATE TABLE "Cars" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "EngineId" integer NOT NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engine_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engine" ("Id") + ON DELETE CASCADE +); +CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); +``` + ## HasMany This exposes a to-many relationship. diff --git a/docs/usage/routing.md b/docs/usage/routing.md index 79ad1a1e05..c68914a04a 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -29,9 +29,9 @@ public class OrderLine : Identifiable public class OrderLineController : JsonApiController { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -65,9 +65,9 @@ It is possible to bypass the default routing convention for a controller. [Route("v1/custom/route/lines-in-order"), DisableRoutingConvention] public class OrderLineController : JsonApiController { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/docs/usage/toc.md b/docs/usage/toc.md index a8b5473007..f6924036ea 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..6c9d90768e 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=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 928dddc82b..3f049429cd 100644 --- a/src/Examples/GettingStarted/Controllers/BooksController.cs +++ b/src/Examples/GettingStarted/Controllers/BooksController.cs @@ -8,8 +8,8 @@ namespace GettingStarted.Controllers { public sealed class BooksController : JsonApiController { - public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BooksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs index cef47189ae..e7a5537f14 100644 --- a/src/Examples/GettingStarted/Controllers/PeopleController.cs +++ b/src/Examples/GettingStarted/Controllers/PeopleController.cs @@ -8,8 +8,9 @@ namespace GettingStarted.Controllers { public sealed class PeopleController : JsonApiController { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index b54011ff14..c5460db810 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -7,16 +7,11 @@ namespace GettingStarted.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public class SampleDbContext : DbContext { - public DbSet Books { get; set; } + public DbSet Books => Set(); public SampleDbContext(DbContextOptions options) : base(options) { } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity(); - } } } diff --git a/src/Examples/GettingStarted/Models/Book.cs b/src/Examples/GettingStarted/Models/Book.cs index f636dd54e1..0957461cd7 100644 --- a/src/Examples/GettingStarted/Models/Book.cs +++ b/src/Examples/GettingStarted/Models/Book.cs @@ -8,12 +8,12 @@ namespace GettingStarted.Models public sealed class Book : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] public int PublishYear { get; set; } [HasOne] - public Person Author { get; set; } + public Person Author { get; set; } = null!; } } diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 52a95ac330..f9b8e55fff 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -9,9 +9,9 @@ namespace GettingStarted.Models public sealed class Person : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ICollection Books { get; set; } + public ICollection Books { get; set; } = new List(); } } diff --git a/src/Examples/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 36c772b29b..0ebafd1767 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreExample.Controllers { public sealed class PeopleController : JsonApiController { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index 98b5051774..b08af4e399 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -8,8 +8,8 @@ namespace JsonApiDotNetCoreExample.Controllers { public sealed class TagsController : JsonApiController { - public TagsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TagsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs index 0eaa59b1ca..c862853302 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreExample.Controllers { public sealed class TodoItemsController : JsonApiController { - public TodoItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TodoItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index cc59628fc6..f9f6752990 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class AppDbContext : DbContext { - public DbSet TodoItems { get; set; } + public DbSet TodoItems => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -21,16 +21,12 @@ protected override void OnModelCreating(ModelBuilder builder) // When deleting a person, un-assign him/her from existing todo items. builder.Entity() .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee) - .IsRequired(false) - .OnDelete(DeleteBehavior.SetNull); + .WithOne(todoItem => todoItem.Assignee!); // When deleting a person, the todo items he/she owns are deleted too. builder.Entity() .HasOne(todoItem => todoItem.Owner) - .WithMany() - .IsRequired() - .OnDelete(DeleteBehavior.Cascade); + .WithMany(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index ddf19c5a43..306315d05f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -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 4d7d3369fa..44be2df864 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreExample.Models public sealed class Person : Identifiable { [Attr] - public string FirstName { get; set; } + public string? FirstName { get; set; } [Attr] - public string LastName { get; set; } + public string LastName { get; set; } = null!; [HasMany] - public ISet AssignedTodoItems { get; set; } + public ISet AssignedTodoItems { get; set; } = new HashSet(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 03fb527c4b..713eafe605 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -9,12 +9,11 @@ namespace JsonApiDotNetCoreExample.Models [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class Tag : Identifiable { - [Required] - [MinLength(1)] [Attr] - public string Name { get; set; } + [MinLength(1)] + public string Name { get; set; } = null!; [HasMany] - public ISet TodoItems { get; set; } + public ISet TodoItems { get; set; } = new HashSet(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index cc6341bdb2..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; @@ -10,10 +11,11 @@ namespace JsonApiDotNetCoreExample.Models public sealed class TodoItem : Identifiable { [Attr] - public string Description { get; set; } + public string Description { get; set; } = null!; [Attr] - public TodoItemPriority Priority { get; set; } + [Required] + public TodoItemPriority? Priority { get; set; } [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] public DateTimeOffset CreatedAt { get; set; } @@ -22,12 +24,12 @@ public sealed class TodoItem : Identifiable public DateTimeOffset? LastModifiedAt { get; set; } [HasOne] - public Person Owner { get; set; } + public Person Owner { get; set; } = null!; [HasOne] - public Person Assignee { get; set; } + public Person? Assignee { get; set; } [HasMany] - public ISet Tags { get; set; } + public ISet Tags { get; set; } = new HashSet(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index f14c1df8ce..84ebc30360 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -49,7 +49,6 @@ 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()); diff --git a/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs b/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs index 6fc542cf1d..5fd3c662a4 100644 --- a/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs +++ b/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs @@ -8,8 +8,9 @@ namespace MultiDbContextExample.Controllers { public sealed class ResourceAsController : JsonApiController { - public ResourceAsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ResourceAsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs b/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs index 0316fbde1b..33b89aa9ec 100644 --- a/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs +++ b/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs @@ -8,8 +8,9 @@ namespace MultiDbContextExample.Controllers { public sealed class ResourceBsController : JsonApiController { - public ResourceBsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ResourceBsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/MultiDbContextExample/Data/DbContextA.cs b/src/Examples/MultiDbContextExample/Data/DbContextA.cs index cb6000e051..23b2f4a37c 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextA.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextA.cs @@ -7,7 +7,7 @@ namespace MultiDbContextExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class DbContextA : DbContext { - public DbSet ResourceAs { get; set; } + public DbSet ResourceAs => Set(); public DbContextA(DbContextOptions options) : base(options) diff --git a/src/Examples/MultiDbContextExample/Data/DbContextB.cs b/src/Examples/MultiDbContextExample/Data/DbContextB.cs index b3e4e6e47f..bf9c575fa9 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextB.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextB.cs @@ -7,7 +7,7 @@ namespace MultiDbContextExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class DbContextB : DbContext { - public DbSet ResourceBs { get; set; } + public DbSet ResourceBs => Set(); public DbContextB(DbContextOptions options) : base(options) diff --git a/src/Examples/MultiDbContextExample/Models/ResourceA.cs b/src/Examples/MultiDbContextExample/Models/ResourceA.cs index f536237f14..1c754be6ed 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceA.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceA.cs @@ -8,6 +8,6 @@ namespace MultiDbContextExample.Models public sealed class ResourceA : Identifiable { [Attr] - public string NameA { get; set; } + public string? NameA { get; set; } } } diff --git a/src/Examples/MultiDbContextExample/Models/ResourceB.cs b/src/Examples/MultiDbContextExample/Models/ResourceB.cs index 55a3d79a59..70941a1f4d 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceB.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceB.cs @@ -8,6 +8,6 @@ namespace MultiDbContextExample.Models public sealed class ResourceB : Identifiable { [Attr] - public string NameB { get; set; } + public string? NameB { get; set; } } } diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index 6d7e1b5cbd..e328cc07be 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": false, - "launchUrl": "/resourceBs", + "launchUrl": "resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": false, - "launchUrl": "/resourceBs", + "launchUrl": "resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs index d50fa1183f..055fa60ed8 100644 --- a/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs +++ b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs @@ -8,8 +8,9 @@ namespace NoEntityFrameworkExample.Controllers { public sealed class WorkItemsController : JsonApiController { - public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WorkItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs index 336951eec3..bfe2115f7d 100644 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs @@ -7,7 +7,7 @@ namespace NoEntityFrameworkExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class AppDbContext : DbContext { - public DbSet WorkItems { get; set; } + public DbSet WorkItems => Set(); public AppDbContext(DbContextOptions options) : base(options) diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs index 98aa8a9fd9..083894fd04 100644 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs @@ -12,7 +12,7 @@ public sealed class WorkItem : Identifiable 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 45c1b9a83a..5fbd062b11 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -46,17 +46,17 @@ public async Task GetAsync(int id, CancellationToken cancellationToken return workItems.Single(); } - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) + public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) { const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; @@ -78,12 +78,12 @@ public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, IS throw new NotImplementedException(); } - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) + public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task SetRelationshipAsync(int leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index db1b73ec57..8c177e7db0 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -12,8 +12,8 @@ namespace ReportsExample.Controllers [Route("api/[controller]")] public class ReportsController : BaseJsonApiController { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAllService) - : base(options, loggerFactory, getAllService) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IGetAllService getAllService) + : base(options, resourceGraph, loggerFactory, getAllService) { } diff --git a/src/Examples/ReportsExample/Models/Report.cs b/src/Examples/ReportsExample/Models/Report.cs index ef4a360a22..65f6972d16 100644 --- a/src/Examples/ReportsExample/Models/Report.cs +++ b/src/Examples/ReportsExample/Models/Report.cs @@ -8,9 +8,9 @@ namespace ReportsExample.Models public sealed class Report : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] - public ReportStatistics Statistics { get; set; } + public ReportStatistics Statistics { get; set; } = null!; } } diff --git a/src/Examples/ReportsExample/Models/ReportStatistics.cs b/src/Examples/ReportsExample/Models/ReportStatistics.cs index 53c2c2d2ee..7c520eded8 100644 --- a/src/Examples/ReportsExample/Models/ReportStatistics.cs +++ b/src/Examples/ReportsExample/Models/ReportStatistics.cs @@ -5,7 +5,7 @@ namespace ReportsExample.Models [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ReportStatistics { - public string ProgressIndication { get; set; } + public string ProgressIndication { get; set; } = null!; public int HoursSpent { get; set; } } } diff --git a/src/Examples/ReportsExample/Properties/launchSettings.json b/src/Examples/ReportsExample/Properties/launchSettings.json index ee2eba1f80..7add074ef2 100644 --- a/src/Examples/ReportsExample/Properties/launchSettings.json +++ b/src/Examples/ReportsExample/Properties/launchSettings.json @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/reports", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/reports", "applicationUrl": "https://localhost:44348;http://localhost:14148", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index 62c5dc22b9..19f18bfed3 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -33,6 +33,7 @@ private IReadOnlyCollection GetReports() { new() { + Id = 1, Title = "Status Report", Statistics = new ReportStatistics { diff --git a/src/JsonApiDotNetCore/ArgumentGuard.cs b/src/JsonApiDotNetCore/ArgumentGuard.cs index c9f9e2d6a7..1877078df9 100644 --- a/src/JsonApiDotNetCore/ArgumentGuard.cs +++ b/src/JsonApiDotNetCore/ArgumentGuard.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; #pragma warning disable AV1008 // Class should not be static @@ -10,8 +11,7 @@ namespace JsonApiDotNetCore internal static class ArgumentGuard { [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) + public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) where T : class { if (value is null) @@ -21,9 +21,7 @@ public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [In } [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty([CanBeNull] IEnumerable value, [NotNull] [InvokerParameterName] string name, - [CanBeNull] string collectionName = null) + public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string? collectionName = null) { NotNull(value, name); @@ -34,8 +32,7 @@ public static void NotNullNorEmpty([CanBeNull] IEnumerable value, [NotNull } [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty([CanBeNull] string value, [NotNull] [InvokerParameterName] string name) + public static void NotNullNorEmpty([SysNotNull] string? value, [InvokerParameterName] string name) { NotNull(value, name); diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs index 693bd6098b..d35d6e6154 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs @@ -13,6 +13,6 @@ public interface IOperationProcessorAccessor /// /// Invokes on a processor compatible with the operation kind. /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs index 839d0d6cb0..f6c736ee9d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs @@ -13,6 +13,6 @@ public interface IOperationsProcessor /// /// Processes the list of specified operations. /// - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 972440c4bc..408553529e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -94,7 +94,7 @@ private static void AssertSameResourceType(string currentType, string declaredTy private sealed class LocalIdState { public string ResourceType { get; } - public string ServerId { get; set; } + public string? ServerId { get; set; } public LocalIdState(string resourceType) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index 5fd790a318..f08c1bc44c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -59,7 +59,7 @@ private void ValidateOperation(OperationContainer operation) { if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType); + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); } else { @@ -73,7 +73,7 @@ private void ValidateOperation(OperationContainer operation) if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - AssignLocalId(operation, operation.Request.PrimaryResourceType); + AssignLocalId(operation, operation.Request.PrimaryResourceType!); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index 68cfb752b2..67596ee697 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -24,7 +24,7 @@ public OperationProcessorAccessor(IServiceProvider serviceProvider) } /// - public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); @@ -34,8 +34,8 @@ public Task ProcessAsync(OperationContainer operation, Cance protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) { - Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation.GetValueOrDefault()); - ResourceType resourceType = operation.Request.PrimaryResourceType; + Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation!.Value); + ResourceType resourceType = operation.Request.PrimaryResourceType!; Type processorType = processorInterface.MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType); diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index be266286db..b9cbe983e0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -48,14 +48,14 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso } /// - public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) + public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operations, nameof(operations)); _localIdValidator.Validate(operations); _localIdTracker.Reset(); - var results = new List(); + var results = new List(); await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); @@ -69,7 +69,7 @@ public virtual async Task> ProcessAsync(IList> ProcessAsync(IList ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) + protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -119,7 +119,7 @@ protected void TrackLocalIdsForOperation(OperationContainer operation) { if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType); + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); } else { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 775e896ebc..1b6025cf40 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -22,14 +22,14 @@ public AddToRelationshipProcessor(IAddToRelationshipService serv } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 4a408f368e..36cb8c573f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -25,16 +25,16 @@ public CreateProcessor(ICreateService service, ILocalIdTracker l } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); - TResource newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); + TResource? newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); if (operation.Resource.LocalId != null) { - string serverId = newResource != null ? newResource.StringId : operation.Resource.StringId; - ResourceType resourceType = operation.Request.PrimaryResourceType; + string serverId = newResource != null ? newResource.StringId! : operation.Resource.StringId!; + ResourceType resourceType = operation.Request.PrimaryResourceType!; _localIdTracker.Assign(operation.Resource.LocalId, resourceType.PublicName, serverId); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 929ffe73a9..29750b395b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -21,7 +21,7 @@ public DeleteProcessor(IDeleteService service) } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs index 6b51694260..559bd4cbf4 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -12,6 +12,6 @@ public interface IOperationProcessor /// /// Processes the specified operation. /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index 74197f417f..a186967cf0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -22,14 +22,14 @@ public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 92bd69942e..bec2a47854 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -25,22 +25,22 @@ public SetRelationshipProcessor(ISetRelationshipService service) } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); - object rightValue = GetRelationshipRightValue(operation); + object? rightValue = GetRelationshipRightValue(operation); - await _service.SetRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightValue, cancellationToken); + await _service.SetRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); return null; } - private object GetRelationshipRightValue(OperationContainer operation) + private object? GetRelationshipRightValue(OperationContainer operation) { - RelationshipAttribute relationship = operation.Request.Relationship; - object rightValue = relationship.GetValue(operation.Resource); + RelationshipAttribute relationship = operation.Request.Relationship!; + object? rightValue = relationship.GetValue(operation.Resource); if (relationship is HasManyAttribute) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 151d91adfe..f88bf086df 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -21,12 +21,12 @@ public UpdateProcessor(IUpdateService service) } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var resource = (TResource)operation.Resource; - TResource updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + TResource? updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); return updated == null ? null : operation.WithResource(updated); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs index 8c2e150a23..b2a1a8daa5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCore.AtomicOperations internal sealed class RevertRequestStateOnDispose : IDisposable { private readonly IJsonApiRequest _sourceRequest; - private readonly ITargetedFields _sourceTargetedFields; + private readonly ITargetedFields? _sourceTargetedFields; private readonly IJsonApiRequest _backupRequest = new JsonApiRequest(); private readonly ITargetedFields _backupTargetedFields = new TargetedFields(); - public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields targetedFields) + public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields? targetedFields) { ArgumentGuard.NotNull(request, nameof(request)); diff --git a/src/JsonApiDotNetCore/CollectionConverter.cs b/src/JsonApiDotNetCore/CollectionConverter.cs index 1f403b4ccd..5c5dcba845 100644 --- a/src/JsonApiDotNetCore/CollectionConverter.cs +++ b/src/JsonApiDotNetCore/CollectionConverter.cs @@ -33,11 +33,11 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType ArgumentGuard.NotNull(collectionType, nameof(collectionType)); Type concreteCollectionType = ToConcreteCollectionType(collectionType); - dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType); + dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; foreach (object item in source) { - concreteCollectionInstance!.Add((dynamic)item); + concreteCollectionInstance.Add((dynamic)item); } return concreteCollectionInstance; @@ -69,7 +69,7 @@ private Type ToConcreteCollectionType(Type collectionType) /// /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. /// - public ICollection ExtractResources(object value) + public ICollection ExtractResources(object? value) { if (value is ICollection resourceCollection) { @@ -92,7 +92,7 @@ public ICollection ExtractResources(object value) /// /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. /// - public Type TryGetCollectionElementType(Type type) + public Type? FindCollectionElementType(Type? type) { if (type != null) { @@ -114,6 +114,8 @@ public Type TryGetCollectionElementType(Type type) /// public bool TypeCanContainHashSet(Type collectionType) { + ArgumentGuard.NotNull(collectionType, nameof(collectionType)); + if (collectionType.IsGenericType) { Type openCollectionType = collectionType.GetGenericTypeDefinition(); diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index 863b22d5fd..b258fa866d 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -1,15 +1,15 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; using System.Linq; -using JetBrains.Annotations; namespace JsonApiDotNetCore { internal static class CollectionExtensions { [Pure] - [ContractAnnotation("source: null => true")] - public static bool IsNullOrEmpty(this IEnumerable source) + public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? source) { if (source == null) { @@ -35,10 +35,10 @@ public static int FindIndex(this IReadOnlyList source, Predicate match) return -1; } - public static bool DictionaryEqual(this IReadOnlyDictionary first, IReadOnlyDictionary second, - IEqualityComparer valueComparer = null) + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, + IEqualityComparer? valueComparer = null) { - if (first == second) + if (ReferenceEquals(first, second)) { return true; } @@ -57,7 +57,7 @@ public static bool DictionaryEqual(this IReadOnlyDictionary(this IReadOnlyDictionary EmptyIfNull(this IEnumerable source) + public static IEnumerable EmptyIfNull(this IEnumerable? source) { return source ?? Enumerable.Empty(); } diff --git a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs index 4a7aac4ec2..aa7c77d6e0 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Configuration { /// /// Responsible for populating . This service is instantiated in the configure phase of the - /// application. When using a data access layer different from EF Core, you will need to implement and register this service, or set + /// application. When using a data access layer different from Entity Framework Core, you will need to implement and register this service, or set /// explicitly. /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs index 53b84e36d1..b3f33a1b38 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs @@ -5,6 +5,6 @@ namespace JsonApiDotNetCore.Configuration { internal interface IJsonApiApplicationBuilder { - public Action ConfigureMvcOptions { set; } + public Action? ConfigureMvcOptions { set; } } } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 6e8e2d981e..15f46e7e79 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -17,7 +17,7 @@ public interface IJsonApiOptions /// /// options.Namespace = "api/v1"; /// - string Namespace { get; } + string? Namespace { get; } /// /// Specifies the default query string capabilities that can be used on exposed JSON:API attributes. Defaults to . @@ -90,20 +90,20 @@ public interface IJsonApiOptions /// /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default. /// - PageSize DefaultPageSize { get; } + PageSize? DefaultPageSize { get; } /// /// The maximum page size that can be used, or null for unconstrained (default). /// - PageSize MaximumPageSize { get; } + PageSize? MaximumPageSize { get; } /// /// The maximum page number that can be used, or null for unconstrained (default). /// - PageNumber MaximumPageNumber { get; } + PageNumber? MaximumPageNumber { get; } /// - /// Whether or not to enable ASP.NET Core model state validation. False by default. + /// Whether or not to enable ASP.NET ModelState validation. True by default. /// bool ValidateModelState { get; } diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index 1c2c058150..89684e5d86 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -38,12 +38,12 @@ ResourceType GetResourceType() /// /// Attempts to get the metadata for the resource that is publicly exposed by the specified name. Returns null when not found. /// - ResourceType TryGetResourceType(string publicName); + ResourceType? FindResourceType(string publicName); /// /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found. /// - ResourceType TryGetResourceType(Type resourceClrType); + ResourceType? FindResourceType(Type resourceClrType); /// /// Gets the fields (attributes and relationships) for that are targeted by the selector. @@ -56,7 +56,7 @@ ResourceType GetResourceType() /// (TResource resource) => new { resource.Attribute1, resource.Relationship2 } /// ]]> /// - IReadOnlyCollection GetFields(Expression> selector) + IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable; /// @@ -70,7 +70,7 @@ IReadOnlyCollection GetFields(Expression new { resource.attribute1, resource.Attribute2 } /// ]]> /// - IReadOnlyCollection GetAttributes(Expression> selector) + IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable; /// @@ -84,7 +84,7 @@ IReadOnlyCollection GetAttributes(Expression new { resource.Relationship1, resource.Relationship2 } /// ]]> /// - IReadOnlyCollection GetRelationships(Expression> selector) + IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable; } } diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index a3461cfdcf..3814422da6 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -64,7 +64,7 @@ private void ResolveRelationships(IReadOnlyCollection rel { foreach (RelationshipAttribute relationship in relationships) { - if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase navigation)) + if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase? navigation)) { relationship.InverseNavigationProperty = navigation.Inverse?.PropertyInfo; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 5b6f479ba3..8bc921af78 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -38,7 +38,7 @@ internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, ID private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; private readonly ServiceProvider _intermediateProvider; - public Action ConfigureMvcOptions { get; set; } + public Action? ConfigureMvcOptions { get; set; } public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { @@ -58,7 +58,7 @@ public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mv /// /// Executes the action provided by the user to configure . /// - public void ConfigureJsonApiOptions(Action configureOptions) + public void ConfigureJsonApiOptions(Action? configureOptions) { configureOptions?.Invoke(_options); } @@ -66,7 +66,7 @@ public void ConfigureJsonApiOptions(Action configureOptions) /// /// Executes the action provided by the user to configure . /// - public void ConfigureAutoDiscovery(Action configureAutoDiscovery) + public void ConfigureAutoDiscovery(Action? configureAutoDiscovery) { configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); } @@ -74,8 +74,10 @@ public void ConfigureAutoDiscovery(Action configureAutoD /// /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. /// - public void AddResourceGraph(ICollection dbContextTypes, Action configureResourceGraph) + public void ConfigureResourceGraph(ICollection dbContextTypes, Action? configureResourceGraph) { + ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + _serviceDiscoveryFacade.DiscoverResources(); foreach (Type dbContextType in dbContextTypes) @@ -94,7 +96,7 @@ public void AddResourceGraph(ICollection dbContextTypes, Action - /// Configures built-in ASP.NET Core MVC components. Most of this configuration can be adjusted for the developers' need. + /// Configures built-in ASP.NET MVC components. Most of this configuration can be adjusted for the developers' need. /// public void ConfigureMvc() { @@ -127,6 +129,8 @@ public void DiscoverInjectables() /// public void ConfigureServiceContainer(ICollection dbContextTypes) { + ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + if (dbContextTypes.Any()) { _services.AddScoped(typeof(DbContextResolver<>)); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 322e2cd725..39a5e197f2 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -27,7 +27,7 @@ public sealed class JsonApiOptions : IJsonApiOptions internal bool DisableChildrenPagination { get; set; } /// - public string Namespace { get; set; } + public string? Namespace { get; set; } /// public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; @@ -57,16 +57,16 @@ public sealed class JsonApiOptions : IJsonApiOptions public bool IncludeTotalResourceCount { get; set; } /// - public PageSize DefaultPageSize { get; set; } = new(10); + public PageSize? DefaultPageSize { get; set; } = new(10); /// - public PageSize MaximumPageSize { get; set; } + public PageSize? MaximumPageSize { get; set; } /// - public PageNumber MaximumPageNumber { get; set; } + public PageNumber? MaximumPageNumber { get; set; } /// - public bool ValidateModelState { get; set; } + public bool ValidateModelState { get; set; } = true; /// public bool AllowClientGeneratedIds { get; set; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index 208167fa2b..beb978b137 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Validation filter that blocks ASP.NET Core ModelState validation on data according to the JSON:API spec. + /// Validation filter that blocks ASP.NET ModelState validation on data according to the JSON:API spec. /// internal sealed class JsonApiValidationFilter : IPropertyValidationFilter { @@ -41,7 +41,7 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt return false; } - if (_httpContextAccessor.HttpContext!.Request.Method == HttpMethods.Patch || request.WriteOperation == WriteOperationKind.UpdateResource) + if (request.WriteOperation == WriteOperationKind.UpdateResource) { var targetedFields = serviceProvider.GetRequiredService(); return IsFieldTargeted(entry, targetedFields); @@ -52,7 +52,7 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt private IServiceProvider GetScopedServiceProvider() { - HttpContext httpContext = _httpContextAccessor.HttpContext; + HttpContext? httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) { @@ -74,7 +74,8 @@ private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) 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/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs index d673fe11a7..aaad96abb8 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -9,6 +9,9 @@ internal sealed class ResourceDescriptor public ResourceDescriptor(Type resourceClrType, Type idClrType) { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentGuard.NotNull(idClrType, nameof(idClrType)); + ResourceClrType = resourceClrType; IdClrType = idClrType; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index 3eaa0d828d..67a0329f9f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Configuration internal sealed class ResourceDescriptorAssemblyCache { private readonly TypeLocator _typeLocator = new(); - private readonly Dictionary> _resourceDescriptorsPerAssembly = new(); + private readonly Dictionary?> _resourceDescriptorsPerAssembly = new(); public void RegisterAssembly(Assembly assembly) { @@ -25,7 +25,7 @@ public IReadOnlyCollection GetResourceDescriptors() { EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value).ToArray(); + return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray(); } public IReadOnlyCollection GetAssemblies() @@ -47,7 +47,7 @@ private IEnumerable ScanForResourceDescriptors(Assembly asse { foreach (Type type in assembly.GetTypes()) { - ResourceDescriptor resourceDescriptor = _typeLocator.TryGetResourceDescriptor(type); + ResourceDescriptor? resourceDescriptor = _typeLocator.ResolveResourceDescriptor(type); if (resourceDescriptor != null) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 90df89c576..418c1ba0b7 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -13,7 +13,7 @@ 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 _resourceTypeSet; private readonly Dictionary _resourceTypesByClrType = new(); @@ -41,7 +41,7 @@ public IReadOnlySet GetResourceTypes() /// public ResourceType GetResourceType(string publicName) { - ResourceType resourceType = TryGetResourceType(publicName); + ResourceType? resourceType = FindResourceType(publicName); if (resourceType == null) { @@ -52,17 +52,17 @@ public ResourceType GetResourceType(string publicName) } /// - public ResourceType TryGetResourceType(string publicName) + public ResourceType? FindResourceType(string publicName) { ArgumentGuard.NotNull(publicName, nameof(publicName)); - return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType resourceType) ? resourceType : null; + return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType? resourceType) ? resourceType : null; } /// public ResourceType GetResourceType(Type resourceClrType) { - ResourceType resourceType = TryGetResourceType(resourceClrType); + ResourceType? resourceType = FindResourceType(resourceClrType); if (resourceType == null) { @@ -73,12 +73,12 @@ public ResourceType GetResourceType(Type resourceClrType) } /// - public ResourceType TryGetResourceType(Type resourceClrType) + public ResourceType? FindResourceType(Type resourceClrType) { ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType : resourceClrType; - return _resourceTypesByClrType.TryGetValue(typeToFind!, out ResourceType resourceType) ? resourceType : null; + Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; + return _resourceTypesByClrType.TryGetValue(typeToFind, out ResourceType? resourceType) ? resourceType : null; } private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) @@ -94,7 +94,7 @@ public ResourceType GetResourceType() } /// - public IReadOnlyCollection GetFields(Expression> selector) + public IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -103,7 +103,7 @@ public IReadOnlyCollection GetFields(Expressi } /// - public IReadOnlyCollection GetAttributes(Expression> selector) + public IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -112,7 +112,7 @@ public IReadOnlyCollection GetAttributes(Expression - public IReadOnlyCollection GetRelationships(Expression> selector) + public IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -120,7 +120,7 @@ public IReadOnlyCollection GetRelationships(Ex return FilterFields(selector); } - private IReadOnlyCollection FilterFields(Expression> selector) + private IReadOnlyCollection FilterFields(Expression> selector) where TResource : class, IIdentifiable where TField : ResourceFieldAttribute { @@ -129,7 +129,7 @@ private IReadOnlyCollection FilterFields(Expression field.Property.Name == memberName); + TField? matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); if (matchingField == null) { @@ -160,7 +160,7 @@ private IReadOnlyCollection GetFieldsOfType() return (IReadOnlyCollection)resourceType.Fields; } - private IEnumerable ToMemberNames(Expression> selector) + private IEnumerable ToMemberNames(Expression> selector) { Expression selectorBody = RemoveConvert(selector.Body); diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 4e19bed130..d405537374 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -42,8 +42,8 @@ public IResourceGraph Build() foreach (RelationshipAttribute relationship in _resourceTypes.SelectMany(resourceType => resourceType.Relationships)) { - relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType); - relationship.RightType = resourceGraph.GetResourceType(relationship.RightClrType); + relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!); + relationship.RightType = resourceGraph.GetResourceType(relationship.RightClrType!); } return resourceGraph; @@ -66,9 +66,9 @@ public ResourceGraphBuilder Add(DbContext dbContext) private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) { -#pragma warning disable EF1001 // Internal EF Core API usage. +#pragma warning disable EF1001 // Internal Entity Framework Core API usage. return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true }; -#pragma warning restore EF1001 // Internal EF Core API usage. +#pragma warning restore EF1001 // Internal Entity Framework Core API usage. } /// @@ -81,7 +81,7 @@ private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR /// type name. /// - public ResourceGraphBuilder Add(string publicName = null) + public ResourceGraphBuilder Add(string? publicName = null) where TResource : class, IIdentifiable { return Add(publicName); @@ -100,7 +100,7 @@ public ResourceGraphBuilder Add(string publicName = null) /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR /// type name. /// - public ResourceGraphBuilder Add(string publicName = null) + public ResourceGraphBuilder Add(string? publicName = null) where TResource : class, IIdentifiable { return Add(typeof(TResource), typeof(TId), publicName); @@ -119,7 +119,7 @@ public ResourceGraphBuilder Add(string publicName = null) /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR /// type name. /// - public ResourceGraphBuilder Add(Type resourceClrType, Type idClrType = null, string publicName = null) + public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, string? publicName = null) { ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); @@ -131,7 +131,7 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type idClrType = null, str if (resourceClrType.IsOrImplementsInterface(typeof(IIdentifiable))) { string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); - Type effectiveIdType = idClrType ?? _typeLocator.TryGetIdType(resourceClrType); + Type? effectiveIdType = idClrType ?? _typeLocator.LookupIdType(resourceClrType); if (effectiveIdType == null) { @@ -155,7 +155,7 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, IReadOnlyCollection relationships = GetRelationships(resourceClrType); IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); - var linksAttribute = (ResourceLinksAttribute)resourceClrType.GetCustomAttribute(typeof(ResourceLinksAttribute)); + var linksAttribute = (ResourceLinksAttribute?)resourceClrType.GetCustomAttribute(typeof(ResourceLinksAttribute)); return linksAttribute == null ? new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads) @@ -169,12 +169,10 @@ private IReadOnlyCollection GetAttributes(Type resourceClrType) foreach (PropertyInfo property in resourceClrType.GetProperties()) { - var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); - // Although strictly not correct, 'id' is added to the list of attributes for convenience. // For example, it enables to filter on ID, without the need to special-case existing logic. // And when using sparse fields, it silently adds 'id' to the set of attributes to retrieve. - if (property.Name == nameof(Identifiable.Id) && attribute == null) + if (property.Name == nameof(Identifiable.Id)) { var idAttr = new AttrAttribute { @@ -187,12 +185,14 @@ private IReadOnlyCollection GetAttributes(Type resourceClrType) 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) @@ -213,12 +213,12 @@ private IReadOnlyCollection GetRelationships(Type resourc foreach (PropertyInfo property in properties) { - var relationship = (RelationshipAttribute)property.GetCustomAttribute(typeof(RelationshipAttribute)); + var relationship = (RelationshipAttribute?)property.GetCustomAttribute(typeof(RelationshipAttribute)); if (relationship != null) { relationship.Property = property; - relationship.PublicName ??= FormatPropertyName(property); + SetPublicName(relationship, property); relationship.LeftClrType = resourceClrType; relationship.RightClrType = GetRelationshipType(relationship, property); @@ -229,6 +229,12 @@ private IReadOnlyCollection GetRelationships(Type resourc return relationships; } + private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) + { + // ReSharper disable once ConstantNullCoalescingCondition + field.PublicName ??= FormatPropertyName(property); + } + private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) { ArgumentGuard.NotNull(relationship, nameof(relationship)); @@ -246,18 +252,18 @@ private IReadOnlyCollection GetEagerLoads(Type resourceClrTy 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; diff --git a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs index d968c8eda7..c6be029b1b 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -8,9 +8,9 @@ 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; } @@ -20,6 +20,8 @@ public ResourceNameFormatter(JsonNamingPolicy namingPolicy) /// public string FormatResourceName(Type resourceClrType) { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + if (resourceClrType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) { return attribute.PublicName; diff --git a/src/JsonApiDotNetCore/Configuration/ResourceType.cs b/src/JsonApiDotNetCore/Configuration/ResourceType.cs index 75dddfbcc4..2f71fa5d2b 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceType.cs @@ -78,8 +78,8 @@ public sealed class ResourceType /// public LinkTypes RelationshipLinks { get; } - public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null, IReadOnlyCollection eagerLoads = null, + public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes = null, + IReadOnlyCollection? relationships = null, IReadOnlyCollection? eagerLoads = null, LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured) { @@ -107,60 +107,60 @@ public ResourceType(string publicName, Type clrType, Type identityClrType, IRead 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 '{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 '{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; } @@ -170,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)) { diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index f217852bc8..c69d9305ca 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -19,9 +19,9 @@ public static class ServiceCollectionExtensions /// /// Configures JsonApiDotNetCore by registering resources manually. /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, Action resources = null, IMvcCoreBuilder mvcBuilder = null, - ICollection dbContextTypes = null) + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null, + ICollection? dbContextTypes = null) { ArgumentGuard.NotNull(services, nameof(services)); @@ -33,22 +33,22 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Ac /// /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, Action resources = null, IMvcCoreBuilder mvcBuilder = null) + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) where TDbContext : DbContext { return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); } - private static void SetupApplicationBuilder(IServiceCollection services, Action configureOptions, - Action configureAutoDiscovery, Action configureResourceGraph, IMvcCoreBuilder mvcBuilder, + private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, + Action? configureAutoDiscovery, Action? configureResources, IMvcCoreBuilder? mvcBuilder, ICollection dbContextTypes) { using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); applicationBuilder.ConfigureJsonApiOptions(configureOptions); applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); - applicationBuilder.AddResourceGraph(dbContextTypes, configureResourceGraph); + applicationBuilder.ConfigureResourceGraph(dbContextTypes, configureResources); applicationBuilder.ConfigureMvc(); applicationBuilder.DiscoverInjectables(); applicationBuilder.ConfigureServiceContainer(dbContextTypes); @@ -96,7 +96,7 @@ public static IServiceCollection AddResourceDefinition(this private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) { bool seenCompatibleInterface = false; - ResourceDescriptor resourceDescriptor = TryGetResourceTypeFromServiceImplementation(implementationType); + ResourceDescriptor? resourceDescriptor = ResolveResourceTypeFromServiceImplementation(implementationType); if (resourceDescriptor != null) { @@ -118,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/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 7f0e85371e..6282ef05f4 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -14,9 +14,9 @@ internal sealed class TypeLocator /// /// Attempts to lookup the ID type of the specified resource type. Returns null if it does not implement . /// - public Type TryGetIdType(Type resourceClrType) + public Type? LookupIdType(Type? resourceClrType) { - Type identifiableInterface = resourceClrType.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 resourceClrType) /// /// Attempts to get a descriptor for the specified resource type. /// - public ResourceDescriptor TryGetResourceDescriptor(Type type) + public ResourceDescriptor? ResolveResourceDescriptor(Type? type) { - if (type.IsOrImplementsInterface(typeof(IIdentifiable))) + if (type != null && type.IsOrImplementsInterface(typeof(IIdentifiable))) { - Type idType = TryGetIdType(type); + Type? idType = LookupIdType(type); if (idType != null) { @@ -126,6 +126,10 @@ private static (Type implementation, Type registrationInterface)? FindGenericInt /// public IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(openGenericType, nameof(openGenericType)); + ArgumentGuard.NotNull(genericArguments, nameof(genericArguments)); + Type genericType = openGenericType.MakeGenericType(genericArguments); return GetDerivedTypes(assembly, genericType).ToArray(); } @@ -146,6 +150,9 @@ public IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type /// public IEnumerable GetDerivedTypes(Assembly assembly, Type inheritedType) { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(inheritedType, nameof(inheritedType)); + foreach (Type type in assembly.GetTypes()) { if (inheritedType.IsAssignableFrom(type)) diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs index 3323ba13a5..bfa842ed9a 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Controllers.Annotations { /// - /// Used on an ASP.NET Core controller class to indicate which query string parameters are blocked. + /// Used on an ASP.NET controller class to indicate which query string parameters are blocked. /// /// - /// Used on an ASP.NET Core controller class to indicate that a custom route is used instead of the built-in routing convention. + /// Used on an ASP.NET controller class to indicate that a custom route is used instead of the built-in routing convention. /// /// - /// Used on an ASP.NET Core controller class to indicate write actions must be blocked. + /// Used on an ASP.NET controller class to indicate write actions must be blocked. /// /// - /// Used on an ASP.NET Core controller class to indicate the DELETE verb must be blocked. + /// Used on an ASP.NET controller class to indicate the DELETE verb must be blocked. /// /// - /// Used on an ASP.NET Core controller class to indicate the PATCH verb must be blocked. + /// Used on an ASP.NET controller class to indicate the PATCH verb must be blocked. /// /// - /// Used on an ASP.NET Core controller class to indicate the POST verb must be blocked. + /// Used on an ASP.NET controller class to indicate the POST verb must be blocked. /// /// - /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. + /// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. /// /// /// The resource type. @@ -25,50 +25,54 @@ public abstract class BaseJsonApiController : CoreJsonApiControl where TResource : class, IIdentifiable { private readonly IJsonApiOptions _options; - private readonly IGetAllService _getAll; - private readonly IGetByIdService _getById; - private readonly IGetSecondaryService _getSecondary; - private readonly IGetRelationshipService _getRelationship; - private readonly ICreateService _create; - private readonly IAddToRelationshipService _addToRelationship; - private readonly IUpdateService _update; - private readonly ISetRelationshipService _setRelationship; - private readonly IDeleteService _delete; - private readonly IRemoveFromRelationshipService _removeFromRelationship; + private readonly IResourceGraph _resourceGraph; + private readonly IGetAllService? _getAll; + private readonly IGetByIdService? _getById; + private readonly IGetSecondaryService? _getSecondary; + private readonly IGetRelationshipService? _getRelationship; + private readonly ICreateService? _create; + private readonly IAddToRelationshipService? _addToRelationship; + private readonly IUpdateService? _update; + private readonly ISetRelationshipService? _setRelationship; + private readonly IDeleteService? _delete; + private readonly IRemoveFromRelationshipService? _removeFromRelationship; private readonly TraceLogWriter> _traceWriter; /// /// Creates an instance from a read/write service. /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : this(options, loggerFactory, resourceService, resourceService) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : this(options, resourceGraph, loggerFactory, resourceService, resourceService) { } /// /// Creates an instance from separate services for reading and writing. /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, - commandService, commandService, commandService) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService? queryService = null, IResourceCommandService? commandService = null) + : this(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, + commandService, commandService, commandService, commandService) { } /// /// Creates an instance from separate services for the various individual read and write methods. /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) { ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); _options = options; + _resourceGraph = resourceGraph; _traceWriter = new TraceLogWriter>(loggerFactory); _getAll = getAll; _getById = getById; @@ -137,9 +141,9 @@ public virtual async Task GetSecondaryAsync(TId id, string relati throw new RequestMethodNotAllowedException(HttpMethod.Get); } - object relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); + object? rightValue = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); - return Ok(relationship); + return Ok(rightValue); } /// @@ -160,9 +164,9 @@ public virtual async Task GetRelationshipAsync(TId id, string rel throw new RequestMethodNotAllowedException(HttpMethod.Get); } - object rightResources = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); + object? rightValue = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); - return Ok(rightResources); + return Ok(rightValue); } /// @@ -184,13 +188,12 @@ public virtual async Task PostAsync([FromBody] TResource resource 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) @@ -261,11 +264,10 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource 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); } @@ -285,7 +287,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource /// /// Propagates notification that request handling should be canceled. /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 2960b23447..4a63eb5f54 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -16,28 +16,31 @@ namespace JsonApiDotNetCore.Controllers { /// - /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See + /// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See /// https://jsonapi.org/ext/atomic/ for details. Delegates work to . /// [PublicAPI] public abstract class BaseJsonApiOperationsController : CoreJsonApiController { private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; private readonly IOperationsProcessor _processor; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly TraceLogWriter _traceWriter; - protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) + protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) { ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); ArgumentGuard.NotNull(processor, nameof(processor)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); _options = options; + _resourceGraph = resourceGraph; _processor = processor; _request = request; _targetedFields = targetedFields; @@ -118,63 +121,91 @@ public virtual async Task PostOperationsAsync([FromBody] IList results = await _processor.ProcessAsync(operations, cancellationToken); + IList results = await _processor.ProcessAsync(operations, cancellationToken); return results.Any(result => result != null) ? Ok(results) : NoContent(); } - protected virtual void ValidateModelState(IEnumerable operations) + protected virtual void ValidateModelState(IList operations) { // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. // Instead of validating IIdentifiable we need to validate the resource runtime-type. - var violations = new List(); - - int index = 0; using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); + int operationIndex = 0; + var requestModelState = new List<(string key, ModelStateEntry entry)>(); + int maxErrorsRemaining = ModelState.MaxAllowedErrors; + foreach (OperationContainer operation in operations) { - if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + if (maxErrorsRemaining < 1) { - _targetedFields.CopyFrom(operation.TargetedFields); - _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 resourceClrType, int operationIndex, List violations) - { - foreach ((string propertyName, ModelStateEntry entry) in modelState) + if (requestModelState.Any()) { - AddValidationErrors(entry, propertyName, resourceClrType, operationIndex, violations); + Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); + + throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors, + _resourceGraph, + (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetType() : null); } } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceClrType, int operationIndex, - List violations) + private int ValidateOperation(OperationContainer operation, int operationIndex, List<(string key, ModelStateEntry entry)> requestModelState, + int maxErrorsRemaining) { - foreach (ModelError error in entry.Errors) + if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) { - string prefix = $"/atomic:operations[{operationIndex}]/data/attributes/"; - var violation = new ModelStateViolation(prefix, propertyName, resourceClrType, error); + _targetedFields.CopyFrom(operation.TargetedFields); + _request.CopyFrom(operation.Request); - violations.Add(violation); + var validationContext = new ActionContext + { + ModelState = + { + MaxAllowedErrors = maxErrorsRemaining + } + }; + + 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 0f7ae00488..7bb96adedf 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -22,14 +22,18 @@ protected IActionResult Error(ErrorObject error) protected IActionResult Error(IEnumerable errors) { - ArgumentGuard.NotNull(errors, nameof(errors)); + IReadOnlyList? errorList = ToErrorList(errors); + ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); - ErrorObject[] errorArray = errors.ToArray(); - - return new ObjectResult(errorArray) + return new ObjectResult(errorList) { - StatusCode = (int)ErrorObject.GetResponseStatusCode(errorArray) + StatusCode = (int)ErrorObject.GetResponseStatusCode(errorList) }; } + + private static IReadOnlyList? ToErrorList(IEnumerable? errors) + { + return errors?.ToArray(); + } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index bae72fb615..5c641804e5 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -26,8 +26,9 @@ public abstract class JsonApiCommandController : BaseJsonApiCont /// /// Creates an instance from a write-only service. /// - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService commandService) - : base(options, loggerFactory, null, commandService) + protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService commandService) + : base(options, resourceGraph, loggerFactory, null, commandService) { } @@ -55,7 +56,7 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 3d06d695f8..6654dc534c 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -23,20 +23,21 @@ public abstract class JsonApiController : BaseJsonApiController< where TResource : class, IIdentifiable { /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + : base(options, resourceGraph, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, + delete, removeFromRelationship) { } @@ -96,7 +97,7 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index 7e8e4956ac..3f034c08d8 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -16,9 +16,9 @@ namespace JsonApiDotNetCore.Controllers /// public abstract class JsonApiOperationsController : BaseJsonApiOperationsController { - protected JsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + protected JsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 05446ca2b1..ce4ef04718 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -25,13 +25,15 @@ public abstract class JsonApiQueryController : BaseJsonApiContro /// /// Creates an instance from a read-only service. /// - protected JsonApiQueryController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(options, loggerFactory, queryService) + protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService queryService) + : base(options, resourceGraph, loggerFactory, queryService) { } /// [HttpGet] + [HttpHead] public override async Task GetAsync(CancellationToken cancellationToken) { return await base.GetAsync(cancellationToken); @@ -39,6 +41,7 @@ public override async Task GetAsync(CancellationToken cancellatio /// [HttpGet("{id}")] + [HttpHead("{id}")] public override async Task GetAsync(TId id, CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); @@ -46,6 +49,7 @@ public override async Task GetAsync(TId id, CancellationToken can /// [HttpGet("{id}/{relationshipName}")] + [HttpHead("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); @@ -53,6 +57,7 @@ public override async Task GetSecondaryAsync(TId id, string relat /// [HttpGet("{id}/relationships/{relationshipName}")] + [HttpHead("{id}/relationships/{relationshipName}")] public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); diff --git a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs deleted file mode 100644 index 49a935a7ef..0000000000 --- a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace JsonApiDotNetCore.Controllers -{ - /// - /// Represents the violation of a model state validation rule. - /// - [PublicAPI] - public sealed class ModelStateViolation - { - public string Prefix { get; } - public string PropertyName { get; } - public Type ResourceClrType { get; set; } - public ModelError Error { get; } - - public ModelStateViolation(string prefix, string propertyName, Type resourceClrType, ModelError error) - { - ArgumentGuard.NotNullNorEmpty(prefix, nameof(prefix)); - ArgumentGuard.NotNullNorEmpty(propertyName, nameof(propertyName)); - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - ArgumentGuard.NotNull(error, nameof(error)); - - Prefix = prefix; - PropertyName = propertyName; - ResourceClrType = resourceClrType; - 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 47e64d8338..2d6b8eaae9 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs @@ -13,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 { @@ -26,7 +26,7 @@ public static ICodeTimer Current AssertHasActiveSession(); - return _session.CodeTimer; + return _session!.CodeTimer; } } @@ -83,7 +83,7 @@ private static void AssertNoActiveSession() } } - private static void SessionOnDisposed(object sender, EventArgs args) + private static void SessionOnDisposed(object? sender, EventArgs args) { if (_session != null) { diff --git a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs index b56eeab962..d35d08cd1f 100644 --- a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Diagnostics /// public sealed class DefaultCodeTimerSession : ICodeTimerSession { - private readonly AsyncLocal _codeTimerInContext = new(); + private readonly AsyncLocal _codeTimerInContext = new(); public ICodeTimer CodeTimer { @@ -17,11 +17,11 @@ public ICodeTimer CodeTimer { AssertNotDisposed(); - return _codeTimerInContext.Value; + return _codeTimerInContext.Value!; } } - public event EventHandler Disposed; + public event EventHandler? Disposed; public DefaultCodeTimerSession() { diff --git a/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs index ebf9a2fadd..2926ee43b0 100644 --- a/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs +++ b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; 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 44066d430e..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,103 +15,176 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when model state validation fails. + /// The error that is thrown when ASP.NET ModelState validation fails. /// [PublicAPI] public sealed class InvalidModelStateException : JsonApiException { - public InvalidModelStateException(ModelStateDictionary modelState, Type resourceClrType, bool includeExceptionStackTraceInErrors, - JsonNamingPolicy namingPolicy) - : this(FromModelStateDictionary(modelState, resourceClrType), includeExceptionStackTraceInErrors, namingPolicy) + public InvalidModelStateException(IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, + IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback = null) + : base(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) { } - public InvalidModelStateException(IEnumerable violations, bool includeExceptionStackTraceInErrors, JsonNamingPolicy namingPolicy) - : base(FromModelStateViolations(violations, includeExceptionStackTraceInErrors, namingPolicy)) - { - } - - private static IEnumerable FromModelStateDictionary(ModelStateDictionary modelState, Type resourceClrType) + private static IEnumerable FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, + IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func? getCollectionElementTypeCallback) { ArgumentGuard.NotNull(modelState, nameof(modelState)); - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - var violations = new List(); + List errorObjects = new(); - foreach ((string propertyName, ModelStateEntry entry) in modelState) + foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, + getCollectionElementTypeCallback)) { - AddValidationErrors(entry, propertyName, resourceClrType, violations); + AppendToErrorObjects(entry, errorObjects, sourcePointer, includeExceptionStackTraceInErrors); } - return violations; + return errorObjects; } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceClrType, List violations) + private static IEnumerable<(ModelStateEntry entry, string? sourcePointer)> ResolveSourcePointers( + IReadOnlyDictionary modelState, Type modelType, IResourceGraph resourceGraph, + Func? getCollectionElementTypeCallback) { - foreach (ModelError error in entry.Errors) + foreach (string key in modelState.Keys) { - var violation = new ModelStateViolation("/data/attributes/", propertyName, resourceClrType, error); - violations.Add(violation); + var rootSegment = ModelStateKeySegment.Create(modelType, key, getCollectionElementTypeCallback); + string? sourcePointer = ResolveSourcePointer(rootSegment, resourceGraph); + + yield return (modelState[key], sourcePointer); } } - private static IEnumerable FromModelStateViolations(IEnumerable violations, bool includeExceptionStackTraceInErrors, - JsonNamingPolicy namingPolicy) + private static string? ResolveSourcePointer(ModelStateKeySegment segment, IResourceGraph resourceGraph) { - ArgumentGuard.NotNull(violations, nameof(violations)); - - return violations.SelectMany(violation => FromModelStateViolation(violation, includeExceptionStackTraceInErrors, namingPolicy)); - } + if (segment is ArrayIndexerSegment indexerSegment) + { + return ResolveSourcePointerInArrayIndexer(indexerSegment, resourceGraph); + } - private static IEnumerable FromModelStateViolation(ModelStateViolation violation, bool includeExceptionStackTraceInErrors, - JsonNamingPolicy namingPolicy) - { - if (violation.Error.Exception is JsonApiException jsonApiException) + if (segment is PropertySegment propertySegment) { - foreach (ErrorObject error in jsonApiException.Errors) + if (segment.IsInComplexType) + { + return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); + } + + if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && + propertySegment.Parent.ModelType == typeof(IList)) { - yield return error; + // Special case: Stepping over OperationContainer.Resource property. + + if (segment.GetNextSegment(propertySegment.ModelType, false, $"{segment.SourcePointer}/data") is not PropertySegment nextPropertySegment) + { + return null; + } + + propertySegment = nextPropertySegment; } + + return ResolveSourcePointerInResourceField(propertySegment, resourceGraph); } - else - { - string attributeName = GetDisplayNameForProperty(violation.PropertyName, violation.ResourceClrType, 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 resourceClrType, JsonNamingPolicy namingPolicy) + private static string? ResolveSourcePointerInResourceField(PropertySegment segment, IResourceGraph resourceGraph) { - PropertyInfo property = resourceClrType.GetProperty(propertyName); + ResourceType? resourceType = resourceGraph.FindResourceType(segment.ModelType); - if (property != null) + if (resourceType != null) { - var attrAttribute = property.GetCustomAttribute(); + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(segment.PropertyName); - if (attrAttribute?.PublicName != null) + if (attribute != null) { - return attrAttribute.PublicName; + return ResolveSourcePointerInAttribute(segment, attribute, resourceGraph); } - return namingPolicy != null ? namingPolicy.ConvertName(property.Name) : property.Name; + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(segment.PropertyName); + + if (relationship != null) + { + return ResolveSourcePointerInRelationship(segment, relationship, resourceGraph); + } } - return propertyName; + return null; + } + + private static string? ResolveSourcePointerInAttribute(PropertySegment segment, AttrAttribute attribute, IResourceGraph resourceGraph) + { + string sourcePointer = attribute.Property.Name == nameof(Identifiable.Id) + ? $"{segment.SourcePointer ?? "/data"}/{attribute.PublicName}" + : $"{segment.SourcePointer ?? "/data"}/attributes/{attribute.PublicName}"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(attribute.Property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInRelationship(PropertySegment segment, RelationshipAttribute relationship, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/data"}/relationships/{relationship.PublicName}/data"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(relationship.RightType.ClrType, false, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; } - private static ErrorObject FromModelError(ModelError modelError, string attributePath, bool includeExceptionStackTraceInErrors) + private static void AppendToErrorObjects(ModelStateEntry entry, List errorObjects, string? sourcePointer, + bool includeExceptionStackTraceInErrors) + { + foreach (ModelError error in entry.Errors) + { + if (error.Exception is JsonApiException jsonApiException) + { + errorObjects.AddRange(jsonApiException.Errors); + } + else + { + ErrorObject errorObject = FromModelError(error, sourcePointer, includeExceptionStackTraceInErrors); + errorObjects.Add(errorObject); + } + } + } + + private static ErrorObject FromModelError(ModelError modelError, string? 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 } }; @@ -121,12 +195,188 @@ private static ErrorObject FromModelError(ModelError modelError, string attribut if (stackTraceLines.Any()) { - error.Meta ??= new Dictionary(); + error.Meta ??= new Dictionary(); error.Meta["StackTrace"] = stackTraceLines; } } return error; } + + /// + /// Base type that represents a segment in a ModelState key. + /// + private abstract class ModelStateKeySegment + { + private const char Dot = '.'; + private const char BracketOpen = '['; + private const char BracketClose = ']'; + private static readonly char[] KeySegmentStartTokens = ArrayFactory.Create(Dot, BracketOpen); + + // The right part of the full key, which nested segments are produced from. + private readonly string _nextKey; + + // Enables to resolve the runtime-type of a collection element, such as the resource type in an atomic:operation. + protected Func? GetCollectionElementTypeCallback { get; } + + // In case of a property, its declaring type. In case of an indexer, the collection type or collection element type (in case the parent is a relationship). + public Type ModelType { get; } + + // Indicates we're in a complex object, so to determine public name, inspect [JsonPropertyName] instead of [Attr], [HasOne] etc. + public bool IsInComplexType { get; } + + // The source pointer we've built up, so far. This is null whenever input is not recognized. + public string? SourcePointer { get; } + + public ModelStateKeySegment? Parent { get; } + + protected ModelStateKeySegment(Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(nextKey, nameof(nextKey)); + + ModelType = modelType; + IsInComplexType = isInComplexType; + _nextKey = nextKey; + SourcePointer = sourcePointer; + Parent = parent; + GetCollectionElementTypeCallback = getCollectionElementTypeCallback; + } + + public ModelStateKeySegment? GetNextSegment(Type modelType, bool isInComplexType, string? sourcePointer) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + + return _nextKey == string.Empty + ? null + : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); + } + + public static ModelStateKeySegment Create(Type modelType, string key, Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(key, nameof(key)); + + return CreateSegment(modelType, key, false, null, null, getCollectionElementTypeCallback); + } + + private static ModelStateKeySegment CreateSegment(Type modelType, string key, bool isInComplexType, ModelStateKeySegment? parent, + string? sourcePointer, Func? getCollectionElementTypeCallback) + { + string? segmentValue = null; + string? nextKey = null; + + int segmentEndIndex = key.IndexOfAny(KeySegmentStartTokens); + + if (segmentEndIndex == 0 && key[0] == BracketOpen) + { + int bracketCloseIndex = key.IndexOf(BracketClose); + + if (bracketCloseIndex != -1) + { + segmentValue = key[1.. bracketCloseIndex]; + + int nextKeyStartIndex = key.Length > bracketCloseIndex + 1 && key[bracketCloseIndex + 1] == Dot + ? bracketCloseIndex + 2 + : bracketCloseIndex + 1; + + nextKey = key[nextKeyStartIndex..]; + + if (int.TryParse(segmentValue, out int indexValue)) + { + return new ArrayIndexerSegment(indexValue, modelType, isInComplexType, nextKey, sourcePointer, parent, + getCollectionElementTypeCallback); + } + + // If the value between brackets is not numeric, consider it an unspeakable property. For example: + // "Foo[Bar]" instead of "Foo.Bar". Its unclear when this happens, but ASP.NET source contains tests for such keys. + } + } + + if (segmentValue == null) + { + segmentValue = segmentEndIndex == -1 ? key : key[..segmentEndIndex]; + + nextKey = segmentEndIndex != -1 && key.Length > segmentEndIndex && key[segmentEndIndex] == Dot + ? key[(segmentEndIndex + 1)..] + : key[segmentValue.Length..]; + } + + // Workaround for a quirk in ModelState validation. Some controller action methods have an 'id' parameter before the [FromBody] parameter. + // When a validation error occurs on top-level 'Id' in the request body, its key contains 'id' instead of 'Id' (the error message is correct, though). + // We compensate for that case here, so that we'll find 'Id' in the resource graph when building the source pointer. + if (segmentValue == "id") + { + segmentValue = "Id"; + } + + return new PropertySegment(segmentValue, modelType, isInComplexType, nextKey!, sourcePointer, parent, getCollectionElementTypeCallback); + } + } + + /// + /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". + /// + private sealed class ArrayIndexerSegment : ModelStateKeySegment + { + private static readonly CollectionConverter CollectionConverter = new(); + + public int ArrayIndex { get; } + + public ArrayIndexerSegment(int arrayIndex, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, + ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArrayIndex = arrayIndex; + } + + public Type GetCollectionElementType() + { + Type? type = GetCollectionElementTypeCallback?.Invoke(ModelType, ArrayIndex); + return type ?? GetDeclaredCollectionElementType(); + } + + private Type GetDeclaredCollectionElementType() + { + if (ModelType != typeof(string)) + { + Type? elementType = CollectionConverter.FindCollectionElementType(ModelType); + + if (elementType != null) + { + return elementType; + } + } + + // In case of a to-many relationship, the ModelType already contains the element type. + return ModelType; + } + } + + /// + /// Represents a property in a ModelState key, such as "Orders" in "Customer.Orders[1].Amount". + /// + private sealed class PropertySegment : ModelStateKeySegment + { + public string PropertyName { get; } + + public PropertySegment(string propertyName, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, + ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + + PropertyName = propertyName; + } + + public static string GetPublicNameForProperty(PropertyInfo property) + { + ArgumentGuard.NotNull(property, nameof(property)); + + var jsonNameAttribute = (JsonPropertyNameAttribute?)property.GetCustomAttribute(typeof(JsonPropertyNameAttribute)); + return jsonNameAttribute?.Name ?? property.Name; + } + } } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs index fb02f2a5e4..22c8738002 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidQueryException : JsonApiException { - public InvalidQueryException(string reason, Exception exception) + public InvalidQueryException(string reason, Exception? innerException) : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = reason, - Detail = exception?.Message - }, exception) + Detail = innerException?.Message + }, innerException) { } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs index 949710d62a..e85c7798f5 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs @@ -11,20 +11,20 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidQueryStringParameterException : JsonApiException { - public string QueryParameterName { get; } + public string ParameterName { get; } - public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, string specificMessage, Exception innerException = null) + public InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = genericMessage, Detail = specificMessage, Source = new ErrorSource { - Parameter = queryParameterName + Parameter = parameterName } }, innerException) { - QueryParameterName = queryParameterName; + ParameterName = parameterName; } } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index a508a82ad2..18929ed3d0 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -12,8 +12,8 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidRequestBodyException : JsonApiException { - public InvalidRequestBodyException(string requestBody, string genericMessage, string specificMessage, string sourcePointer, - HttpStatusCode? alternativeStatusCode = null, Exception innerException = null) + public InvalidRequestBodyException(string? requestBody, string? genericMessage, string? specificMessage, string? sourcePointer, + HttpStatusCode? alternativeStatusCode = null, Exception? innerException = null) : base(new ErrorObject(alternativeStatusCode ?? HttpStatusCode.UnprocessableEntity) { Title = genericMessage != null ? $"Failed to deserialize request body: {genericMessage}" : "Failed to deserialize request body.", @@ -26,7 +26,7 @@ public InvalidRequestBodyException(string requestBody, string genericMessage, st }, Meta = string.IsNullOrEmpty(requestBody) ? null - : new Dictionary + : new Dictionary { ["RequestBody"] = requestBody } diff --git a/src/JsonApiDotNetCore/Errors/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs index ae2cbcdca0..ea68e67144 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -24,7 +24,7 @@ public class JsonApiException : Exception public IReadOnlyList Errors { get; } - public JsonApiException(ErrorObject error, Exception innerException = null) + public JsonApiException(ErrorObject error, Exception? innerException = null) : base(null, innerException) { ArgumentGuard.NotNull(error, nameof(error)); @@ -32,15 +32,20 @@ public JsonApiException(ErrorObject error, Exception innerException = null) Errors = error.AsArray(); } - public JsonApiException(IEnumerable errors, Exception innerException = null) + public JsonApiException(IEnumerable errors, Exception? innerException = null) : base(null, innerException) { - List errorList = errors?.ToList(); + IReadOnlyList? errorList = ToErrorList(errors); ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); Errors = errorList; } + private static IReadOnlyList? ToErrorList(IEnumerable? errors) + { + return errors?.ToList(); + } + public string GetSummary() { return $"{nameof(JsonApiException)}: Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; diff --git a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs index 8dfa1bd842..35e6a5f40d 100644 --- a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs +++ b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; 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 jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net - A framework for building JSON:API compliant REST APIs using .NET Core and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. + A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. https://www.jsonapi.net/ MIT false diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 3d7630b9bf..5a0aab7787 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -100,7 +100,7 @@ private void IncludeStackTraces(Exception exception, IReadOnlyList { foreach (ErrorObject error in errors) { - error.Meta ??= new Dictionary(); + error.Meta ??= new Dictionary(); error.Meta["StackTrace"] = stackTraceLines; } } diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs index 6bd345d99d..36105b5e88 100644 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs +++ b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; @@ -13,16 +14,17 @@ namespace JsonApiDotNetCore.Middleware internal sealed class FixedQueryFeature : IQueryFeature { // Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624 - private static readonly Func NullRequestFeature = _ => null; + private static readonly Func NullRequestFeature = _ => null; private FeatureReferences _features; - private string _original; - private IQueryCollection _parsedValues; + private string? _original; + private IQueryCollection? _parsedValues; - private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature); + private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature)!; /// + [AllowNull] public IQueryCollection Query { get @@ -38,7 +40,7 @@ public IQueryCollection Query { _original = current; - Dictionary result = FixedQueryHelpers.ParseNullableQuery(current); + Dictionary? result = FixedQueryHelpers.ParseNullableQuery(current); _parsedValues = result == null ? QueryCollection.Empty : new QueryCollection(result); } @@ -58,7 +60,7 @@ public IQueryCollection Query } else { - _original = QueryString.Create(_parsedValues).ToString(); + _original = QueryString.Create(_parsedValues!).ToString(); HttpRequestFeature.QueryString = _original; } } diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs index 621aca493d..7c42d0aeea 100644 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs +++ b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs @@ -25,7 +25,7 @@ internal static class FixedQueryHelpers /// /// A collection of parsed keys and values, null if there are no entries. /// - public static Dictionary ParseNullableQuery(string queryString) + public static Dictionary? ParseNullableQuery(string queryString) { var accumulator = new KeyValueAccumulator(); diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs index b77cf79a2a..a3d137fefd 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -15,7 +15,7 @@ public static bool IsJsonApiRequest(this HttpContext httpContext) { ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - string value = httpContext.Items[IsJsonApiRequestKey] as string; + string? value = httpContext.Items[IsJsonApiRequestKey] as string; return value == bool.TrueString; } diff --git a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index 2ff155d324..c15f3037bd 100644 --- a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs +++ b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs @@ -11,11 +11,11 @@ public interface IControllerResourceMapping /// /// Gets the associated resource type for the provided controller type. /// - ResourceType TryGetResourceTypeForController(Type controllerType); + ResourceType? GetResourceTypeForController(Type? controllerType); /// /// Gets the associated controller name for the provided resource type. /// - string TryGetControllerNameForResourceType(ResourceType resourceType); + string? GetControllerNameForResourceType(ResourceType? resourceType); } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index f3f7dfcf81..3a4d009bd8 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -14,26 +14,30 @@ public interface IJsonApiRequest public EndpointKind Kind { get; } /// - /// The ID of the primary (top-level) resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". + /// The ID of the primary (top-level) resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is + /// null before and after processing operations in an atomic:operations request. /// - string PrimaryId { get; } + string? PrimaryId { get; } /// - /// The primary (top-level) resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". + /// The primary (top-level) resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null + /// before and after processing operations in an atomic:operations request. /// - ResourceType PrimaryResourceType { get; } + ResourceType? PrimaryResourceType { get; } /// /// The secondary (nested) resource type for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in - /// "/blogs/123/author" and "/blogs/123/relationships/author". + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. /// - ResourceType SecondaryResourceType { get; } + ResourceType? SecondaryResourceType { get; } /// /// The relationship for this nested request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in - /// "/blogs/123/author" and "/blogs/123/relationships/author". + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. /// - RelationshipAttribute Relationship { get; } + RelationshipAttribute? Relationship { get; } /// /// Indicates whether this request targets a single resource or a collection of resources. @@ -46,14 +50,15 @@ public interface IJsonApiRequest bool IsReadOnly { get; } /// - /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. + /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. This is null when processing a + /// read-only operations, or before and after processing operations in an atomic:operations request. /// WriteOperationKind? WriteOperation { get; } /// /// In case of an atomic:operations request, identifies the overarching transaction. /// - string TransactionId { get; } + string? TransactionId { get; } /// /// Performs a shallow copy. diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index d22904bebc..46153e6502 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -23,7 +23,7 @@ public async Task ReadAsync(InputFormatterContext context) var reader = context.HttpContext.RequestServices.GetRequiredService(); - object model = await reader.ReadAsync(context.HttpContext.Request); + object? model = await reader.ReadAsync(context.HttpContext.Request); return model == null ? await InputFormatterResult.NoValueAsync() : await InputFormatterResult.SuccessAsync(model); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index a41e172d18..96849fe499 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -55,7 +55,7 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin } RouteValueDictionary routeValues = httpContext.GetRouteData().Values; - ResourceType primaryResourceType = TryCreatePrimaryResourceType(httpContext, controllerResourceMapping); + ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, controllerResourceMapping); if (primaryResourceType != null) { @@ -82,7 +82,10 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin httpContext.RegisterJsonApiRequest(); } - // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394 + // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394 (fixed in .NET 6) + // Note that integration tests do not cover this, because the query string is short-circuited through WebApplicationFactory. + // To manually test, execute a GET request such as http://localhost:14140/api/v1/todoItems?include=owner&fields[people]= + // and observe it does not fail with 400 "Unknown query string parameter". httpContext.Features.Set(new FixedQueryFeature(httpContext.Features)); using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) @@ -118,13 +121,13 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso return true; } - private static ResourceType TryCreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) + private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) { - Endpoint endpoint = httpContext.GetEndpoint(); + Endpoint? endpoint = httpContext.GetEndpoint(); var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); return controllerActionDescriptor != null - ? controllerResourceMapping.TryGetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) + ? controllerResourceMapping.GetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) : null; } @@ -167,7 +170,7 @@ private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue a foreach (string acceptHeader in acceptHeaders) { - if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue headerValue)) + if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue)) { headerValue.Quality = null; @@ -224,7 +227,7 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr request.PrimaryResourceType = primaryResourceType; request.PrimaryId = GetPrimaryRequestId(routeValues); - string relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); + string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); if (relationshipName != null) { @@ -241,7 +244,7 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - RelationshipAttribute requestRelationship = primaryResourceType.TryGetRelationshipByPublicName(relationshipName); + RelationshipAttribute? requestRelationship = primaryResourceType.FindRelationshipByPublicName(relationshipName); if (requestRelationship != null) { @@ -269,25 +272,25 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr 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/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 0c61b7ff38..7801d059d3 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -12,16 +12,16 @@ public sealed class JsonApiRequest : IJsonApiRequest public EndpointKind Kind { get; set; } /// - public string PrimaryId { get; set; } + public string? PrimaryId { get; set; } /// - public ResourceType PrimaryResourceType { get; set; } + public ResourceType? PrimaryResourceType { get; set; } /// - public ResourceType SecondaryResourceType { get; set; } + public ResourceType? SecondaryResourceType { get; set; } /// - public RelationshipAttribute Relationship { get; set; } + public RelationshipAttribute? Relationship { get; set; } /// public bool IsCollection { get; set; } @@ -33,7 +33,7 @@ public sealed class JsonApiRequest : IJsonApiRequest public WriteOperationKind? WriteOperation { get; set; } /// - public string TransactionId { get; set; } + public string? TransactionId { get; set; } /// public void CopyFrom(IJsonApiRequest other) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index fee1edad0f..a6b7e426c9 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -47,19 +47,19 @@ public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resource } /// - public ResourceType TryGetResourceTypeForController(Type controllerType) + public ResourceType? GetResourceTypeForController(Type? controllerType) { - ArgumentGuard.NotNull(controllerType, nameof(controllerType)); - - return _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType resourceType) ? resourceType : null; + return controllerType != null && _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType? resourceType) + ? resourceType + : null; } /// - public string TryGetControllerNameForResourceType(ResourceType resourceType) + public string? GetControllerNameForResourceType(ResourceType? resourceType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - return _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel controllerModel) ? controllerModel.ControllerName : null; + return resourceType != null && _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? controllerModel) + ? controllerModel.ControllerName + : null; } /// @@ -73,11 +73,11 @@ public void Apply(ApplicationModel application) if (!isOperationsController) { - Type resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); + Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); if (resourceClrType != null) { - ResourceType resourceType = _resourceGraph.TryGetResourceType(resourceClrType); + ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType); if (resourceType != null) { @@ -100,7 +100,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 { @@ -118,9 +118,9 @@ private bool IsRoutingConventionEnabled(ControllerModel controller) /// /// Derives a template from the resource type, and checks if this template was already registered. /// - private string TemplateFromResource(ControllerModel model) + private string? TemplateFromResource(ControllerModel model) { - if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType resourceType)) + if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) { return $"{_options.Namespace}/{resourceType.PublicName}"; } @@ -143,20 +143,20 @@ private string TemplateFromController(ControllerModel model) /// /// Determines the resource associated to a controller by inspecting generic arguments in its inheritance tree. /// - private Type ExtractResourceClrTypeFromController(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 resourceClrType = currentType.GetGenericArguments() + Type? resourceClrType = currentType.GetGenericArguments() .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface(typeof(IIdentifiable))); if (resourceClrType != null) @@ -167,7 +167,7 @@ private Type ExtractResourceClrTypeFromController(Type type) currentType = nextBaseType; - if (nextBaseType == null) + if (currentType == null) { break; } diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index 2ed2dbab48..03e38d827d 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -30,7 +30,7 @@ public TraceLogWriter(ILoggerFactory loggerFactory) _logger = loggerFactory.CreateLogger(typeof(T)); } - public void LogMethodStart(object parameters = null, [CallerMemberName] string memberName = "") + public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") { if (IsEnabled) { @@ -48,7 +48,7 @@ public void LogMessage(Func messageFactory) } } - private static string FormatMessage(string memberName, object parameters) + private static string FormatMessage(string memberName, object? parameters) { var builder = new StringBuilder(); @@ -61,7 +61,7 @@ private static string FormatMessage(string memberName, object parameters) return builder.ToString(); } - private static void WriteProperties(StringBuilder builder, object propertyContainer) + private static void WriteProperties(StringBuilder builder, object? propertyContainer) { if (propertyContainer != null) { @@ -88,7 +88,7 @@ private static void WriteProperty(StringBuilder builder, PropertyInfo property, builder.Append(property.Name); builder.Append(": "); - object value = property.GetValue(instance); + object? value = property.GetValue(instance); if (value == null) { @@ -119,11 +119,11 @@ private static void WriteObject(StringBuilder builder, object value) } } - private static bool HasToStringOverload(Type type) + private static bool HasToStringOverload(Type? type) { if (type != null) { - MethodInfo toStringMethod = type.GetMethod("ToString", Array.Empty()); + MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) { diff --git a/src/JsonApiDotNetCore/ObjectExtensions.cs b/src/JsonApiDotNetCore/ObjectExtensions.cs index 8657b64e96..e0f4ce6af7 100644 --- a/src/JsonApiDotNetCore/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore/ObjectExtensions.cs @@ -21,7 +21,7 @@ public static T[] AsArray(this T element) public static List AsList(this T element) { - return new() + return new List { element }; @@ -29,7 +29,7 @@ public static List AsList(this T element) public static HashSet AsHashSet(this T element) { - return new() + return new HashSet { element }; diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs index 5475baed85..5b73deee0a 100644 --- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -10,10 +10,10 @@ namespace JsonApiDotNetCore.Queries [PublicAPI] public class ExpressionInScope { - public ResourceFieldChainExpression Scope { get; } + public ResourceFieldChainExpression? Scope { get; } public QueryExpression Expression { get; } - public ExpressionInScope(ResourceFieldChainExpression scope, QueryExpression expression) + public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) { ArgumentGuard.NotNull(expression, nameof(expression)); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index 3c049e86b4..0682cc64a0 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -49,7 +49,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index ab06961738..77d0281063 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -34,7 +34,7 @@ public override string ToString() return $"{Operator.ToString().Camelize()}({Left},{Right})"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 57163936f7..e60867c63d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -28,7 +28,7 @@ public override string ToString() return $"{Keywords.Count}({TargetCollection})"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index 97f3bb5544..7e38acd447 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -12,9 +12,9 @@ namespace JsonApiDotNetCore.Queries.Expressions public class HasExpression : FilterExpression { public ResourceFieldChainExpression TargetCollection { get; } - public FilterExpression Filter { get; } + public FilterExpression? Filter { get; } - public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression filter) + public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) { ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); @@ -45,7 +45,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index d1c097676b..19bc92e5d6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -74,7 +74,7 @@ public IncludeExpression FromRelationshipChains(IEnumerable ConvertChainsToElements(IEnumerable chains) { - var rootNode = new MutableIncludeNode(null); + var rootNode = new MutableIncludeNode(null!); foreach (ResourceFieldChainExpression chain in chains) { @@ -99,13 +99,13 @@ private static void ConvertChainToElement(ResourceFieldChainExpression chain, Mu } } - private sealed class IncludeToChainsConverter : QueryExpressionVisitor + private sealed class IncludeToChainsConverter : QueryExpressionVisitor { private readonly Stack _parentRelationshipStack = new(); public List Chains { get; } = new(); - public override object VisitInclude(IncludeExpression expression, object argument) + public override object? VisitInclude(IncludeExpression expression, object? argument) { foreach (IncludeElementExpression element in expression.Elements) { @@ -115,7 +115,7 @@ public override object VisitInclude(IncludeExpression expression, object argumen return null; } - public override object VisitIncludeElement(IncludeElementExpression expression, object argument) + public override object? VisitIncludeElement(IncludeElementExpression expression, object? argument) { if (!expression.Children.Any()) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index 648986fe18..8cc148376b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -50,7 +50,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/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 632e04af30..3c5cbeb333 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -41,7 +41,7 @@ public override string 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)) { 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..670fe15daa 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -51,7 +51,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 0df64dbb44..2f82548e1d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -42,7 +42,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index 3ac97b46bc..f7a34aa212 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -28,7 +28,7 @@ public override string ToString() return $"{Keywords.Not}({Child})"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index 1041be47dd..172a900884 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -10,6 +10,12 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class NullConstantExpression : IdentifierExpression { + public static readonly NullConstantExpression Instance = new(); + + private NullConstantExpression() + { + } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitNullConstant(this, argument); @@ -20,7 +26,7 @@ public override string ToString() return Keywords.Null; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index d62ca621e0..69bb675bdc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class PaginationElementQueryStringValueExpression : QueryExpression { - public ResourceFieldChainExpression Scope { get; } + public ResourceFieldChainExpression? Scope { get; } public int Value { get; } - public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression scope, int value) + public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value) { Scope = scope; Value = value; @@ -28,7 +28,7 @@ public override string ToString() return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs index 3d8f2c5870..f15e714c2e 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -11,9 +11,9 @@ namespace JsonApiDotNetCore.Queries.Expressions public class PaginationExpression : QueryExpression { public PageNumber PageNumber { get; } - public PageSize PageSize { get; } + public PageSize? PageSize { get; } - public PaginationExpression(PageNumber pageNumber, PageSize pageSize) + public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) { ArgumentGuard.NotNull(pageNumber, nameof(pageNumber)); @@ -31,7 +31,7 @@ public override string ToString() return PageSize != null ? $"Page number: {PageNumber}, size: {PageSize}" : "(none)"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index 706d3d9e15..3afce49b3b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -30,7 +30,7 @@ public override string ToString() return string.Join(",", Elements.Select(constant => constant.ToString())); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index e2049795f9..8ba23c826d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCore.Queries.Expressions /// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. /// [PublicAPI] - public class QueryExpressionRewriter : QueryExpressionVisitor + public class QueryExpressionRewriter : QueryExpressionVisitor { - public override QueryExpression Visit(QueryExpression expression, TArgument argument) + public override QueryExpression? Visit(QueryExpression expression, TArgument argument) { return expression.Accept(this, argument); } @@ -20,21 +20,21 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume return expression; } - public override QueryExpression VisitComparison(ComparisonExpression expression, TArgument argument) + public override QueryExpression? VisitComparison(ComparisonExpression expression, TArgument argument) { - if (expression == null) + QueryExpression? newLeft = Visit(expression.Left, argument); + QueryExpression? newRight = Visit(expression.Right, argument); + + if (newLeft != null && newRight != null) { - return null; + var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); + return newExpression.Equals(expression) ? expression : newExpression; } - QueryExpression newLeft = Visit(expression.Left, argument); - QueryExpression newRight = Visit(expression.Right, argument); - - var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); - return newExpression.Equals(expression) ? expression : newExpression; + return null; } - public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) { return expression; } @@ -49,98 +49,83 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return expression; } - public override QueryExpression VisitLogical(LogicalExpression expression, TArgument argument) + public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newTerms = VisitList(expression.Terms, argument); + IImmutableList newTerms = VisitList(expression.Terms, argument); - if (newTerms.Count == 1) - { - return newTerms[0]; - } + if (newTerms.Count == 1) + { + return newTerms[0]; + } - if (newTerms.Count != 0) - { - var newExpression = new LogicalExpression(expression.Operator, newTerms); - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newTerms.Count != 0) + { + var newExpression = new LogicalExpression(expression.Operator, newTerms); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitNot(NotExpression expression, TArgument argument) + public override QueryExpression? VisitNot(NotExpression expression, TArgument argument) { - if (expression != null) + if (Visit(expression.Child, argument) is FilterExpression newChild) { - if (Visit(expression.Child, argument) is FilterExpression newChild) - { - var newExpression = new NotExpression(newChild); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new NotExpression(newChild); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitHas(HasExpression expression, TArgument argument) + public override QueryExpression? VisitHas(HasExpression expression, TArgument argument) { - if (expression != null) + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - FilterExpression newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; + FilterExpression? newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; - var newExpression = new HasExpression(newTargetCollection, newFilter); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new HasExpression(newTargetCollection, newFilter); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitSortElement(SortElementExpression expression, TArgument argument) + public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { - if (expression != null) - { - SortElementExpression newExpression = null; + SortElementExpression? newExpression = null; - if (expression.Count != null) + if (expression.Count != null) + { + if (Visit(expression.Count, argument) is CountExpression newCount) { - if (Visit(expression.Count, argument) is CountExpression newCount) - { - newExpression = new SortElementExpression(newCount, expression.IsAscending); - } + newExpression = new SortElementExpression(newCount, expression.IsAscending); } - else if (expression.TargetAttribute != null) + } + else if (expression.TargetAttribute != null) + { + if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) { - if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) - { - newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); - } + newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); } + } - if (newExpression != null) - { - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newExpression != null) + { + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitSort(SortExpression expression, TArgument argument) + public override QueryExpression? VisitSort(SortExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newElements = VisitList(expression.Elements, argument); + IImmutableList newElements = VisitList(expression.Elements, argument); - if (newElements.Count != 0) - { - var newExpression = new SortExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newElements.Count != 0) + { + var newExpression = new SortExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } return null; @@ -151,27 +136,24 @@ public override QueryExpression VisitPagination(PaginationExpression expression, return expression; } - public override QueryExpression VisitCount(CountExpression expression, TArgument argument) + public override QueryExpression? VisitCount(CountExpression expression, TArgument argument) { - if (expression != null) + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - var newExpression = new CountExpression(newTargetCollection); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new CountExpression(newTargetCollection); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitMatchText(MatchTextExpression expression, TArgument argument) + public override QueryExpression? VisitMatchText(MatchTextExpression expression, TArgument argument) { - if (expression != null) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; + if (newTargetAttribute != null && newTextValue != null) + { var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); return newExpression.Equals(expression) ? expression : newExpression; } @@ -179,13 +161,13 @@ public override QueryExpression VisitMatchText(MatchTextExpression expression, T return null; } - public override QueryExpression VisitAny(AnyExpression expression, TArgument argument) + public override QueryExpression? VisitAny(AnyExpression expression, TArgument argument) { - if (expression != null) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - IImmutableSet newConstants = VisitSet(expression.Constants, argument); + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + IImmutableSet newConstants = VisitSet(expression.Constants, argument); + if (newTargetAttribute != null) + { var newExpression = new AnyExpression(newTargetAttribute, newConstants); return newExpression.Equals(expression) ? expression : newExpression; } @@ -193,26 +175,23 @@ public override QueryExpression VisitAny(AnyExpression expression, TArgument arg return null; } - public override QueryExpression VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) { - if (expression != null) - { - ImmutableDictionary.Builder newTable = - ImmutableDictionary.CreateBuilder(); + ImmutableDictionary.Builder newTable = + ImmutableDictionary.CreateBuilder(); - foreach ((ResourceType resourceType, 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[resourceType] = newSparseFieldSet; - } + newTable[resourceType] = newSparseFieldSet; } + } - if (newTable.Count > 0) - { - var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newTable.Count > 0) + { + var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); + return newExpression.Equals(expression) ? expression : newExpression; } return null; @@ -223,14 +202,13 @@ public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression exp return expression; } - public override QueryExpression VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) + public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) { - if (expression != null) - { - var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; - - ResourceFieldChainExpression newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + if (newParameterName != null) + { var newExpression = new QueryStringParameterScopeExpression(newParameterName, newScope); return newExpression.Equals(expression) ? expression : newExpression; } @@ -240,59 +218,39 @@ public override QueryExpression VisitQueryStringParameterScope(QueryStringParame public override QueryExpression PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newElements = VisitList(expression.Elements, argument); + IImmutableList newElements = VisitList(expression.Elements, argument); - var newExpression = new PaginationQueryStringValueExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = new PaginationQueryStringValueExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) { - if (expression != null) - { - ResourceFieldChainExpression newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression VisitInclude(IncludeExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableSet newElements = VisitSet(expression.Elements, argument); + IImmutableSet newElements = VisitSet(expression.Elements, argument); - if (newElements.Count == 0) - { - return IncludeExpression.Empty; - } - - var newExpression = new IncludeExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; + if (newElements.Count == 0) + { + return IncludeExpression.Empty; } - return null; + var newExpression = new IncludeExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableSet newElements = VisitSet(expression.Children, argument); + IImmutableSet newElements = VisitSet(expression.Children, argument); - var newExpression = new IncludeElementExpression(expression.Relationship, newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = new IncludeElementExpression(expression.Relationship, newElements); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index dad2958931..dc5963a573 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -15,7 +15,7 @@ public virtual TResult Visit(QueryExpression expression, TArgument argument) public virtual TResult DefaultVisit(QueryExpression expression, TArgument argument) { - return default; + return default!; } public virtual TResult VisitComparison(ComparisonExpression expression, TArgument argument) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index 7a6071e450..dcd59928d4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCore.Queries.Expressions public class QueryStringParameterScopeExpression : QueryExpression { public LiteralConstantExpression ParameterName { get; } - public ResourceFieldChainExpression Scope { get; } + public ResourceFieldChainExpression? Scope { get; } - public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression scope) + public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) { ArgumentGuard.NotNull(parameterName, nameof(parameterName)); @@ -30,7 +30,7 @@ public override string ToString() return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index 9ebf5f0c2b..6e57c55172 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -40,7 +40,7 @@ public override string ToString() return $"handler('{_parameterValue}')"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 502cd03976..59b78d9e55 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -38,7 +38,7 @@ public override string ToString() return string.Join(".", Fields.Select(field => field.PublicName)); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index d84564ae9b..d2f342db16 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -10,8 +10,8 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class SortElementExpression : QueryExpression { - public ResourceFieldChainExpression TargetAttribute { get; } - public CountExpression Count { get; } + public ResourceFieldChainExpression? TargetAttribute { get; } + public CountExpression? Count { get; } public bool IsAscending { get; } public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) @@ -22,7 +22,7 @@ public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool IsAscending = isAscending; } - public SortElementExpression(CountExpression count, in bool isAscending) + public SortElementExpression(CountExpression count, bool isAscending) { ArgumentGuard.NotNull(count, nameof(count)); @@ -56,7 +56,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index 38f68df707..c8a375d7cc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -30,7 +30,7 @@ public override string ToString() return string.Join(",", Elements.Select(child => child.ToString())); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index bf96dad409..6bb4611375 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -31,7 +31,7 @@ public override string ToString() return string.Join(",", Fields.Select(child => child.PublicName).OrderBy(name => name)); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 35aff8b711..a9d037165a 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -11,14 +11,14 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public static class SparseFieldSetExpressionExtensions { - public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - SparseFieldSetExpression newSparseFieldSet = sparseFieldSet; + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) { @@ -28,7 +28,7 @@ public static SparseFieldSetExpression Including(this SparseFieldSetE return newSparseFieldSet; } - private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToInclude) + private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) { if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) { @@ -39,14 +39,14 @@ private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sp return new SparseFieldSetExpression(newSparseFieldSet); } - public static SparseFieldSetExpression Excluding(this SparseFieldSetExpression sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - SparseFieldSetExpression newSparseFieldSet = sparseFieldSet; + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) { @@ -56,7 +56,7 @@ public static SparseFieldSetExpression Excluding(this SparseFieldSetE return newSparseFieldSet; } - private static SparseFieldSetExpression ExcludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToExclude) + private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) { // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 4f1bca8127..b17255fe3a 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -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)) { diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index fb249cfbec..da769a3ade 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -16,7 +16,7 @@ public interface IPaginationContext /// The default page size from options, unless specified in query string. Can be null, which means no paging. Cannot be higher than /// options.MaximumPageSize. /// - PageSize PageSize { get; set; } + PageSize? PageSize { get; set; } /// /// Indicates whether the number of resources on the current page equals the page size. When true, a subsequent page might exist (assuming diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index a6ef61605d..3905bd1041 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -14,7 +14,7 @@ public interface IQueryLayerComposer /// /// Builds a top-level filter from constraints, used to determine total resource count. /// - FilterExpression GetTopFilterFromConstraints(ResourceType primaryResourceType); + FilterExpression? GetTopFilterFromConstraints(ResourceType primaryResourceType); /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs index 1e9202827b..44baafc65f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs @@ -5,16 +5,18 @@ namespace JsonApiDotNetCore.Queries.Internal /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { - private IncludeExpression _include; + private IncludeExpression? _include; /// public void Set(IncludeExpression include) { + ArgumentGuard.NotNull(include, nameof(include)); + _include = include; } /// - public IncludeExpression Get() + public IncludeExpression? Get() { return _include; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs index d7c924f066..618caeb286 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs @@ -18,6 +18,6 @@ public interface IEvaluatedIncludeCache /// /// Gets the evaluated inclusion tree that was stored earlier. /// - IncludeExpression Get(); + IncludeExpression? Get(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index 390c1fe891..de2f246503 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -14,10 +14,10 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing public class FilterParser : QueryExpressionParser { private readonly IResourceFactory _resourceFactory; - private readonly Action _validateSingleFieldCallback; - private ResourceType _resourceTypeInScope; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public FilterParser(IResourceFactory resourceFactory, Action validateSingleFieldCallback = null) + public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) { ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); @@ -42,7 +42,7 @@ public FilterExpression Parse(string source, ResourceType resourceTypeInScope) 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) { @@ -110,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); @@ -156,7 +156,7 @@ protected ComparisonExpression ParseComparison(string operatorName) if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) { - string id = DeObfuscateStringId(leftProperty.ReflectedType, rightConstant.Value); + string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); rightTerm = new LiteralConstantExpression(id); } } @@ -200,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); @@ -230,7 +230,7 @@ private IImmutableSet DeObfuscateIdConstants(IImmutab foreach (LiteralConstantExpression idConstant in constantSet) { string stringId = idConstant.Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType, stringId); + string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); idConstantsBuilder.Add(new LiteralConstantExpression(id)); } @@ -244,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); @@ -260,7 +260,7 @@ protected HasExpression ParseHas() private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) { - ResourceType outerScopeBackup = _resourceTypeInScope; + ResourceType outerScopeBackup = _resourceTypeInScope!; _resourceTypeInScope = hasManyRelationship.RightType; @@ -272,7 +272,7 @@ private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) { - CountExpression count = TryParseCount(); + CountExpression? count = TryParseCount(); if (count != null) { @@ -284,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) { @@ -301,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!); } } @@ -323,9 +323,9 @@ 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."); @@ -335,24 +335,24 @@ private string DeObfuscateStringId(Type resourceClrType, string stringId) { IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); tempResource.StringId = stringId; - return tempResource.GetTypedId().ToString(); + return tempResource.GetTypedId().ToString()!; } protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, _validateSingleFieldCallback); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); } if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope, 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 1646c3bcb0..012fd617c3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -14,10 +14,10 @@ public class IncludeParser : QueryExpressionParser { private static readonly IncludeChainConverter IncludeChainConverter = new(); - private readonly Action _validateSingleRelationshipCallback; - private ResourceType _resourceTypeInScope; + private readonly Action? _validateSingleRelationshipCallback; + private ResourceType? _resourceTypeInScope; - public IncludeParser(Action validateSingleRelationshipCallback = null) + public IncludeParser(Action? validateSingleRelationshipCallback = null) { _validateSingleRelationshipCallback = validateSingleRelationshipCallback; } @@ -73,7 +73,7 @@ private static void ValidateMaximumIncludeDepth(int? maximumDepth, IEnumerable OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope, 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 980ec8450a..dd76b4e58f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs @@ -11,10 +11,10 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class PaginationParser : QueryExpressionParser { - private readonly Action _validateSingleFieldCallback; - private ResourceType _resourceTypeInScope; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public PaginationParser(Action validateSingleFieldCallback = null) + public PaginationParser(Action? validateSingleFieldCallback = null) { _validateSingleFieldCallback = validateSingleFieldCallback; } @@ -78,7 +78,7 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() protected int? TryParseNumber() { - if (TokenStack.TryPeek(out Token nextToken)) + if (TokenStack.TryPeek(out Token? nextToken)) { int number; @@ -86,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; } @@ -106,7 +106,7 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveToManyChain(_resourceTypeInScope, 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 b33e01aaf3..f0e1392e30 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -17,13 +17,8 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public abstract class QueryExpressionParser { - protected Stack TokenStack { get; private set; } - private protected ResourceFieldChainResolver ChainResolver { get; } - - protected QueryExpressionParser() - { - ChainResolver = new ResourceFieldChainResolver(); - } + protected Stack TokenStack { get; private set; } = null!; + private protected ResourceFieldChainResolver ChainResolver { get; } = new(); /// /// Takes a dotted path and walks the resource graph to produce a chain of fields. @@ -36,11 +31,11 @@ protected virtual void Tokenize(string source) TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); } - protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string alternativeErrorMessage) + protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) { - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - IImmutableList chain = OnResolveFieldChain(token.Value, chainRequirements); + IImmutableList chain = OnResolveFieldChain(token.Value!, chainRequirements); if (chain.Any()) { @@ -51,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(); @@ -71,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."); } @@ -79,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 7d9f848862..c25ce3a1c3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing public class QueryStringParameterScopeParser : QueryExpressionParser { private readonly FieldChainRequirements _chainRequirements; - private readonly Action _validateSingleFieldCallback; - private ResourceType _resourceTypeInScope; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, - Action validateSingleFieldCallback = null) + Action? validateSingleFieldCallback = null) { _chainRequirements = chainRequirements; _validateSingleFieldCallback = validateSingleFieldCallback; @@ -38,16 +38,16 @@ public QueryStringParameterScopeExpression Parse(string source, ResourceType res 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(); @@ -64,12 +64,12 @@ protected override IImmutableList OnResolveFieldChain(st if (chainRequirements == FieldChainRequirements.EndsInToMany) { // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. - return ChainResolver.ResolveToManyChain(_resourceTypeInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); } if (chainRequirements == FieldChainRequirements.IsRelationship) { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); } throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs index f050d9f7b0..518531902a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs @@ -57,7 +57,7 @@ public IEnumerable EnumerateTokens() _isInQuotedSection = false; - Token literalToken = ProduceTokenFromTextBuffer(true); + Token literalToken = ProduceTokenFromTextBuffer(true)!; yield return literalToken; } else @@ -76,7 +76,7 @@ public IEnumerable EnumerateTokens() if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) { - Token identifierToken = ProduceTokenFromTextBuffer(false); + Token? identifierToken = ProduceTokenFromTextBuffer(false); if (identifierToken != null) { @@ -104,7 +104,7 @@ public IEnumerable EnumerateTokens() throw new QueryParseException("' expected."); } - Token lastToken = ProduceTokenFromTextBuffer(false); + Token? lastToken = ProduceTokenFromTextBuffer(false); if (lastToken != null) { @@ -124,10 +124,10 @@ private bool IsMinusInsideText(TokenKind kind) private static TokenKind? TryGetSingleCharacterTokenKind(char ch) { - return SingleCharacterToTokenKinds.ContainsKey(ch) ? SingleCharacterToTokenKinds[ch] : null; + return SingleCharacterToTokenKinds.TryGetValue(ch, out TokenKind tokenKind) ? tokenKind : null; } - private Token ProduceTokenFromTextBuffer(bool isQuotedText) + private Token? ProduceTokenFromTextBuffer(bool isQuotedText) { if (isQuotedText || _textBuffer.Length > 0) { diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index 6b0900ef96..3233ec92d1 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -15,7 +15,7 @@ internal sealed class ResourceFieldChainResolver /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments /// public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, - Action validateCallback = null) + Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); @@ -54,7 +54,7 @@ public IImmutableList ResolveToManyChain(ResourceType re /// /// public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, - Action validateCallback = null) + Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); ResourceType nextResourceType = resourceType; @@ -80,7 +80,7 @@ public IImmutableList ResolveRelationshipChain(ResourceT /// name /// public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, - Action validateCallback = null) + Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); @@ -116,7 +116,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute /// /// public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, - Action validateCallback = null) + Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); @@ -153,7 +153,7 @@ public IImmutableList ResolveToOneChainEndingInToMany(Re /// /// public IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, - Action validateCallback = null) + Action? validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); @@ -188,7 +188,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path) { - RelationshipAttribute relationship = resourceType.TryGetRelationshipByPublicName(publicName); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); if (relationship == null) { @@ -230,7 +230,7 @@ private RelationshipAttribute GetToOneRelationship(string publicName, ResourceTy private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path) { - AttrAttribute attribute = resourceType.TryGetAttributeByPublicName(publicName); + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); if (attribute == null) { @@ -244,7 +244,7 @@ private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) { - ResourceFieldAttribute field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); + ResourceFieldAttribute? field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); if (field == null) { diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs index 4b14f2d996..a78ec99b66 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs @@ -11,10 +11,10 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class SortParser : QueryExpressionParser { - private readonly Action _validateSingleFieldCallback; - private ResourceType _resourceTypeInScope; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public SortParser(Action validateSingleFieldCallback = null) + public SortParser(Action? validateSingleFieldCallback = null) { _validateSingleFieldCallback = validateSingleFieldCallback; } @@ -56,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) { @@ -78,12 +78,12 @@ protected override IImmutableList OnResolveFieldChain(st { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope, path); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope, 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 be8daf350c..2a2fa60e5d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -11,15 +11,15 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class SparseFieldSetParser : QueryExpressionParser { - private readonly Action _validateSingleFieldCallback; - private ResourceType _resourceType; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceType; - public SparseFieldSetParser(Action validateSingleFieldCallback = null) + public SparseFieldSetParser(Action? validateSingleFieldCallback = null) { _validateSingleFieldCallback = validateSingleFieldCallback; } - public SparseFieldSetExpression Parse(string source, ResourceType resourceType) + public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -27,14 +27,14 @@ public SparseFieldSetExpression Parse(string source, ResourceType resourceType) Tokenize(source); - SparseFieldSetExpression expression = ParseSparseFieldSet(); + SparseFieldSetExpression? expression = ParseSparseFieldSet(); AssertTokenStackIsEmpty(); return expression; } - protected SparseFieldSetExpression ParseSparseFieldSet() + protected SparseFieldSetExpression? ParseSparseFieldSet() { ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); @@ -55,9 +55,9 @@ protected SparseFieldSetExpression ParseSparseFieldSet() protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType, path); + ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); - _validateSingleFieldCallback?.Invoke(field, _resourceType, 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 d071514a1a..d9c97dd220 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs @@ -31,7 +31,7 @@ public ResourceType Parse(string source) 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."); } @@ -47,9 +47,9 @@ private ResourceType ParseSparseFieldTarget() private ResourceType ParseResourceName() { - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - return GetResourceType(token.Value); + return GetResourceType(token.Value!); } throw new QueryParseException("Resource type expected."); @@ -57,7 +57,7 @@ private ResourceType ParseResourceName() private ResourceType GetResourceType(string publicName) { - ResourceType resourceType = _resourceGraph.TryGetResourceType(publicName); + ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); if (resourceType == null) { 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 6df4be7495..88f65f576c 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -46,7 +46,7 @@ public QueryLayerComposer(IEnumerable constraintProvid } /// - public FilterExpression GetTopFilterFromConstraints(ResourceType primaryResourceType) + public FilterExpression? GetTopFilterFromConstraints(ResourceType primaryResourceType) { ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); @@ -96,12 +96,8 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R // @formatter:wrap_chained_method_calls restore PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); - - if (topPagination != null) - { - _paginationContext.PageSize = topPagination.PageSize; - _paginationContext.PageNumber = topPagination.PageNumber; - } + _paginationContext.PageSize = topPagination.PageSize; + _paginationContext.PageNumber = topPagination.PageNumber; return new QueryLayer(resourceType) { @@ -139,14 +135,13 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, ICollection parentRelationshipChain, ICollection constraints) { - IImmutableSet includeElementsEvaluated = - GetIncludeElements(includeElements, parentLayer.ResourceType) ?? ImmutableHashSet.Empty; + IImmutableSet includeElementsEvaluated = GetIncludeElements(includeElements, parentLayer.ResourceType); var updatesInChildren = new Dictionary>(); foreach (IncludeElementExpression includeElement in includeElementsEvaluated) { - parentLayer.Projection ??= new Dictionary(); + parentLayer.Projection ??= new Dictionary(); if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) { @@ -223,7 +218,7 @@ public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceTyp if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - queryLayer.Projection = new Dictionary + queryLayer.Projection = new Dictionary { [idAttribute] = null }; @@ -252,11 +247,11 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryRes return secondaryLayer; } - private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType) + private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType) { IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); - return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); + return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); } /// @@ -267,17 +262,17 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); ArgumentGuard.NotNull(relationship, nameof(relationship)); - IncludeExpression innerInclude = secondaryLayer.Include; + IncludeExpression? innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); - Dictionary primaryProjection = - primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); + Dictionary primaryProjection = + primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); primaryProjection[relationship] = secondaryLayer; - FilterExpression primaryFilter = GetFilter(Array.Empty(), primaryResourceType); + FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); return new QueryLayer(primaryResourceType) @@ -288,7 +283,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, }; } - private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude, RelationshipAttribute secondaryRelationship) + private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) { IncludeElementExpression parentElement = relativeInclude != null ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) @@ -297,20 +292,20 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r return new IncludeExpression(ImmutableHashSet.Create(parentElement)); } - private FilterExpression CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression existingFilter) + private FilterExpression? CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression? existingFilter) { var idChain = new ResourceFieldChainExpression(idAttribute); - FilterExpression filter = null; + FilterExpression? filter = null; if (ids.Count == 1) { - var constant = new LiteralConstantExpression(ids.Single().ToString()); + var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); } else if (ids.Count > 1) { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToImmutableHashSet(); + ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); filter = new AnyExpression(idChain, constants); } @@ -344,7 +339,7 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResource) foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { - object rightValue = relationship.GetValue(primaryResource); + object? rightValue = relationship.GetValue(primaryResource); ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); if (rightResourceIds.Any()) @@ -365,14 +360,14 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression baseFilter = GetFilter(Array.Empty(), relationship.RightType); - FilterExpression filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); + FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); return new QueryLayer(relationship.RightType) { Include = IncludeExpression.Empty, Filter = filter, - Projection = new Dictionary + Projection = new Dictionary { [rightIdAttribute] = null } @@ -389,19 +384,19 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); - FilterExpression rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); + FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); + FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); return new QueryLayer(hasManyRelationship.LeftType) { Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), Filter = leftFilter, - Projection = new Dictionary + Projection = new Dictionary { [hasManyRelationship] = new(hasManyRelationship.RightType) { Filter = rightFilter, - Projection = new Dictionary + Projection = new Dictionary { [rightIdAttribute] = null } @@ -419,13 +414,13 @@ protected virtual IImmutableSet GetIncludeElements(IIm return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); } - protected virtual FilterExpression GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ImmutableArray filters = expressionsInScope.OfType().ToImmutableArray(); - FilterExpression filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault(); + FilterExpression? filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault(); return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); } @@ -435,7 +430,7 @@ protected virtual SortExpression GetSort(IReadOnlyCollection ex ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - SortExpression sort = expressionsInScope.OfType().FirstOrDefault(); + SortExpression? sort = expressionsInScope.OfType().FirstOrDefault(); sort = _resourceDefinitionAccessor.OnApplySort(resourceType, sort); @@ -454,7 +449,7 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection().FirstOrDefault(); + PaginationExpression? pagination = expressionsInScope.OfType().FirstOrDefault(); pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); @@ -463,7 +458,7 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection GetProjectionForSparseAttributeSet(ResourceType resourceType) + protected virtual IDictionary? GetProjectionForSparseAttributeSet(ResourceType resourceType) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -478,7 +473,7 @@ protected virtual IDictionary GetProjectionF 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(ResourceType resourceType) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs index 4dce446d7c..7f4cbc895e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// Transforms into calls. /// [PublicAPI] - public class IncludeClauseBuilder : QueryClauseBuilder + public class IncludeClauseBuilder : QueryClauseBuilder { private static readonly IncludeChainConverter IncludeChainConverter = new(); @@ -37,7 +37,7 @@ 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, _resourceType.EagerLoads, null); @@ -51,7 +51,7 @@ 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()) @@ -61,10 +61,10 @@ private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, result = ApplyEagerLoads(result, relationship.RightType.EagerLoads, path); } - return IncludeExtensionMethodCall(result, path); + return IncludeExtensionMethodCall(result, path!); } - private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string pathPrefix) + private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string? pathPrefix) { Expression result = source; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs index 8fb8b96b2f..d80b373e3a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -15,7 +15,7 @@ public sealed class LambdaScope : IDisposable public ParameterExpression Parameter { get; } public Expression Accessor { get; } - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression) + public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) { ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); ArgumentGuard.NotNull(elementType, nameof(elementType)); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs index 26e8059ca8..d5b55fec13 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -16,7 +16,7 @@ public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) _nameFactory = nameFactory; } - public LambdaScope CreateScope(Type elementType, Expression accessorExpression = null) + public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) { ArgumentGuard.NotNull(elementType, nameof(elementType)); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs index bdbff2bb19..e2692b75de 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// calls. /// [PublicAPI] - public class OrderClauseBuilder : QueryClauseBuilder + public class OrderClauseBuilder : QueryClauseBuilder { private readonly Expression _source; private readonly Type _extensionType; @@ -33,21 +33,21 @@ public Expression ApplyOrderBy(SortExpression expression) return Visit(expression, null); } - public override Expression VisitSort(SortExpression expression, Expression argument) + public override Expression VisitSort(SortExpression expression, Expression? argument) { - Expression sortExpression = null; + Expression? sortExpression = null; foreach (SortElementExpression sortElement in expression.Elements) { sortExpression = Visit(sortElement, sortExpression); } - return sortExpression; + return sortExpression!; } - public override Expression VisitSortElement(SortElementExpression expression, Expression previousExpression) + public override Expression VisitSortElement(SortElementExpression expression, Expression? previousExpression) { - Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute, null); + Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute!, null); LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index 1defb30729..60b25fb9a6 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -25,7 +25,7 @@ public override Expression VisitCount(CountExpression expression, TArgument argu { Expression collectionExpression = Visit(expression.TargetCollection, argument); - Expression propertyExpression = TryGetCollectionCount(collectionExpression); + Expression? propertyExpression = GetCollectionCount(collectionExpression); if (propertyExpression == null) { @@ -35,23 +35,26 @@ public override Expression VisitCount(CountExpression expression, TArgument argu return propertyExpression; } - private static Expression TryGetCollectionCount(Expression collectionExpression) + private static Expression? GetCollectionCount(Expression? collectionExpression) { - var properties = new HashSet(collectionExpression.Type.GetProperties()); - - if (collectionExpression.Type.IsInterface) + if (collectionExpression != null) { - foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) + var properties = new HashSet(collectionExpression.Type.GetProperties()); + + if (collectionExpression.Type.IsInterface) { - properties.Add(item); + foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) + { + properties.Add(item); + } } - } - foreach (PropertyInfo property in properties) - { - if (property.Name is "Count" or "Length") + foreach (PropertyInfo property in properties) { - return Expression.Property(collectionExpression, property); + if (property.Name is "Count" or "Length") + { + return Expression.Property(collectionExpression, property); + } } } @@ -67,7 +70,7 @@ public override Expression VisitResourceFieldChain(ResourceFieldChainExpression private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components) { - MemberExpression property = null; + MemberExpression? property = null; foreach (string propertyName in components) { @@ -81,10 +84,10 @@ private static MemberExpression CreatePropertyExpressionFromComponents(Expressio property = property == null ? Expression.Property(source, propertyName) : Expression.Property(property, propertyName); } - return property; + return property!; } - protected Expression CreateTupleAccessExpressionForConstant(object value, Type type) + protected Expression CreateTupleAccessExpressionForConstant(object? value, Type type) { // To enable efficient query plan caching, inline constants (that vary per request) should be converted into query parameters. // https://stackoverflow.com/questions/54075758/building-a-parameterized-entityframework-core-expression diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs index d1412eee39..99036e5d1d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs @@ -25,7 +25,7 @@ public class QueryableBuilder private readonly LambdaScopeFactory _lambdaScopeFactory; public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory, IModel entityModel, LambdaScopeFactory lambdaScopeFactory = null) + IResourceFactory resourceFactory, IModel entityModel, LambdaScopeFactory? lambdaScopeFactory = null) { ArgumentGuard.NotNull(source, nameof(source)); ArgumentGuard.NotNull(elementType, nameof(elementType)); @@ -109,7 +109,7 @@ protected virtual Expression ApplyPagination(Expression source, PaginationExpres return builder.ApplySkipTake(pagination); } - protected virtual Expression ApplyProjection(Expression source, IDictionary projection, ResourceType resourceType) + protected virtual Expression ApplyProjection(Expression source, IDictionary projection, ResourceType resourceType) { using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 2fc9c773fc..5a8d990225 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -46,7 +46,7 @@ public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel en _resourceFactory = resourceFactory; } - public Expression ApplySelect(IDictionary selectors, ResourceType resourceType) + public Expression ApplySelect(IDictionary selectors, ResourceType resourceType) { ArgumentGuard.NotNull(selectors, nameof(selectors)); @@ -62,7 +62,7 @@ public Expression ApplySelect(IDictionary se return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); } - private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType, + private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType, LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) { ICollection propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type); @@ -81,7 +81,7 @@ private Expression CreateLambdaBodyInitializer(IDictionary ToPropertySelectors(IDictionary resourceFieldSelectors, + private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, ResourceType resourceType, Type elementType) { var propertySelectors = new Dictionary(); @@ -92,7 +92,7 @@ private ICollection ToPropertySelectors(IDictionary selector.Key is RelationshipAttribute); - foreach ((ResourceFieldAttribute resourceField, QueryLayer queryLayer) in resourceFieldSelectors) + foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) { var propertySelector = new PropertySelector(resourceField.Property, queryLayer); @@ -155,7 +155,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) @@ -206,9 +206,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 into and calls. /// [PublicAPI] - public class SkipTakeClauseBuilder : QueryClauseBuilder + public class SkipTakeClauseBuilder : QueryClauseBuilder { private readonly Expression _source; private readonly Type _extensionType; @@ -32,7 +32,7 @@ public Expression ApplySkipTake(PaginationExpression expression) return Visit(expression, null); } - public override Expression VisitPagination(PaginationExpression expression, object argument) + public override Expression VisitPagination(PaginationExpression expression, object? argument) { Expression skipTakeExpression = _source; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 8009328405..fcb934d0f9 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// calls. /// [PublicAPI] - public class WhereClauseBuilder : QueryClauseBuilder + public class WhereClauseBuilder : QueryClauseBuilder { private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); @@ -56,18 +56,18 @@ private Expression WhereExtensionMethodCall(LambdaExpression predicate) return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); } - public override Expression VisitHas(HasExpression expression, Type argument) + public override Expression VisitHas(HasExpression expression, Type? argument) { Expression property = Visit(expression.TargetCollection, argument); - Type elementType = CollectionConverter.TryGetCollectionElementType(property.Type); + Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); if (elementType == null) { throw new InvalidOperationException("Expression must be a collection."); } - Expression predicate = null; + Expression? predicate = null; if (expression.Filter != null) { @@ -81,17 +81,14 @@ public override Expression VisitHas(HasExpression expression, Type argument) return AnyExtensionMethodCall(elementType, property, predicate); } - private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression predicate) + private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) { - if (predicate != null) - { - return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate); - } - - return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); + return predicate != null + ? Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate) + : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); } - public override Expression VisitMatchText(MatchTextExpression expression, Type argument) + public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) { Expression property = Visit(expression.TargetAttribute, argument); @@ -115,16 +112,16 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type a return Expression.Call(property, "Contains", null, text); } - public override Expression VisitAny(AnyExpression expression, Type argument) + public override Expression VisitAny(AnyExpression expression, Type? argument) { Expression property = Visit(expression.TargetAttribute, argument); - var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type)); + var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; foreach (LiteralConstantExpression constant in expression.Constants) { - object value = ConvertTextToTargetType(constant.Value, property.Type); - valueList!.Add(value); + object? value = ConvertTextToTargetType(constant.Value, property.Type); + valueList.Add(value); } ConstantExpression collection = Expression.Constant(valueList); @@ -136,7 +133,7 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); } - public override Expression VisitLogical(LogicalExpression expression, Type argument) + public override Expression VisitLogical(LogicalExpression expression, Type? argument) { var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); @@ -169,15 +166,15 @@ private static BinaryExpression Compose(Queue argumentQueue, Func).MakeGenericType(leftType); } - Type rightType = TryResolveFixedType(right); + Type? rightType = TryResolveFixedType(right); if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) { @@ -239,7 +236,7 @@ private Type ResolveFixedType(QueryExpression expression) return result.Type; } - private Type TryResolveFixedType(QueryExpression expression) + private Type? TryResolveFixedType(QueryExpression expression) { if (expression is CountExpression) { @@ -255,7 +252,7 @@ private Type TryResolveFixedType(QueryExpression expression) return null; } - private static Expression WrapInConvert(Expression expression, Type targetType) + private static Expression WrapInConvert(Expression expression, Type? targetType) { try { @@ -267,19 +264,19 @@ private static Expression WrapInConvert(Expression expression, Type targetType) } } - public override Expression VisitNullConstant(NullConstantExpression expression, Type expressionType) + public override Expression VisitNullConstant(NullConstantExpression expression, Type? expressionType) { return NullConstant; } - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type expressionType) + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type? expressionType) { - object convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; + object? convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; return CreateTupleAccessExpressionForConstant(convertedValue, expressionType ?? typeof(string)); } - private static object ConvertTextToTargetType(string text, Type targetType) + private static object? ConvertTextToTargetType(string text, Type targetType) { try { diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs index b6759bdcae..2915882d10 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -12,6 +13,8 @@ namespace JsonApiDotNetCore.Queries.Internal /// public sealed class SparseFieldSetCache : ISparseFieldSetCache { + private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly Lazy>> _lazySourceTable; private readonly IDictionary> _visitedTable; @@ -75,11 +78,12 @@ public IImmutableSet GetSparseFieldSetForQuery(ResourceT if (!_visitedTable.ContainsKey(resourceType)) { - SparseFieldSetExpression inputExpression = _lazySourceTable.Value.ContainsKey(resourceType) - ? new SparseFieldSetExpression(_lazySourceTable.Value[resourceType]) - : null; + SparseFieldSetExpression? inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : null; - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); IImmutableSet outputFields = outputExpression == null ? ImmutableHashSet.Empty @@ -100,7 +104,7 @@ public IImmutableSet GetIdAttributeSetForRelationshipQuery(Resour var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); ImmutableHashSet outputAttributes = outputExpression == null ? ImmutableHashSet.Empty @@ -117,15 +121,16 @@ public IImmutableSet GetSparseFieldSetForSerializer(Reso if (!_visitedTable.ContainsKey(resourceType)) { - IImmutableSet inputFields = _lazySourceTable.Value.ContainsKey(resourceType) - ? _lazySourceTable.Value[resourceType] - : GetResourceFields(resourceType); + SparseFieldSetExpression inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : GetCachedViewableFieldSet(resourceType); - var inputExpression = new SparseFieldSetExpression(inputFields); - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - IImmutableSet outputFields = - outputExpression == null ? GetResourceFields(resourceType) : inputFields.Intersect(outputExpression.Fields); + IImmutableSet outputFields = outputExpression == null + ? GetCachedViewableFieldSet(resourceType).Fields + : inputExpression.Fields.Intersect(outputExpression.Fields); _visitedTable[resourceType] = outputFields; } @@ -133,10 +138,20 @@ public IImmutableSet GetSparseFieldSetForSerializer(Reso return _visitedTable[resourceType]; } - private IImmutableSet GetResourceFields(ResourceType resourceType) + private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) + { + IImmutableSet viewableFields = GetViewableFields(resourceType); + fieldSet = new SparseFieldSetExpression(viewableFields); + ViewableFieldSetCache[resourceType] = fieldSet; + } + + return fieldSet; + } + private static IImmutableSet GetViewableFields(ResourceType resourceType) + { ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); foreach (AttrAttribute attribute in resourceType.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) diff --git a/src/JsonApiDotNetCore/Queries/PaginationContext.cs b/src/JsonApiDotNetCore/Queries/PaginationContext.cs index beb760555c..466659fe22 100644 --- a/src/JsonApiDotNetCore/Queries/PaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/PaginationContext.cs @@ -7,10 +7,10 @@ namespace JsonApiDotNetCore.Queries internal sealed class PaginationContext : IPaginationContext { /// - public PageNumber PageNumber { get; set; } + public PageNumber PageNumber { get; set; } = PageNumber.ValueOne; /// - public PageSize PageSize { get; set; } + public PageSize? PageSize { get; set; } /// public bool IsPageFull { get; set; } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index e8fa0e6cfa..9d32ca89d9 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -16,11 +16,11 @@ public sealed class QueryLayer { public ResourceType ResourceType { get; } - public IncludeExpression Include { get; set; } - public FilterExpression Filter { get; set; } - public SortExpression Sort { get; set; } - public PaginationExpression Pagination { get; set; } - public IDictionary Projection { get; set; } + public IncludeExpression? Include { get; set; } + public FilterExpression? Filter { get; set; } + public SortExpression? Sort { get; set; } + public PaginationExpression? Pagination { get; set; } + public IDictionary? Projection { get; set; } public QueryLayer(ResourceType resourceType) { @@ -39,7 +39,7 @@ 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.ResourceType.ClrType.Name}>"); @@ -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/IQueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs index 39c07ec036..04d3ffe26f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs @@ -13,6 +13,6 @@ public interface IQueryStringReader /// /// The if set on the controller that is targeted by the current request. /// - void ReadAll(DisableQueryStringAttribute disableQueryStringAttribute); + void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index 9cf8ede3dc..245aa7a787 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -27,7 +27,7 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); private readonly Dictionary.Builder> _filtersPerScope = new(); - private string _lastParameterName; + private string? _lastParameterName; public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) @@ -44,7 +44,7 @@ protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType re { 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,18 +104,18 @@ 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, RequestResourceType); @@ -127,13 +127,13 @@ private ResourceFieldChainExpression GetScope(string parameterName) return parameterScope.Scope; } - private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) + private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression? scope) { 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 9bdf16851a..77a5bce864 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -18,8 +18,8 @@ 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 IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) @@ -34,7 +34,7 @@ protected void ValidateSingleRelationship(RelationshipAttribute relationship, Re { 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 '{resourceType.PublicName}' is not allowed." : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceType.PublicName}' is not allowed."); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs index d3cfaec888..0b47e4427b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs @@ -52,7 +52,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) { - string expression = parameterValue.Substring(ExpressionPrefix.Length); + string expression = parameterValue[ExpressionPrefix.Length..]; return (parameterName, expression); } @@ -62,7 +62,7 @@ public IEnumerable ExtractConditions(string parameterValue) { if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) { - string value = parameterValue.Substring(prefix.Length); + string value = parameterValue[prefix.Length..]; string escapedValue = EscapeQuotes(value); string expression = $"{keyword}({attributeName},'{escapedValue}')"; @@ -72,7 +72,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) { - string value = parameterValue.Substring(NotEqualsPrefix.Length); + string value = parameterValue[NotEqualsPrefix.Length..]; string escapedValue = EscapeQuotes(value); string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; @@ -81,7 +81,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) { - string[] valueParts = parameterValue.Substring(InPrefix.Length).Split(","); + string[] valueParts = parameterValue[InPrefix.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Any}({attributeName},{valueList})"; @@ -90,7 +90,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) { - string[] valueParts = parameterValue.Substring(NotInPrefix.Length).Split(","); + string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index e54d9617b0..2dc722c621 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -22,8 +22,8 @@ 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 PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) @@ -147,7 +147,7 @@ private sealed class PaginationState private readonly MutablePaginationEntry _globalScope = new(); private readonly Dictionary _nestedScopes = new(); - public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression scope) + public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) { if (scope == null) { @@ -189,21 +189,21 @@ public IReadOnlyCollection GetExpressionsInScope() private IEnumerable EnumerateExpressionsInScope() { - yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber, _globalScope.PageSize)); + yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry entry) in _nestedScopes) { - yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber, entry.PageSize)); + yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); } } } private sealed class MutablePaginationEntry { - public PageSize PageSize { get; set; } + public PageSize? PageSize { get; set; } public bool HasSetPageSize { get; set; } - public PageNumber PageNumber { get; set; } + public PageNumber? PageNumber { get; set; } } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs index 615bf8ced8..4513f8e23a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs @@ -21,11 +21,12 @@ protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph res _resourceGraph = resourceGraph; _isCollectionRequest = request.IsCollection; - RequestResourceType = request.SecondaryResourceType ?? request.PrimaryResourceType; + // There are currently no query string readers that work with operations, so non-nullable for convenience. + RequestResourceType = (request.SecondaryResourceType ?? request.PrimaryResourceType)!; IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; } - protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression scope) + protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression? scope) { if (scope == null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs index 28aabb53e5..52bfa648fe 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs @@ -35,7 +35,7 @@ public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor qu } /// - public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttribute) + public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); @@ -43,7 +43,7 @@ public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttrib foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query) { - IQueryStringParameterReader reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); + IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); if (reader != null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs index fef422d2ac..1ca5fa55f5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.AspNetCore.Http; namespace JsonApiDotNetCore.QueryStrings.Internal @@ -7,7 +8,18 @@ internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { private readonly IHttpContextAccessor _httpContextAccessor; - public IQueryCollection Query => _httpContextAccessor.HttpContext!.Request.Query; + public IQueryCollection Query + { + get + { + if (_httpContextAccessor.HttpContext == null) + { + throw new InvalidOperationException("An active HTTP request is required."); + } + + return _httpContextAccessor.HttpContext.Request.Query; + } + } public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs index 98994c4c6c..125700b0a3 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -42,22 +42,22 @@ public virtual bool CanRead(string parameterName) return false; } - object queryableHandler = GetQueryableHandler(parameterName); + object? queryableHandler = GetQueryableHandler(parameterName); return queryableHandler != null; } /// public virtual void Read(string parameterName, StringValues parameterValue) { - object queryableHandler = GetQueryableHandler(parameterName); + object queryableHandler = GetQueryableHandler(parameterName)!; var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); _constraints.Add(expressionInScope); } - private object GetQueryableHandler(string parameterName) + private object? GetQueryableHandler(string parameterName) { - Type resourceClrType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType).ClrType; - object handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceClrType, 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 c9583b5edf..80f3510766 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -19,7 +19,7 @@ public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQ private readonly QueryStringParameterScopeParser _scopeParser; private readonly SortParser _sortParser; private readonly List _constraints = new(); - private string _lastParameterName; + private string? _lastParameterName; public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) : base(request, resourceGraph) @@ -32,7 +32,7 @@ protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType re { 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 +61,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,7 +73,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) } } - private ResourceFieldChainExpression GetScope(string parameterName) + private ResourceFieldChainExpression? GetScope(string parameterName) { QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); @@ -85,7 +85,7 @@ private ResourceFieldChainExpression GetScope(string parameterName) return parameterScope.Scope; } - private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) + private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression? scope) { ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); return _sortParser.Parse(parameterValue, resourceTypeInScope); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 007cd8553f..111f82b7c2 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -25,7 +25,7 @@ public class SparseFieldSetQueryStringParameterReader : QueryStringParameterRead private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = ImmutableDictionary.CreateBuilder(); - private string _lastParameterName; + private string? _lastParameterName; /// bool IQueryStringParameterReader.AllowEmptyValue => true; @@ -41,7 +41,7 @@ protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType re { 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."); } } @@ -87,7 +87,7 @@ private ResourceType GetSparseFieldType(string parameterName) private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) { - SparseFieldSetExpression sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); + SparseFieldSetExpression? sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); if (sparseFieldSet == null) { 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 f80bb3833c..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,20 +32,20 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti /// /// Searches the change tracker for an entity that matches the type and ID of . /// - public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + public static object? GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { ArgumentGuard.NotNull(dbContext, nameof(dbContext)); ArgumentGuard.NotNull(identifiable, nameof(identifiable)); Type resourceClrType = identifiable.GetType(); - string stringId = identifiable.StringId; + string? stringId = identifiable.StringId; - EntityEntry entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); + EntityEntry? entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); return entityEntry?.Entity; } - private static bool IsResource(EntityEntry entry, Type resourceClrType, string stringId) + private static bool IsResource(EntityEntry entry, Type resourceClrType, string? stringId) { return entry.Entity.GetType() == resourceClrType && ((IIdentifiable)entry.Entity).StringId == stringId; } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index f8c79d7a12..a445e6a5ae 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,7 +40,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly TraceLogWriter> _traceWriter; /// - public virtual string TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); + public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, @@ -63,18 +64,18 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextR } /// - public virtual async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public virtual async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { - layer + queryLayer }); - ArgumentGuard.NotNull(layer, nameof(layer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); using (CodeTimingSessionManager.Current.Measure("Repository - Get resource(s)")) { - IQueryable query = ApplyQueryLayer(layer); + IQueryable query = ApplyQueryLayer(queryLayer); using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) { @@ -84,7 +85,7 @@ public virtual async Task> GetAsync(QueryLayer la } /// - public virtual async Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public virtual async Task CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -109,14 +110,14 @@ public virtual async Task CountAsync(FilterExpression topFilter, Cancellati } } - protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) + protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) { _traceWriter.LogMethodStart(new { - layer + queryLayer }); - ArgumentGuard.NotNull(layer, nameof(layer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); using (CodeTimingSessionManager.Current.Measure("Convert QueryLayer to System.Expression")) { @@ -144,7 +145,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) 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")) { @@ -161,6 +162,11 @@ protected virtual IQueryable GetAll() /// public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) { + _traceWriter.LogMethodStart(new + { + id + }); + var resource = _resourceFactory.CreateInstance(); resource.Id = id; @@ -183,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); @@ -208,12 +214,12 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r _dbContext.ResetChangeTracker(); } - private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightValue, + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, WriteOperationKind writeOperation, CancellationToken cancellationToken) { if (relationship is HasOneAttribute hasOneRelationship) { - return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable)rightValue, + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, writeOperation, cancellationToken); } @@ -231,8 +237,15 @@ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, has } /// - public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { + _traceWriter.LogMethodStart(new + { + queryLayer + }); + + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); @@ -255,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); } @@ -279,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 = GetNavigation(relationship); + bool isRelationshipRequired = navigation?.ForeignKey?.IsRequired ?? false; - INavigation navigation = TryGetNavigation(relationship); - bool relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; + bool isClearingRelationship = rightValue == null; - bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship - ? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue) - : rightValue == null; - - if (relationshipIsRequired && relationshipIsBeingCleared) - { - string resourceName = _resourceGraph.GetResourceType().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId, resourceName); + if (isRelationshipRequired && isClearingRelationship) + { + string resourceName = _resourceGraph.GetResourceType().PublicName; + throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); + } } } - private bool IsToManyRelationshipBeingCleared(HasManyAttribute hasManyRelationship, TResource leftResource, object valueToAssign) - { - ICollection newRightResourceIds = _collectionConverter.ExtractResources(valueToAssign); - - object existingRightValue = hasManyRelationship.GetValue(leftResource); - - HashSet existingRightResourceIds = - _collectionConverter.ExtractResources(existingRightValue).ToHashSet(IdentifiableComparer.Instance); - - existingRightResourceIds.ExceptWith(newRightResourceIds); - - return existingRightResourceIds.Any(); - } - /// public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) { @@ -336,8 +330,8 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType().Relationships) { - // Loads the data of the relationship, if in EF Core it is configured in such a way that loading the related - // entities into memory is required for successfully executing the selected deletion behavior. + // Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading + // the related entities into memory is required for successfully executing the selected deletion behavior. if (RequiresLoadOfRelationshipForDeletion(relationship)) { NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship); @@ -375,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); @@ -383,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)); return entityType?.FindNavigation(relationship.Property.Name); } - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation navigation) + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation? navigation) { return relationship is HasOneAttribute && navigation is { IsOnDependent: true }; } /// - public virtual async Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken) + public virtual async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -403,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); @@ -465,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"); @@ -478,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 @@ -503,7 +500,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); - AssertIsNotClearingRequiredRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); @@ -522,15 +519,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); } @@ -538,7 +535,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) { @@ -553,7 +550,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 }; diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs index e654d288b7..0344e3cbf9 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -11,6 +11,6 @@ public interface IRepositorySupportsTransaction /// /// Identifies the currently active transaction. /// - string TransactionId { get; } + string? TransactionId { get; } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs index 438a9c9390..3f6d4a872c 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs @@ -24,11 +24,11 @@ public interface IResourceReadRepository /// /// Executes a read query using the specified constraints and returns the collection of matching resources. /// - Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken); + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken); /// /// Executes a read query using the specified top-level filter and returns the top-level count of matching resources. /// - Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken); + Task CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index bf67a0a480..68360f02cb 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -16,18 +16,18 @@ public interface IResourceRepositoryAccessor /// /// Invokes . /// - Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// /// Invokes for the specified resource type. /// - Task> GetAsync(ResourceType resourceType, QueryLayer layer, CancellationToken cancellationToken); + Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken); /// /// Invokes for the specified resource type. /// - Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + Task CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// @@ -45,7 +45,7 @@ Task CreateAsync(TResource resourceFromRequest, TResource resourceFor /// /// Invokes . /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// @@ -63,7 +63,7 @@ Task DeleteAsync(TId id, CancellationToken cancellationToken) /// /// Invokes . /// - Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken) + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 9b92f951d1..06a94748a0 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -36,7 +36,7 @@ public interface IResourceWriteRepository /// /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for . /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); /// /// Updates the attributes and relationships of an existing resource in the underlying data store. @@ -51,7 +51,7 @@ public interface IResourceWriteRepository /// /// Performs a complete replacement of the relationship in the underlying data store. /// - Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken); + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken); /// /// Adds resources to a to-many relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 975a26805c..b6ec29fb95 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -33,24 +33,24 @@ public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGra } /// - public async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = ResolveReadRepository(typeof(TResource)); - return (IReadOnlyCollection)await repository.GetAsync(layer, cancellationToken); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); } /// - public async Task> GetAsync(ResourceType resourceType, QueryLayer layer, CancellationToken cancellationToken) + public async Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); dynamic repository = ResolveReadRepository(resourceType); - return (IReadOnlyCollection)await repository.GetAsync(layer, cancellationToken); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); } /// - public async Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public async Task CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = ResolveReadRepository(typeof(TResource)); @@ -74,7 +74,7 @@ public async Task CreateAsync(TResource resourceFromRequest, TResourc } /// - public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); @@ -98,7 +98,7 @@ public async Task DeleteAsync(TId id, CancellationToken cancella } /// - public async Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken) + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); diff --git a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs index 23461fc3dd..79ffbbed3d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs @@ -33,37 +33,7 @@ public AttrCapabilities Capabilities set => _capabilities = value; } - /// - /// Get the value of the attribute for the given object. Throws if the attribute does not belong to the provided object. - /// - public object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (Property.GetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); - } - - return Property.GetValue(resource); - } - - /// - /// Sets the value of the attribute on the given object. - /// - public void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (Property.SetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); - } - - Property.SetValue(resource, newValue); - } - - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs index 623e7eeeb2..bc81116763 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs @@ -38,8 +38,10 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class EagerLoadAttribute : Attribute { - public PropertyInfo Property { get; internal set; } + // These properties are definitely assigned after building the resource graph, which is why they are declared as non-nullable. - public IReadOnlyCollection Children { get; internal set; } + public PropertyInfo Property { get; internal set; } = null!; + + public IReadOnlyCollection Children { get; internal set; } = null!; } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 166112baed..0adb22cb77 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -37,7 +37,7 @@ private bool EvaluateIsManyToMany() { if (InverseNavigationProperty != null) { - Type elementType = CollectionConverter.TryGetCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType != null; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index e0ce2ecc47..05e9483260 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -36,7 +36,7 @@ private bool EvaluateIsOneToOne() { if (InverseNavigationProperty != null) { - Type elementType = CollectionConverter.TryGetCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType == null; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index 732da4c4fc..6324766f96 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -18,7 +18,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// /// The CLR type in which this relationship is declared. /// - internal Type LeftClrType { get; set; } + internal Type? LeftClrType { get; set; } /// /// The CLR type this relationship points to. In the case of a relationship, this value will be the collection element @@ -29,11 +29,11 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// public ISet Tags { get; set; } // RightClrType: typeof(Tag) /// ]]> /// - internal Type RightClrType { get; set; } + internal Type? RightClrType { get; set; } /// - /// The of the EF Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed as a JSON:API - /// relationship. + /// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed + /// as a JSON:API relationship. /// /// /// /// - public PropertyInfo InverseNavigationProperty { get; set; } + public PropertyInfo? InverseNavigationProperty { get; set; } /// /// The containing resource type in which this relationship is declared. /// - public ResourceType LeftType { get; internal set; } + public ResourceType LeftType { get; internal set; } = null!; /// /// The resource type this relationship points to. In the case of a relationship, this value will be the collection /// element type. /// - public ResourceType RightType { get; internal set; } + public ResourceType RightType { get; internal set; } = null!; /// /// Configures which links to show in the object for this relationship. Defaults to @@ -79,27 +79,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// public bool CanInclude { get; set; } = true; - /// - /// Gets the value of the resource property this attribute was declared on. - /// - public object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - return Property.GetValue(resource); - } - - /// - /// Sets the value of the resource property this attribute was declared on. - /// - public void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - Property.SetValue(resource, newValue); - } - - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs index 93efe49696..8463689301 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs @@ -13,14 +13,16 @@ namespace JsonApiDotNetCore.Resources.Annotations [PublicAPI] public abstract class ResourceFieldAttribute : Attribute { - private string _publicName; + // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. + private string? _publicName; + private PropertyInfo? _property; /// /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. /// public string PublicName { - get => _publicName; + get => _publicName!; set { if (string.IsNullOrWhiteSpace(value)) @@ -35,14 +37,72 @@ public string PublicName /// /// The resource property that this attribute is declared on. /// - public PropertyInfo Property { get; internal set; } + public PropertyInfo Property + { + get => _property!; + internal set + { + ArgumentGuard.NotNull(value, nameof(value)); + _property = value; + } + } + + /// + /// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the + /// specified resource instance. + /// + public object? GetValue(object resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + if (Property.GetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); + } + + try + { + return Property.GetValue(resource); + } + catch (TargetException exception) + { + throw new InvalidOperationException( + $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception); + } + } + + /// + /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified + /// resource instance. + /// + public void SetValue(object resource, object? newValue) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + if (Property.SetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); + } + + try + { + Property.SetValue(resource, newValue); + } + catch (TargetException exception) + { + throw new InvalidOperationException( + $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception); + } + } - public override string ToString() + public override string? ToString() { - return PublicName ?? (Property != null ? Property.Name : base.ToString()); + return _publicName ?? (_property != null ? _property.Name : base.ToString()); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { @@ -56,12 +116,12 @@ public override bool Equals(object obj) var other = (ResourceFieldAttribute)obj; - return PublicName == other.PublicName && Property == other.Property; + return _publicName == other._publicName && _property == other._property; } public override int GetHashCode() { - return HashCode.Combine(PublicName, Property); + return HashCode.Combine(_publicName, _property); } } } diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs index fb377cf8de..ee1f73942a 100644 --- a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs +++ b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs @@ -8,12 +8,12 @@ public interface IIdentifiable /// /// The value for element 'id' in a JSON:API request or response. /// - string StringId { get; set; } + string? StringId { get; set; } /// /// The value for element 'lid' in a JSON:API request. /// - string LocalId { get; set; } + string? LocalId { get; set; } } /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index fa79da4f2c..9eed0aaa16 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -43,7 +43,7 @@ public interface IResourceDefinition /// /// The new filter, or null to disable the existing filter. /// - FilterExpression OnApplyFilter(FilterExpression existingFilter); + FilterExpression? OnApplyFilter(FilterExpression? existingFilter); /// /// Enables to extend, replace or remove a sort order that is being applied on a set of this resource type. Tip: Use @@ -55,7 +55,7 @@ public interface IResourceDefinition /// /// The new sort order, or null to disable the existing sort order and sort by ID. /// - SortExpression OnApplySort(SortExpression existingSort); + SortExpression? OnApplySort(SortExpression? existingSort); /// /// Enables to extend, replace or remove pagination that is being applied on a set of this resource type. @@ -67,7 +67,7 @@ public interface IResourceDefinition /// The changed pagination, or null to use the first page with default size from options. To disable paging, set /// to null. /// - PaginationExpression OnApplyPagination(PaginationExpression existingPagination); + PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); /// /// Enables to extend, replace or remove a sparse fieldset that is being applied on a set of this resource type. Tip: Use @@ -86,7 +86,7 @@ public interface IResourceDefinition /// /// The new sparse fieldset, or null to discard the existing sparse fieldset and select all viewable fields. /// - SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet); + SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet); /// /// Enables to adapt the Entity Framework Core query, based on custom query string parameters. Note this only works on @@ -113,13 +113,13 @@ public interface IResourceDefinition /// ]]> /// #pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters(); + QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters(); #pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type /// /// Enables to add JSON:API meta information, specific to this resource. /// - IDictionary GetMeta(TResource resource); + IDictionary? GetMeta(TResource resource); /// /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. @@ -172,7 +172,7 @@ public interface IResourceDefinition /// /// The replacement resource identifier, or null to clear the relationship. Returns by default. /// - Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken); /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index c4b91365bd..ed4dad1270 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -24,33 +24,33 @@ public interface IResourceDefinitionAccessor /// /// Invokes for the specified resource type. /// - FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpression existingFilter); + FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter); /// /// Invokes for the specified resource type. /// - SortExpression OnApplySort(ResourceType resourceType, SortExpression existingSort); + SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort); /// /// Invokes for the specified resource type. /// - PaginationExpression OnApplyPagination(ResourceType resourceType, PaginationExpression existingPagination); + PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination); /// /// Invokes for the specified resource type. /// - SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression existingSparseFieldSet); + SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet); /// /// Invokes for the specified resource type, then /// returns the expression for the specified parameter name. /// - object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); + object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); /// /// Invokes for the specified resource. /// - IDictionary GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); + IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); /// /// Invokes for the specified resource. @@ -61,8 +61,8 @@ Task OnPrepareWriteAsync(TResource resource, WriteOperationKind write /// /// Invokes for the specified resource. /// - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// diff --git a/src/JsonApiDotNetCore/Resources/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs index 5ca96dfb2b..6a379bfcad 100644 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -14,11 +14,11 @@ namespace JsonApiDotNetCore.Resources public abstract class Identifiable : IIdentifiable { /// - public virtual TId Id { get; set; } + public virtual TId Id { get; set; } = default!; /// [NotMapped] - public string StringId + public string? StringId { get => GetStringId(Id); set => Id = GetTypedId(value); @@ -26,22 +26,22 @@ public string StringId /// [NotMapped] - public string LocalId { get; set; } + public string? LocalId { get; set; } /// /// Converts an outgoing typed resource identifier to string format for use in a JSON:API response. /// - protected virtual string GetStringId(TId value) + protected virtual string? GetStringId(TId value) { - return EqualityComparer.Default.Equals(value, default) ? null : value.ToString(); + return EqualityComparer.Default.Equals(value, default) ? null : value!.ToString(); } /// /// Converts an incoming 'id' element from a JSON:API request to the typed resource identifier. /// - protected virtual TId GetTypedId(string value) + protected virtual TId GetTypedId(string? value) { - return value == null ? default : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId)); + return value == null ? default! : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId))!; } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 5cffe890c2..67c0cc833c 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -17,24 +17,24 @@ private IdentifiableComparer() { } - public bool Equals(IIdentifiable x, IIdentifiable y) + public bool Equals(IIdentifiable? left, IIdentifiable? right) { - if (ReferenceEquals(x, y)) + if (ReferenceEquals(left, right)) { return true; } - if (x is null || y is null || x.GetType() != y.GetType()) + if (left is null || right is null || left.GetType() != right.GetType()) { return false; } - if (x.StringId == null && y.StringId == null) + if (left.StringId == null && right.StringId == null) { - return x.LocalId == y.LocalId; + return left.LocalId == right.LocalId; } - return x.StringId == y.StringId; + return left.StringId == right.StringId; } public int GetHashCode(IIdentifiable obj) diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index d9456e5014..7a7102291e 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources { @@ -9,14 +10,28 @@ 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(nameof(Identifiable.Id)); if (property == null) { throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an 'Id' property."); } - return property.GetValue(identifiable); + 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}.{nameof(Identifiable.Id)}' should " + + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); + } + } + + return propertyValue!; } } } diff --git a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs index 826b375bd3..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)) { @@ -85,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 21a2f6cccf..da1d76b395 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -41,13 +41,13 @@ public virtual IImmutableSet OnApplyIncludes(IImmutabl } /// - public virtual FilterExpression OnApplyFilter(FilterExpression existingFilter) + public virtual FilterExpression? OnApplyFilter(FilterExpression? existingFilter) { return existingFilter; } /// - public virtual SortExpression OnApplySort(SortExpression existingSort) + public virtual SortExpression? OnApplySort(SortExpression? existingSort) { return existingSort; } @@ -66,11 +66,11 @@ public virtual SortExpression OnApplySort(SortExpression existingSort) /// protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { - ArgumentGuard.NotNull(keySelectors, nameof(keySelectors)); + ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); - foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) + foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) { bool isAscending = sortDirection == ListSortDirection.Ascending; AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single(); @@ -83,25 +83,25 @@ protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySel } /// - public virtual PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + public virtual PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) { return existingPagination; } /// - public virtual SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public virtual SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { return existingSparseFieldSet; } /// - public virtual QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + public virtual QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() { return null; } /// - public virtual IDictionary GetMeta(TResource resource) + public virtual IDictionary? GetMeta(TResource resource) { return null; } @@ -113,8 +113,8 @@ public virtual Task OnPrepareWriteAsync(TResource resource, WriteOperationKind w } /// - public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { return Task.FromResult(rightResourceId); } @@ -166,7 +166,7 @@ public virtual void OnSerialize(TResource resource) /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. /// - public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> + public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> { } } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index ada612de59..4336fef3f3 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -54,7 +54,7 @@ public ISet GetSecondaryResources() private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) { - object rightValue = relationship.GetValue(Resource); + object? rightValue = relationship.GetValue(Resource); ICollection rightResources = CollectionConverter.ExtractResources(rightValue); secondaryResources.AddRange(rightResources); diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 1158df6183..92d797e14e 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -14,9 +14,9 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker _initiallyStoredAttributeValues; - private IDictionary _requestAttributeValues; - private IDictionary _finallyStoredAttributeValues; + private IDictionary? _initiallyStoredAttributeValues; + private IDictionary? _requestAttributeValues; + private IDictionary? _finallyStoredAttributeValues; public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields) { @@ -57,7 +57,7 @@ private IDictionary CreateAttributeDictionary(TResource resource foreach (AttrAttribute attribute in attributes) { - object value = attribute.GetValue(resource); + object? value = attribute.GetValue(resource); string json = JsonSerializer.Serialize(value); result.Add(attribute.PublicName, json); } @@ -68,26 +68,28 @@ private IDictionary CreateAttributeDictionary(TResource resource /// public bool HasImplicitChanges() { - foreach (string key in _initiallyStoredAttributeValues.Keys) + if (_initiallyStoredAttributeValues != null && _requestAttributeValues != null && _finallyStoredAttributeValues != null) { - if (_requestAttributeValues.ContainsKey(key)) + foreach (string key in _initiallyStoredAttributeValues.Keys) { - string requestValue = _requestAttributeValues[key]; - string actualValue = _finallyStoredAttributeValues[key]; - - if (requestValue != actualValue) + if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) { - return true; - } - } - else - { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + string actualValue = _finallyStoredAttributeValues[key]; - if (initiallyStoredValue != finallyStoredValue) + if (requestValue != actualValue) + { + return true; + } + } + else { - return true; + string initiallyStoredValue = _initiallyStoredAttributeValues[key]; + string finallyStoredValue = _finallyStoredAttributeValues[key]; + + if (initiallyStoredValue != finallyStoredValue) + { + return true; + } } } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index b5982ceb4d..1622a32bd2 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -38,7 +38,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso } /// - public FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpression existingFilter) + public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -47,7 +47,7 @@ public FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpressio } /// - public SortExpression OnApplySort(ResourceType resourceType, SortExpression existingSort) + public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -56,7 +56,7 @@ public SortExpression OnApplySort(ResourceType resourceType, SortExpression exis } /// - public PaginationExpression OnApplyPagination(ResourceType resourceType, PaginationExpression existingPagination) + public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -65,7 +65,7 @@ public PaginationExpression OnApplyPagination(ResourceType resourceType, Paginat } /// - public SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression existingSparseFieldSet) + public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -74,7 +74,7 @@ public SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, } /// - public object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) { ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); @@ -82,11 +82,19 @@ public object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, s dynamic resourceDefinition = ResolveResourceDefinition(resourceClrType); dynamic handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); - return handlers != null && handlers.ContainsKey(parameterName) ? handlers[parameterName] : null; + if (handlers != null) + { + if (handlers.ContainsKey(parameterName)) + { + return handlers[parameterName]; + } + } + + return null; } /// - public IDictionary GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -105,8 +113,8 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat } /// - public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(leftResource, nameof(leftResource)); diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index dd5a03306e..f5e305a29f 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -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 diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs index 51f19ac274..f77f21cdab 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCore.Serialization.JsonConverters { public abstract class JsonObjectConverter : JsonConverter { - protected static TValue ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) + protected static TValue? ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) { - if (typeof(TValue) != typeof(object) && options?.GetConverter(typeof(TValue)) is JsonConverter converter) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { return converter.Read(ref reader, typeof(TValue), options); } @@ -17,7 +17,7 @@ protected static TValue ReadSubTree(ref Utf8JsonReader reader, JsonSeria protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) { - if (typeof(TValue) != typeof(object) && options?.GetConverter(typeof(TValue)) is JsonConverter converter) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { converter.Write(writer, value, options); } @@ -29,7 +29,7 @@ protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, protected static JsonException GetEndOfStreamError() { - return new("Unexpected end of JSON stream."); + return new JsonException("Unexpected end of JSON stream."); } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index a95cc59f77..9fb5e4c607 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -48,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) }; - ResourceType resourceType = resourceObject.Type != null ? _resourceGraph.TryGetResourceType(resourceObject.Type) : null; + ResourceType? resourceType = resourceObject.Type != null ? _resourceGraph.FindResourceType(resourceObject.Type) : null; while (reader.Read()) { @@ -63,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) @@ -101,7 +101,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "relationships": { - resourceObject.Relationships = ReadSubTree>(ref reader, options); + resourceObject.Relationships = ReadSubTree>(ref reader, options); break; } case "links": @@ -111,7 +111,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "meta": { - resourceObject.Meta = ReadSubTree>(ref reader, options); + resourceObject.Meta = ReadSubTree>(ref reader, options); break; } default: @@ -129,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; @@ -138,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) @@ -159,9 +159,9 @@ private static string TryPeekType(ref Utf8JsonReader reader) return null; } - private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { - var attributes = new Dictionary(); + var attributes = new Dictionary(); while (reader.Read()) { @@ -173,15 +173,15 @@ private static IDictionary ReadAttributes(ref Utf8JsonReader rea } case JsonTokenType.PropertyName: { - string attributeName = reader.GetString(); + string attributeName = reader.GetString() ?? string.Empty; reader.Read(); - AttrAttribute attribute = resourceType.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)) { @@ -205,11 +205,11 @@ private static IDictionary ReadAttributes(ref Utf8JsonReader rea } } - attributes.Add(attributeName!, attributeValue); + attributes.Add(attributeName, attributeValue); } else { - attributes.Add(attributeName!, null); + attributes.Add(attributeName, null); reader.Skip(); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index 0ca65c237e..2ad8ad6e06 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -25,15 +25,15 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer Type objectType = typeToConvert.GetGenericArguments()[0]; Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null); + return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> - where T : class, IResourceIdentity + where T : class, IResourceIdentity, new() { public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) { - var objects = new List(); + var objects = new List(); bool isManyData = false; bool hasCompletedToMany = false; @@ -46,6 +46,15 @@ public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToC hasCompletedToMany = true; break; } + case JsonTokenType.Null: + { + if (isManyData) + { + objects.Add(new T()); + } + + break; + } case JsonTokenType.StartObject: { var resourceObject = ReadSubTree(ref reader, serializerOptions); @@ -61,7 +70,7 @@ public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToC } while (isManyData && !hasCompletedToMany && reader.Read()); - object data = isManyData ? objects : objects.FirstOrDefault(); + object? data = isManyData ? objects : objects.FirstOrDefault(); return new SingleOrManyData(data); } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index 27c3f58c44..b4cddf1aa7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -20,14 +20,14 @@ public sealed class AtomicOperationObject [JsonPropertyName("ref")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public AtomicReference Ref { get; set; } + public AtomicReference? Ref { get; set; } [JsonPropertyName("href")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Href { get; set; } + public string? Href { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs index bff24ad299..7c4f93caa9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -11,18 +11,18 @@ public sealed class AtomicReference : IResourceIdentity { [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Type { get; set; } + public string? Type { get; set; } [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } + public string? Id { get; set; } [JsonPropertyName("lid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Lid { get; set; } + public string? Lid { get; set; } [JsonPropertyName("relationship")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Relationship { get; set; } + public string? Relationship { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs index 14f67a5247..0d55c65a3c 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -16,6 +16,6 @@ public sealed class AtomicResultObject [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index 995b2070e8..9242398d34 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -10,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. @@ -22,22 +22,22 @@ public sealed class Document [JsonPropertyName("atomic:operations")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Operations { get; set; } + public IList? Operations { get; set; } [JsonPropertyName("atomic:results")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Results { get; set; } + public IList? Results { get; set; } [JsonPropertyName("errors")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Errors { get; set; } + public IList? Errors { get; set; } [JsonPropertyName("included")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Included { get; set; } + public IList? Included { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs index 3b03f68cda..e45dc22a2f 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs @@ -11,10 +11,10 @@ public sealed class ErrorLinks { [JsonPropertyName("about")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string About { get; set; } + public string? About { get; set; } [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Type { get; set; } + public string? Type { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs index 38326d2ae5..9fb0eb6f85 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs @@ -15,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; } @@ -34,23 +34,23 @@ public string Status [JsonPropertyName("code")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Code { get; set; } + public string? Code { get; set; } [JsonPropertyName("title")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Title { get; set; } + public string? Title { get; set; } [JsonPropertyName("detail")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Detail { get; set; } + public string? Detail { get; set; } [JsonPropertyName("source")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ErrorSource Source { get; set; } + public ErrorSource? Source { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } public ErrorObject(HttpStatusCode statusCode) { diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs index ebd8ee49bd..ec363c2f8d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs @@ -11,14 +11,14 @@ public sealed class ErrorSource { [JsonPropertyName("pointer")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Pointer { get; set; } + public string? Pointer { get; set; } [JsonPropertyName("parameter")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Parameter { get; set; } + public string? Parameter { get; set; } [JsonPropertyName("header")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Header { get; set; } + public string? Header { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs index ff936f4d46..9b683c6922 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs @@ -2,8 +2,8 @@ namespace JsonApiDotNetCore.Serialization.Objects { public interface IResourceIdentity { - public string Type { get; } - public string Id { get; } - public string Lid { get; } + public string? Type { get; } + public string? Id { get; } + public string? Lid { get; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs index 11b214b434..d0a385e404 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs @@ -12,18 +12,18 @@ public sealed class JsonApiObject { [JsonPropertyName("version")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Version { get; set; } + public string? Version { get; set; } [JsonPropertyName("ext")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Ext { get; set; } + public IList? Ext { get; set; } [JsonPropertyName("profile")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Profile { get; set; } + public IList? Profile { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index b66f33daa8..944b811605 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -11,11 +11,11 @@ public sealed class RelationshipLinks { [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Self { get; set; } + public string? Self { get; set; } [JsonPropertyName("related")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Related { get; set; } + public string? Related { get; set; } internal bool HasValue() { diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs index fb4296d70d..96f5414eea 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs @@ -12,7 +12,7 @@ public sealed class RelationshipObject { [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public RelationshipLinks Links { get; set; } + public RelationshipLinks? Links { get; set; } [JsonPropertyName("data")] // JsonIgnoreCondition is determined at runtime by WriteOnlyRelationshipObjectConverter. @@ -20,6 +20,6 @@ public sealed class RelationshipObject [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index de4104d28a..9b9de3afb8 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -12,18 +12,18 @@ public sealed class ResourceIdentifierObject : IResourceIdentity { [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Type { get; set; } + public string? Type { get; set; } [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } + public string? Id { get; set; } [JsonPropertyName("lid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Lid { get; set; } + public string? Lid { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs index 7ab1f6861e..ddee80c85d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -11,7 +11,7 @@ public sealed class ResourceLinks { [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Self { get; set; } + public string? Self { get; set; } internal bool HasValue() { diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs index f418a63ed1..85f340075d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs @@ -12,30 +12,30 @@ public sealed class ResourceObject : IResourceIdentity { [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Type { get; set; } + public string? Type { get; set; } [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } + public string? Id { get; set; } [JsonPropertyName("lid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Lid { get; set; } + public string? Lid { get; set; } [JsonPropertyName("attributes")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Attributes { get; set; } + public IDictionary? Attributes { get; set; } [JsonPropertyName("relationships")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Relationships { get; set; } + public IDictionary? Relationships { get; set; } [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ResourceLinks Links { get; set; } + public ResourceLinks? Links { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs index c2a6c23876..548dde07e9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs @@ -13,22 +13,24 @@ namespace JsonApiDotNetCore.Serialization.Objects /// [PublicAPI] public readonly struct SingleOrManyData - where T : class, IResourceIdentity + // The "new()" constraint exists for parity with SingleOrManyDataConverterFactory, which creates empty instances + // to ensure ManyValue never contains null items. + where T : class, IResourceIdentity, new() { // ReSharper disable once MergeConditionalExpression // Justification: ReSharper reporting this is a bug, which is fixed in v2021.2.1. This condition cannot be merged. - public object Value => ManyValue != null ? ManyValue : SingleValue; + public object? Value => ManyValue != null ? ManyValue : SingleValue; [JsonIgnore] public bool IsAssigned { get; } [JsonIgnore] - public T SingleValue { get; } + public T? SingleValue { get; } [JsonIgnore] - public IList ManyValue { get; } + public IList? ManyValue { get; } - public SingleOrManyData(object value) + public SingleOrManyData(object? value) { IsAssigned = true; @@ -40,7 +42,7 @@ public SingleOrManyData(object value) else { ManyValue = null; - SingleValue = (T)value; + SingleValue = (T?)value; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs index 0817e56d8a..abb8a365e9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -11,31 +11,31 @@ public sealed class TopLevelLinks { [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Self { get; set; } + public string? Self { get; set; } [JsonPropertyName("related")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Related { get; set; } + public string? Related { get; set; } [JsonPropertyName("describedby")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string DescribedBy { get; set; } + public string? DescribedBy { get; set; } [JsonPropertyName("first")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string First { get; set; } + public string? First { get; set; } [JsonPropertyName("last")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Last { get; set; } + public string? Last { get; set; } [JsonPropertyName("prev")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Prev { get; set; } + public string? Prev { get; set; } [JsonPropertyName("next")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Next { get; set; } + public string? Next { get; set; } internal bool HasValue() { diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index 317404fafe..f21d1181ee 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -44,14 +44,14 @@ public OperationContainer Convert(AtomicOperationObject atomicOperationObject, R WriteOperation = writeOperation }; - (ResourceIdentityRequirements requirements, IIdentifiable primaryResource) = ConvertRef(atomicOperationObject, state); + (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); + return new OperationContainer(primaryResource!, state.WritableTargetedFields, state.Request); } private static void AssertNoHref(AtomicOperationObject atomicOperationObject, RequestAdapterState state) @@ -69,7 +69,10 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper { case AtomicOperationCode.Add: { - if (atomicOperationObject.Ref is { Relationship: null }) + // ReSharper disable once MergeIntoPattern + // Justification: Merging this into a pattern crashes the command-line versions of CleanupCode/InspectCode. + // Tracked at: https://youtrack.jetbrains.com/issue/RSRP-486717 + if (atomicOperationObject.Ref != null && atomicOperationObject.Ref.Relationship == null) { using IDisposable _ = state.Position.PushElement("ref"); throw new ModelConversionException(state.Position, "The 'relationship' element is required.", null); @@ -95,13 +98,13 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper throw new NotSupportedException($"Unknown operation code '{atomicOperationObject.Code}'."); } - private (ResourceIdentityRequirements requirements, IIdentifiable primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, + private (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, RequestAdapterState state) { ResourceIdentityRequirements requirements = CreateIdentityRequirements(state); - IIdentifiable primaryResource = null; + IIdentifiable? primaryResource = null; - AtomicReferenceResult refResult = atomicOperationObject.Ref != null + AtomicReferenceResult? refResult = atomicOperationObject.Ref != null ? _atomicReferenceAdapter.Convert(atomicOperationObject.Ref, requirements, state) : null; @@ -116,7 +119,7 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper RelationshipName = refResult.Relationship?.PublicName }; - state.WritableRequest.PrimaryId = refResult.Resource.StringId; + state.WritableRequest!.PrimaryId = refResult.Resource.StringId; state.WritableRequest.PrimaryResourceType = refResult.ResourceType; state.WritableRequest.Relationship = refResult.Relationship; state.WritableRequest.IsCollection = refResult.Relationship is HasManyAttribute; @@ -145,11 +148,11 @@ private void ConvertRefRelationship(SingleOrManyData relationshi { if (refResult.Relationship != null) { - state.WritableRequest.SecondaryResourceType = refResult.Relationship.RightType; + state.WritableRequest!.SecondaryResourceType = refResult.Relationship.RightType; - state.WritableTargetedFields.Relationships.Add(refResult.Relationship); + state.WritableTargetedFields!.Relationships.Add(refResult.Relationship); - object rightValue = _relationshipDataAdapter.Convert(relationshipData, refResult.Relationship, true, state); + object? rightValue = _relationshipDataAdapter.Convert(relationshipData, refResult.Relationship, true, state); refResult.Relationship.SetValue(refResult.Resource, rightValue); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs index 9ff01bc770..b17c94edb6 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs @@ -1,4 +1,5 @@ using System; +using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -7,6 +8,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters { /// + [PublicAPI] public sealed class AtomicReferenceAdapter : ResourceIdentityAdapter, IAtomicReferenceAdapter { public AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) @@ -24,7 +26,7 @@ public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceId using IDisposable _ = state.Position.PushElement("ref"); (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(atomicReference, requirements, state); - RelationshipAttribute relationship = atomicReference.Relationship != null + RelationshipAttribute? relationship = atomicReference.Relationship != null ? ConvertRelationship(atomicReference.Relationship, resourceType, state) : null; @@ -34,7 +36,7 @@ public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceId private RelationshipAttribute ConvertRelationship(string relationshipName, ResourceType resourceType, RequestAdapterState state) { using IDisposable _ = state.Position.PushElement("relationship"); - RelationshipAttribute relationship = resourceType.TryGetRelationshipByPublicName(relationshipName); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); AssertToManyInAddOrRemoveRelationship(relationship, state); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs index 1b85f7021b..724a5da96c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs @@ -13,9 +13,9 @@ public sealed class AtomicReferenceResult { public IIdentifiable Resource { get; } public ResourceType ResourceType { get; } - public RelationshipAttribute Relationship { get; } + public RelationshipAttribute? Relationship { get; } - public AtomicReferenceResult(IIdentifiable resource, ResourceType resourceType, RelationshipAttribute relationship) + public AtomicReferenceResult(IIdentifiable resource, ResourceType resourceType, RelationshipAttribute? relationship) { ArgumentGuard.NotNull(resource, nameof(resource)); ArgumentGuard.NotNull(resourceType, nameof(resourceType)); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs new file mode 100644 index 0000000000..72c4d12b1e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs @@ -0,0 +1,65 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Contains shared assertions for derived types. + /// + public abstract class BaseAdapter + { + [AssertionMethod] + protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (!data.IsAssigned) + { + throw new ModelConversionException(state.Position, "The 'data' element is required.", null); + } + } + + [AssertionMethod] + protected static void AssertDataHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (data.SingleValue == null) + { + if (!allowNull) + { + if (data.ManyValue == null) + { + AssertObjectIsNotNull(data.SingleValue, state); + } + + throw new ModelConversionException(state.Position, "Expected an object, instead of an array.", null); + } + + if (data.ManyValue != null) + { + throw new ModelConversionException(state.Position, "Expected an object or 'null', instead of an array.", null); + } + } + } + + [AssertionMethod] + protected static void AssertDataHasManyValue(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (data.ManyValue == null) + { + throw new ModelConversionException(state.Position, + data.SingleValue == null ? "Expected an array, instead of 'null'." : "Expected an array, instead of an object.", null); + } + } + + protected static void AssertObjectIsNotNull([SysNotNull] T? value, RequestAdapterState state) + where T : class + { + if (value is null) + { + throw new ModelConversionException(state.Position, "Expected an object, instead of 'null'.", null); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs deleted file mode 100644 index 2dffca2653..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs +++ /dev/null @@ -1,55 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Request.Adapters -{ - /// - /// Contains shared assertions for derived types. - /// - public abstract class BaseDataAdapter - { - [AssertionMethod] - protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) - where T : class, IResourceIdentity - { - if (!data.IsAssigned) - { - throw new ModelConversionException(state.Position, "The 'data' element is required.", null); - } - } - - [AssertionMethod] - protected static void AssertHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) - where T : class, IResourceIdentity - { - if (data.SingleValue == null) - { - if (!allowNull) - { - throw new ModelConversionException(state.Position, - data.ManyValue == null - ? "Expected an object in 'data' element, instead of 'null'." - : "Expected an object in 'data' element, instead of an array.", null); - } - - if (data.ManyValue != null) - { - throw new ModelConversionException(state.Position, "Expected an object or 'null' in 'data' element, instead of an array.", null); - } - } - } - - [AssertionMethod] - protected static void AssertHasManyValue(SingleOrManyData data, RequestAdapterState state) - where T : class, IResourceIdentity - { - if (data.ManyValue == null) - { - throw new ModelConversionException(state.Position, - data.SingleValue == null - ? "Expected an array in 'data' element, instead of 'null'." - : "Expected an array in 'data' element, instead of an object.", null); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs index eea4c7849b..46bcc1ca25 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs @@ -28,7 +28,7 @@ public DocumentAdapter(IJsonApiRequest request, ITargetedFields targetedFields, } /// - public object Convert(Document document) + public object? Convert(Document document) { ArgumentGuard.NotNull(document, nameof(document)); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs index 0c283e9bf0..a5088cf1af 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Request.Adapters { - /// - public sealed class DocumentInOperationsRequestAdapter : IDocumentInOperationsRequestAdapter + /// + public sealed class DocumentInOperationsRequestAdapter : BaseAdapter, IDocumentInOperationsRequestAdapter { private readonly IJsonApiOptions _options; private readonly IAtomicOperationObjectAdapter _atomicOperationObjectAdapter; @@ -33,7 +34,7 @@ public IList Convert(Document document, RequestAdapterState return ConvertOperations(document.Operations, state); } - private static void AssertHasOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) + private static void AssertHasOperations([NotNull] IEnumerable? atomicOperationObjects, RequestAdapterState state) { if (atomicOperationObjects.IsNullOrEmpty()) { @@ -41,7 +42,7 @@ private static void AssertHasOperations(IEnumerable atomi } } - private void AssertMaxOperationsNotExceeded(ICollection atomicOperationObjects, RequestAdapterState state) + private void AssertMaxOperationsNotExceeded(ICollection atomicOperationObjects, RequestAdapterState state) { if (atomicOperationObjects.Count > _options.MaximumOperationsPerRequest) { @@ -51,14 +52,15 @@ private void AssertMaxOperationsNotExceeded(ICollection a } } - private IList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) + private IList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) { var operations = new List(); int operationIndex = 0; - foreach (AtomicOperationObject atomicOperationObject in atomicOperationObjects) + foreach (AtomicOperationObject? atomicOperationObject in atomicOperationObjects) { using IDisposable _ = state.Position.PushArrayIndex(operationIndex); + AssertObjectIsNotNull(atomicOperationObject, state); OperationContainer operation = _atomicOperationObjectAdapter.Convert(atomicOperationObject, state); operations.Add(operation); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs index 2a0080cc4b..1a127b49ed 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -26,7 +26,7 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I } /// - public object Convert(Document document, RequestAdapterState state) + public object? Convert(Document document, RequestAdapterState state) { state.WritableTargetedFields = new TargetedFields(); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs index 3f13b25685..78e3ed2f1a 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs @@ -33,6 +33,6 @@ public interface IDocumentAdapter /// /// /// - object Convert(Document document); + object? Convert(Document document); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs index da6222e166..a1b8fc0585 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs @@ -10,6 +10,6 @@ public interface IDocumentInResourceOrRelationshipRequestAdapter /// /// Validates and converts the specified . /// - object Convert(Document document, RequestAdapterState state); + object? Convert(Document document, RequestAdapterState state); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs index cc4da5bc5e..222642dc76 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs @@ -13,12 +13,12 @@ public interface IRelationshipDataAdapter /// /// Validates and converts the specified . /// - object Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); + object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); /// /// Validates and converts the specified . /// - object Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, + object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs index 38a841d45d..f47e25dfa0 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs @@ -1,3 +1,4 @@ +using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -6,6 +7,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters /// /// Validates and converts the data from an entry in an atomic:operations request that creates or updates a resource. /// + [PublicAPI] public interface IResourceDataInOperationsRequestAdapter { /// diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index ad5aebac9e..6cc42bacdd 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters { /// - public sealed class RelationshipDataAdapter : BaseDataAdapter, IRelationshipDataAdapter + public sealed class RelationshipDataAdapter : BaseAdapter, IRelationshipDataAdapter { private static readonly CollectionConverter CollectionConverter = new(); @@ -23,7 +23,7 @@ public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifi } /// - public object Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) + public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) { SingleOrManyData identifierData = ToIdentifierData(data); return Convert(identifierData, relationship, useToManyElementType, state); @@ -36,7 +36,7 @@ private static SingleOrManyData ToIdentifierData(Singl return default; } - object newValue = null; + object? newValue = null; if (data.ManyValue != null) { @@ -61,7 +61,7 @@ private static SingleOrManyData ToIdentifierData(Singl } /// - public object Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, + public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) { ArgumentGuard.NotNull(relationship, nameof(relationship)); @@ -82,10 +82,10 @@ public object Convert(SingleOrManyData data, Relations : ConvertToManyRelationshipData(data, relationship, requirements, useToManyElementType, state); } - private IIdentifiable ConvertToOneRelationshipData(SingleOrManyData data, ResourceIdentityRequirements requirements, + private IIdentifiable? ConvertToOneRelationshipData(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) { - AssertHasSingleValue(data, true, state); + AssertDataHasSingleValue(data, true, state); return data.SingleValue != null ? _resourceIdentifierObjectAdapter.Convert(data.SingleValue, requirements, state) : null; } @@ -93,12 +93,12 @@ private IIdentifiable ConvertToOneRelationshipData(SingleOrManyData data, RelationshipAttribute relationship, ResourceIdentityRequirements requirements, bool useToManyElementType, RequestAdapterState state) { - AssertHasManyValue(data, state); + AssertDataHasManyValue(data, state); int arrayIndex = 0; var rightResources = new List(); - foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue) + foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue!) { using IDisposable _ = state.Position.PushArrayIndex(arrayIndex); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs index 4c2d34b28d..252c0fe2a6 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs @@ -34,7 +34,7 @@ public IDisposable PushArrayIndex(int index) return _disposable; } - public string ToSourcePointer() + public string? ToSourcePointer() { if (!_stack.Any()) { diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs index 2730bcbf9b..b333b61140 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs @@ -12,13 +12,13 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters [PublicAPI] public sealed class RequestAdapterState : IDisposable { - private readonly IDisposable _backupRequestState; + private readonly IDisposable? _backupRequestState; public IJsonApiRequest InjectableRequest { get; } public ITargetedFields InjectableTargetedFields { get; } - public JsonApiRequest WritableRequest { get; set; } - public TargetedFields WritableTargetedFields { get; set; } + public JsonApiRequest? WritableRequest { get; set; } + public TargetedFields? WritableTargetedFields { get; set; } public RequestAdapterPosition Position { get; } = new(); public IJsonApiRequest Request => WritableRequest ?? InjectableRequest; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs index f1747fffc8..6e1d72fc17 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters { /// - public class ResourceDataAdapter : BaseDataAdapter, IResourceDataAdapter + public class ResourceDataAdapter : BaseAdapter, IResourceDataAdapter { private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IResourceObjectAdapter _resourceObjectAdapter; @@ -29,7 +29,7 @@ public IIdentifiable Convert(SingleOrManyData data, ResourceIden AssertHasData(data, state); using IDisposable _ = state.Position.PushElement("data"); - AssertHasSingleValue(data, false, state); + AssertDataHasSingleValue(data, false, state); (IIdentifiable resource, ResourceType _) = ConvertResourceObject(data, requirements, state); @@ -43,7 +43,7 @@ public IIdentifiable Convert(SingleOrManyData data, ResourceIden protected virtual (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) { - return _resourceObjectAdapter.Convert(data.SingleValue, requirements, state); + return _resourceObjectAdapter.Convert(data.SingleValue!, requirements, state); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs index b0e59b6afd..5ebdb6f3cd 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs @@ -19,7 +19,7 @@ protected override (IIdentifiable resource, ResourceType resourceType) ConvertRe (IIdentifiable resource, ResourceType resourceType) = base.ConvertResourceObject(data, requirements, state); - state.WritableRequest.PrimaryResourceType = resourceType; + state.WritableRequest!.PrimaryResourceType = resourceType; state.WritableRequest.PrimaryId = resource.StringId; return (resource, resourceType); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index 5bb2a52d53..80d927c1b0 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -11,7 +12,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters /// /// Base class for validating and converting objects that represent an identity. /// - public abstract class ResourceIdentityAdapter + public abstract class ResourceIdentityAdapter : BaseAdapter { private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; @@ -40,10 +41,10 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) { - AssertHasType(identity, state); + AssertHasType(identity.Type, state); using IDisposable _ = state.Position.PushElement("type"); - ResourceType resourceType = _resourceGraph.TryGetResourceType(identity.Type); + ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type); AssertIsKnownResourceType(resourceType, identity.Type, state); AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); @@ -51,15 +52,15 @@ private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityReq return resourceType; } - private static void AssertHasType(IResourceIdentity identity, RequestAdapterState state) + private static void AssertHasType([NotNull] string? identityType, RequestAdapterState state) { - if (identity.Type == null) + if (identityType == null) { throw new ModelConversionException(state.Position, "The 'type' element is required.", null); } } - private static void AssertIsKnownResourceType(ResourceType resourceType, string typeName, RequestAdapterState state) + private static void AssertIsKnownResourceType([NotNull] ResourceType? resourceType, string typeName, RequestAdapterState state) { if (resourceType == null) { @@ -67,7 +68,7 @@ private static void AssertIsKnownResourceType(ResourceType resourceType, string } } - private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType expected, string relationshipName, RequestAdapterState state) + private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType? expected, string? relationshipName, RequestAdapterState state) { if (expected != null && !expected.ClrType.IsAssignableFrom(actual.ClrType)) { @@ -126,7 +127,7 @@ private static void AssertNoIdWithLid(IResourceIdentity identity, RequestAdapter private static void AssertHasIdOrLid(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) { - string message = null; + string? message = null; if (requirements.IdValue != null && identity.Id == null) { @@ -156,7 +157,7 @@ private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterStat } } - private static void AssertSameIdValue(IResourceIdentity identity, string expected, RequestAdapterState state) + private static void AssertSameIdValue(IResourceIdentity identity, string? expected, RequestAdapterState state) { if (expected != null && identity.Id != expected) { @@ -167,7 +168,7 @@ private static void AssertSameIdValue(IResourceIdentity identity, string expecte } } - private static void AssertSameLidValue(IResourceIdentity identity, string expected, RequestAdapterState state) + private static void AssertSameLidValue(IResourceIdentity identity, string? expected, RequestAdapterState state) { if (expected != null && identity.Lid != expected) { @@ -194,7 +195,7 @@ private void AssignStringId(IResourceIdentity identity, IIdentifiable resource, } } - protected static void AssertIsKnownRelationship(RelationshipAttribute relationship, string relationshipName, ResourceType resourceType, + protected static void AssertIsKnownRelationship([NotNull] RelationshipAttribute? relationship, string relationshipName, ResourceType resourceType, RequestAdapterState state) { if (relationship == null) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index 601212ec93..0483723abd 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -13,7 +13,7 @@ public sealed class ResourceIdentityRequirements /// /// When not null, indicates that the "type" element must be compatible with the specified resource type. /// - public ResourceType ResourceType { get; init; } + public ResourceType? ResourceType { get; init; } /// /// When not null, indicates the presence or absence of the "id" element. @@ -23,16 +23,16 @@ public sealed class ResourceIdentityRequirements /// /// When not null, indicates what the value of the "id" element must be. /// - public string IdValue { get; init; } + public string? IdValue { get; init; } /// /// When not null, indicates what the value of the "lid" element must be. /// - public string LidValue { get; init; } + public string? LidValue { get; init; } /// /// When not null, indicates the name of the relationship to use in error messages. /// - public string RelationshipName { get; init; } + public string? RelationshipName { get; init; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 5c49a4e0e4..5e1ebb5311 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using JetBrains.Annotations; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -42,21 +42,22 @@ public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory reso return (resource, resourceType); } - private void ConvertAttributes(IDictionary resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, + private void ConvertAttributes(IDictionary? resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, RequestAdapterState state) { using IDisposable _ = state.Position.PushElement("attributes"); - foreach ((string attributeName, object attributeValue) in resourceObjectAttributes.EmptyIfNull()) + 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) + private void ConvertAttribute(IIdentifiable resource, string attributeName, object? attributeValue, ResourceType resourceType, + RequestAdapterState state) { using IDisposable _ = state.Position.PushElement(attributeName); - AttrAttribute attr = resourceType.TryGetAttributeByPublicName(attributeName); + AttrAttribute? attr = resourceType.FindAttributeByPublicName(attributeName); if (attr == null && _options.AllowUnknownFieldsInRequestBody) { @@ -69,12 +70,11 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje AssertNoBlockedChange(attr, resourceType, state); AssertNotReadOnly(attr, resourceType, state); - attr!.SetValue(resource, attributeValue); - state.WritableTargetedFields.Attributes.Add(attr); + attr.SetValue(resource, attributeValue); + state.WritableTargetedFields!.Attributes.Add(attr); } - [AssertionMethod] - private static void AssertIsKnownAttribute(AttrAttribute attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, ResourceType resourceType, RequestAdapterState state) { if (attr == null) { @@ -83,7 +83,7 @@ private static void AssertIsKnownAttribute(AttrAttribute attr, string attributeN } } - private static void AssertNoInvalidAttribute(object attributeValue, RequestAdapterState state) + private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdapterState state) { if (attributeValue is JsonInvalidAttributeInfo info) { @@ -126,22 +126,24 @@ private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceT } } - private void ConvertRelationships(IDictionary resourceObjectRelationships, IIdentifiable resource, + private void ConvertRelationships(IDictionary? resourceObjectRelationships, IIdentifiable resource, ResourceType resourceType, RequestAdapterState state) { using IDisposable _ = state.Position.PushElement("relationships"); - foreach ((string relationshipName, RelationshipObject relationshipObject) in resourceObjectRelationships.EmptyIfNull()) + foreach ((string relationshipName, RelationshipObject? relationshipObject) in resourceObjectRelationships.EmptyIfNull()) { - ConvertRelationship(relationshipName, relationshipObject.Data, resource, resourceType, state); + ConvertRelationship(relationshipName, relationshipObject, resource, resourceType, state); } } - private void ConvertRelationship(string relationshipName, SingleOrManyData relationshipData, IIdentifiable resource, - ResourceType resourceType, RequestAdapterState state) + private void ConvertRelationship(string relationshipName, RelationshipObject? relationshipObject, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) { using IDisposable _ = state.Position.PushElement(relationshipName); - RelationshipAttribute relationship = resourceType.TryGetRelationshipByPublicName(relationshipName); + AssertObjectIsNotNull(relationshipObject, state); + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); if (relationship == null && _options.AllowUnknownFieldsInRequestBody) { @@ -150,10 +152,10 @@ private void ConvertRelationship(string relationshipName, SingleOrManyData - /// Deserializes the incoming JSON:API request body and converts it to models, which are passed to controller actions by ASP.NET Core on `FromBody` + /// Deserializes the incoming JSON:API request body and converts it to models, which are passed to controller actions by ASP.NET on `FromBody` /// parameters. /// [PublicAPI] @@ -14,6 +14,6 @@ public interface IJsonApiReader /// /// Reads an object from the request body. /// - Task ReadAsync(HttpRequest httpRequest); + Task ReadAsync(HttpRequest httpRequest); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs index 96831014c3..312c11b0b4 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; namespace JsonApiDotNetCore.Serialization.Request { @@ -36,7 +37,7 @@ public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, } /// - public async Task ReadAsync(HttpRequest httpRequest) + public async Task ReadAsync(HttpRequest httpRequest) { ArgumentGuard.NotNull(httpRequest, nameof(httpRequest)); @@ -55,7 +56,7 @@ private static async Task ReceiveRequestBodyAsync(HttpRequest httpReques return await reader.ReadToEndAsync(); } - private object GetModel(string requestBody) + private object? GetModel(string requestBody) { AssertHasRequestBody(requestBody); @@ -81,7 +82,11 @@ private Document DeserializeDocument(string requestBody) using IDisposable _ = CodeTimingSessionManager.Current.Measure("JsonSerializer.Deserialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); - return JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); + var document = JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); + + AssertHasDocument(document, requestBody); + + return document; } catch (JsonException exception) { @@ -92,7 +97,16 @@ private Document DeserializeDocument(string requestBody) } } - private object ConvertDocumentToModel(Document document, string requestBody) + 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 { diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs index 5c448613a6..7080da1c9d 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs @@ -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 index c9922e855b..70be3f7366 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCore.Serialization.Request [PublicAPI] public sealed class ModelConversionException : Exception { - public string GenericMessage { get; } - public string SpecificMessage { get; } + public string? GenericMessage { get; } + public string? SpecificMessage { get; } public HttpStatusCode? StatusCode { get; } - public string SourcePointer { get; } + public string? SourcePointer { get; } - public ModelConversionException(RequestAdapterPosition position, string genericMessage, string specificMessage, HttpStatusCode? statusCode = null) + public ModelConversionException(RequestAdapterPosition position, string? genericMessage, string? specificMessage, HttpStatusCode? statusCode = null) : base(genericMessage) { ArgumentGuard.NotNull(position, nameof(position)); diff --git a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs index a06e4b76c3..a787901494 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Serialization.Response public sealed class EmptyResponseMeta : IResponseMeta { /// - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary? GetMeta() { return null; } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs index e8c0e6dab4..8c904cf032 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs @@ -13,6 +13,6 @@ public interface IJsonApiWriter /// /// Writes an object to the response body. /// - Task WriteAsync(object model, HttpContext httpContext); + Task WriteAsync(object? model, HttpContext httpContext); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs index ce3e027410..db16a774dd 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -13,16 +12,16 @@ public interface ILinkBuilder /// /// Builds the links object that is included in the top-level of the document. /// - TopLevelLinks GetTopLevelLinks(); + TopLevelLinks? GetTopLevelLinks(); /// /// Builds the links object for a returned resource (primary or included). /// - ResourceLinks GetResourceLinks(ResourceType resourceType, string id); + ResourceLinks? GetResourceLinks(ResourceType resourceType, string id); /// /// Builds the links object for a relationship inside a returned resource. /// - RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); + RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, string leftId); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs index c94b0150da..285f0df550 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs @@ -13,11 +13,11 @@ public interface IMetaBuilder /// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will /// overwrite the existing one. /// - void Add(IReadOnlyDictionary values); + void Add(IReadOnlyDictionary values); /// /// Builds the top-level meta data object. /// - IDictionary Build(); + IDictionary? Build(); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs index ca13ceaad2..83596f9146 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs @@ -13,6 +13,6 @@ public interface IResponseMeta /// /// Gets the global top-level JSON:API meta information to add to the response. /// - IReadOnlyDictionary GetMeta(); + IReadOnlyDictionary? GetMeta(); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs index 960d541cd4..153e993b0d 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs @@ -27,7 +27,7 @@ public interface IResponseModelAdapter /// /// /// - /// ]]> + /// ]]> /// /// /// @@ -42,6 +42,6 @@ public interface IResponseModelAdapter /// /// /// - Document Convert(object model); + Document Convert(object? model); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 57d33276a1..793a720c3a 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -49,7 +49,7 @@ public JsonApiWriter(IJsonApiRequest request, IJsonApiOptions options, IResponse } /// - public async Task WriteAsync(object model, HttpContext httpContext) + public async Task WriteAsync(object? model, HttpContext httpContext) { ArgumentGuard.NotNull(httpContext, nameof(httpContext)); @@ -59,11 +59,11 @@ public async Task WriteAsync(object model, HttpContext httpContext) return; } - string responseBody = GetResponseBody(model, httpContext); + string? responseBody = GetResponseBody(model, httpContext); if (httpContext.Request.Method == HttpMethod.Head.Method) { - httpContext.Response.GetTypedHeaders().ContentLength = Encoding.UTF8.GetByteCount(responseBody); + httpContext.Response.GetTypedHeaders().ContentLength = responseBody == null ? 0 : Encoding.UTF8.GetByteCount(responseBody); return; } @@ -78,7 +78,7 @@ 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) + private string? GetResponseBody(object? model, HttpContext httpContext) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Write response body"); @@ -120,7 +120,7 @@ private static bool IsSuccessStatusCode(HttpStatusCode statusCode) return new HttpResponseMessage(statusCode).IsSuccessStatusCode; } - private string RenderModel(object model) + private string RenderModel(object? model) { Document document = _responseModelAdapter.Convert(model); return SerializeDocument(document); @@ -143,12 +143,9 @@ private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, s string url = request.GetEncodedUrl(); EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); - if (responseETag != null) - { - response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); + response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); - return RequestContainsMatchingETag(request.Headers, responseETag); - } + return RequestContainsMatchingETag(request.Headers, responseETag); } return false; @@ -157,7 +154,7 @@ private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, s private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag) { if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) && - EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList requestETags)) + EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList? requestETags)) { foreach (EntityTagHeaderValue requestETag in requestETags) { @@ -171,7 +168,7 @@ private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders return false; } - private async Task SendResponseBodyAsync(HttpResponse httpResponse, string responseBody) + private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody) { if (!string.IsNullOrEmpty(responseBody)) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index aa6d76255e..e810552d18 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -39,6 +39,19 @@ public class LinkBuilder : ILinkBuilder private readonly LinkGenerator _linkGenerator; private readonly 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) { @@ -62,10 +75,10 @@ private static string NoAsyncSuffix(string actionName) } /// - public TopLevelLinks GetTopLevelLinks() + public TopLevelLinks? GetTopLevelLinks() { var links = new TopLevelLinks(); - ResourceType resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; + ResourceType? resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; if (ShouldIncludeTopLevelLink(LinkTypes.Self, resourceType)) { @@ -74,12 +87,12 @@ public TopLevelLinks GetTopLevelLinks() 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, resourceType)) { - SetPaginationInTopLevelLinks(resourceType, links); + SetPaginationInTopLevelLinks(resourceType!, links); } return links.HasValue() ? links : null; @@ -89,7 +102,7 @@ public TopLevelLinks GetTopLevelLinks() /// Checks if the top-level should be added by first checking configuration on the , and if not /// configured, by checking with the global configuration in . /// - private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType resourceType) + private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourceType) { if (resourceType != null && resourceType.TopLevelLinks != LinkTypes.NotConfigured) { @@ -101,14 +114,12 @@ private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType resource private string GetLinkForTopLevelSelf() { - return _options.UseRelativeLinks - ? _httpContextAccessor.HttpContext!.Request.GetEncodedPathAndQuery() - : _httpContextAccessor.HttpContext!.Request.GetEncodedUrl(); + return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl(); } private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLinks links) { - string pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, resourceType); + string? pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, resourceType); links.First = GetLinkForPagination(1, pageSizeValue); @@ -131,15 +142,15 @@ private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLin } } - private string CalculatePageSizeValue(PageSize topPageSize, ResourceType resourceType) + 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; + PageSize? newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, resourceType); } - private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize, ResourceType resourceType) + private string? ChangeTopPageSize(string pageSizeParameterValue, PageSize? topPageSize, ResourceType resourceType) { IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, resourceType); int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); @@ -164,7 +175,7 @@ private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPage return parameterValue == string.Empty ? null : parameterValue; } - private IImmutableList ParsePageSizeExpression(string pageSizeParameterValue, ResourceType resourceType) + private IImmutableList ParsePageSizeExpression(string? pageSizeParameterValue, ResourceType resourceType) { if (pageSizeParameterValue == null) { @@ -177,11 +188,11 @@ private IImmutableList ParsePageSiz 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 }; @@ -190,10 +201,9 @@ private string GetLinkForPagination(int pageOffset, string pageSizeValue) return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); } - private string GetQueryStringInPaginationLink(int pageOffset, string pageSizeValue) + private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeValue) { - IDictionary parameters = - _httpContextAccessor.HttpContext!.Request.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); + IDictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); if (pageSizeValue == null) { @@ -213,7 +223,7 @@ private string GetQueryStringInPaginationLink(int pageOffset, string pageSizeVal parameters[PageNumberParameterName] = pageOffset.ToString(); } - string queryStringValue = QueryString.Create(parameters).Value; + string queryStringValue = QueryString.Create(parameters).Value ?? string.Empty; return DecodeSpecialCharacters(queryStringValue); } @@ -223,7 +233,7 @@ private static string DecodeSpecialCharacters(string uri) } /// - public ResourceLinks GetResourceLinks(ResourceType resourceType, string id) + public ResourceLinks? GetResourceLinks(ResourceType resourceType, string id) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ArgumentGuard.NotNullNorEmpty(id, nameof(id)); @@ -254,28 +264,28 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string GetLinkForResourceSelf(ResourceType resourceType, string resourceId) { - string controllerName = _controllerResourceMapping.TryGetControllerNameForResourceType(resourceType); - IDictionary routeValues = GetRouteValues(resourceId, null); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); + IDictionary routeValues = GetRouteValues(resourceId, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } /// - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, string leftId) { ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNullNorEmpty(leftId, nameof(leftId)); var links = new RelationshipLinks(); if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) { - links.Self = GetLinkForRelationshipSelf(leftResource.StringId, relationship); + links.Self = GetLinkForRelationshipSelf(leftId, relationship); } if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) { - links.Related = GetLinkForRelationshipRelated(leftResource.StringId, relationship); + links.Related = GetLinkForRelationshipRelated(leftId, relationship); } return links.HasValue() ? links : null; @@ -283,26 +293,26 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship private string GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) { - string controllerName = _controllerResourceMapping.TryGetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); } private string GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) { - string controllerName = _controllerResourceMapping.TryGetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); } - private IDictionary GetRouteValues(string primaryId, string relationshipName) + private IDictionary GetRouteValues(string primaryId, string? relationshipName) { // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, // so users must override RenderLinkForAction to supply them, if applicable. - RouteValueDictionary routeValues = _httpContextAccessor.HttpContext!.Request.RouteValues; + RouteValueDictionary routeValues = HttpContext.Request.RouteValues; routeValues["id"] = primaryId; routeValues["relationshipName"] = relationshipName; @@ -310,7 +320,7 @@ private IDictionary GetRouteValues(string primaryId, string rela return routeValues; } - protected virtual string RenderLinkForAction(string controllerName, string actionName, IDictionary routeValues) + protected virtual string RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) { return _options.UseRelativeLinks ? _linkGenerator.GetPathByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues) diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index 265154cca4..ef75f6472a 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -14,7 +14,7 @@ public sealed class MetaBuilder : IMetaBuilder private readonly IJsonApiOptions _options; private readonly IResponseMeta _responseMeta; - private Dictionary _meta = new(); + private Dictionary _meta = new(); public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) { @@ -28,7 +28,7 @@ public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options } /// - public void Add(IReadOnlyDictionary values) + public void Add(IReadOnlyDictionary values) { ArgumentGuard.NotNull(values, nameof(values)); @@ -36,7 +36,7 @@ public void Add(IReadOnlyDictionary values) } /// - public IDictionary Build() + public IDictionary? Build() { if (_paginationContext.TotalResourceCount != null) { @@ -49,7 +49,7 @@ public IDictionary Build() _meta.Add(key, _paginationContext.TotalResourceCount); } - IReadOnlyDictionary extraMeta = _responseMeta.GetMeta(); + IReadOnlyDictionary? extraMeta = _responseMeta.GetMeta(); if (extraMeta != null) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 5137f62716..f60ee1a288 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -22,10 +22,10 @@ internal sealed class ResourceObjectTreeNode : IEquatable _directChildren; + private List? _directChildren; // Related resource objects per relationship. These are emitted in 'included'. - private Dictionary> _childrenByRelationship; + private Dictionary>? _childrenByRelationship; private bool IsTreeRoot => RootType.Equals(Type); @@ -51,7 +51,7 @@ public ResourceObjectTreeNode(IIdentifiable resource, ResourceType type, Resourc public static ResourceObjectTreeNode CreateRoot() { - return new(RootResource, RootType, new ResourceObject()); + return new ResourceObjectTreeNode(RootResource, RootType, new ResourceObject()); } public void AttachDirectChild(ResourceObjectTreeNode treeNode) @@ -79,6 +79,11 @@ public void AttachRelationshipChild(RelationshipAttribute relationship, Resource ArgumentGuard.NotNull(relationship, nameof(relationship)); ArgumentGuard.NotNull(rightNode, nameof(rightNode)); + if (_childrenByRelationship == null) + { + throw new InvalidOperationException("Call EnsureHasRelationship() first."); + } + HashSet rightNodes = _childrenByRelationship[relationship]; rightNodes.Add(rightNode); } @@ -130,7 +135,7 @@ private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode tr { foreach (RelationshipAttribute relationship in treeNode.Type.Relationships) { - if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet rightNodes)) + if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes)) { VisitRelationshipChildInSubtree(rightNodes, visited); } @@ -146,9 +151,9 @@ private static void VisitRelationshipChildInSubtree(HashSet GetRightNodesInRelationship(RelationshipAttribute relationship) + public ISet? GetRightNodesInRelationship(RelationshipAttribute relationship) { - return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet rightNodes) + return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes) ? rightNodes : null; } @@ -195,7 +200,7 @@ private void AssertIsTreeRoot() } } - public bool Equals(ResourceObjectTreeNode other) + public bool Equals(ResourceObjectTreeNode? other) { if (ReferenceEquals(null, other)) { @@ -210,7 +215,7 @@ public bool Equals(ResourceObjectTreeNode other) return ResourceObjectComparer.Instance.Equals(ResourceObject, other.ResourceObject); } - public override bool Equals(object other) + public override bool Equals(object? other) { return Equals(other as ResourceObjectTreeNode); } @@ -239,8 +244,8 @@ public override string ToString() private sealed class EmptyResource : IIdentifiable { - public string StringId { get; set; } - public string LocalId { get; set; } + public string? StringId { get; set; } + public string? LocalId { get; set; } } private sealed class ResourceObjectComparer : IEqualityComparer @@ -251,7 +256,7 @@ private ResourceObjectComparer() { } - public bool Equals(ResourceObject x, ResourceObject y) + public bool Equals(ResourceObject? x, ResourceObject? y) { if (ReferenceEquals(x, y)) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index df19cb6f70..40dd7c045c 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -59,21 +59,22 @@ public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, IL } /// - public Document Convert(object model) + public Document Convert(object? model) { _sparseFieldSetCache.Reset(); _resourceToTreeNodeCache.Clear(); var document = new Document(); - IncludeExpression include = _evaluatedIncludeCache.Get(); + IncludeExpression? include = _evaluatedIncludeCache.Get(); IImmutableSet includeElements = include?.Elements ?? ImmutableHashSet.Empty; var rootNode = ResourceObjectTreeNode.CreateRoot(); - ResourceType resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; if (model is IEnumerable resources) { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + foreach (IIdentifiable resource in resources) { TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); @@ -86,6 +87,8 @@ public Document Convert(object model) } else if (model is IIdentifiable resource) { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); PopulateRelationshipsInTree(rootNode, _request.Kind); @@ -96,7 +99,7 @@ public Document Convert(object model) { document.Data = new SingleOrManyData(null); } - else if (model is IEnumerable operations) + else if (model is IEnumerable operations) { using var _ = new RevertRequestStateOnDispose(_request, null); document.Results = operations.Select(operation => ConvertOperation(operation, includeElements)).ToList(); @@ -122,15 +125,15 @@ public Document Convert(object model) return document; } - protected virtual AtomicResultObject ConvertOperation(OperationContainer operation, IImmutableSet includeElements) + protected virtual AtomicResultObject ConvertOperation(OperationContainer? operation, IImmutableSet includeElements) { - ResourceObject resourceObject = null; + ResourceObject? resourceObject = null; if (operation != null) { _request.CopyFrom(operation.Request); - ResourceType resourceType = operation.Request.SecondaryResourceType ?? operation.Request.PrimaryResourceType; + ResourceType resourceType = (operation.Request.SecondaryResourceType ?? operation.Request.PrimaryResourceType)!; var rootNode = ResourceObjectTreeNode.CreateRoot(); TraverseResource(operation.Resource, resourceType, operation.Request.Kind, includeElements, rootNode, null); @@ -149,7 +152,7 @@ protected virtual AtomicResultObject ConvertOperation(OperationContainer operati } private void TraverseResource(IIdentifiable resource, ResourceType type, EndpointKind kind, IImmutableSet includeElements, - ResourceObjectTreeNode parentTreeNode, RelationshipAttribute parentRelationship) + ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) { ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, type, kind); @@ -170,7 +173,7 @@ private void TraverseResource(IIdentifiable resource, ResourceType type, Endpoin private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType type, EndpointKind kind) { - if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode treeNode)) + if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) { ResourceObject resourceObject = ConvertResource(resource, type, kind); treeNode = new ResourceObjectTreeNode(resource, type, resourceObject); @@ -201,17 +204,17 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(type); resourceObject.Attributes = ConvertAttributes(resource, type, fieldSet); - resourceObject.Links = _linkBuilder.GetResourceLinks(type, resource.StringId); + resourceObject.Links = _linkBuilder.GetResourceLinks(type, resource.StringId!); resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(type, resource); } return resourceObject; } - protected virtual IDictionary ConvertAttributes(IIdentifiable resource, ResourceType resourceType, + protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, IImmutableSet fieldSet) { - var attrMap = new Dictionary(resourceType.Attributes.Count); + var attrMap = new Dictionary(resourceType.Attributes.Count); foreach (AttrAttribute attr in resourceType.Attributes) { @@ -220,7 +223,7 @@ protected virtual IDictionary ConvertAttributes(IIdentifiable re continue; } - object value = attr.GetValue(resource); + object? value = attr.GetValue(resource); if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && value == null) { @@ -251,7 +254,7 @@ private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTre private void TraverseRelationship(RelationshipAttribute relationship, IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IncludeElementExpression includeElement, EndpointKind kind) { - object rightValue = relationship.GetValue(leftResource); + object? rightValue = relationship.GetValue(leftResource); ICollection rightResources = CollectionConverter.ExtractResources(rightValue); leftTreeNode.EnsureHasRelationship(relationship); @@ -289,7 +292,7 @@ private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNo private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) { SingleOrManyData data = GetRelationshipData(treeNode, relationship); - RelationshipLinks links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource); + RelationshipLinks? links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource.StringId!); if (links != null || data.IsAssigned) { @@ -299,14 +302,14 @@ private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNod Data = data }; - treeNode.ResourceObject.Relationships ??= new Dictionary(); + treeNode.ResourceObject.Relationships ??= new Dictionary(); treeNode.ResourceObject.Relationships.Add(relationship.PublicName, relationshipObject); } } private static SingleOrManyData GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) { - ISet rightNodes = treeNode.GetRightNodesInRelationship(relationship); + ISet? rightNodes = treeNode.GetRightNodesInRelationship(relationship); if (rightNodes != null) { @@ -324,7 +327,7 @@ private static SingleOrManyData GetRelationshipData(Re return default; } - protected virtual JsonApiObject GetApiObject() + protected virtual JsonApiObject? GetApiObject() { if (!_options.IncludeJsonApiVersion) { @@ -347,7 +350,7 @@ protected virtual JsonApiObject GetApiObject() return jsonApiObject; } - private IList GetIncluded(ResourceObjectTreeNode rootNode) + private IList? GetIncluded(ResourceObjectTreeNode rootNode) { IList resourceObjects = rootNode.GetResponseIncluded(); diff --git a/src/JsonApiDotNetCore/Services/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs index a030622f97..56286f9fe7 100644 --- a/src/JsonApiDotNetCore/Services/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -11,6 +11,6 @@ public interface ICreateService /// /// Handles a JSON:API request to create a new resource with attributes, relationships or both. /// - Task CreateAsync(TResource resource, CancellationToken cancellationToken); + Task CreateAsync(TResource resource, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index b8305105ec..d57962b72d 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -13,6 +13,6 @@ public interface IGetRelationshipService /// /// Handles a JSON:API request to retrieve a single relationship. /// - Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs index a1ebbc5c75..1820f435bd 100644 --- a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs +++ b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs @@ -14,6 +14,6 @@ public interface IGetSecondaryService /// Handles a JSON:API request to retrieve a single resource or a collection of resources for a secondary endpoint, such as /articles/1/author or /// /articles/1/revisions. /// - Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index bf7f377dba..2f6f8aefad 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -25,6 +25,6 @@ public interface ISetRelationshipService /// /// Propagates notification that request handling should be canceled. /// - Task SetRelationshipAsync(TId leftId, string relationshipName, object rightValue, CancellationToken cancellationToken); + Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index 068a6c9b50..93bb79bca3 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -12,6 +12,6 @@ public interface IUpdateService /// Handles a JSON:API request to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. /// And only the values of sent relationships are replaced. /// - Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); + Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 798d6c8423..b7509deafc 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,9 +65,11 @@ public virtual async Task> GetAsync(CancellationT using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + if (_options.IncludeTotalResourceCount) { - FilterExpression topFilter = _queryLayerComposer.GetTopFilterFromConstraints(_request.PrimaryResourceType); + FilterExpression? topFilter = _queryLayerComposer.GetTopFilterFromConstraints(_request.PrimaryResourceType); _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(topFilter, cancellationToken); if (_paginationContext.TotalResourceCount == 0) @@ -100,7 +103,7 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella } /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -110,19 +113,20 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType!); QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - TResource primaryResource = primaryResources.SingleOrDefault(); + TResource? primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - object rightValue = _request.Relationship.GetValue(primaryResource); + object? rightValue = _request.Relationship.GetValue(primaryResource); if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) { @@ -133,7 +137,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN } /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -145,23 +149,24 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResourceType); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResourceType!); QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - TResource primaryResource = primaryResources.SingleOrDefault(); + TResource? primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); return _request.Relationship.GetValue(primaryResource); } /// - public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -169,6 +174,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio }); ArgumentGuard.NotNull(resource, nameof(resource)); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); @@ -189,12 +195,12 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio { if (!Equals(resourceFromRequest.Id, default(TId))) { - TResource existingResource = - await TryGetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + TResource? existingResource = + await GetPrimaryResourceByIdOrDefaultAsync(resourceFromRequest.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); if (existingResource != null) { - throw new ResourceAlreadyExistsException(resourceFromRequest.StringId, _request.PrimaryResourceType.PublicName); + throw new ResourceAlreadyExistsException(resourceFromRequest.StringId!, _request.PrimaryResourceType.PublicName); } } @@ -222,7 +228,7 @@ protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds( primaryResource)) { - object rightValue = relationship.GetValue(primaryResource); + object? rightValue = relationship.GetValue(primaryResource); ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); IAsyncEnumerable missingResourcesInRelationship = @@ -243,14 +249,14 @@ private async IAsyncEnumerable GetMissingRightRes IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, existingRightResourceIdsQueryLayer, cancellationToken); - string[] existingResourceIds = existingResources.Select(resource => resource.StringId).ToArray(); + string[] existingResourceIds = existingResources.Select(resource => resource.StringId!).ToArray(); foreach (IIdentifiable rightResourceId in rightResourceIds) { if (!existingResourceIds.Contains(rightResourceId.StringId)) { yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName, - rightResourceId.StringId); + rightResourceId.StringId!); } } } @@ -294,9 +300,11 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); + TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); - object rightValue = _request.Relationship.GetValue(leftResource); + object? rightValue = _request.Relationship.GetValue(leftResource); ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); rightResourceIds.ExceptWith(existingRightResourceIds); @@ -308,14 +316,16 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); IReadOnlyCollection leftResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); - TResource leftResource = leftResources.FirstOrDefault(); + TResource? leftResource = leftResources.FirstOrDefault(); AssertPrimaryResourceExists(leftResource); return leftResource; } - protected async Task AssertRightResourcesExistAsync(object rightValue, CancellationToken cancellationToken) + protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); + ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); if (rightResourceIds.Any()) @@ -333,7 +343,7 @@ protected async Task AssertRightResourcesExistAsync(object rightValue, Cancellat } /// - public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -373,7 +383,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can } /// - public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -452,14 +462,16 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); + TResource? primaryResource = await GetPrimaryResourceByIdOrDefaultAsync(id, fieldSelection, cancellationToken); AssertPrimaryResourceExists(primaryResource); return primaryResource; } - private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + private async Task GetPrimaryResourceByIdOrDefaultAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); @@ -468,6 +480,8 @@ private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); @@ -476,20 +490,41 @@ protected async Task GetPrimaryResourceForUpdateAsync(TId id, Cancell } [AssertionMethod] - private void AssertPrimaryResourceExists(TResource resource) + private void AssertPrimaryResourceExists([SysNotNull] TResource? resource) { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + if (resource == null) { - throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResourceType.PublicName); + throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); + } + } + + [AssertionMethod] + private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) + { + if (relationship == null) + { + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); + } + } + + [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 AssertHasRelationship(RelationshipAttribute relationship, string name) + private void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] RelationshipAttribute? relationship) { if (relationship == null) { - throw new RelationshipNotFoundException(name, _request.PrimaryResourceType.PublicName); + 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 50514f3128..41450afdf7 100644 --- a/src/JsonApiDotNetCore/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/TypeExtensions.cs @@ -8,7 +8,7 @@ internal static class TypeExtensions /// /// Whether the specified source type implements or equals the specified interface. /// - public static bool IsOrImplementsInterface(this Type source, Type interfaceType) + public static bool IsOrImplementsInterface(this Type? source, Type interfaceType) { ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); 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 187e018176..065c63afbd 100644 --- a/test/DiscoveryTests/TestResource.cs +++ b/test/DiscoveryTests/PrivateResource.cs @@ -4,7 +4,7 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResource : Identifiable + public sealed class PrivateResource : Identifiable { } } diff --git a/test/DiscoveryTests/TestResourceDefinition.cs b/test/DiscoveryTests/PrivateResourceDefinition.cs similarity index 62% rename from test/DiscoveryTests/TestResourceDefinition.cs rename to test/DiscoveryTests/PrivateResourceDefinition.cs index 318a36642b..b3a33f556c 100644 --- a/test/DiscoveryTests/TestResourceDefinition.cs +++ b/test/DiscoveryTests/PrivateResourceDefinition.cs @@ -5,9 +5,9 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceDefinition : JsonApiResourceDefinition + public sealed class PrivateResourceDefinition : JsonApiResourceDefinition { - public TestResourceDefinition(IResourceGraph resourceGraph) + public PrivateResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } diff --git a/test/DiscoveryTests/TestResourceRepository.cs b/test/DiscoveryTests/PrivateResourceRepository.cs similarity index 75% rename from test/DiscoveryTests/TestResourceRepository.cs rename to test/DiscoveryTests/PrivateResourceRepository.cs index 00644e14d7..1d5b4a4a4e 100644 --- a/test/DiscoveryTests/TestResourceRepository.cs +++ b/test/DiscoveryTests/PrivateResourceRepository.cs @@ -9,9 +9,9 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceRepository : EntityFrameworkCoreRepository + public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository { - public TestResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + public PrivateResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) diff --git a/test/DiscoveryTests/TestResourceService.cs b/test/DiscoveryTests/PrivateResourceService.cs similarity index 55% rename from test/DiscoveryTests/TestResourceService.cs rename to test/DiscoveryTests/PrivateResourceService.cs index f308b403eb..47df356881 100644 --- a/test/DiscoveryTests/TestResourceService.cs +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -10,11 +10,11 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceService : JsonApiResourceService + public sealed class PrivateResourceService : JsonApiResourceService { - public TestResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, - IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + public PrivateResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 798ddf0bcf..668cf7d66f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -11,15 +11,15 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using TestBuildingBlocks; using Xunit; namespace DiscoveryTests { public sealed class ServiceDiscoveryFacadeTests { - private static readonly NullLoggerFactory LoggerFactory = NullLoggerFactory.Instance; + private static readonly ILoggerFactory LoggerFactory = NullLoggerFactory.Instance; private readonly IServiceCollection _services = new ServiceCollection(); - private readonly JsonApiOptions _options = new(); private readonly ResourceGraphBuilder _resourceGraphBuilder; public ServiceDiscoveryFacadeTests() @@ -28,8 +28,10 @@ public ServiceDiscoveryFacadeTests() dbResolverMock.Setup(resolver => resolver.GetContext()).Returns(new Mock().Object); _services.AddScoped(_ => dbResolverMock.Object); - _services.AddSingleton(_options); - _services.AddSingleton(LoggerFactory); + IJsonApiOptions options = new JsonApiOptions(); + + _services.AddSingleton(options); + _services.AddSingleton(LoggerFactory); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); @@ -40,7 +42,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _resourceGraphBuilder = new ResourceGraphBuilder(_options, LoggerFactory); + _resourceGraphBuilder = new ResourceGraphBuilder(options, LoggerFactory); } [Fact] @@ -56,11 +58,11 @@ public void Can_add_resources_from_assembly_to_graph() // Assert IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - ResourceType personType = resourceGraph.TryGetResourceType(typeof(Person)); - personType.Should().NotBeNull(); + ResourceType? personType = resourceGraph.FindResourceType(typeof(Person)); + personType.ShouldNotBeNull(); - ResourceType todoItemType = resourceGraph.TryGetResourceType(typeof(TodoItem)); - todoItemType.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(); - ResourceType testResourceType = resourceGraph.TryGetResourceType(typeof(TestResource)); - testResourceType.Should().NotBeNull(); + ResourceType? testResourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); + testResourceType.ShouldNotBeNull(); } [Fact] @@ -93,8 +95,8 @@ public void Can_add_resource_service_from_current_assembly_to_container() // Assert ServiceProvider services = _services.BuildServiceProvider(); - var resourceService = services.GetRequiredService>(); - resourceService.Should().BeOfType(); + var resourceService = services.GetRequiredService>(); + resourceService.Should().BeOfType(); } [Fact] @@ -110,8 +112,8 @@ public void Can_add_resource_repository_from_current_assembly_to_container() // Assert ServiceProvider services = _services.BuildServiceProvider(); - var resourceRepository = services.GetRequiredService>(); - resourceRepository.Should().BeOfType(); + var resourceRepository = services.GetRequiredService>(); + resourceRepository.Should().BeOfType(); } [Fact] @@ -127,8 +129,8 @@ public void Can_add_resource_definition_from_current_assembly_to_container() // Assert ServiceProvider services = _services.BuildServiceProvider(); - var resourceDefinition = services.GetRequiredService>(); - resourceDefinition.Should().BeOfType(); + var resourceDefinition = services.GetRequiredService>(); + resourceDefinition.Should().BeOfType(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 2634ffae2a..039cd0840e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -52,9 +52,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes["archivedAt"].As().Should().BeCloseTo(broadcast.ArchivedAt.GetValueOrDefault()); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") + .With(value => value.As().Should().BeCloseTo(broadcast.ArchivedAt!.Value)); } [Fact] @@ -78,9 +80,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes["archivedAt"].As().Should().BeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeNull()); } [Fact] @@ -105,9 +107,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].As().Should().BeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeNull()); } [Fact] @@ -132,11 +134,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].As().Should().BeCloseTo(broadcasts[0].ArchivedAt.GetValueOrDefault()); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt") + .With(value => value.As().Should().BeCloseTo(broadcasts[0].ArchivedAt!.Value)); + responseDocument.Data.ManyValue[1].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -161,12 +166,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -192,16 +197,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt.GetValueOrDefault(); + DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt0); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeCloseTo(archivedAt0)); responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -225,11 +230,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt = comment.AppliesTo.ArchivedAt.GetValueOrDefault(); - - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(comment.AppliesTo.StringId); - responseDocument.Data.SingleValue.Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") + .With(value => value.As().Should().BeCloseTo(comment.AppliesTo.ArchivedAt!.Value)); } [Fact] @@ -254,9 +259,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -281,13 +286,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt.GetValueOrDefault(); - - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt0); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => + value.As().Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt!.Value)); + responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -313,12 +319,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -344,16 +350,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt.GetValueOrDefault(); + DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt0); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeCloseTo(archivedAt0)); responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -378,7 +384,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); } @@ -404,7 +410,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); } @@ -436,10 +442,13 @@ public async Task Can_create_unarchived_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(newBroadcast.Title); - responseDocument.Data.SingleValue.Attributes["airedAt"].As().Should().BeCloseTo(newBroadcast.AiredAt); - responseDocument.Data.SingleValue.Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newBroadcast.Title)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("airedAt") + .With(value => value.As().Should().BeCloseTo(newBroadcast.AiredAt)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -470,7 +479,7 @@ public async Task Cannot_create_archived_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -602,7 +611,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -634,7 +643,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); + TelevisionBroadcast? broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); broadcastInDatabase.Should().BeNull(); }); @@ -661,7 +670,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs index 82f658ea8a..1a5e733de0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving public sealed class BroadcastComment : Identifiable { [Attr] - public string Text { get; set; } + public string Text { get; set; } = null!; [Attr] public DateTimeOffset CreatedAt { get; set; } [HasOne] - public TelevisionBroadcast AppliesTo { get; set; } + public TelevisionBroadcast AppliesTo { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs index 8cfbb4cdbf..d801d2eaa5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { public sealed class BroadcastCommentsController : JsonApiController { - public BroadcastCommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BroadcastCommentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs index 5304c6135f..07c57183cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving public sealed class TelevisionBroadcast : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] public DateTimeOffset AiredAt { get; set; } @@ -19,9 +19,9 @@ public sealed class TelevisionBroadcast : Identifiable public DateTimeOffset? ArchivedAt { get; set; } [HasOne] - public TelevisionStation AiredOn { get; set; } + public TelevisionStation? AiredOn { get; set; } [HasMany] - public ISet Comments { get; set; } + public ISet Comments { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 88dc9dbf43..2cb9d2a4a6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -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) { @@ -46,7 +46,7 @@ public override FilterExpression OnApplyFilter(FilterExpression existingFilter) 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); } @@ -99,7 +99,7 @@ private bool IsIncludingCollectionOfTelevisionBroadcasts() return false; } - private bool HasFilterOnArchivedAt(FilterExpression existingFilter) + private bool HasFilterOnArchivedAt(FilterExpression? existingFilter) { if (existingFilter == null) { @@ -182,11 +182,11 @@ private static void AssertIsArchived(TelevisionBroadcast broadcast) } } - private sealed class FilterWalker : QueryExpressionRewriter + private sealed class FilterWalker : QueryExpressionRewriter { public bool HasFilterOnArchivedAt { get; private set; } - public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object argument) + public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs index bd3b9832f6..d5cd933b56 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { public sealed class TelevisionBroadcastsController : JsonApiController { - public TelevisionBroadcastsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionBroadcastsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs index 9566f3b424..5ff8a1406c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class TelevisionDbContext : DbContext { - public DbSet Networks { get; set; } - public DbSet Stations { get; set; } - public DbSet Broadcasts { get; set; } - public DbSet Comments { get; set; } + public DbSet Networks => Set(); + public DbSet Stations => Set(); + public DbSet Broadcasts => Set(); + public DbSet Comments => Set(); public TelevisionDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs index cdf0b64195..fb4dafda39 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving public sealed class TelevisionNetwork : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Stations { get; set; } + public ISet Stations { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs index 3c9c89dbd0..4b981a8586 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { public sealed class TelevisionNetworksController : JsonApiController { - public TelevisionNetworksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionNetworksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs index 2c6a73307c..9f49047ddc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving public sealed class TelevisionStation : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Broadcasts { get; set; } + public ISet Broadcasts { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs index aef39f7a94..4ab018ea8d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { public sealed class TelevisionStationsController : JsonApiController { - public TelevisionStationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionStationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs index d00cda4a39..022ae35c3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs @@ -67,7 +67,7 @@ public async Task Can_create_resources_for_matching_resource_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); } [Fact] @@ -100,12 +100,13 @@ public async Task Cannot_create_resource_for_mismatching_resource_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -148,12 +149,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -203,12 +205,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index c0b8319fac..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) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index ad9ab8197d..fc963b2fae 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -39,7 +39,7 @@ public AtomicCreateResourceTests(IntegrationTestContext().Should().BeCloseTo(newBornAt); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().BeNull(); + responseDocument.Results.ShouldHaveCount(1); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue.Id); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(newBornAt)); + resource.Relationships.Should().BeNull(); + }); + + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -130,28 +133,35 @@ public async Task Can_create_resources() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(elementCount); + responseDocument.Results.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { - ResourceObject singleData = responseDocument.Results[index].Data.SingleValue; + responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.ShouldNotBeNull(); + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTracks[index].Title)); + + resource.Attributes.ShouldContainKey("lengthInSeconds") + .With(value => value.As().Should().BeApproximately(newTracks[index].LengthInSeconds)); + + resource.Attributes.ShouldContainKey("genre").With(value => value.Should().Be(newTracks[index].Genre)); - singleData.Should().NotBeNull(); - singleData.Type.Should().Be("musicTracks"); - singleData.Attributes["title"].Should().Be(newTracks[index].Title); - singleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTracks[index].LengthInSeconds); - singleData.Attributes["genre"].Should().Be(newTracks[index].Genre); - singleData.Attributes["releasedAt"].As().Should().BeCloseTo(newTracks[index].ReleasedAt); - singleData.Relationships.Should().NotBeEmpty(); + resource.Attributes.ShouldContainKey("releasedAt") + .With(value => value.As().Should().BeCloseTo(newTracks[index].ReleasedAt)); + + resource.Relationships.ShouldNotBeEmpty(); + }); } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue.Id)).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); await _testContext.RunOnDatabaseAsync(async dbContext => { List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); - tracksInDatabase.Should().HaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -198,14 +208,17 @@ public async Task Can_create_resource_without_attributes_or_relationships() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["bornAt"].As().Should().BeCloseTo(default); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().BeNull(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().BeNull()); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(default)); + resource.Relationships.Should().BeNull(); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue.Id); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -250,14 +263,15 @@ public async Task Cannot_create_resource_with_unknown_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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -297,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); + + 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); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -351,14 +368,15 @@ public async Task Cannot_create_resource_with_unknown_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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -368,6 +386,8 @@ public async Task Can_create_resource_with_unknown_relationship() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = true; + string newLyricText = _fakers.Lyric.Generate().Text; + var requestBody = new { atomic__operations = new[] @@ -378,6 +398,10 @@ public async Task Can_create_resource_with_unknown_relationship() data = new { type = "lyrics", + attributes = new + { + text = newLyricText + }, relationships = new { doesNotExist = new @@ -402,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); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("lyrics"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); + + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); - lyricInDatabase.Should().NotBeNull(); + lyricInDatabase.ShouldNotBeNull(); }); } @@ -453,14 +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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -487,14 +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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -524,14 +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 'relationship' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -557,14 +587,15 @@ 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -578,7 +609,7 @@ public async Task Cannot_create_resource_for_null_data() new { op = "add", - data = (object)null + data = (object?)null } } }; @@ -591,21 +622,22 @@ public async Task Cannot_create_resource_for_null_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: Expected an object in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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; + string newArtistName = _fakers.Performer.Generate().ArtistName!; var requestBody = new { @@ -637,14 +669,15 @@ public async Task Cannot_create_resource_for_array_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: Expected an object in 'data' element, instead of an array."); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -676,14 +709,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: The 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -713,14 +747,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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -754,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -798,14 +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' on resource type 'playlists' is read-only."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -839,14 +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: Incompatible attribute value found."); error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '12345' of type 'Number' to type 'DateTimeOffset'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -922,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 => { @@ -946,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 1574e20095..8932b1e635 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -72,12 +72,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,12 +182,13 @@ 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"); } @@ -222,14 +226,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: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -261,14 +266,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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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 c3952cf1c6..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,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -279,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -329,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -394,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] @@ -450,14 +463,15 @@ public async Task Cannot_create_on_relationship_type_mismatch() // 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -519,19 +533,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.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } @@ -569,14 +586,15 @@ public async Task Cannot_create_with_missing_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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -597,7 +615,7 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() { tracks = new { - data = (object)null + data = (object?)null } } } @@ -613,14 +631,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 an array in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -659,14 +678,15 @@ public async Task Cannot_create_with_object_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 an array in 'data' element, instead of an object."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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 51c9dc27c9..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); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("lyrics"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); + + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); 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); - 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.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,54 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); } }); } + [Fact] + public async Task Cannot_create_for_null_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = (object?)null + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_for_missing_data_in_relationship() { @@ -289,14 +345,15 @@ public async Task Cannot_create_for_missing_data_in_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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -340,14 +397,15 @@ public async Task Cannot_create_for_array_data_in_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 an object or 'null' in 'data' element, instead of an array."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -387,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -435,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -482,20 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] public async Task Cannot_create_with_unknown_relationship_ID() { // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string lyricId = Unknown.StringId.For(); var requestBody = new @@ -508,6 +571,10 @@ public async Task Cannot_create_with_unknown_relationship_ID() data = new { type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, relationships = new { lyric = new @@ -532,12 +599,13 @@ 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"); } @@ -580,14 +648,15 @@ public async Task Cannot_create_on_relationship_type_mismatch() // 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -651,19 +720,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.OwnedBy).FirstWithIdAsync(newTrackId); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 5eef754388..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,14 +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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -385,14 +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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -422,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -460,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -497,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -537,12 +542,13 @@ 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"); } @@ -577,14 +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: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -616,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index fb3246013a..8331548d1f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -85,29 +85,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); - - string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; - - ResourceObject singleData1 = responseDocument.Results[0].Data.SingleValue; - singleData1.Should().NotBeNull(); - singleData1.Links.Should().NotBeNull(); - singleData1.Links.Self.Should().Be(languageLink); - singleData1.Relationships.Should().NotBeEmpty(); - singleData1.Relationships["lyrics"].Links.Should().NotBeNull(); - singleData1.Relationships["lyrics"].Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - singleData1.Relationships["lyrics"].Links.Related.Should().Be($"{languageLink}/lyrics"); - - string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; - - ResourceObject singleData2 = responseDocument.Results[1].Data.SingleValue; - singleData2.Should().NotBeNull(); - singleData2.Links.Should().NotBeNull(); - singleData2.Links.Self.Should().Be(companyLink); - singleData2.Relationships.Should().NotBeEmpty(); - singleData2.Relationships["tracks"].Links.Should().NotBeNull(); - singleData2.Relationships["tracks"].Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - singleData2.Relationships["tracks"].Links.Related.Should().Be($"{companyLink}/tracks"); + responseDocument.Results.ShouldHaveCount(2); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; + + resource.ShouldNotBeNull(); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(languageLink); + + resource.Relationships.ShouldContainKey("lyrics").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); + value.Links.Related.Should().Be($"{languageLink}/lyrics"); + }); + }); + + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; + + resource.ShouldNotBeNull(); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(companyLink); + + resource.Relationships.ShouldContainKey("tracks").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); + value.Links.Related.Should().Be($"{companyLink}/tracks"); + }); + }); } [Fact] @@ -149,12 +161,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - ResourceObject singleData = responseDocument.Results[0].Data.SingleValue; - singleData.Should().NotBeNull(); - singleData.Links.Should().BeNull(); - singleData.Relationships.Should().BeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.ShouldNotBeNull(); + resource.Links.Should().BeNull(); + resource.Relationships.Should().BeNull(); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index 176b7162dc..06ebfbab3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -16,6 +16,7 @@ public sealed class AtomicRelativeLinksWithNamespaceTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); public AtomicRelativeLinksWithNamespaceTests( IntegrationTestContext, OperationsDbContext> testContext) @@ -38,6 +39,8 @@ public AtomicRelativeLinksWithNamespaceTests( public async Task Create_resource_with_side_effects_returns_relative_links() { // Arrange + string newCompanyName = _fakers.RecordCompany.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -61,6 +64,7 @@ public async Task Create_resource_with_side_effects_returns_relative_links() type = "recordCompanies", attributes = new { + name = newCompanyName } } } @@ -75,29 +79,43 @@ public async Task Create_resource_with_side_effects_returns_relative_links() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull(); - string languageLink = $"/api/textLanguages/{Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id)}"; + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.ShouldNotBeNull())}"; - responseDocument.Results[0].Data.SingleValue.Links.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Links.Self.Should().Be(languageLink); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships["lyrics"].Links.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Relationships["lyrics"].Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - responseDocument.Results[0].Data.SingleValue.Relationships["lyrics"].Links.Related.Should().Be($"{languageLink}/lyrics"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(languageLink); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); + resource.Relationships.ShouldContainKey("lyrics").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); + value.Links.Related.Should().Be($"{languageLink}/lyrics"); + }); + }); - string companyLink = $"/api/recordCompanies/{short.Parse(responseDocument.Results[1].Data.SingleValue.Id)}"; + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull(); - responseDocument.Results[1].Data.SingleValue.Links.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Links.Self.Should().Be(companyLink); - responseDocument.Results[1].Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Results[1].Data.SingleValue.Relationships["tracks"].Links.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Relationships["tracks"].Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - responseDocument.Results[1].Data.SingleValue.Relationships["tracks"].Links.Related.Should().Be($"{companyLink}/tracks"); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.ShouldNotBeNull())}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(companyLink); + + resource.Relationships.ShouldContainKey("tracks").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); + value.Links.Related.Should().Be($"{companyLink}/tracks"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index a683635358..11af30d546 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -84,21 +84,25 @@ public async Task Can_create_resource_with_ManyToOne_relationship_using_local_ID // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("recordCompanies"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newCompany.Name); - responseDocument.Results[0].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(newCompany.CountryOfResidence); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompany.Name)); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(newCompany.CountryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -106,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); @@ -177,21 +181,25 @@ public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().Be(newPerformer.ArtistName); - responseDocument.Results[0].Data.SingleValue.Attributes["bornAt"].As().Should().BeCloseTo(newPerformer.BornAt); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newPerformer.ArtistName)); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(newPerformer.BornAt)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -199,7 +207,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); trackInDatabase.Performers[0].BornAt.Should().BeCloseTo(newPerformer.BornAt); @@ -269,20 +277,24 @@ public async Task Can_create_resource_with_ManyToMany_relationship_using_local_I // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -290,7 +302,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -302,6 +314,8 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() // Arrange const string companyLocalId = "company-1"; + string newCompanyName = _fakers.RecordCompany.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -322,6 +336,10 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() { type = "recordCompanies", lid = companyLocalId, + attributes = new + { + name = newCompanyName + }, relationships = new { parent = new @@ -346,12 +364,13 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Local ID cannot be both defined and used within the same operation."); error.Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -412,12 +431,13 @@ public async Task Cannot_reassign_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Another local ID with the same name is already defined at this point."); error.Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -426,7 +446,7 @@ public async Task Can_update_resource_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newTrackGenre = _fakers.MusicTrack.Generate().Genre; + string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; const string trackLocalId = "track-1"; @@ -471,17 +491,19 @@ public async Task Can_update_resource_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); - responseDocument.Results[0].Data.SingleValue.Attributes["genre"].Should().BeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.ShouldContainKey("genre").With(value => value.Should().BeNull()); + }); responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -497,7 +519,7 @@ public async Task Can_update_resource_with_relationships_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; string newCompanyName = _fakers.RecordCompany.Generate().Name; const string trackLocalId = "track-1"; @@ -589,28 +611,34 @@ public async Task Can_update_resource_with_relationships_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); - responseDocument.Results[2].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[2].Data.SingleValue.Type.Should().Be("recordCompanies"); - responseDocument.Results[2].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[2].Data.SingleValue.Attributes["name"].Should().Be(newCompanyName); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + }); responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); - short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -627,10 +655,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -702,22 +730,26 @@ public async Task Can_create_ManyToOne_relationship_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("recordCompanies"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(newCompanyName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -725,7 +757,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); }); @@ -736,7 +768,7 @@ public async Task Can_create_OneToMany_relationship_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; const string trackLocalId = "track-1"; const string performerLocalId = "performer-1"; @@ -800,22 +832,26 @@ public async Task Can_create_OneToMany_relationship_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -823,7 +859,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -898,22 +934,26 @@ public async Task Can_create_ManyToMany_relationship_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -921,7 +961,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -934,7 +974,7 @@ public async Task Can_replace_OneToMany_relationship_using_local_ID() Performer existingPerformer = _fakers.Performer.Generate(); string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1018,22 +1058,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1041,7 +1085,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -1138,22 +1182,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1161,7 +1209,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -1174,7 +1222,7 @@ public async Task Can_add_to_OneToMany_relationship_using_local_ID() Performer existingPerformer = _fakers.Performer.Generate(); string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1258,22 +1306,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1281,7 +1333,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); @@ -1400,24 +1452,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); responseDocument.Results[3].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1425,7 +1481,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.ShouldHaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); @@ -1439,8 +1495,8 @@ public async Task Can_remove_from_OneToMany_relationship_using_local_ID() Performer existingPerformer = _fakers.Performer.Generate(); string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName1 = _fakers.Performer.Generate().ArtistName; - string newArtistName2 = _fakers.Performer.Generate().ArtistName; + string newArtistName1 = _fakers.Performer.Generate().ArtistName!; + string newArtistName2 = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1553,26 +1609,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName1); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName1)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName2); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName2)); + }); - responseDocument.Results[2].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[2].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[2].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[2].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1580,7 +1642,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); }); @@ -1685,12 +1747,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[1].Data.Value.Should().BeNull(); @@ -1702,7 +1766,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); }); } @@ -1752,20 +1816,22 @@ public async Task Can_delete_resource_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); trackInDatabase.Should().BeNull(); }); @@ -1808,12 +1874,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1857,12 +1924,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1920,12 +1988,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1933,6 +2002,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() { // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + var requestBody = new { atomic__operations = new object[] @@ -1952,6 +2023,10 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen data = new { type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, relationships = new { ownedBy = new @@ -1976,12 +2051,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1989,6 +2065,8 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() { // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -2008,6 +2086,10 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( data = new { type = "playlists", + attributes = new + { + name = newPlaylistName + }, relationships = new { tracks = new @@ -2035,12 +2117,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2049,6 +2132,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() { // Arrange const string trackLocalId = "track-1"; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; var requestBody = new { @@ -2070,6 +2154,10 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() { type = "musicTracks", lid = trackLocalId, + attributes = new + { + title = newTrackTitle + }, relationships = new { ownedBy = new @@ -2094,12 +2182,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("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("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("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("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("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("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs index 84b9a2c108..e39e52ffdd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -9,18 +9,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class Lyric : Identifiable { [Attr] - public string Format { get; set; } + public string? Format { get; set; } [Attr] - public string Text { get; set; } + public string Text { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.None)] public DateTimeOffset CreatedAt { get; set; } [HasOne] - public TextLanguage Language { get; set; } + public TextLanguage? Language { get; set; } [HasOne] - public MusicTrack Track { get; set; } + public MusicTrack? Track { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs index 12d83708d9..24936babb9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class LyricsController : JsonApiController { - public LyricsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public LyricsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index a13c92eb2c..c4fe7fcaa7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -85,13 +85,29 @@ public async Task Returns_resource_meta_in_create_resource_with_side_effects() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Meta.Should().HaveCount(1); - ((JsonElement)responseDocument.Results[0].Data.SingleValue.Meta["copyright"]).GetString().Should().Be("(C) 2018. All rights reserved."); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); - responseDocument.Results[1].Data.SingleValue.Meta.Should().HaveCount(1); - ((JsonElement)responseDocument.Results[1].Data.SingleValue.Meta["copyright"]).GetString().Should().Be("(C) 1994. All rights reserved."); + resource.Meta.ShouldContainKey("copyright").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("(C) 2018. All rights reserved."); + }); + }); + + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); + + resource.Meta.ShouldContainKey("copyright").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("(C) 1994. All rights reserved."); + }); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -141,9 +157,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Meta.Should().HaveCount(1); - ((JsonElement)responseDocument.Results[0].Data.SingleValue.Meta["notice"]).GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); + + resource.Meta.ShouldContainKey("notice").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); + }); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs index a1f67e0bb2..f1b4d771b1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta { public sealed class AtomicResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["license"] = "MIT", ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index e1469b3454..103dc746f5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -62,17 +62,31 @@ public async Task Returns_top_level_meta_in_create_resource_with_side_effects() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().HaveCount(3); - ((JsonElement)responseDocument.Meta["license"]).GetString().Should().Be("MIT"); - ((JsonElement)responseDocument.Meta["projectUrl"]).GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + responseDocument.Meta.ShouldHaveCount(3); - string[] versionArray = ((JsonElement)responseDocument.Meta["versions"]).EnumerateArray().Select(element => element.GetString()).ToArray(); + responseDocument.Meta.ShouldContainKey("license").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("MIT"); + }); - versionArray.Should().HaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); + responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); + + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); + + versionArray.ShouldHaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + }); } [Fact] @@ -114,17 +128,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().HaveCount(3); - ((JsonElement)responseDocument.Meta["license"]).GetString().Should().Be("MIT"); - ((JsonElement)responseDocument.Meta["projectUrl"]).GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + responseDocument.Meta.ShouldHaveCount(3); - string[] versionArray = ((JsonElement)responseDocument.Meta["versions"]).EnumerateArray().Select(element => element.GetString()).ToArray(); + responseDocument.Meta.ShouldContainKey("license").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("MIT"); + }); - versionArray.Should().HaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); + responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); + + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); + + versionArray.ShouldHaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs index b2b9ae37fc..1ee27009e1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs @@ -17,11 +17,11 @@ public MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinition _hitCounter = hitCounter; } - public override IDictionary GetMeta(MusicTrack resource) + public override IDictionary GetMeta(MusicTrack resource) { _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); - return new Dictionary + return new Dictionary { ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs index 28a0322e91..bb9f9ec006 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -17,11 +17,11 @@ public TextLanguageMetaDefinition(IResourceGraph resourceGraph, OperationsDbCont _hitCounter = hitCounter; } - public override IDictionary GetMeta(TextLanguage resource) + public override IDictionary GetMeta(TextLanguage resource) { _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); - return new Dictionary + return new Dictionary { ["Notice"] = NoticeText }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index eb3bae022b..f37cbd170f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -79,15 +79,16 @@ public async Task Logs_at_error_level_on_unhandled_exception() // 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 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.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); @@ -122,9 +123,9 @@ public async Task Logs_at_info_level_on_invalid_request_body() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - 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", StringComparison.Ordinal)); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index 0b61701084..8bc07968e8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -27,12 +27,12 @@ public async Task Cannot_process_for_missing_request_body() const string route = "/operations"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null); + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null!); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -42,6 +42,30 @@ public async Task Cannot_process_for_missing_request_body() error.Meta.Should().NotContainKey("requestBody"); } + [Fact] + public async Task Cannot_process_for_null_request_body() + { + // Arrange + const string requestBody = "null"; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_process_for_broken_JSON_request_body() { @@ -56,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); @@ -85,14 +109,14 @@ public async Task Cannot_process_for_missing_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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -112,14 +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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_process_null_operation() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + (object?)null + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + 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] @@ -152,7 +207,7 @@ 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); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index 199f91f6ae..b15a8215e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -68,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 39b43ecf01..9fad376626 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -3,20 +3,18 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ModelStateValidation { - public sealed class AtomicModelStateValidationTests - : IClassFixture, OperationsDbContext>> + public sealed class AtomicModelStateValidationTests : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); - public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -54,18 +52,20 @@ public async Task Cannot_create_resource_with_multiple_violations() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Title field is required."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } @@ -123,15 +123,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -161,7 +161,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, attributes = new { - title = (string)null, + title = (string?)null, lengthInSeconds = -1 } } @@ -177,18 +177,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Title field is required."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } @@ -197,7 +199,7 @@ public async Task Can_update_resource_with_omitted_required_attribute() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTrackGenre = _fakers.MusicTrack.Generate().Genre; + string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -301,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -355,7 +357,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -412,7 +414,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -434,7 +436,7 @@ public async Task Validates_all_operations_before_execution_starts() id = Unknown.StringId.For(), attributes = new { - name = (string)null + name = (string?)null } } }, @@ -446,6 +448,7 @@ public async Task Validates_all_operations_before_execution_starts() type = "musicTracks", attributes = new { + title = "some", lengthInSeconds = -1 } } @@ -461,25 +464,111 @@ public async Task Validates_all_operations_before_execution_starts() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(3); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Title field is required."); - error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/title"); + error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); + } + + [Fact] + public async Task Does_not_exceed_MaxModelValidationErrors() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = (string?)null + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = (string?)null + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + lengthInSeconds = -1 + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = (string?)null + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Name field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); ErrorObject error3 = responseDocument.Errors[2]; error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); + error3.Detail.Should().Be("The Name field is required."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 5cca1995b5..8e3071aeb7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -14,29 +14,28 @@ public sealed class MusicTrack : Identifiable public override Guid Id { get; set; } [Attr] - [Required] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] [Range(1, 24 * 60)] public decimal? LengthInSeconds { get; set; } [Attr] - public string Genre { get; set; } + public string? Genre { get; set; } [Attr] public DateTimeOffset ReleasedAt { get; set; } [HasOne] - public Lyric Lyric { get; set; } + public Lyric? Lyric { get; set; } [HasOne] - public RecordCompany OwnedBy { get; set; } + public RecordCompany? OwnedBy { get; set; } [HasMany] - public IList Performers { get; set; } + public IList Performers { get; set; } = new List(); [HasMany] - public IList OccursIn { get; set; } + public IList OccursIn { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs index c7c17f0c43..697ba4a00e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class MusicTracksController : JsonApiController { - public MusicTracksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public MusicTracksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs index 851a0eceb2..eb1aa68911 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index 6c62dfaa0f..dc46d4e672 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class OperationsDbContext : DbContext { - public DbSet Playlists { get; set; } - public DbSet MusicTracks { get; set; } - public DbSet Lyrics { get; set; } - public DbSet TextLanguages { get; set; } - public DbSet Performers { get; set; } - public DbSet RecordCompanies { get; set; } + public DbSet Playlists => Set(); + public DbSet MusicTracks => Set(); + public DbSet Lyrics => Set(); + public DbSet TextLanguages => Set(); + public DbSet Performers => Set(); + public DbSet RecordCompanies => Set(); public OperationsDbContext(DbContextOptions options) : base(options) @@ -24,7 +24,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric.Track) + .WithOne(lyric => lyric!.Track!) .HasForeignKey("LyricId"); builder.Entity() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs index 99485daa63..d0403e340b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class Performer : Identifiable { [Attr] - public string ArtistName { get; set; } + public string? ArtistName { get; set; } [Attr] public DateTimeOffset BornAt { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs index 79921bc122..59c5dfc60c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class PerformersController : JsonApiController { - public PerformersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PerformersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs index c63467b3ec..43d05609a9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -11,14 +10,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class Playlist : Identifiable { [Attr] - [Required] - public string Name { get; set; } + public string Name { get; set; } = null!; [NotMapped] [Attr] public bool IsArchived => false; [HasMany] - public IList Tracks { get; set; } + public IList Tracks { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs index e5da241908..1b893615f3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class PlaylistsController : JsonApiController { - public PlaylistsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PlaylistsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index cd63f41dd1..a9d0f8a1c7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -69,12 +69,13 @@ public async Task Cannot_include_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -108,12 +109,13 @@ public async Task Cannot_filter_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -147,12 +149,13 @@ public async Task Cannot_sort_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("sort"); } @@ -186,12 +189,13 @@ public async Task Cannot_use_pagination_number_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -225,12 +229,13 @@ public async Task Cannot_use_pagination_size_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -264,12 +269,13 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("fields[recordCompanies]"); } @@ -297,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(musicTracks[2].StringId); } @@ -334,7 +340,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -343,6 +349,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("isRecentlyReleased"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs index 7544515cbc..1d3264bda7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -24,7 +24,7 @@ public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock sy public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() { - return new() + return new QueryStringParameterHandlers { ["isRecentlyReleased"] = FilterOnRecentlyReleased }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs index a2fff83748..6cc63b23db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class RecordCompaniesController : JsonApiController { - public RecordCompaniesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public RecordCompaniesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs index 3a4611cbeb..b8ab7be551 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs @@ -9,15 +9,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class RecordCompany : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] - public string CountryOfResidence { get; set; } + public string? CountryOfResidence { get; set; } [HasMany] - public IList Tracks { get; set; } + public IList Tracks { get; set; } = new List(); [HasOne] - public RecordCompany Parent { get; set; } + public RecordCompany? Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index fbde96e586..c608be732d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -91,18 +91,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newCompanies[0].Name.ToUpperInvariant()); - responseDocument.Results[0].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(newCompanies[0].CountryOfResidence.ToUpperInvariant()); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[0].Name.ToUpperInvariant())); + + string countryOfResidence = newCompanies[0].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(newCompanies[1].Name.ToUpperInvariant()); - responseDocument.Results[1].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(newCompanies[1].CountryOfResidence.ToUpperInvariant()); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[1].Name.ToUpperInvariant())); + + string countryOfResidence = newCompanies[1].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); @@ -174,7 +184,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); hitCounter.HitExtensibilityPoints.Should().BeEmpty(); } @@ -233,20 +243,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - string country0 = existingCompanies[0].CountryOfResidence.ToUpperInvariant(); - string country1 = existingCompanies[1].CountryOfResidence.ToUpperInvariant(); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[0].Name)); + + string countryOfResidence = existingCompanies[0].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(existingCompanies[0].Name); - responseDocument.Results[0].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(country0); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(existingCompanies[1].Name); - responseDocument.Results[1].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(country1); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[1].Name)); + + string countryOfResidence = existingCompanies[1].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); @@ -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/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index 4c6df1fc48..297fdbf5c1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -89,13 +89,19 @@ 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[] { @@ -162,13 +168,19 @@ 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[] { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs index 094338a329..7b41cd1545 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs @@ -18,7 +18,7 @@ public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider _hitCounter = hitCounter; } - public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs index d78ddd55a0..4606858341 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class TextLanguage : Identifiable { [Attr] - public string IsoCode { get; set; } + public string? IsoCode { get; set; } [Attr(Capabilities = AttrCapabilities.None)] public bool IsRightToLeft { get; set; } [HasMany] - public ICollection Lyrics { get; set; } + public ICollection Lyrics { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs index 6da4f35d38..b2d2683313 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class TextLanguagesController : JsonApiController { - public TextLanguagesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TextLanguagesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 542a6971b3..02c03b9fac 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -27,7 +27,7 @@ public AtomicRollbackTests(IntegrationTestContext // 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.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 245af3c761..1f7cc37cef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -15,6 +15,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions public sealed class AtomicTransactionConsistencyTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) { @@ -65,12 +66,13 @@ public async Task Cannot_use_non_transactional_repository() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported resource type in atomic:operations request."); error.Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -78,6 +80,8 @@ public async Task Cannot_use_non_transactional_repository() public async Task Cannot_use_transactional_repository_without_active_transaction() { // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + var requestBody = new { atomic__operations = new object[] @@ -90,6 +94,7 @@ public async Task Cannot_use_transactional_repository_without_active_transaction type = "musicTracks", attributes = new { + title = newTrackTitle } } } @@ -104,12 +109,13 @@ public async Task Cannot_use_transactional_repository_without_active_transaction // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -117,6 +123,8 @@ public async Task Cannot_use_transactional_repository_without_active_transaction public async Task Cannot_use_distributed_transaction() { // Arrange + string newLyricText = _fakers.Lyric.Generate().Text; + var requestBody = new { atomic__operations = new object[] @@ -129,6 +137,7 @@ public async Task Cannot_use_distributed_transaction() type = "lyrics", attributes = new { + text = newLyricText } } } @@ -143,12 +152,13 @@ public async Task Cannot_use_distributed_transaction() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs index b6268ee068..524439bc18 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class MusicTrackRepository : EntityFrameworkCoreRepository { - public override string TransactionId => null; + public override string? TransactionId => null; public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs index da6f3c9539..316e9c7db2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class PerformerRepository : IResourceRepository { - public Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public Task CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -33,7 +33,7 @@ public Task CreateAsync(Performer resourceFromRequest, Performer resourceForData throw new NotImplementedException(); } - public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -48,7 +48,7 @@ public Task DeleteAsync(int id, CancellationToken cancellationToken) throw new NotImplementedException(); } - public Task SetRelationshipAsync(Performer leftResource, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(Performer leftResource, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index c234c09182..1dac3f571d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -66,14 +66,15 @@ 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -149,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); @@ -229,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); @@ -260,14 +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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -298,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -337,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -375,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -432,12 +437,13 @@ 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"); } @@ -472,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -510,14 +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 'relationship' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -549,14 +557,15 @@ 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -596,14 +605,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: The 'data' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -631,7 +641,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object?)null } } }; @@ -644,14 +654,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 an array in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -694,14 +705,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 an array in 'data' element, instead of an object."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -740,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -787,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -833,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -881,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -947,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]"); } @@ -1007,14 +1025,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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1062,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 95840ff95d..d0673adab0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -67,14 +67,15 @@ 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -148,11 +149,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); + performersInDatabase.ShouldHaveCount(3); }); } @@ -227,12 +228,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); + tracksInDatabase.ShouldHaveCount(3); }); } @@ -260,14 +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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -298,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -337,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -375,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -432,12 +437,13 @@ 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"); } @@ -472,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -511,14 +518,15 @@ 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -558,14 +566,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: The 'data' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -593,7 +602,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object?)null } } }; @@ -606,14 +615,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 an array in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -656,14 +666,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 an array in 'data' element, instead of an object."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -702,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -749,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -795,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -843,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -909,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]"); } @@ -969,14 +986,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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1025,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 c7b6972438..a77441709a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -72,7 +72,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Performers.Should().BeEmpty(); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); + performersInDatabase.ShouldHaveCount(2); }); } @@ -126,7 +126,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -191,12 +191,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); + performersInDatabase.ShouldHaveCount(3); }); } @@ -261,13 +261,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(2); + playlistInDatabase.Tracks.ShouldHaveCount(2); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); + tracksInDatabase.ShouldHaveCount(3); }); } @@ -295,14 +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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -333,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -372,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -410,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -467,12 +471,13 @@ 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"); } @@ -524,14 +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: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -564,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -603,14 +610,15 @@ 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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -650,14 +658,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: The 'data' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -685,7 +694,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object?)null } } }; @@ -698,14 +707,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 an array in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -748,14 +758,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 an array in 'data' element, instead of an object."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -794,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -841,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -887,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -935,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1001,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]"); } @@ -1061,14 +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: Incompatible 'id' value found."); error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1116,14 +1134,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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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 30640ad32a..6569ae5638 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -50,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingLyric.StringId, relationship = "track" }, - data = (object)null + data = (object?)null } } }; @@ -72,7 +72,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lyricInDatabase.Track.Should().BeNull(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); + tracksInDatabase.ShouldHaveCount(1); }); } @@ -103,7 +103,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "lyric" }, - data = (object)null + data = (object?)null } } }; @@ -125,7 +125,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Lyric.Should().BeNull(); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); + lyricsInDatabase.ShouldHaveCount(1); }); } @@ -156,7 +156,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "ownedBy" }, - data = (object)null + data = (object?)null } } }; @@ -178,7 +178,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Should().BeNull(); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(1); + companiesInDatabase.ShouldHaveCount(1); }); } @@ -231,6 +231,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -284,6 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -337,6 +339,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -393,10 +396,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -452,10 +456,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); + lyricsInDatabase.ShouldHaveCount(2); }); } @@ -511,10 +516,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); }); } @@ -542,14 +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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -580,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -619,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -657,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -711,12 +721,13 @@ 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"); } @@ -763,14 +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: Incompatible 'id' value found."); error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -803,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -842,14 +855,15 @@ 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -889,14 +903,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: The 'data' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -944,14 +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 an object or 'null' in 'data' element, instead of an array."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -987,14 +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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1031,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1074,14 +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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1119,14 +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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1173,12 +1193,13 @@ 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"); } @@ -1225,14 +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: Incompatible 'id' value found."); error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1277,14 +1299,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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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 3e8e8f9740..e942553a6d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -77,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Performers.Should().BeEmpty(); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); + performersInDatabase.ShouldHaveCount(2); }); } @@ -136,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -206,12 +206,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); + performersInDatabase.ShouldHaveCount(3); }); } @@ -281,13 +281,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(2); + playlistInDatabase.Tracks.ShouldHaveCount(2); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); + tracksInDatabase.ShouldHaveCount(3); }); } @@ -333,14 +333,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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -370,7 +371,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { performers = new { - data = (object)null + data = (object?)null } } } @@ -386,14 +387,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 an array in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -441,14 +443,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 an array in 'data' element, instead of an object."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -492,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -544,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -595,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -648,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -719,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]"); } @@ -784,14 +793,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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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 f3892baa47..f7dca8b732 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -92,7 +92,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -156,7 +156,7 @@ 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); }); } @@ -203,14 +203,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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -315,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -385,7 +387,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 => { @@ -432,7 +434,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); }); } @@ -446,7 +448,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 => @@ -497,7 +499,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); }); } @@ -507,7 +509,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 => { @@ -543,18 +545,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); }); } @@ -595,10 +601,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] @@ -625,14 +634,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: The 'href' element is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -640,7 +650,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 => { @@ -730,14 +740,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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -778,14 +789,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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -828,14 +840,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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -861,14 +874,15 @@ 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -882,7 +896,7 @@ public async Task Cannot_update_resource_for_null_data() new { op = "update", - data = (object)null + data = (object?)null } } }; @@ -895,14 +909,15 @@ public async Task Cannot_update_resource_for_null_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: Expected an object in 'data' element, instead of 'null'."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -948,14 +963,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 an object in 'data' element, instead of an array."); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -991,14 +1007,15 @@ 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 'type' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1034,14 +1051,15 @@ 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 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1079,14 +1097,15 @@ 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 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1128,14 +1147,15 @@ public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() // 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1180,14 +1200,15 @@ public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() // 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1229,14 +1250,15 @@ public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_da // 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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1280,14 +1302,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: The 'id' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1331,14 +1354,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: The 'lid' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1375,14 +1399,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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1421,13 +1446,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] @@ -1468,14 +1495,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: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1518,14 +1546,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 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1568,14 +1597,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' on resource type 'playlists' is read-only."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1618,14 +1648,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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1668,14 +1699,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: Incompatible attribute value found."); error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '123.45' of type 'Number' to type 'DateTimeOffset'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1687,7 +1719,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(); @@ -1776,13 +1808,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 1ec00d4b48..3f7c089e44 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -52,7 +52,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { track = new { - data = (object)null + data = (object?)null } } } @@ -77,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lyricInDatabase.Track.Should().BeNull(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); + tracksInDatabase.ShouldHaveCount(1); }); } @@ -110,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { lyric = new { - data = (object)null + data = (object?)null } } } @@ -135,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Lyric.Should().BeNull(); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); + lyricsInDatabase.ShouldHaveCount(1); }); } @@ -168,7 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { ownedBy = new { - data = (object)null + data = (object?)null } } } @@ -193,7 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Should().BeNull(); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(1); + companiesInDatabase.ShouldHaveCount(1); }); } @@ -251,6 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -309,6 +310,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -367,6 +369,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -428,10 +431,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -492,10 +496,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); + lyricsInDatabase.ShouldHaveCount(2); }); } @@ -556,13 +561,65 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); }); } + [Fact] + public async Task Cannot_create_for_null_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = (object?)null + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_for_missing_data_in_relationship() { @@ -605,14 +662,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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -665,14 +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 an object or 'null' in 'data' element, instead of an array."); + 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -713,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -762,14 +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: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -810,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -860,14 +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: 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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -919,12 +982,13 @@ 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"); } @@ -976,14 +1040,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("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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + 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 74ebc3a865..9146bf865a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs @@ -7,10 +7,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Car : Identifiable + public sealed class Car : Identifiable { [NotMapped] - public override string Id + public override string? Id { get => RegionId == default && LicensePlate == default ? null : $"{RegionId}:{LicensePlate}"; set @@ -24,7 +24,7 @@ public override string Id string[] elements = value.Split(':'); - if (elements.Length == 2 && int.TryParse(elements[0], out int regionId)) + if (elements.Length == 2 && long.TryParse(elements[0], out long regionId)) { RegionId = regionId; LicensePlate = elements[1]; @@ -37,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 6f0543fd2f..5f3e5e01d9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -24,30 +24,30 @@ public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContext _writer = new CarExpressionRewriter(resourceGraph); } - protected override IQueryable ApplyQueryLayer(QueryLayer layer) + protected override IQueryable ApplyQueryLayer(QueryLayer queryLayer) { - RecursiveRewriteFilterInLayer(layer); + RecursiveRewriteFilterInLayer(queryLayer); - return base.ApplyQueryLayer(layer); + return base.ApplyQueryLayer(queryLayer); } private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) { if (queryLayer.Filter != null) { - queryLayer.Filter = (FilterExpression)_writer.Visit(queryLayer.Filter, null); + queryLayer.Filter = (FilterExpression?)_writer.Visit(queryLayer.Filter, null); } if (queryLayer.Sort != null) { - queryLayer.Sort = (SortExpression)_writer.Visit(queryLayer.Sort, null); + queryLayer.Sort = (SortExpression?)_writer.Visit(queryLayer.Sort, null); } if (queryLayer.Projection != null) { - foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) + foreach (QueryLayer? nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) { - RecursiveRewriteFilterInLayer(nextLayer); + RecursiveRewriteFilterInLayer(nextLayer!); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index 392d71703f..301de2901f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -18,7 +18,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys /// /// This enables queries to use , which is not mapped in the database. /// - internal sealed class CarExpressionRewriter : QueryExpressionRewriter + internal sealed class CarExpressionRewriter : QueryExpressionRewriter { private readonly AttrAttribute _regionIdAttribute; private readonly AttrAttribute _licensePlateAttribute; @@ -31,7 +31,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) _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; @@ -92,7 +92,7 @@ private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression StringId = carStringId }; - FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate); + FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate!); outerTermsBuilder.Add(keyComparison); } @@ -115,7 +115,7 @@ private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldCha return new LogicalExpression(LogicalOperator.And, regionIdComparison, licensePlateComparison); } - public override QueryExpression VisitSort(SortExpression expression, object argument) + public override QueryExpression VisitSort(SortExpression expression, object? argument) { ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(expression.Elements.Count); @@ -123,10 +123,10 @@ public override QueryExpression VisitSort(SortExpression expression, object argu { if (IsSortOnCarId(sortElement)) { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _regionIdAttribute); + ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _licensePlateAttribute); + ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); } else diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs index ad0dbb18ce..aa0a2099be 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { - public sealed class CarsController : JsonApiController + public sealed class CarsController : JsonApiController { - public CarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public CarsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index 62418ba2c2..25a8a04201 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CompositeDbContext : DbContext { - public DbSet Cars { get; set; } - public DbSet Engines { get; set; } - public DbSet Dealerships { get; set; } + public DbSet Cars => Set(); + public DbSet Engines => Set(); + public DbSet Dealerships => Set(); public CompositeDbContext(DbContextOptions options) : base(options) @@ -28,12 +28,12 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(engine => engine.Car) - .WithOne(car => car.Engine) + .WithOne(car => car!.Engine) .HasForeignKey(); builder.Entity() .HasMany(dealership => dealership.Inventory) - .WithOne(car => car.Dealership); + .WithOne(car => car.Dealership!); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs new file mode 100644 index 0000000000..a470a32b6b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs @@ -0,0 +1,32 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +{ + internal sealed class CompositeKeyFakers : FakerContainer + { + private readonly Lazy> _lazyCarFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) + .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); + + private readonly Lazy> _lazyEngineFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); + + private readonly Lazy> _lazyDealershipFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); + + public Faker Car => _lazyCarFaker.Value; + public Faker Engine => _lazyEngineFaker.Value; + public Faker Dealership => _lazyDealershipFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 70ba5c4539..8f99e777b4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -16,6 +15,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> { private readonly IntegrationTestContext, CompositeDbContext> _testContext; + private readonly CompositeKeyFakers _fakers = new(); public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) { @@ -27,7 +27,7 @@ public CompositeKeyTests(IntegrationTestContext { - services.AddResourceRepository>(); + services.AddResourceRepository>(); services.AddResourceRepository>(); }); @@ -39,11 +39,7 @@ public CompositeKeyTests(IntegrationTestContext { @@ -52,7 +48,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/cars?filter=any(id,'123:AA-BB-11','999:XX-YY-22')"; + string route = $"/cars?filter=any(id,'{car.RegionId}:{car.LicensePlate}','999:XX-YY-22')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -60,7 +56,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -68,11 +64,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_primary_resource_by_ID() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -89,7 +81,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(car.StringId); } @@ -97,11 +89,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_on_ID() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -118,7 +106,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -126,11 +114,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_select_ID() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -147,7 +131,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -155,9 +139,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange + Engine existingEngine = _fakers.Engine.Generate(); + + Car newCar = _fakers.Car.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); + dbContext.Engines.Add(existingEngine); + await dbContext.SaveChangesAsync(); }); var requestBody = new @@ -167,8 +157,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "cars", attributes = new { - regionId = 123, - licensePlate = "AA-BB-11" + regionId = newCar.RegionId, + licensePlate = newCar.LicensePlate + }, + relationships = new + { + engine = new + { + data = new + { + type = "engines", + id = existingEngine.StringId + } + } } } }; @@ -185,10 +186,10 @@ 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 +197,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 +241,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 +250,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 +270,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { car = new { - data = (object)null + data = (object?)null } } } @@ -312,23 +298,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", - Inventory = new HashSet - { - new() - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }, - new() - { - RegionId = 456, - LicensePlate = "CC-DD-22" - } - } - }; + Dealership existingDealership = _fakers.Dealership.Generate(); + existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -344,7 +315,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "123:AA-BB-11" + id = existingDealership.Inventory.ElementAt(0).StringId } } }; @@ -361,10 +332,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 +344,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 +361,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "123:AA-BB-11" + id = existingCar.StringId } } }; @@ -415,10 +378,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 +390,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", - Inventory = new HashSet - { - new() - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }, - new() - { - RegionId = 456, - LicensePlate = "CC-DD-22" - } - } - }; + Dealership existingDealership = _fakers.Dealership.Generate(); + existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); - var existingCar = new Car - { - RegionId = 789, - LicensePlate = "EE-FF-33" - }; + Car existingCar = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -465,12 +409,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 +431,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 +444,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 +462,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "999:XX-YY-22" + id = unknownCarId } } }; @@ -532,23 +475,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 => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs index 2c6d312ecd..42d11da754 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys public sealed class Dealership : Identifiable { [Attr] - public string Address { get; set; } + public string Address { get; set; } = null!; [HasMany] - public ISet Inventory { get; set; } + public ISet Inventory { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs index 8c108f0396..2ec7d85cda 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { public sealed class DealershipsController : JsonApiController { - public DealershipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DealershipsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs index dd4f38a97f..2a322e7513 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys public sealed class Engine : Identifiable { [Attr] - public string SerialCode { get; set; } + public string SerialCode { get; set; } = null!; [HasOne] - public Car Car { get; set; } + public Car? Car { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs index aa0a08c48d..f995a72233 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { public sealed class EnginesController : JsonApiController { - public EnginesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public EnginesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index cca76a2e53..35b24149f6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -192,12 +192,13 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); error.Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Accept"); } @@ -239,12 +240,13 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Accept"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 1ae336c663..9c96bf0a1d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -32,7 +32,8 @@ public async Task Returns_JsonApi_ContentType_header() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); } [Fact] @@ -65,7 +66,8 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); } [Fact] @@ -93,12 +95,13 @@ public async Task Denies_unknown_ContentType_header() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -187,12 +190,13 @@ public async Task Denies_JsonApi_ContentType_header_with_profile() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -221,12 +225,13 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -255,12 +260,13 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -289,12 +295,13 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -323,12 +330,13 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -365,7 +373,7 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; @@ -373,6 +381,7 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs index 9d2a3df6f3..06cf328d23 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation { public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs index f690f676c4..4edb2dfdec 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation { public sealed class PoliciesController : JsonApiController { - public PoliciesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PoliciesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs index e92f0978a9..27d850107c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs @@ -8,6 +8,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation public sealed class Policy : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs index 8fafc120b7..3e952ff6ba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class PolicyDbContext : DbContext { - public DbSet Policies { get; set; } + public DbSet Policies => Set(); public PolicyDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs index b678e21fde..c8219d8dd6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ActionResultDbContext : DbContext { - public DbSet Toothbrushes { get; set; } + public DbSet Toothbrushes => Set(); public ActionResultDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index bb7fe9fe33..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); } @@ -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); @@ -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); @@ -95,7 +95,7 @@ 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); @@ -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); @@ -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/ToothbrushesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs index 5795f074c5..92bdf57157 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -18,8 +18,9 @@ public sealed class ToothbrushesController : BaseJsonApiController resourceService) - : base(options, loggerFactory, resourceService) + public ToothbrushesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } 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 046220d59e..13141feca6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs @@ -8,6 +8,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes public sealed class Civilian : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs index 562b778669..aad9ccb421 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -13,8 +13,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes [Route("world-civilians")] public sealed class CiviliansController : JsonApiController { - public CiviliansController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public CiviliansController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs index c3e3b9063d..fc24c83f45 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CustomRouteDbContext : DbContext { - public DbSet Towns { get; set; } - public DbSet Civilians { get; set; } + public DbSet Towns => Set(); + public DbSet Civilians => Set(); public CustomRouteDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index 05b7c0214d..4c313aa2c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -46,15 +46,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("towns"); responseDocument.Data.SingleValue.Id.Should().Be(town.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(town.Name); - responseDocument.Data.SingleValue.Attributes["latitude"].Should().Be(town.Latitude); - responseDocument.Data.SingleValue.Attributes["longitude"].Should().Be(town.Longitude); - responseDocument.Data.SingleValue.Relationships["civilians"].Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); - responseDocument.Data.SingleValue.Relationships["civilians"].Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(town.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("latitude").With(value => value.Should().Be(town.Latitude)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("longitude").With(value => value.Should().Be(town.Longitude)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("civilians").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); + }); + + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); } @@ -79,10 +88,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(5); + responseDocument.Data.ManyValue.ShouldHaveCount(5); responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.Any()); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.Any()); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldNotBeNull().Any()); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldNotBeNull().Any()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs index 1f586d0521..ca893947a6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes public sealed class Town : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public double Latitude { get; set; } @@ -18,6 +18,6 @@ public sealed class Town : Identifiable public double Longitude { get; set; } [HasMany] - public ISet Civilians { get; set; } + public ISet Civilians { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs index b9d63cd03f..79b9cbc63b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -18,9 +18,9 @@ public sealed class TownsController : JsonApiController { private readonly CustomRouteDbContext _dbContext; - public TownsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService, + public TownsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService, CustomRouteDbContext dbContext) - : base(options, loggerFactory, resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { _dbContext = dbContext; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs index 17abe93afc..eb4a3d2fc7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs @@ -9,25 +9,37 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class Building : Identifiable { - private string _tempPrimaryDoorColor; + private string? _tempPrimaryDoorColor; [Attr] - public string Number { get; set; } + public string Number { get; set; } = null!; [NotMapped] [Attr] - public int WindowCount => Windows?.Count ?? 0; + public int WindowCount => Windows.Count; [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] public string PrimaryDoorColor { - get => _tempPrimaryDoorColor ?? PrimaryDoor.Color; + get + { + if (_tempPrimaryDoorColor == null && PrimaryDoor == null) + { + // The ASP.NET model validator reads the value of this required property, to ensure it is not null. + // When creating a resource, BuildingDefinition ensures a value is assigned. But when updating a resource + // and PrimaryDoorColor is explicitly set to null in the request body and ModelState validation is enabled, + // we want it to produce a validation error, so return null here. + return null!; + } + + return _tempPrimaryDoorColor ?? PrimaryDoor!.Color; + } set { if (PrimaryDoor == null) { - // A request body is being deserialized. At this time, related entities have not been loaded. + // A request body is being deserialized. At this time, related entities have not been loaded yet. // We cache the assigned value in a private field, so it can be used later. _tempPrimaryDoorColor = value; } @@ -40,15 +52,15 @@ public string PrimaryDoorColor [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public string SecondaryDoorColor => SecondaryDoor?.Color; + public string? SecondaryDoorColor => SecondaryDoor?.Color; [EagerLoad] - public IList Windows { get; set; } + public IList Windows { get; set; } = new List(); [EagerLoad] - public Door PrimaryDoor { get; set; } + public Door? PrimaryDoor { get; set; } [EagerLoad] - public Door SecondaryDoor { get; set; } + public Door? SecondaryDoor { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs new file mode 100644 index 0000000000..a6fc726a19 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs @@ -0,0 +1,35 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class BuildingDefinition : JsonApiResourceDefinition + { + private readonly IJsonApiRequest _request; + + public BuildingDefinition(IResourceGraph resourceGraph, IJsonApiRequest request) + : base(resourceGraph) + { + ArgumentGuard.NotNull(request, nameof(request)); + + _request = request; + } + + public override void OnDeserialize(Building resource) + { + if (_request.WriteOperation == WriteOperationKind.CreateResource) + { + // Must ensure that an instance exists for this required relationship, + // so that ASP.NET ModelState validation does not produce a validation error. + resource.PrimaryDoor = new Door + { + Color = "(unspecified)" + }; + } + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs index a822baebd2..88a4f6d714 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs @@ -24,8 +24,11 @@ public override async Task GetForCreateAsync(int id, CancellationToken { Building building = await base.GetForCreateAsync(id, cancellationToken); - // Must ensure that an instance exists for this required relationship, so that POST succeeds. - building.PrimaryDoor = new Door(); + // Must ensure that an instance exists for this required relationship, so that POST Resource succeeds. + building.PrimaryDoor = new Door + { + Color = "(unspecified)" + }; return building; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs index 85aafbfb22..f26107ba89 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { public sealed class BuildingsController : JsonApiController { - public BuildingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BuildingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs index fafa1b85d5..ce9788abb7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading public sealed class City : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList Streets { get; set; } + public IList Streets { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs index 73b50af911..c42235ea23 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs @@ -6,6 +6,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading public sealed class Door { public int Id { get; set; } - public string Color { get; set; } + public string Color { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs index bc9e95c9e9..aec0207c25 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs @@ -8,9 +8,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class EagerLoadingDbContext : DbContext { - public DbSet States { get; set; } - public DbSet Streets { get; set; } - public DbSet Buildings { get; set; } + public DbSet States => Set(); + public DbSet Streets => Set(); + public DbSet Buildings => Set(); + public DbSet Doors => Set(); public EagerLoadingDbContext(DbContextOptions options) : base(options) @@ -23,13 +24,15 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(building => building.PrimaryDoor) .WithOne() .HasForeignKey("PrimaryDoorId") + // The PrimaryDoor relationship property is declared as nullable, because the Door type is not publicly exposed, + // so we don't want ModelState validation to fail when it isn't provided by the client. But because + // BuildingRepository ensures a value is assigned on Create, we can make it a required relationship in the database. .IsRequired(); builder.Entity() .HasOne(building => building.SecondaryDoor) .WithOne() - .HasForeignKey("SecondaryDoorId") - .IsRequired(false); + .HasForeignKey("SecondaryDoorId"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index 0e0482dbce..b0c22d0bff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -25,6 +25,7 @@ public EagerLoadingTests(IntegrationTestContext { + services.AddResourceDefinition(); services.AddResourceRepository(); }); } @@ -52,12 +53,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(building.StringId); - responseDocument.Data.SingleValue.Attributes["number"].Should().Be(building.Number); - responseDocument.Data.SingleValue.Attributes["windowCount"].Should().Be(4); - responseDocument.Data.SingleValue.Attributes["primaryDoorColor"].Should().Be(building.PrimaryDoor.Color); - responseDocument.Data.SingleValue.Attributes["secondaryDoorColor"].Should().Be(building.SecondaryDoor.Color); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(building.Number)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(4)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be(building.PrimaryDoor.Color)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().Be(building.SecondaryDoor.Color)); } [Fact] @@ -88,12 +89,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(street.Name); - responseDocument.Data.SingleValue.Attributes["buildingCount"].Should().Be(2); - responseDocument.Data.SingleValue.Attributes["doorTotalCount"].Should().Be(3); - responseDocument.Data.SingleValue.Attributes["windowTotalCount"].Should().Be(5); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(street.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(2)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(3)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(5)); } [Fact] @@ -119,10 +120,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["windowTotalCount"].Should().Be(3); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -151,21 +152,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(state.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(state.Name); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Name)); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Type.Should().Be("cities"); responseDocument.Included[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Included[0].Attributes["name"].Should().Be(state.Cities[0].Name); + responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); responseDocument.Included[1].Type.Should().Be("streets"); responseDocument.Included[1].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[1].Attributes["buildingCount"].Should().Be(1); - responseDocument.Included[1].Attributes["doorTotalCount"].Should().Be(1); - responseDocument.Included[1].Attributes["windowTotalCount"].Should().Be(3); + responseDocument.Included[1].Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(1)); + responseDocument.Included[1].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(1)); + responseDocument.Included[1].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); } [Fact] @@ -194,18 +195,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["name"].Should().Be(state.Cities[0].Name); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("streets"); responseDocument.Included[0].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(2); - responseDocument.Included[0].Attributes["doorTotalCount"].Should().Be(2); - responseDocument.Included[0].Attributes["windowTotalCount"].Should().Be(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(2); + responseDocument.Included[0].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(2)); + responseDocument.Included[0].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(1)); responseDocument.Included[0].Relationships.Should().BeNull(); } @@ -235,20 +236,20 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["number"].Should().Be(newBuilding.Number); - responseDocument.Data.SingleValue.Attributes["windowCount"].Should().Be(0); - responseDocument.Data.SingleValue.Attributes["primaryDoorColor"].Should().BeNull(); - responseDocument.Data.SingleValue.Attributes["secondaryDoorColor"].Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(newBuilding.Number)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(0)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be("(unspecified)")); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().BeNull()); - int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id); + int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true - Building buildingInDatabase = await dbContext.Buildings + Building? buildingInDatabase = await dbContext.Buildings .Include(building => building.PrimaryDoor) .Include(building => building.SecondaryDoor) .Include(building => building.Windows) @@ -257,9 +258,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - buildingInDatabase.Should().NotBeNull(); + buildingInDatabase.ShouldNotBeNull(); buildingInDatabase.Number.Should().Be(newBuilding.Number); - buildingInDatabase.PrimaryDoor.Should().NotBeNull(); + buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); + buildingInDatabase.PrimaryDoor.Color.Should().Be("(unspecified)"); buildingInDatabase.SecondaryDoor.Should().BeNull(); buildingInDatabase.Windows.Should().BeEmpty(); }); @@ -312,7 +314,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true - Building buildingInDatabase = await dbContext.Buildings + Building? buildingInDatabase = await dbContext.Buildings .Include(building => building.PrimaryDoor) .Include(building => building.SecondaryDoor) .Include(building => building.Windows) @@ -321,15 +323,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - buildingInDatabase.Should().NotBeNull(); + buildingInDatabase.ShouldNotBeNull(); buildingInDatabase.Number.Should().Be(newBuildingNumber); - buildingInDatabase.PrimaryDoor.Should().NotBeNull(); + buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); buildingInDatabase.PrimaryDoor.Color.Should().Be(newPrimaryDoorColor); - buildingInDatabase.SecondaryDoor.Should().NotBeNull(); - buildingInDatabase.Windows.Should().HaveCount(2); + buildingInDatabase.SecondaryDoor.ShouldNotBeNull(); + buildingInDatabase.Windows.ShouldHaveCount(2); }); } + [Fact] + public async Task Cannot_update_resource_when_primaryDoorColor_is_set_to_null() + { + // Arrange + Building existingBuilding = _fakers.Building.Generate(); + existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Buildings.Add(existingBuilding); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "buildings", + id = existingBuilding.StringId, + attributes = new + { + primaryDoorColor = (string?)null + } + } + }; + + string route = $"/buildings/{existingBuilding.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The PrimaryDoorColor field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/primaryDoorColor"); + } + [Fact] public async Task Can_delete_resource() { @@ -355,7 +401,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Building buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); + Building? buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); buildingInDatabase.Should().BeNull(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs index 7e63c968eb..e15f5e2ee2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading public sealed class State : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList Cities { get; set; } + public IList Cities { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs index 5187a1a061..5e792adefd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { public sealed class StatesController : JsonApiController { - public StatesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public StatesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs index 1910b8b773..d6aa1a97e3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs @@ -11,21 +11,21 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading public sealed class Street : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public int BuildingCount => Buildings?.Count ?? 0; + public int BuildingCount => Buildings.Count; [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public int DoorTotalCount => Buildings?.Sum(building => building.SecondaryDoor == null ? 1 : 2) ?? 0; + public int DoorTotalCount => Buildings.Sum(building => building.SecondaryDoor == null ? 1 : 2); [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public int WindowTotalCount => Buildings?.Sum(building => building.WindowCount) ?? 0; + public int WindowTotalCount => Buildings.Sum(building => building.WindowCount); [EagerLoad] - public IList Buildings { get; set; } + public IList Buildings { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs index 828ffa47af..19aab24dda 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { public sealed class StreetsController : JsonApiController { - public StreetsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public StreetsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs index 0ccbcf7947..46f81275f9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -28,7 +28,7 @@ protected override IReadOnlyList CreateErrorResponse(Exception exce { if (exception is ConsumerArticleIsNoLongerAvailableException articleException) { - articleException.Errors[0].Meta = new Dictionary + articleException.Errors[0].Meta = new Dictionary { ["Support"] = $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}." }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs index 244a0dec5a..4c914b05b5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs @@ -8,6 +8,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling public sealed class ConsumerArticle : Identifiable { [Attr] - public string Code { get; set; } + public string Code { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs index 9841432bd6..c7e13af033 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { public sealed class ConsumerArticlesController : JsonApiController { - public ConsumerArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ConsumerArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs index ed118f4862..70141a7998 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ErrorDbContext : DbContext { - public DbSet ConsumerArticles { get; set; } - public DbSet ThrowingArticles { get; set; } + public DbSet ConsumerArticles => Set(); + public DbSet ThrowingArticles => Set(); public ErrorDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index a7dcd3329a..5622f87280 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -72,17 +72,22 @@ 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."); + + error.Meta.ShouldContainKey("support").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); + }); responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.Should().HaveCount(1); + 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."); } @@ -104,16 +109,26 @@ public async Task Logs_and_produces_error_response_on_deserialization_failure() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + 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["requestBody"].ToString().Should().Be(requestBody); - IEnumerable stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString()); - stackTraceLines.Should().NotBeEmpty(); + error.Meta.ShouldContainKey("requestBody").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be(requestBody); + }); + + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); + + stackTraceLines.ShouldNotBeEmpty(); + }); loggerFactory.Logger.Messages.Should().BeEmpty(); } @@ -141,19 +156,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); error.Title.Should().Be("An unhandled error occurred while processing this request."); error.Detail.Should().Be("Exception has been thrown by the target of an invocation."); - IEnumerable stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString()); - stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); + + stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); + }); responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.ShouldHaveCount(1); loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs index 4c519bea9b..d518902f47 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { public sealed class ThrowingArticlesController : JsonApiController { - public ThrowingArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ThrowingArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs index 07496253a4..adec47514b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS { public sealed class ArtGalleriesController : JsonApiController { - public ArtGalleriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ArtGalleriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs index 856551aacf..34bec33ba3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS public sealed class ArtGallery : Identifiable { [Attr] - public string Theme { get; set; } + public string Theme { get; set; } = null!; [HasMany] - public ISet Paintings { get; set; } + public ISet Paintings { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs index 19077c24a2..81e9ab0a1a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class HostingDbContext : DbContext { - public DbSet ArtGalleries { get; set; } - public DbSet Paintings { get; set; } + public DbSet ArtGalleries => Set(); + public DbSet Paintings => Set(); public HostingDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs index 02073c4bdf..7fc0374f96 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs @@ -46,6 +46,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().Be($"{HostPrefix}{route}"); @@ -53,20 +54,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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,6 +114,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().Be($"{HostPrefix}{route}"); @@ -98,19 +122,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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 bc6820ddbd..f2dd3082a4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS public sealed class Painting : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [HasOne] - public ArtGallery ExposedAt { get; set; } + public ArtGallery? ExposedAt { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs index 76b222eceb..6dcc0d930b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -11,8 +11,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS [Route("custom/path/to/paintings-of-the-world")] public sealed class PaintingsController : JsonApiController { - public PaintingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PaintingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs index 2b1b713ecf..197cee35c6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation public sealed class BankAccount : ObfuscatedIdentifiable { [Attr] - public string Iban { get; set; } + public string Iban { get; set; } = null!; [HasMany] - public IList Cards { get; set; } + public IList Cards { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs index 03cdf37d67..d064824979 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs @@ -6,8 +6,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { public sealed class BankAccountsController : ObfuscatedIdentifiableController { - public BankAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BankAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs index bd6f12ca27..13e8cb583f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -7,12 +7,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation public sealed class DebitCard : ObfuscatedIdentifiable { [Attr] - public string OwnerName { get; set; } + public string OwnerName { get; set; } = null!; [Attr] public short PinCode { get; set; } [HasOne] - public BankAccount Account { get; set; } + public BankAccount Account { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs index e023036a82..2d733f0a34 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs @@ -6,8 +6,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { public sealed class DebitCardsController : ObfuscatedIdentifiableController { - public DebitCardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DebitCardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 4d7c5b64ea..7796ec4ed5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { internal sealed class HexadecimalCodec { - public int Decode(string value) + public int Decode(string? value) { if (value == null) { @@ -26,7 +26,7 @@ public int Decode(string value) }); } - string stringValue = FromHexString(value.Substring(1)); + string stringValue = FromHexString(value[1..]); return int.Parse(stringValue); } @@ -45,7 +45,7 @@ private static string FromHexString(string hexString) return new string(chars); } - public string Encode(int value) + public string? Encode(int value) { if (value == 0) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index 8c81bee08b..bbe61c5059 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -44,7 +44,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } @@ -70,7 +70,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } @@ -86,7 +86,7 @@ public async Task Cannot_get_primary_resource_for_invalid_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -99,6 +99,7 @@ public async Task Can_get_primary_resource_by_ID() { // Arrange DebitCard card = _fakers.DebitCard.Generate(); + card.Account = _fakers.BankAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -114,7 +115,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(card.StringId); } @@ -139,7 +140,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(account.Cards[1].StringId); } @@ -165,12 +166,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(account.StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); responseDocument.Included[0].Relationships.Should().BeNull(); } @@ -195,7 +196,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); } @@ -244,8 +245,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Attributes["ownerName"].Should().Be(newCard.OwnerName); - responseDocument.Data.SingleValue.Attributes["pinCode"].Should().Be(newCard.PinCode); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("ownerName").With(value => value.Should().Be(newCard.OwnerName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("pinCode").With(value => value.Should().Be(newCard.PinCode)); var codec = new HexadecimalCodec(); int newCardId = codec.Decode(responseDocument.Data.SingleValue.Id); @@ -257,7 +259,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => cardInDatabase.OwnerName.Should().Be(newCard.OwnerName); cardInDatabase.PinCode.Should().Be(newCard.PinCode); - cardInDatabase.Account.Should().NotBeNull(); + cardInDatabase.Account.ShouldNotBeNull(); cardInDatabase.Account.Id.Should().Be(existingAccount.Id); cardInDatabase.Account.StringId.Should().Be(existingAccount.StringId); }); @@ -271,6 +273,7 @@ public async Task Can_update_resource_with_relationship() existingAccount.Cards = _fakers.DebitCard.Generate(1); DebitCard existingCard = _fakers.DebitCard.Generate(); + existingCard.Account = _fakers.BankAccount.Generate(); string newIban = _fakers.BankAccount.Generate().Iban; @@ -323,7 +326,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => accountInDatabase.Iban.Should().Be(newIban); - accountInDatabase.Cards.Should().HaveCount(1); + accountInDatabase.Cards.ShouldHaveCount(1); accountInDatabase.Cards[0].Id.Should().Be(existingCard.Id); accountInDatabase.Cards[0].StringId.Should().Be(existingCard.StringId); }); @@ -337,6 +340,7 @@ public async Task Can_add_to_ToMany_relationship() existingAccount.Cards = _fakers.DebitCard.Generate(1); DebitCard existingDebitCard = _fakers.DebitCard.Generate(); + existingDebitCard.Account = _fakers.BankAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -370,7 +374,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.Should().HaveCount(2); + accountInDatabase.Cards.ShouldHaveCount(2); }); } @@ -413,7 +417,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.Should().HaveCount(1); + accountInDatabase.Cards.ShouldHaveCount(1); }); } @@ -442,7 +446,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); + BankAccount? accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); accountInDatabase.Should().BeNull(); }); @@ -453,7 +457,7 @@ public async Task Cannot_delete_unknown_resource() { // Arrange var codec = new HexadecimalCodec(); - string stringId = codec.Encode(Unknown.TypedId.Int32); + string? stringId = codec.Encode(Unknown.TypedId.Int32); string route = $"/bankAccounts/{stringId}"; @@ -463,7 +467,7 @@ public async Task Cannot_delete_unknown_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs index 41bf98c51c..223a29229f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -6,12 +6,12 @@ public abstract class ObfuscatedIdentifiable : Identifiable { private static readonly HexadecimalCodec Codec = new(); - protected override string GetStringId(int value) + protected override string? GetStringId(int value) { return value == default ? null : Codec.Encode(value); } - protected override int GetTypedId(string value) + protected override int GetTypedId(string? value) { return value == null ? default : Codec.Decode(value); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs index 245e565359..0eb276db99 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -15,8 +15,9 @@ public abstract class ObfuscatedIdentifiableController : BaseJsonApiC { private readonly HexadecimalCodec _codec = new(); - protected ObfuscatedIdentifiableController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + protected ObfuscatedIdentifiableController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs index a4af915fc5..4cf9791745 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ObfuscationDbContext : DbContext { - public DbSet BankAccounts { get; set; } - public DbSet DebitCards { get; set; } + public DbSet BankAccounts => Set(); + public DbSet DebitCards => Set(); public ObfuscationDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index 627d044ba2..a67e9f2647 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ModelStateDbContext : DbContext { - public DbSet Directories { get; set; } - public DbSet Files { get; set; } + public DbSet Volumes => Set(); + public DbSet Directories => Set(); + public DbSet Files => Set(); public ModelStateDbContext(DbContextOptions options) : base(options) @@ -18,9 +19,15 @@ public ModelStateDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { + builder.Entity() + .HasOne(systemVolume => systemVolume.RootDirectory) + .WithOne() + .HasForeignKey("RootDirectoryId") + .IsRequired(); + builder.Entity() .HasMany(systemDirectory => systemDirectory.Subdirectories) - .WithOne(systemDirectory => systemDirectory.Parent); + .WithOne(systemDirectory => systemDirectory.Parent!); builder.Entity() .HasOne(systemDirectory => systemDirectory.Self) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs new file mode 100644 index 0000000000..06b8829ebe --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs @@ -0,0 +1,34 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +{ + internal sealed class ModelStateFakers : FakerContainer + { + private readonly Lazy> _lazySystemVolumeFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazySystemFileFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) + .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + + private readonly Lazy> _lazySystemDirectoryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemDirectory => systemDirectory.Name, faker => faker.Address.City()) + .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool()) + .RuleFor(systemDirectory => systemDirectory.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + + public Faker SystemVolume => _lazySystemVolumeFaker.Value; + public Faker SystemFile => _lazySystemFileFaker.Value; + public Faker SystemDirectory => _lazySystemDirectoryFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 5e819e5ce6..e314bf5461 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -1,21 +1,20 @@ -using System; -using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { - public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; @@ -47,13 +46,14 @@ public async Task Cannot_create_resource_with_omitted_required_attribute() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The Name field is required."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] @@ -67,7 +67,7 @@ public async Task Cannot_create_resource_with_null_for_required_attribute_value( type = "systemDirectories", attributes = new { - name = (string)null, + directoryName = (string?)null, isCaseSensitive = true } } @@ -81,13 +81,14 @@ public async Task Cannot_create_resource_with_null_for_required_attribute_value( // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The Name field is required."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] @@ -101,7 +102,7 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() type = "systemDirectories", attributes = new { - name = "!@#$%^&*().-", + directoryName = "!@#$%^&*().-", isCaseSensitive = true } } @@ -115,19 +116,22 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] public async Task Can_create_resource_with_valid_attribute_value() { // Arrange + SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); + var requestBody = new { data = new @@ -135,8 +139,8 @@ public async Task Can_create_resource_with_valid_attribute_value() type = "systemDirectories", attributes = new { - name = "Projects", - isCaseSensitive = true + directoryName = newDirectory.Name, + isCaseSensitive = newDirectory.IsCaseSensitive } } }; @@ -149,9 +153,9 @@ public async Task Can_create_resource_with_valid_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be("Projects"); - responseDocument.Data.SingleValue.Attributes["isCaseSensitive"].Should().Be(true); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); } [Fact] @@ -165,6 +169,7 @@ public async Task Cannot_create_resource_with_multiple_violations() type = "systemDirectories", attributes = new { + isCaseSensitive = false, sizeInBytes = -1 } } @@ -178,24 +183,67 @@ public async Task Cannot_create_resource_with_multiple_violations() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(3); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); - error1.Source.Pointer.Should().Be("/data/attributes/name"); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes"); + } + + [Fact] + public async Task Does_not_exceed_MaxModelValidationErrors() + { + // Arrange + var requestBody = new + { + data = new + { + type = "systemDirectories", + attributes = new + { + sizeInBytes = -1 + } + } + }; + + const string route = "/systemDirectories"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Name field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/directoryName"); ErrorObject error3 = responseDocument.Errors[2]; error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error3.Title.Should().Be("Input validation failed."); error3.Detail.Should().Be("The IsCaseSensitive field is required."); + error3.Source.ShouldNotBeNull(); error3.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } @@ -203,28 +251,16 @@ public async Task Cannot_create_resource_with_multiple_violations() public async Task Can_create_resource_with_annotated_relationships() { // Arrange - var parentDirectory = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = true - }; + SystemDirectory existingParentDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - var subdirectory = new SystemDirectory - { - Name = "Open Source", - IsCaseSensitive = true - }; - - var file = new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - }; + SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.AddRange(parentDirectory, subdirectory); - dbContext.Files.Add(file); + dbContext.Directories.AddRange(existingParentDirectory, existingSubdirectory); + dbContext.Files.Add(existingFile); await dbContext.SaveChangesAsync(); }); @@ -235,8 +271,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "systemDirectories", attributes = new { - name = "Projects", - isCaseSensitive = true + directoryName = newDirectory.Name, + isCaseSensitive = newDirectory.IsCaseSensitive }, relationships = new { @@ -247,7 +283,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemDirectories", - id = subdirectory.StringId + id = existingSubdirectory.StringId } } }, @@ -258,7 +294,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = file.StringId + id = existingFile.StringId } } }, @@ -267,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = parentDirectory.StringId + id = existingParentDirectory.StringId } } } @@ -282,30 +318,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be("Projects"); - responseDocument.Data.SingleValue.Attributes["isCaseSensitive"].Should().Be(true); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); } [Fact] public async Task Can_add_to_annotated_ToMany_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - var file = new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddInRange(directory, file); + dbContext.AddInRange(existingDirectory, existingFile); await dbContext.SaveChangesAsync(); }); @@ -316,12 +343,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = file.StringId + id = existingFile.StringId } } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -336,15 +363,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_omitted_required_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + + long newSizeInBytes = _fakers.SystemDirectory.Generate().SizeInBytes; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -353,15 +378,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - sizeInBytes = 100 + sizeInBytes = newSizeInBytes } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -373,18 +398,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_resource_with_null_for_required_attribute_value() + public async Task Cannot_update_resource_with_null_for_required_attribute_values() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -393,15 +414,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = (string)null + directoryName = (string?)null, + isCaseSensitive = (bool?)null } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -409,28 +431,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The Name field is required."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The IsCaseSensitive field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } [Fact] public async Task Cannot_update_resource_with_invalid_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -439,15 +465,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "!@#$%^&*().-" + directoryName = "!@#$%^&*().-" } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -455,41 +481,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] public async Task Cannot_update_resource_with_invalid_ID() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - var requestBody = new { data = new { type = "systemDirectories", id = "-1", - attributes = new - { - name = "Repositories" - }, relationships = new { subdirectories = new @@ -515,34 +526,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error1.Source.Pointer.Should().Be("/data/attributes/id"); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/id"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error2.Source.Pointer.Should().Be("/data/attributes/Subdirectories[0].Id"); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/relationships/subdirectories/data[0]/id"); } [Fact] public async Task Can_update_resource_with_valid_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + + string newDirectoryName = _fakers.SystemDirectory.Generate().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -551,15 +562,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "Repositories" + directoryName = newDirectoryName } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -574,53 +585,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_annotated_relationships() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false, - Subdirectories = new List - { - new() - { - Name = "C#", - IsCaseSensitive = false - } - }, - Files = new List - { - new() - { - FileName = "readme.txt" - } - }, - Parent = new SystemDirectory - { - Name = "Data", - IsCaseSensitive = false - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Subdirectories = _fakers.SystemDirectory.Generate(1); + existingDirectory.Files = _fakers.SystemFile.Generate(1); + existingDirectory.Parent = _fakers.SystemDirectory.Generate(); - var otherParent = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = false - }; + SystemDirectory existingParent = _fakers.SystemDirectory.Generate(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - var otherSubdirectory = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = false - }; - - var otherFile = new SystemFile - { - FileName = "readme.md" - }; + string newDirectoryName = _fakers.SystemDirectory.Generate().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.AddRange(directory, otherParent, otherSubdirectory); - dbContext.Files.Add(otherFile); + dbContext.Directories.AddRange(existingDirectory, existingParent, existingSubdirectory); + dbContext.Files.Add(existingFile); await dbContext.SaveChangesAsync(); }); @@ -629,10 +608,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "Project Files" + directoryName = newDirectoryName }, relationships = new { @@ -643,7 +622,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemDirectories", - id = otherSubdirectory.StringId + id = existingSubdirectory.StringId } } }, @@ -654,7 +633,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = otherFile.StringId + id = existingFile.StringId } } }, @@ -663,14 +642,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = otherParent.StringId + id = existingParent.StringId } } } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -685,15 +664,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_multiple_self_references() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -702,11 +677,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project files" - }, + id = existingDirectory.StringId, relationships = new { self = new @@ -714,7 +685,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId + id = existingDirectory.StringId } }, alsoSelf = new @@ -722,14 +693,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId + id = existingDirectory.StringId } } } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -744,15 +715,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_collection_of_self_references() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -761,11 +728,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project files" - }, + id = existingDirectory.StringId, relationships = new { subdirectories = new @@ -775,7 +738,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemDirectories", - id = directory.StringId + id = existingDirectory.StringId } } } @@ -783,7 +746,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -798,26 +761,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_annotated_ToOne_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Parent = new SystemDirectory - { - Name = "Data", - IsCaseSensitive = true - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Parent = _fakers.SystemDirectory.Generate(); - var otherParent = new SystemDirectory - { - Name = "Data files", - IsCaseSensitive = true - }; + SystemDirectory otherExistingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.AddRange(directory, otherParent); + dbContext.Directories.AddRange(existingDirectory, otherExistingDirectory); await dbContext.SaveChangesAsync(); }); @@ -826,11 +777,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = otherParent.StringId + id = otherExistingDirectory.StringId } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/parent"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/parent"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -845,32 +796,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_annotated_ToMany_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Files = new List - { - new() - { - FileName = "Main.cs" - }, - new() - { - FileName = "Program.cs" - } - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Files = _fakers.SystemFile.Generate(2); - var otherFile = new SystemFile - { - FileName = "EntryPoint.cs" - }; + SystemFile existingFile = _fakers.SystemFile.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); - dbContext.Files.Add(otherFile); + dbContext.AddInRange(existingDirectory, existingFile); await dbContext.SaveChangesAsync(); }); @@ -881,12 +814,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = otherFile.StringId + id = existingFile.StringId } } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -901,32 +834,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_annotated_ToMany_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Files = new List - { - new() - { - FileName = "Main.cs", - SizeInBytes = 100 - } - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Files = _fakers.SystemFile.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddInRange(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); var requestBody = new { - data = Array.Empty() + data = new[] + { + new + { + type = "systemFiles", + id = existingDirectory.Files.ElementAt(0).StringId + } + } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index bbf49d0b73..4a7968e68c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -3,19 +3,23 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { - public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class NoModelStateValidationTests + : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; + testContext.UseController(); testContext.UseController(); testContext.UseController(); } @@ -31,7 +35,7 @@ public async Task Can_create_resource_with_invalid_attribute_value() type = "systemDirectories", attributes = new { - name = "!@#$%^&*().-", + directoryName = "!@#$%^&*().-", isCaseSensitive = false } } @@ -45,23 +49,19 @@ public async Task Can_create_resource_with_invalid_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be("!@#$%^&*().-"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be("!@#$%^&*().-")); } [Fact] public async Task Can_update_resource_with_invalid_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -70,15 +70,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "!@#$%^&*().-" + directoryName = "!@#$%^&*().-" } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -88,5 +88,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } + + [Fact] + public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() + { + // Arrange + SystemVolume existingVolume = _fakers.SystemVolume.Generate(); + existingVolume.RootDirectory = _fakers.SystemDirectory.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Volumes.Add(existingVolume); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "systemVolumes", + id = existingVolume.StringId, + relationships = new + { + rootDirectory = new + { + data = (object?)null + } + } + } + }; + + string route = $"/systemVolumes/{existingVolume.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + + error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + + "cannot be cleared because it is a required relationship."); + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs index b427470fd2..0c4af4fa1a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { public sealed class SystemDirectoriesController : JsonApiController { - public SystemDirectoriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SystemDirectoriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs index e0accf3621..549356dbca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs @@ -9,14 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SystemDirectory : Identifiable { - [Required] [RegularExpression("^[0-9]+$")] public override int Id { get; set; } - [Attr] - [Required] + [Attr(PublicName = "directoryName")] [RegularExpression(@"^[\w\s]+$")] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] [Required] @@ -27,18 +25,18 @@ public sealed class SystemDirectory : Identifiable public long SizeInBytes { get; set; } [HasMany] - public ICollection Subdirectories { get; set; } + public ICollection Subdirectories { get; set; } = new List(); [HasMany] - public ICollection Files { get; set; } + public ICollection Files { get; set; } = new List(); [HasOne] - public SystemDirectory Self { get; set; } + public SystemDirectory? Self { get; set; } [HasOne] - public SystemDirectory AlsoSelf { get; set; } + public SystemDirectory? AlsoSelf { get; set; } [HasOne] - public SystemDirectory Parent { get; set; } + public SystemDirectory? Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index 16ed4b6925..7a8d796a58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -9,13 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState public sealed class SystemFile : Identifiable { [Attr] - [Required] [MinLength(1)] - public string FileName { get; set; } + public string FileName { get; set; } = null!; [Attr] [Required] [Range(typeof(long), "0", "9223372036854775807")] - public long SizeInBytes { get; set; } + public long? SizeInBytes { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs index ccc70687fd..90fb26d246 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { public sealed class SystemFilesController : JsonApiController { - public SystemFilesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SystemFilesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs new file mode 100644 index 0000000000..b2c4ede226 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class SystemVolume : Identifiable + { + [Attr] + public string? Name { get; set; } + + [HasOne] + public SystemDirectory RootDirectory { get; set; } = null!; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolumesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolumesController.cs new file mode 100644 index 0000000000..a649619ec1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolumesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +{ + public sealed class SystemVolumesController : JsonApiController + { + public SystemVolumesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs index 2eb55a784d..ab1442ea05 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class WorkflowDbContext : DbContext { - public DbSet Workflows { get; set; } + public DbSet Workflows => Set(); public WorkflowDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs index 912bd9c9ac..cebebb9fda 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs @@ -102,9 +102,8 @@ private static void AssertCanTransitionToStage(WorkflowStage fromStage, Workflow private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) { - if (StageTransitionTable.ContainsKey(fromStage)) + if (StageTransitionTable.TryGetValue(fromStage, out ICollection? possibleNextStages)) { - ICollection possibleNextStages = StageTransitionTable[fromStage]; return possibleNextStages.Contains(toStage); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs index feea8f6748..07c9dbe4ca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody { public enum WorkflowStage { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index d3cc69efb6..3b7d0f9f16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -4,17 +4,16 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody { - public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> + public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> { - private readonly IntegrationTestContext, WorkflowDbContext> _testContext; + private readonly IntegrationTestContext, WorkflowDbContext> _testContext; - public WorkflowTests(IntegrationTestContext, WorkflowDbContext> testContext) + public WorkflowTests(IntegrationTestContext, WorkflowDbContext> testContext) { _testContext = testContext; @@ -50,7 +49,7 @@ public async Task Can_create_in_valid_stage() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); } [Fact] @@ -77,12 +76,13 @@ public async Task Cannot_create_in_invalid_stage() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Invalid workflow stage."); error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/stage"); } @@ -122,12 +122,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Invalid workflow stage."); error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/stage"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs index 737cfc2a08..b31523b7e1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody { public sealed class WorkflowsController : JsonApiController { - public WorkflowsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WorkflowsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 7f03363f30..cd10a76501 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -17,6 +17,7 @@ public sealed class AbsoluteLinksWithNamespaceTests : IClassFixture, LinksDbContext>> { private const string HostPrefix = "http://localhost"; + private const string PathPrefix = "/api"; private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -49,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -57,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -64,10 +66,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -84,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -92,6 +101,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().Be($"{HostPrefix}{route}"); @@ -99,19 +109,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); + + 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 = $"{HostPrefix}/api/photos/{album.Photos.ElementAt(0).StringId}"; + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -127,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -135,6 +167,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -142,12 +175,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/api/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -163,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -171,6 +211,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().Be($"{HostPrefix}{route}"); @@ -178,12 +219,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/api/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -199,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -207,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/api/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -232,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -240,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -258,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_absolute // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -269,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -286,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -294,6 +354,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -301,18 +362,38 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/api/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"{HostPrefix}/api/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -348,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/api/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -356,6 +437,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -363,19 +445,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/api/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"{HostPrefix}/api/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 26d5dcc46a..dc87ac7fa9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -17,6 +17,7 @@ public sealed class AbsoluteLinksWithoutNamespaceTests : IClassFixture, LinksDbContext>> { private const string HostPrefix = "http://localhost"; + private const string PathPrefix = ""; private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -49,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -57,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -64,10 +66,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -84,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -92,6 +101,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().Be($"{HostPrefix}{route}"); @@ -99,19 +109,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); + + 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 = $"{HostPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -127,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -135,6 +167,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -142,12 +175,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -163,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -171,6 +211,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().Be($"{HostPrefix}{route}"); @@ -178,12 +219,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -199,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -207,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -232,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -240,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -258,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_absolute // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -269,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -286,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -294,6 +354,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -301,18 +362,38 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"{HostPrefix}/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -348,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -356,6 +437,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -363,19 +445,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"{HostPrefix}/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs index e7aee555c3..77eaaf376f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs @@ -45,21 +45,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Self.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Related.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["album"].Links.Should().BeNull(); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeNull(); + value.Links.Related.ShouldNotBeNull(); + }); - responseDocument.Included[0].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["location"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["location"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.Should().BeNull(); + }); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.ShouldNotBeNull(); + + resource.Relationships.ShouldContainKey("location").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + }); - responseDocument.Included[1].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["photos"].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["photos"].Links.Related.Should().NotBeNull(); + responseDocument.Included[1].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.ShouldNotBeNull(); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + }); } [Fact] @@ -85,10 +116,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Self.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Related.Should().NotBeNull(); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("album"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs index e672034fce..935c4b6718 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class LinksDbContext : DbContext { - public DbSet PhotoAlbums { get; set; } - public DbSet Photos { get; set; } - public DbSet PhotoLocations { get; set; } + public DbSet PhotoAlbums => Set(); + public DbSet Photos => Set(); + public DbSet PhotoLocations => Set(); public LinksDbContext(DbContextOptions options) : base(options) @@ -21,7 +21,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(photo => photo.Location) - .WithOne(location => location.Photo) + .WithOne(location => location!.Photo) .HasForeignKey("LocationId"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs index b3ef35a541..1ee92f0d58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class Photo : Identifiable { [Attr] - public string Url { get; set; } + public string? Url { get; set; } [HasOne] - public PhotoLocation Location { get; set; } + public PhotoLocation? Location { get; set; } [HasOne] - public PhotoAlbum Album { get; set; } + public PhotoAlbum? Album { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs index 89d79a3353..9f6df3c0d5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class PhotoAlbum : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Photos { get; set; } + public ISet Photos { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs index 065d14432c..29f80bc733 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { public sealed class PhotoAlbumsController : JsonApiController { - public PhotoAlbumsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PhotoAlbumsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs index ad28db953d..5985719055 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class PhotoLocation : Identifiable { [Attr] - public string PlaceName { get; set; } + public string? PlaceName { get; set; } [Attr] public double Latitude { get; set; } @@ -18,9 +18,9 @@ public sealed class PhotoLocation : Identifiable public double Longitude { get; set; } [HasOne] - public Photo Photo { get; set; } + public Photo Photo { get; set; } = null!; [HasOne(Links = LinkTypes.None)] - public PhotoAlbum Album { get; set; } + public PhotoAlbum? Album { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs index 9e63f1d4f3..c77e97d5e4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { public sealed class PhotoLocationsController : JsonApiController { - public PhotoLocationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PhotoLocationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs index 5029d96932..0a3c83b911 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { public sealed class PhotosController : JsonApiController { - public PhotosController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PhotosController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index effa2b0d8c..16efd09971 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -16,6 +16,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class RelativeLinksWithNamespaceTests : IClassFixture, LinksDbContext>> { + private const string HostPrefix = ""; + private const string PathPrefix = "/api"; + private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -47,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -55,17 +58,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(route); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{route}/photos"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -82,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -90,26 +101,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().Be(route); + responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}"); 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}"; - 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.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - string photoLink = $"/api/photos/{album.Photos.ElementAt(0).StringId}"; + 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}"; - 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.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] @@ -125,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -133,19 +167,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/api/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -161,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -169,19 +211,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); + responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/api/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -197,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -205,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/api/photos/{photo.StringId}/album"); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -230,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -238,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -256,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_relative // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -267,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -284,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -292,25 +354,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/api/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"/api/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -346,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/api/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -354,26 +437,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/api/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"/api/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index 6256adf66d..26ce39d7ba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -16,6 +16,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class RelativeLinksWithoutNamespaceTests : IClassFixture, LinksDbContext>> { + private const string HostPrefix = ""; + private const string PathPrefix = ""; + private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -47,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -55,17 +58,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(route); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{route}/photos"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -82,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -90,26 +101,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().Be(route); + responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}"); 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}"; - 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.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - string photoLink = $"/photos/{album.Photos.ElementAt(0).StringId}"; + 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}"; - 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.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] @@ -125,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -133,19 +167,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -161,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -169,19 +211,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); + responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -197,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -205,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/photos/{photo.StringId}/album"); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -230,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -238,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -256,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_relative // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -267,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -284,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -292,25 +354,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -346,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -354,26 +437,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs index 9255ee4ab4..d3104eb45b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { public sealed class AuditEntriesController : JsonApiController { - public AuditEntriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public AuditEntriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs index 958787ea34..00aaa5c6b7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging public sealed class AuditEntry : Identifiable { [Attr] - public string UserName { get; set; } + public string UserName { get; set; } = null!; [Attr] public DateTimeOffset CreatedAt { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs similarity index 55% rename from test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditDbContext.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs index 670b97dcbc..d99bf62f8a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs @@ -4,11 +4,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AuditDbContext : DbContext + public sealed class LoggingDbContext : DbContext { - public DbSet AuditEntries { get; set; } + public DbSet AuditEntries => Set(); - public AuditDbContext(DbContextOptions options) + public LoggingDbContext(DbContextOptions options) : base(options) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs similarity index 92% rename from test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditFakers.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index ff8fc1b2a8..f2e17e5494 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { - internal sealed class AuditFakers : FakerContainer + internal sealed class LoggingFakers : FakerContainer { private readonly Lazy> _lazyAuditEntryFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index d9858db4f3..0ac74170db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { - public sealed class LoggingTests : IClassFixture, AuditDbContext>> + public sealed class LoggingTests : IClassFixture, LoggingDbContext>> { - private readonly IntegrationTestContext, AuditDbContext> _testContext; - private readonly AuditFakers _fakers = new(); + private readonly IntegrationTestContext, LoggingDbContext> _testContext; + private readonly LoggingFakers _fakers = new(); - public LoggingTests(IntegrationTestContext, AuditDbContext> testContext) + public LoggingTests(IntegrationTestContext, LoggingDbContext> testContext) { _testContext = testContext; @@ -67,7 +67,7 @@ public async Task Logs_request_body_at_Trace_level() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - loggerFactory.Logger.Messages.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); @@ -89,7 +89,7 @@ public async Task Logs_response_body_at_Trace_level() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - loggerFactory.Logger.Messages.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); @@ -113,7 +113,7 @@ public async Task Logs_invalid_request_body_error_at_Information_level() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - loggerFactory.Logger.Messages.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body.")); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs new file mode 100644 index 0000000000..9c46c51709 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class MetaDbContext : DbContext + { + public DbSet ProductFamilies => Set(); + public DbSet SupportTickets => Set(); + + public MetaDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs similarity index 94% rename from test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportFakers.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs index d20016a8d6..3deae16930 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - internal sealed class SupportFakers : FakerContainer + internal sealed class MetaFakers : FakerContainer { private readonly Lazy> _lazyProductFamilyFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs index 38d2cdd749..408d6a3414 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { public sealed class ProductFamiliesController : JsonApiController { - public ProductFamiliesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ProductFamiliesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs index 4728385036..feb2d9358a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta public sealed class ProductFamily : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList Tickets { get; set; } + public IList Tickets { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs index fbd9892f9a..c425d264c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class ResourceMetaTests : IClassFixture, SupportDbContext>> + public sealed class ResourceMetaTests : IClassFixture, MetaDbContext>> { - private readonly IntegrationTestContext, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new(); + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); - public ResourceMetaTests(IntegrationTestContext, SupportDbContext> testContext) + public ResourceMetaTests(IntegrationTestContext, MetaDbContext> testContext) { _testContext = testContext; @@ -58,10 +58,10 @@ 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[] { @@ -96,9 +96,9 @@ 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[] { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index ed5e3da83f..d416eac792 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class ResponseMetaTests : IClassFixture, SupportDbContext>> + public sealed class ResponseMetaTests : IClassFixture, MetaDbContext>> { - private readonly IntegrationTestContext, SupportDbContext> _testContext; + private readonly IntegrationTestContext, MetaDbContext> _testContext; - public ResponseMetaTests(IntegrationTestContext, SupportDbContext> testContext) + public ResponseMetaTests(IntegrationTestContext, MetaDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportDbContext.cs deleted file mode 100644 index 0db9f2cc95..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SupportDbContext : DbContext - { - public DbSet ProductFamilies { get; set; } - public DbSet SupportTickets { get; set; } - - public SupportDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs index 1f7eee1262..7bb94a52c8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { public sealed class SupportResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["license"] = "MIT", ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs index 1691f4371a..603b6790fd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs @@ -8,6 +8,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta public sealed class SupportTicket : Identifiable { [Attr] - public string Description { get; set; } + public string Description { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs index 234a9049de..4fd71a830a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs @@ -17,13 +17,13 @@ public SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionH _hitCounter = hitCounter; } - public override IDictionary GetMeta(SupportTicket resource) + public override IDictionary? GetMeta(SupportTicket resource) { _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); - if (resource.Description != null && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) + if (!string.IsNullOrEmpty(resource.Description) && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) { - return new Dictionary + return new Dictionary { ["hasHighPriority"] = true }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs index bfaac2b81e..9e5b6da653 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { public sealed class SupportTicketsController : JsonApiController { - public SupportTicketsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SupportTicketsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index a449e1799b..60604b91f0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class TopLevelCountTests : IClassFixture, SupportDbContext>> + public sealed class TopLevelCountTests : IClassFixture, MetaDbContext>> { - private readonly IntegrationTestContext, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new(); + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); - public TopLevelCountTests(IntegrationTestContext, SupportDbContext> testContext) + public TopLevelCountTests(IntegrationTestContext, MetaDbContext> testContext) { _testContext = testContext; @@ -54,8 +54,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().NotBeNull(); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(1); + responseDocument.Meta.ShouldNotBeNull(); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(1); + }); } [Fact] @@ -75,8 +80,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().NotBeNull(); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(0); + responseDocument.Meta.ShouldNotBeNull(); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(0); + }); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs index 1d7b797884..f8028b650e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices public sealed class DomainGroup : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Users { get; set; } + public ISet Users { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs index a175e8773e..cb1bbc6a32 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices { public sealed class DomainGroupsController : JsonApiController { - public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DomainGroupsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs index a9bfd19c15..6bc4af7483 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs @@ -1,5 +1,4 @@ using System; -using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -10,13 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices public sealed class DomainUser : Identifiable { [Attr] - [Required] - public string LoginName { get; set; } + public string LoginName { get; set; } = null!; [Attr] - public string DisplayName { get; set; } + public string? DisplayName { get; set; } [HasOne] - public DomainGroup Group { get; set; } + public DomainGroup? Group { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs index 1908da2667..3890ced6cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices { public sealed class DomainUsersController : JsonApiController { - public DomainUsersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DomainUsersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs index f2ab748564..c778390d39 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDel [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class FireForgetDbContext : DbContext { - public DbSet Users { get; set; } - public DbSet Groups { get; set; } + public DbSet Users => Set(); + public DbSet Groups => Set(); public FireForgetDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs index e8abb064dd..6001e055e7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs @@ -13,7 +13,7 @@ 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) @@ -45,7 +45,7 @@ protected override Task FlushMessageAsync(OutgoingMessage message, CancellationT return _messageBroker.PostMessageAsync(message, cancellationToken); } - protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) { return Task.FromResult(_groupToDelete); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs index bf762c857b..4ca0baede4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -43,8 +43,8 @@ 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[] { @@ -53,9 +53,9 @@ public async Task Create_group_sends_messages() (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(newGroupId); @@ -121,8 +121,8 @@ 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[] { @@ -132,9 +132,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.GroupId.Should().Be(newGroupId); @@ -197,7 +197,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -282,7 +282,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -329,7 +329,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -367,7 +367,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); @@ -443,7 +443,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -516,7 +516,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -578,7 +578,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUserWithSameGroup2.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs index fe40df6188..1610045a2f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs @@ -21,7 +21,7 @@ public async Task Create_user_sends_messages() var messageBroker = _testContext.Factory.Services.GetRequiredService(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; var requestBody = new { @@ -44,9 +44,9 @@ 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[] { @@ -55,9 +55,9 @@ public async Task Create_user_sends_messages() (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(newUserId); @@ -113,9 +113,9 @@ 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[] { @@ -125,9 +125,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(newUserId); @@ -149,7 +149,7 @@ public async Task Update_user_sends_messages() DomainUser existingUser = _fakers.DomainUser.Generate(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -188,7 +188,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -211,7 +211,7 @@ public async Task Update_user_clear_group_sends_messages() DomainUser existingUser = _fakers.DomainUser.Generate(); existingUser.Group = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -233,7 +233,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { group = new { - data = (object)null + data = (object?)null } } } @@ -257,7 +257,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -279,7 +279,7 @@ public async Task Update_user_add_to_group_sends_messages() DomainUser existingUser = _fakers.DomainUser.Generate(); DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -329,7 +329,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -353,7 +353,7 @@ public async Task Update_user_move_to_group_sends_messages() DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -403,7 +403,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -447,7 +447,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -485,7 +485,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -513,7 +513,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; @@ -534,7 +534,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -584,7 +584,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -636,7 +636,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs index 391ff96781..ebef3ea6fb 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); @@ -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); @@ -110,11 +110,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.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..ee713cb291 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs @@ -13,7 +13,7 @@ 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) @@ -45,7 +45,7 @@ protected override Task FlushMessageAsync(OutgoingMessage message, CancellationT return _messageBroker.PostMessageAsync(message, cancellationToken); } - protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) { return Task.FromResult(_userToDelete); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs index 11541b5133..8b13860f7a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs @@ -8,7 +8,13 @@ public sealed class GroupCreatedContent : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; set; } - public string GroupName { get; set; } + public Guid GroupId { get; } + public string GroupName { get; } + + public GroupCreatedContent(Guid groupId, string groupName) + { + GroupId = groupId; + GroupName = groupName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs index d3bb447513..7dc9d4e93f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs @@ -8,6 +8,11 @@ public sealed class GroupDeletedContent : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; set; } + public Guid GroupId { get; } + + public GroupDeletedContent(Guid groupId) + { + GroupId = groupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs index 21044b4bcf..068c1dabdd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs @@ -8,8 +8,15 @@ public sealed class GroupRenamedContent : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; set; } - public string BeforeGroupName { get; set; } - public string AfterGroupName { get; set; } + public Guid GroupId { get; } + public string BeforeGroupName { get; } + public string AfterGroupName { get; } + + public GroupRenamedContent(Guid groupId, string beforeGroupName, string afterGroupName) + { + GroupId = groupId; + BeforeGroupName = beforeGroupName; + AfterGroupName = afterGroupName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs index fccf23a8ef..eb797263a8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs @@ -11,23 +11,26 @@ public sealed class OutgoingMessage public int FormatVersion { get; set; } public string Content { get; set; } + private OutgoingMessage(string type, int formatVersion, string content) + { + Type = type; + FormatVersion = formatVersion; + Content = content; + } + public T GetContentAs() where T : IMessageContent { - string namespacePrefix = typeof(IMessageContent).Namespace; - var contentType = System.Type.GetType($"{namespacePrefix}.{Type}", true); + string namespacePrefix = typeof(IMessageContent).Namespace!; + var contentType = System.Type.GetType($"{namespacePrefix}.{Type}", true)!; - return (T)JsonSerializer.Deserialize(Content, contentType); + return (T)JsonSerializer.Deserialize(Content, contentType)!; } public static OutgoingMessage CreateFromContent(IMessageContent content) { - return new() - { - Type = content.GetType().Name, - FormatVersion = content.FormatVersion, - Content = JsonSerializer.Serialize(content, content.GetType()) - }; + string value = JsonSerializer.Serialize(content, content.GetType()); + return new OutgoingMessage(content.GetType().Name, content.FormatVersion, value); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs index 0dd40a8ecc..e4cf0d0864 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs @@ -8,7 +8,13 @@ public sealed class UserAddedToGroupContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public Guid GroupId { get; set; } + public Guid UserId { get; } + public Guid GroupId { get; } + + public UserAddedToGroupContent(Guid userId, Guid groupId) + { + UserId = userId; + GroupId = groupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs index eff26c683f..c6de505362 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs @@ -8,8 +8,15 @@ public sealed class UserCreatedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public string UserLoginName { get; set; } - public string UserDisplayName { get; set; } + public Guid UserId { get; } + public string UserLoginName { get; } + public string? UserDisplayName { get; } + + public UserCreatedContent(Guid userId, string userLoginName, string? userDisplayName) + { + UserId = userId; + UserLoginName = userLoginName; + UserDisplayName = userDisplayName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs index d48fd1dedd..21d5789b25 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs @@ -8,6 +8,11 @@ public sealed class UserDeletedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } + public Guid UserId { get; } + + public UserDeletedContent(Guid userId) + { + UserId = userId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs index d9f00f533a..64be5883ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs @@ -8,8 +8,15 @@ public sealed class UserDisplayNameChangedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public string BeforeUserDisplayName { get; set; } - public string AfterUserDisplayName { get; set; } + public Guid UserId { get; } + public string? BeforeUserDisplayName { get; } + public string? AfterUserDisplayName { get; } + + public UserDisplayNameChangedContent(Guid userId, string? beforeUserDisplayName, string? afterUserDisplayName) + { + UserId = userId; + BeforeUserDisplayName = beforeUserDisplayName; + AfterUserDisplayName = afterUserDisplayName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs index 56015fbe13..8adc213fa2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs @@ -8,8 +8,15 @@ public sealed class UserLoginNameChangedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public string BeforeUserLoginName { get; set; } - public string AfterUserLoginName { get; set; } + public Guid UserId { get; } + public string BeforeUserLoginName { get; } + public string AfterUserLoginName { get; } + + public UserLoginNameChangedContent(Guid userId, string beforeUserLoginName, string afterUserLoginName) + { + UserId = userId; + BeforeUserLoginName = beforeUserLoginName; + AfterUserLoginName = afterUserLoginName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs index 29ed680283..2f1234734e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs @@ -8,8 +8,15 @@ public sealed class UserMovedToGroupContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public Guid BeforeGroupId { get; set; } - public Guid AfterGroupId { get; set; } + public Guid UserId { get; } + public Guid BeforeGroupId { get; } + public Guid AfterGroupId { get; } + + public UserMovedToGroupContent(Guid userId, Guid beforeGroupId, Guid afterGroupId) + { + UserId = userId; + BeforeGroupId = beforeGroupId; + AfterGroupId = afterGroupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs index 8f2599e8ae..8bc1805942 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs @@ -8,7 +8,13 @@ public sealed class UserRemovedFromGroupContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public Guid GroupId { get; set; } + public Guid UserId { get; } + public Guid GroupId { get; } + + public UserRemovedFromGroupContent(Guid userId, Guid groupId) + { + UserId = userId; + GroupId = groupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index 7c85223bd2..b79c21e376 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -19,7 +19,7 @@ public abstract class MessagingGroupDefinition : JsonApiResourceDefinition _pendingMessages = new(); - private string _beforeGroupName; + private string? _beforeGroupName; protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, ResourceDefinitionHitCounter hitCounter) @@ -60,44 +60,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); } } } @@ -116,29 +101,21 @@ 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); } } } @@ -155,12 +132,8 @@ 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); } } @@ -172,51 +145,31 @@ protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writ { 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 +181,7 @@ protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writ protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) { return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs index bea8286624..89d5acb903 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -17,8 +17,8 @@ public abstract class MessagingUserDefinition : JsonApiResourceDefinition _pendingMessages = new(); - private string _beforeLoginName; - private string _beforeDisplayName; + private string? _beforeLoginName; + private string? _beforeDisplayName; protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) : base(resourceGraph) @@ -44,7 +44,7 @@ public override Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind wri return Task.CompletedTask; } - public override Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + public override Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync); @@ -52,32 +52,19 @@ public override Task OnSetToOneRelationshipAsync(DomainUser user, 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) @@ -94,61 +81,38 @@ protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeO { 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 +124,7 @@ protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeO protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) { return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs index 42e96e1d00..87e8d63707 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOut [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class OutboxDbContext : DbContext { - public DbSet Users { get; set; } - public DbSet Groups { get; set; } - public DbSet OutboxMessages { get; set; } + public DbSet Users => Set(); + public DbSet Groups => Set(); + public DbSet OutboxMessages => Set(); public OutboxDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index eda25094b2..e83c88348f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -49,8 +49,8 @@ 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[] { @@ -58,12 +58,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(newGroupId); @@ -130,8 +130,8 @@ 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[] { @@ -140,12 +140,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs(); content1.GroupId.Should().Be(newGroupId); @@ -211,7 +211,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -299,7 +299,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -349,7 +349,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -390,7 +390,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); @@ -469,7 +469,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -545,7 +545,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -610,7 +610,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUserWithSameGroup2.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index 219da42053..7c08da151c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -23,7 +23,7 @@ public async Task Create_user_writes_to_outbox() var hitCounter = _testContext.Factory.Services.GetRequiredService(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -51,9 +51,9 @@ 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[] { @@ -61,12 +61,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) }, options => options.WithStrictOrdering()); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(newUserId); @@ -123,9 +123,9 @@ 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[] { @@ -134,12 +134,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) }, options => options.WithStrictOrdering()); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(newUserId); @@ -161,7 +161,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 => { @@ -203,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -226,7 +226,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 +249,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { group = new { - data = (object)null + data = (object?)null } } } @@ -275,7 +275,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -297,7 +297,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 => { @@ -350,7 +350,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -374,7 +374,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 => { @@ -427,7 +427,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -474,7 +474,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -515,7 +515,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -544,7 +544,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; @@ -567,7 +567,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -620,7 +620,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -675,7 +675,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs index 68af373b9d..e864f8f87a 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); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs index fed46351e1..59bc69faff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs @@ -10,8 +10,8 @@ public sealed class MultiTenancyDbContext : DbContext { private readonly ITenantProvider _tenantProvider; - public DbSet WebShops { get; set; } - public DbSet WebProducts { get; set; } + public DbSet WebShops => Set(); + public DbSet WebProducts => Set(); public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options) @@ -23,8 +23,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasMany(webShop => webShop.Products) - .WithOne(webProduct => webProduct.Shop) - .IsRequired(); + .WithOne(webProduct => webProduct.Shop); builder.Entity() .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index 02de41e5dd..6cc4b83b89 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -68,7 +68,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 +98,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 +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("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 +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.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -248,7 +248,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 +278,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 +312,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 +377,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 +431,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 +524,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 +580,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 +633,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 +668,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 +713,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 +737,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 +748,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 +790,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 +835,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 +879,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 +921,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 +955,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 +983,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,6 +1014,7 @@ 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); @@ -1021,19 +1022,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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 eb67a5f690..2d3086a236 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -46,21 +46,21 @@ protected override async Task InitializeResourceAsync(TResource resourceForDatab // To optimize performance, the default resource service does not always fetch all resources on write operations. // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. - public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); return await base.CreateAsync(resource, cancellationToken); } - public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); return await base.UpdateAsync(id, resource, cancellationToken); } - public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { await AssertRightResourcesExistAsync(rightValue, cancellationToken); 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 4e852aa772..310aad6a8b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy public sealed class WebProduct : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public decimal Price { get; set; } [HasOne] - public WebShop Shop { get; set; } + public WebShop Shop { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs index 75297898b1..53a460376b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -11,8 +11,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy [Route("{countryCode}/products")] public sealed class WebProductsController : JsonApiController { - public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WebProductsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs index 04e87b939f..c5830276d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy public sealed class WebShop : Identifiable, IHasTenant { [Attr] - public string Url { get; set; } + public string Url { get; set; } = null!; public Guid TenantId { get; set; } [HasMany] - public IList Products { get; set; } + public IList Products { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs index 0758f91554..0907c67d25 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -11,8 +11,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy [Route("{countryCode}/shops")] public sealed class WebShopsController : JsonApiController { - public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WebShopsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs index 9dcd5091f7..fd84f2231b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -9,7 +9,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions public sealed class DivingBoard : Identifiable { [Attr] - [Required] [Range(1, 20)] public decimal HeightInMeters { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs index 87256faa2e..673ddca0c8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { public sealed class DivingBoardsController : JsonApiController { - public DivingBoardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DivingBoardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs index 952ffd7a14..6114b514cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -16,7 +16,6 @@ protected override void SetJsonApiOptions(JsonApiOptions options) options.Namespace = "public-api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.ValidateModelState = true; options.SerializerOptions.PropertyNamingPolicy = JsonKebabCaseNamingPolicy.Instance; options.SerializerOptions.DictionaryKeyPolicy = JsonKebabCaseNamingPolicy.Instance; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index de91ad49fb..d2be3f1c74 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - public sealed class KebabCasingTests : IClassFixture, SwimmingDbContext>> + public sealed class KebabCasingTests : IClassFixture, NamingDbContext>> { - private readonly IntegrationTestContext, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new(); + private readonly IntegrationTestContext, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); - public KebabCasingTests(IntegrationTestContext, SwimmingDbContext> testContext) + public KebabCasingTests(IntegrationTestContext, NamingDbContext> testContext) { _testContext = testContext; @@ -45,20 +45,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("is-indoor")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("water-slides")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("diving-boards")); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("is-indoor") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("water-slides") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("diving-boards") != null); - responseDocument.Included.Should().HaveCount(1); + decimal height = pools[1].DivingBoards[0].HeightInMeters; + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("diving-boards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes["height-in-meters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes.ShouldContainKey("height-in-meters").With(value => value.As().Should().BeApproximately(height)); responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(2); + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(2); + }); } [Fact] @@ -85,10 +91,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("water-slides"); responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); } [Fact] @@ -117,18 +123,28 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("swimming-pools"); - responseDocument.Data.SingleValue.Attributes["is-indoor"].Should().Be(newPool.IsIndoor); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("is-indoor").With(value => value.Should().Be(newPool.IsIndoor)); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id); + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships["water-slides"].Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); - responseDocument.Data.SingleValue.Relationships["water-slides"].Links.Related.Should().Be($"{poolLink}/water-slides"); - responseDocument.Data.SingleValue.Relationships["diving-boards"].Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); - responseDocument.Data.SingleValue.Relationships["diving-boards"].Links.Related.Should().Be($"{poolLink}/diving-boards"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("water-slides").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); + value.Links.Related.Should().Be($"{poolLink}/water-slides"); + }); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("diving-boards").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); + value.Links.Related.Should().Be($"{poolLink}/diving-boards"); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -152,12 +168,12 @@ public async Task Applies_casing_convention_on_error_stack_trace() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.Should().ContainKey("stack-trace"); + error.Meta.ShouldContainKey("stack-trace"); } [Fact] @@ -193,12 +209,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/height-in-meters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs new file mode 100644 index 0000000000..120c28ff72 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class NamingDbContext : DbContext + { + public DbSet SwimmingPools => Set(); + public DbSet WaterSlides => Set(); + public DbSet DivingBoards => Set(); + + public NamingDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs similarity index 95% rename from test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingFakers.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs index 7733a120b8..fa387cf3bf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - internal sealed class SwimmingFakers : FakerContainer + internal sealed class NamingFakers : FakerContainer { private readonly Lazy> _lazySwimmingPoolFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs index 65deafe3bc..0f863df251 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs @@ -16,7 +16,6 @@ protected override void SetJsonApiOptions(JsonApiOptions options) options.Namespace = "PublicApi"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.ValidateModelState = true; options.SerializerOptions.PropertyNamingPolicy = null; options.SerializerOptions.DictionaryKeyPolicy = null; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs index d1c511bda9..6692bf337e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - public sealed class PascalCasingTests : IClassFixture, SwimmingDbContext>> + public sealed class PascalCasingTests : IClassFixture, NamingDbContext>> { - private readonly IntegrationTestContext, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new(); + private readonly IntegrationTestContext, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); - public PascalCasingTests(IntegrationTestContext, SwimmingDbContext> testContext) + public PascalCasingTests(IntegrationTestContext, NamingDbContext> testContext) { _testContext = testContext; @@ -45,20 +45,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "SwimmingPools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("IsIndoor")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("WaterSlides")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("DivingBoards")); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("IsIndoor") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("WaterSlides") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("DivingBoards") != null); - responseDocument.Included.Should().HaveCount(1); + decimal height = pools[1].DivingBoards[0].HeightInMeters; + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("DivingBoards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes["HeightInMeters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes.ShouldContainKey("HeightInMeters").With(value => value.As().Should().BeApproximately(height)); responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); - ((JsonElement)responseDocument.Meta["Total"]).GetInt32().Should().Be(2); + responseDocument.Meta.ShouldContainKey("Total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(2); + }); } [Fact] @@ -85,10 +91,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("WaterSlides"); responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); } [Fact] @@ -117,18 +123,28 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("SwimmingPools"); - responseDocument.Data.SingleValue.Attributes["IsIndoor"].Should().Be(newPool.IsIndoor); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("IsIndoor").With(value => value.Should().Be(newPool.IsIndoor)); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id); + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships["WaterSlides"].Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); - responseDocument.Data.SingleValue.Relationships["WaterSlides"].Links.Related.Should().Be($"{poolLink}/WaterSlides"); - responseDocument.Data.SingleValue.Relationships["DivingBoards"].Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); - responseDocument.Data.SingleValue.Relationships["DivingBoards"].Links.Related.Should().Be($"{poolLink}/DivingBoards"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("WaterSlides").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); + value.Links.Related.Should().Be($"{poolLink}/WaterSlides"); + }); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("DivingBoards").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); + value.Links.Related.Should().Be($"{poolLink}/DivingBoards"); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -152,12 +168,12 @@ public async Task Applies_casing_convention_on_error_stack_trace() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.Should().ContainKey("StackTrace"); + error.Meta.ShouldContainKey("StackTrace"); } [Fact] @@ -193,12 +209,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/HeightInMeters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs deleted file mode 100644 index c250e90f22..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SwimmingDbContext : DbContext - { - public DbSet SwimmingPools { get; set; } - public DbSet WaterSlides { get; set; } - public DbSet DivingBoards { get; set; } - - public SwimmingDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs index e4c8e82faf..fa05fec5ed 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -12,9 +12,9 @@ public sealed class SwimmingPool : Identifiable public bool IsIndoor { get; set; } [HasMany] - public IList WaterSlides { get; set; } + public IList WaterSlides { get; set; } = new List(); [HasMany] - public IList DivingBoards { get; set; } + public IList DivingBoards { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs index 2de9b97e03..7147413c51 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { public sealed class SwimmingPoolsController : JsonApiController { - public SwimmingPoolsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SwimmingPoolsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/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/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 99c28d8649..0e6371e356 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -9,7 +9,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings public sealed class Appointment : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; + + [Attr] + public string? Description { get; set; } [Attr] public DateTimeOffset StartTime { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index ff9c6ae459..97d11fd04d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -10,18 +10,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings public sealed class Blog : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] - public string PlatformName { get; set; } + public string PlatformName { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); [HasMany] - public IList Posts { get; set; } + public IList Posts { get; set; } = new List(); [HasOne] - public WebAccount Owner { get; set; } + public WebAccount Owner { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs index 0916eb2a1d..2e809d651e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -9,24 +9,24 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings public sealed class BlogPost : Identifiable { [Attr] - public string Caption { get; set; } + public string Caption { get; set; } = null!; [Attr] - public string Url { get; set; } + public string Url { get; set; } = null!; [HasOne] - public WebAccount Author { get; set; } + public WebAccount? Author { get; set; } [HasOne] - public WebAccount Reviewer { get; set; } + public WebAccount? Reviewer { get; set; } [HasMany] - public ISet