diff --git a/Build.ps1 b/Build.ps1 index 0cca69c095..def36a32c3 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -99,8 +99,10 @@ CheckLastExitCode dotnet build -c Release CheckLastExitCode -RunInspectCode -RunCleanupCode +if ($isWindows) { + RunInspectCode + RunCleanupCode +} dotnet test -c Release --no-build --collect:"XPlat Code Coverage" CheckLastExitCode diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index e5e97193c2..93db0ea454 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -44,6 +44,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestBuildingBlocks", "test\TestBuildingBlocks\TestBuildingBlocks.csproj", "{210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CosmosDbExample", "src\Examples\CosmosDbExample\CosmosDbExample.csproj", "{3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CosmosDbTests", "test\CosmosDbTests\CosmosDbTests.csproj", "{2E387408-8689-4335-A7C4-363DDB28E701}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +58,18 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.Build.0 = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.Build.0 = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.Build.0 = Release|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|Any CPU.Build.0 = Debug|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -162,18 +178,6 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x64.Build.0 = Release|Any CPU {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x86.ActiveCfg = Release|Any CPU {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x86.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.Build.0 = Release|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -210,6 +214,30 @@ Global {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.Build.0 = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.ActiveCfg = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.Build.0 = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x64.ActiveCfg = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x64.Build.0 = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x86.ActiveCfg = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x86.Build.0 = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|Any CPU.Build.0 = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x64.ActiveCfg = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x64.Build.0 = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x86.ActiveCfg = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x86.Build.0 = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x64.Build.0 = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x86.Build.0 = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|Any CPU.Build.0 = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x64.ActiveCfg = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x64.Build.0 = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x86.ActiveCfg = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -228,6 +256,8 @@ Global {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {2E387408-8689-4335-A7C4-363DDB28E701} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/appveyor.yml b/appveyor.yml index 2b69ff39ca..fb4da8f1d1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,7 +4,7 @@ image: version: '{build}' -stack: postgresql 13.4 +stack: postgresql 13.4, docker environment: PGUSER: postgres @@ -29,7 +29,7 @@ matrix: fast_finish: true for: -- +- # Visual Studio 2019 matrix: only: - image: Visual Studio 2019 @@ -48,6 +48,11 @@ for: if ($lastexitcode -ne 0) { throw "docfx install failed with exit code $lastexitcode." } + - pwsh: | + # Start Azure Cosmos Emulator on Windows + if ($isWindows) { + & .\start-cosmos-db-emulator.ps1 + } after_build: - pwsh: | CD ./docs @@ -95,6 +100,37 @@ for: on: branch: /release\/.+/ appveyor_repo_tag: true +- # Ubuntu + matrix: + only: + - image: Ubuntu + services: + - docker + before_build: + - sh: | + # Start Azure Cosmos Emulator on Linux + # Pull Azure Cosmos Emulator Docker image + echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." + bash ./pull-docker-azure-cosmos-emulator-linux.sh + + # Start Azure Cosmos Emulator container + echo "Running Azure Cosmos Emulator Docker container ..." + nohup bash ./run-docker-azure-cosmos-emulator-linux.sh & + + # Wait for Docker container being started in the background + echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + sleep 60 + + # Print the background process output to see whether there are any errors + if [ -f "./nohup.out" ]; then + echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" + cat ./nohup.out + echo "--- END CONTENTS OF NOHUP.OUT ---" + fi + + # Install SSL certificate to be able to access the emulator + echo "Installing Azure Cosmos Emulator SSL certificate ..." + sudo bash ./install-azure-cosmos-emulator-linux-certificates.sh build_script: - pwsh: | diff --git a/install-azure-cosmos-emulator-linux-certificates.sh b/install-azure-cosmos-emulator-linux-certificates.sh new file mode 100644 index 0000000000..8aee6e8cb2 --- /dev/null +++ b/install-azure-cosmos-emulator-linux-certificates.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +certfile=~/emulatorcert.crt +echo "Certificate file: ${certfile}" + +result=1 +count=0 + +while [[ "$result" != "0" && "$count" < "5" ]]; do + echo "Trying to download certificate ..." + curl -k https://localhost:8081/_explorer/emulator.pem > $certfile + result=$? + let "count++" + + if [[ "$result" != "0" && "$count" < "5" ]] + then + echo "Could not download certificate. Waiting 10 seconds before trying again ..." + sleep 10 + fi +done + +if [[ $result -eq 0 ]] +then + echo "Updating CA certificates ..." + sudo cp $certfile /usr/local/share/ca-certificates + sudo update-ca-certificates +else + echo "Could not download CA certificate!" + false +fi diff --git a/pull-docker-azure-cosmos-emulator-linux.sh b/pull-docker-azure-cosmos-emulator-linux.sh new file mode 100644 index 0000000000..77b7736702 --- /dev/null +++ b/pull-docker-azure-cosmos-emulator-linux.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Pull the azure-cosmos-emulator Docker image for Linux from the registry. +docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator diff --git a/run-docker-azure-cosmos-emulator-linux.sh b/run-docker-azure-cosmos-emulator-linux.sh new file mode 100644 index 0000000000..6593656787 --- /dev/null +++ b/run-docker-azure-cosmos-emulator-linux.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Run the Docker image that was previously pulled from the Docker repository, creating a container called +# "azure-cosmos-emulator-linux". Do not (!) set +# +# AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE=$ipaddr +# +# as suggested in Microsoft's documentation at https://docs.microsoft.com/en-us/azure/cosmos-db/linux-emulator. +# We would not be able to connect to the emulator at all on appveyor, regardless of the connection mode. +# To connect to the emulator, we must use Gateway mode. Direct mode will not work. + +docker run \ + -p 8081:8081 \ + -p 10251:10251 \ + -p 10252:10252 \ + -p 10253:10253 \ + -p 10254:10254 \ + -m 3g \ + --cpus=2.0 \ + --name=azure-cosmos-emulator-linux \ + -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=3 \ + -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true \ + mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator diff --git a/shutdown-cosmos-db-emulator.ps1 b/shutdown-cosmos-db-emulator.ps1 new file mode 100644 index 0000000000..007f7b7dbe --- /dev/null +++ b/shutdown-cosmos-db-emulator.ps1 @@ -0,0 +1,22 @@ +#Requires -Version 7 + +function ShutdownCosmosDbEmulator { + if ($PSVersionTable.Platform -eq "Unix") { + ShutdownCosmosDbEmulatorDockerContainer + } + else { + ShutdownCosmosDbEmulatorForWindows + } +} + +function ShutdownCosmosDbEmulatorDockerContainer { + Write-Host "Shutting down Cosmos DB Emulator Docker container ..." + docker stop azure-cosmos-emulator-linux +} + +function ShutdownCosmosDbEmulatorForWindows { + Write-Host "Shutting down Cosmos DB Emulator for Windows ..." + Start-Process -FilePath "C:\Program Files\Azure Cosmos DB Emulator\Microsoft.Azure.Cosmos.Emulator.exe" -ArgumentList "/Shutdown" +} + +ShutdownCosmosDbEmulator diff --git a/src/Examples/CosmosDbExample/Controllers/NonJsonApiController.cs b/src/Examples/CosmosDbExample/Controllers/NonJsonApiController.cs new file mode 100644 index 0000000000..b4ac3c25c8 --- /dev/null +++ b/src/Examples/CosmosDbExample/Controllers/NonJsonApiController.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace CosmosDbExample.Controllers +{ + [Route("[controller]")] + public sealed class NonJsonApiController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + string[] result = + { + "Welcome!" + }; + + return Ok(result); + } + + [HttpPost] + public async Task PostAsync() + { + string name = await new StreamReader(Request.Body).ReadToEndAsync(); + + if (string.IsNullOrEmpty(name)) + { + return BadRequest("Please send your name."); + } + + string result = $"Hello, {name}"; + return Ok(result); + } + + [HttpPut] + public IActionResult Put([FromBody] string name) + { + string result = $"Hi, {name}"; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + string result = $"Good day, {name}"; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); + } + } +} diff --git a/src/Examples/CosmosDbExample/Controllers/OperationsController.cs b/src/Examples/CosmosDbExample/Controllers/OperationsController.cs new file mode 100644 index 0000000000..c27456a963 --- /dev/null +++ b/src/Examples/CosmosDbExample/Controllers/OperationsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace CosmosDbExample.Controllers +{ + public sealed class OperationsController : JsonApiOperationsController + { + 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/CosmosDbExample/Controllers/PeopleController.cs b/src/Examples/CosmosDbExample/Controllers/PeopleController.cs new file mode 100644 index 0000000000..b5df1b86b1 --- /dev/null +++ b/src/Examples/CosmosDbExample/Controllers/PeopleController.cs @@ -0,0 +1,18 @@ +using System; +using CosmosDbExample.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace CosmosDbExample.Controllers +{ + public sealed class PeopleController : JsonApiController + { + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/CosmosDbExample/Controllers/TodoItemsController.cs b/src/Examples/CosmosDbExample/Controllers/TodoItemsController.cs new file mode 100644 index 0000000000..ec394fc22d --- /dev/null +++ b/src/Examples/CosmosDbExample/Controllers/TodoItemsController.cs @@ -0,0 +1,18 @@ +using System; +using CosmosDbExample.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace CosmosDbExample.Controllers +{ + public sealed class TodoItemsController : JsonApiController + { + public TodoItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/CosmosDbExample/CosmosDbExample.csproj b/src/Examples/CosmosDbExample/CosmosDbExample.csproj new file mode 100644 index 0000000000..3080b3edb4 --- /dev/null +++ b/src/Examples/CosmosDbExample/CosmosDbExample.csproj @@ -0,0 +1,14 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + diff --git a/src/Examples/CosmosDbExample/Data/AppDbContext.cs b/src/Examples/CosmosDbExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..bf51a53c0a --- /dev/null +++ b/src/Examples/CosmosDbExample/Data/AppDbContext.cs @@ -0,0 +1,72 @@ +using System; +using CosmosDbExample.Models; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +#pragma warning disable IDE0058 // Expression value is never used +#pragma warning disable AV1706 // Identifier contains an abbreviation or is too short + +// @formatter:wrap_chained_method_calls chop_always + +namespace CosmosDbExample.Data +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class AppDbContext : DbContext + { + public DbSet People => Set(); + + public DbSet TodoItems => Set(); + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultContainer("PeopleAndTodoItems"); + builder.UsePropertyAccessMode(PropertyAccessMode.Property); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + + entity.HasPartitionKey(e => e.PartitionKey); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + + entity.HasPartitionKey(e => e.PartitionKey); + + // @formatter:off + + entity.Property(e => e.Priority) + .HasConversion( + value => value.ToString(), + value => (TodoItemPriority)Enum.Parse(typeof(TodoItemPriority), value)); + + // @formatter:on + + entity.HasOne(todoItem => todoItem.Owner) + .WithMany(person => person.OwnedTodoItems) + .HasForeignKey(todoItem => todoItem.OwnerId) + .IsRequired(); + + entity.HasOne(todoItem => todoItem.Assignee) + .WithMany(person => person!.AssignedTodoItems) + .HasForeignKey(todoItem => todoItem.AssigneeId) + .IsRequired(false); + + entity.OwnsMany(todoItem => todoItem.Tags); + }); + } + } +} diff --git a/src/Examples/CosmosDbExample/Definitions/TodoItemDefinition.cs b/src/Examples/CosmosDbExample/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000000..c6509b8f0d --- /dev/null +++ b/src/Examples/CosmosDbExample/Definitions/TodoItemDefinition.cs @@ -0,0 +1,59 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CosmosDbExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; + +#pragma warning disable AV2310 // Code block should not contain inline comment + +namespace CosmosDbExample.Definitions +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class TodoItemDefinition : JsonApiResourceDefinition + { + private readonly ISystemClock _systemClock; + + public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + _systemClock = systemClock; + } + + /// + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } + + private SortExpression GetDefaultSortOrder() + { + // Cosmos DB, we would have to define a composite index in order to support a composite sort expression. + // Therefore, we will only sort on a single property. + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (todoItem => todoItem.Priority, ListSortDirection.Descending) + }); + } + + /// + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + resource.CreatedAt = _systemClock.UtcNow; + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + resource.LastModifiedAt = _systemClock.UtcNow; + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Examples/CosmosDbExample/Models/Person.cs b/src/Examples/CosmosDbExample/Models/Person.cs new file mode 100644 index 0000000000..a5b14c1c56 --- /dev/null +++ b/src/Examples/CosmosDbExample/Models/Person.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace CosmosDbExample.Models +{ + [NoSqlResource] + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Person : Identifiable + { + /// + /// Gets or sets the partition key. + /// + /// + /// In this example, we are using a generic name for the partition key. We could have used any other sensible name such as PersonId, for example. In any + /// case, the property must exist in all classes the instances of which are to be stored in the same container. In our example project, both + /// and instances are stored in that container. A instance and all + /// instances owned by that person will thus be stored in the same logical partition. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public string PartitionKey + { + get => Id.ToString(); + set => Id = Guid.Parse(value); + } + + /// + /// Gets or sets the optional first name. + /// + [Attr] + public string? FirstName { get; set; } + + /// + /// Gets or sets the required last name. + /// + [Attr] + public string LastName { get; set; } = null!; + + /// + /// Gets or sets the set of instances that are owned by this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasMany] + [NoSqlHasForeignKey(nameof(TodoItem.OwnerId))] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + /// + /// Gets or sets the set of instances that are assigned to this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasMany] + [NoSqlHasForeignKey(nameof(TodoItem.AssigneeId))] + public ISet AssignedTodoItems { get; set; } = new HashSet(); + } +} diff --git a/src/Examples/CosmosDbExample/Models/Tag.cs b/src/Examples/CosmosDbExample/Models/Tag.cs new file mode 100644 index 0000000000..5ef395436a --- /dev/null +++ b/src/Examples/CosmosDbExample/Models/Tag.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace CosmosDbExample.Models +{ + /// + /// Represents a tag that can be assigned to a . In this example project, tags are owned by the instances + /// and do not represent separate entities. + /// + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Tag + { + /// + /// Gets or sets the name of the . + /// + [MinLength(1)] + public string Name { get; set; } = null!; + } +} diff --git a/src/Examples/CosmosDbExample/Models/TodoItem.cs b/src/Examples/CosmosDbExample/Models/TodoItem.cs new file mode 100644 index 0000000000..292a8abb0d --- /dev/null +++ b/src/Examples/CosmosDbExample/Models/TodoItem.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using CosmosDbExample.Definitions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace CosmosDbExample.Models +{ + /// + /// Represents a to-do item that is owned by a person and that can be assigned to another person. + /// + [NoSqlResource] + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TodoItem : Identifiable + { + /// + /// Gets or sets the partition key. + /// + /// + /// In this example, we are using a generic name for the partition key. The property must exist in all classes the instances of which are to be stored in + /// the same container. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public string PartitionKey + { + get => OwnerId.ToString(); + set => OwnerId = Guid.Parse(value); + } + + /// + /// Gets or sets the description of the to-do. + /// + [Attr] + public string Description { get; set; } = null!; + + /// + /// Gets or sets the priority of the to-do. + /// + [Attr] + public TodoItemPriority Priority { get; set; } = TodoItemPriority.Low; + + /// + /// Gets or sets the date and time at which the to-do was initially created. + /// + /// + /// This attribute will be set on the back end by the . + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the date and time at which the to-do was last modified. + /// + /// + /// This attribute will be set on the back end by the . + /// + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { get; set; } + + /// + /// Gets or sets the set of tags assigned to this . + /// + /// + /// Cosmos DB has the concept of owned entities, which we can make accessible as complex attributes. + /// + [Attr] + public ISet Tags { get; set; } = new HashSet(); + + /// + /// Gets or sets the person ID of the . + /// + /// + /// With Cosmos DB, the foreign key must at least be accessible for filtering. Making it viewable is discouraged by the JSON:API specification. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public Guid OwnerId { get; set; } + + /// + /// Gets or sets the person ID of the . + /// + /// + /// With Cosmos DB, the foreign key must at least be accessible for filtering. Making it viewable is discouraged by the JSON:API specification. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public Guid? AssigneeId { get; set; } + + /// + /// Gets or sets the owner of this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasOne] + [NoSqlHasForeignKey(nameof(OwnerId))] + public Person Owner { get; set; } = null!; + + /// + /// Gets or sets the optional assignee of this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasOne] + [NoSqlHasForeignKey(nameof(AssigneeId))] + public Person? Assignee { get; set; } + } +} diff --git a/src/Examples/CosmosDbExample/Models/TodoItemPriority.cs b/src/Examples/CosmosDbExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..378d71f0fc --- /dev/null +++ b/src/Examples/CosmosDbExample/Models/TodoItemPriority.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace CosmosDbExample.Models +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum TodoItemPriority + { + Low, + Medium, + High + } +} diff --git a/src/Examples/CosmosDbExample/Program.cs b/src/Examples/CosmosDbExample/Program.cs new file mode 100644 index 0000000000..8d024d4f5a --- /dev/null +++ b/src/Examples/CosmosDbExample/Program.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace CosmosDbExample +{ + internal static class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } + } +} diff --git a/src/Examples/CosmosDbExample/Properties/launchSettings.json b/src/Examples/CosmosDbExample/Properties/launchSettings.json new file mode 100644 index 0000000000..950d5415d6 --- /dev/null +++ b/src/Examples/CosmosDbExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14140", + "sslPort": 44341 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "launchUrl": "api/v1/todoItems", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "api/v1/todoItems", + "applicationUrl": "https://localhost:44341;http://localhost:14141", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/CosmosDbExample/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs new file mode 100644 index 0000000000..d674436c9a --- /dev/null +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -0,0 +1,104 @@ +using System; +using System.Text.Json.Serialization; +using CosmosDbExample.Data; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Azure.Cosmos; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +#pragma warning disable AV2310 // Code block should not contain inline comment + +namespace CosmosDbExample +{ + public sealed class Startup + { + private readonly ICodeTimerSession _codeTimingSession; + private readonly string _connectionString; + + public Startup(IConfiguration configuration) + { + _codeTimingSession = new DefaultCodeTimerSession(); + CodeTimingSessionManager.Capture(_codeTimingSession); + + _connectionString = configuration["Data:DefaultConnection"]; + } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + using (CodeTimingSessionManager.Current.Measure("Configure other (startup)")) + { + services.AddSingleton(); + + services.AddDbContext(options => + { + options.UseCosmos(_connectionString, "TodoItemDB", builder => + { + builder.ConnectionMode(ConnectionMode.Gateway); + builder.LimitToEndpoint(); + }); +#if DEBUG + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); +#endif + }); + + using (CodeTimingSessionManager.Current.Measure("Configure JSON:API (startup)")) + { + services.AddNoSqlResourceServices(); + + services.AddJsonApi(options => + { + options.Namespace = "api/v1"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerOptions.WriteIndented = true; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; +#endif + }, discovery => discovery.AddCurrentAssembly()); + } + } + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + { + ILogger logger = loggerFactory.CreateLogger(); + + using (CodeTimingSessionManager.Current.Measure("Initialize other (startup)")) + { + using (IServiceScope scope = app.ApplicationServices.CreateScope()) + { + var appDbContext = scope.ServiceProvider.GetRequiredService(); + appDbContext.Database.EnsureCreated(); + } + + app.UseRouting(); + + using (CodeTimingSessionManager.Current.Measure("Initialize JSON:API (startup)")) + { + app.UseJsonApi(); + } + + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + + if (CodeTimingSessionManager.IsEnabled) + { + string timingResults = CodeTimingSessionManager.Current.GetResults(); + logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); + } + + _codeTimingSession.Dispose(); + } + } +} diff --git a/src/Examples/CosmosDbExample/appsettings.json b/src/Examples/CosmosDbExample/appsettings.json new file mode 100644 index 0000000000..8fc2aef842 --- /dev/null +++ b/src/Examples/CosmosDbExample/appsettings.json @@ -0,0 +1,17 @@ +{ + "Data": { + "DefaultConnection": + "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Warning", + "Microsoft.EntityFrameworkCore.Update": "Critical", + "Microsoft.EntityFrameworkCore.Database.Command": "Critical", + "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", + "JsonApiDotNetCoreExample": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs new file mode 100644 index 0000000000..8d691672d0 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Provides extension methods for the registration of services and other injectables with the service container. + /// + [PublicAPI] + public static class NoSqlServiceCollectionExtensions + { + /// + /// For each resource annotated with the , adds a scoped service with a service type of + /// and an implementation type of . + /// + /// + /// The . + /// + /// + /// The . + /// + public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services) + { + return services.AddNoSqlResourceServices(Assembly.GetCallingAssembly()); + } + + /// + /// For each resource annotated with the , adds a scoped service with a service type of + /// and an implementation type of . + /// + /// + /// The . + /// + /// + /// The containing the annotated resources. + /// + /// + /// The . + /// + public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services, Assembly assembly) + { + services.AddScoped(); + + foreach (Type resourceType in assembly.ExportedTypes.Where(IsNoSqlResource)) + { + if (TryGetIdType(resourceType, out Type? idType)) + { + services.AddScoped(typeof(IResourceService<,>).MakeGenericType(resourceType, idType), + typeof(NoSqlResourceService<,>).MakeGenericType(resourceType, idType)); + } + } + + return services; + } + + private static bool IsNoSqlResource(Type type) + { + return Attribute.GetCustomAttribute(type, typeof(NoSqlResourceAttribute)) is not null && type.GetInterfaces().Any(IsGenericIIdentifiable); + } + + private static bool IsGenericIIdentifiable(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IIdentifiable<>); + } + + private static bool TryGetIdType(Type resourceType, [NotNullWhen(true)] out Type? idType) + { + Type? identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(IsGenericIIdentifiable); + idType = identifiableInterface?.GetGenericArguments()[0]; + return idType is not null; + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs new file mode 100644 index 0000000000..cd4cba0809 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -0,0 +1,69 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// Takes scoped expressions from s and transforms them. Additionally provides specific transformations for NoSQL + /// databases without support for joins. + /// + [PublicAPI] + public interface INoSqlQueryLayerComposer + { + /// + /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. + /// + FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType); + + /// + /// Composes a and an from the constraints specified by the request. Used for primary + /// resources. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType); + + /// + /// Composes a from the constraints specified by the request and a filter expression in the form + /// "equals({propertyName},'{propertyValue}')". Used for secondary or included resources. + /// + /// + /// The of the secondary or included resource. + /// + /// + /// The name of the property of the secondary or included resource used for filtering. + /// + /// + /// The value of the property of the secondary or included resource used for filtering. + /// + /// + /// , if the resource is included by the request (e.g., "{url}?include={relationshipName}"); , if the + /// resource is a secondary resource (e.g., "/{primary}/{id}/{relationshipName}"). + /// + /// + /// A tuple with a and an . + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType, string propertyName, + string propertyValue, bool isIncluded); + + /// + /// Composes a and an from the constraints specified by the request. Used for primary + /// resources. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, + TopFieldSelection fieldSelection) + where TId : notnull; + + /// + /// Composes a with a filter expression in the form "equals(id,'{stringId}')". + /// + QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull; + + /// + /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete + /// request. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull; + } +} diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs new file mode 100644 index 0000000000..d57c4f5733 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -0,0 +1,332 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +#pragma warning disable AV1551 // Method overload should call another overload +#pragma warning disable AV2310 // Code block should not contain inline comment + +namespace JsonApiDotNetCore.Queries +{ + /// + /// Default implementation of the . + /// + /// + /// Register with the service container as shown in the following example. + /// + /// + /// + /// + [PublicAPI] + public class NoSqlQueryLayerComposer : QueryLayerComposer, INoSqlQueryLayerComposer + { + private readonly IEnumerable _constraintProviders; + private readonly ITargetedFields _targetedFields; + + // ReSharper disable PossibleMultipleEnumeration + + public NoSqlQueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, + ISparseFieldSetCache sparseFieldSetCache) + : base(constraintProviders, resourceDefinitionAccessor, options, paginationContext, targetedFields, evaluatedIncludeCache, sparseFieldSetCache) + { + _constraintProviders = constraintProviders; + _targetedFields = targetedFields; + } + + // ReSharper restore PossibleMultipleEnumeration + + /// + public FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType) + { + return AssertFilterExpressionIsSimple(GetPrimaryFilterFromConstraints(primaryResourceType)); + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType) + { + QueryLayer queryLayer = ComposeFromConstraints(requestResourceType); + + IncludeExpression include = AssertIncludeExpressionIsSimple(queryLayer.Include); + + queryLayer.Filter = AssertFilterExpressionIsSimple(queryLayer.Filter); + queryLayer.Include = IncludeExpression.Empty; + queryLayer.Projection = null; + + return (queryLayer, include); + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType, string propertyName, + string propertyValue, bool isIncluded) + { + // Compose a secondary resource filter in the form "equals({propertyName},'{propertyValue}')". + FilterExpression[] secondaryResourceFilterExpressions = + { + ComposeSecondaryResourceFilter(requestResourceType, propertyName, propertyValue) + }; + + // @formatter:off + + // Get the query expressions from the request. + ExpressionInScope[] constraints = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .ToArray(); + + bool IsQueryLayerConstraint(ExpressionInScope constraint) + { + return constraint.Expression is not IncludeExpression && (!isIncluded || IsResourceScoped(constraint)); + } + + bool IsResourceScoped(ExpressionInScope constraint) + { + return constraint.Scope is not null && + constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName); + } + + QueryExpression[] requestQueryExpressions = constraints + .Where(IsQueryLayerConstraint) + .Select(constraint => constraint.Expression) + .ToArray(); + + FilterExpression[] requestFilterExpressions = requestQueryExpressions + .OfType() + .Select(filterExpression => AssertFilterExpressionIsSimple(filterExpression)!) + .ToArray(); + + FilterExpression[] combinedFilterExpressions = secondaryResourceFilterExpressions + .Concat(requestFilterExpressions) + .ToArray(); + + var queryLayer = new QueryLayer(requestResourceType) + { + Include = IncludeExpression.Empty, + Filter = GetFilter(combinedFilterExpressions, requestResourceType), + Sort = GetSort(requestQueryExpressions, requestResourceType), + Pagination = GetPagination(requestQueryExpressions, requestResourceType) + }; + + // Retrieve the IncludeExpression from the constraints collection. + // There will be zero or one IncludeExpression, even if multiple include query + // parameters were specified in the request. JsonApiDotNetCore combines those + // into a single expression. + IncludeExpression include = isIncluded + ? IncludeExpression.Empty + : AssertIncludeExpressionIsSimple(constraints + .Select(constraint => constraint.Expression) + .OfType() + .DefaultIfEmpty(IncludeExpression.Empty) + .Single()); + + // @formatter:on + + return (queryLayer, include); + } + + private static FilterExpression ComposeSecondaryResourceFilter(ResourceType resourceType, string propertyName, string properyValue) + { + return new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(resourceType.Fields.Single(field => field.Property.Name == propertyName)), + new LiteralConstantExpression(properyValue)); + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, + TopFieldSelection fieldSelection) + where TId : notnull + { + QueryLayer queryLayer = ComposeForGetById(id, primaryResourceType, fieldSelection); + + IncludeExpression include = AssertIncludeExpressionIsSimple(queryLayer.Include); + + queryLayer.Filter = AssertFilterExpressionIsSimple(queryLayer.Filter); + queryLayer.Include = IncludeExpression.Empty; + queryLayer.Projection = null; + + return (queryLayer, include); + } + + /// + public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull + { + return new QueryLayer(primaryResourceType) + { + Filter = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryResourceType.Fields.Single(field => field.Property.Name == nameof(IIdentifiable.Id))), + new LiteralConstantExpression(id.ToString()!)), + Include = IncludeExpression.Empty + }; + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull + { + // Create primary layer without an include expression. + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + + QueryLayer primaryLayer = new(primaryResourceType) + { + Include = IncludeExpression.Empty, + Filter = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(primaryIdAttribute), + new LiteralConstantExpression(id.ToString()!)) + }; + + // Create a separate include expression. + ImmutableHashSet includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); + + IncludeExpression include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; + + return (primaryLayer, include); + } + + private static AttrAttribute GetIdAttribute(ResourceType resourceType) + { + return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + } + + private static FilterExpression? AssertFilterExpressionIsSimple(FilterExpression? filterExpression) + { + if (filterExpression is null) + { + return filterExpression; + } + + var visitor = new FilterExpressionVisitor(); + + return visitor.Visit(filterExpression, null) + ? filterExpression + : throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Unsupported filter expression", + Detail = "Navigation of to-one or to-many relationships is not supported." + }); + } + + private static IncludeExpression AssertIncludeExpressionIsSimple(IncludeExpression? includeExpression) + { + if (includeExpression is null) + { + return IncludeExpression.Empty; + } + + return includeExpression.Elements.Any(element => element.Children.Any()) + ? throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Unsupported include expression", + Detail = "Multi-level include expressions are not supported." + }) + : includeExpression; + } + + private sealed class FilterExpressionVisitor : QueryExpressionVisitor + { + private bool _isSimpleFilterExpression = true; + + /// + public override bool DefaultVisit(QueryExpression expression, object? argument) + { + return _isSimpleFilterExpression; + } + + /// + public override bool VisitComparison(ComparisonExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.Left.Accept(this, argument) && expression.Right.Accept(this, argument); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.Fields.All(field => field is AttrAttribute); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitLogical(LogicalExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.Terms.All(term => term.Accept(this, argument)); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitNot(NotExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.Child.Accept(this, argument); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitHas(HasExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.TargetCollection.Accept(this, argument) && + (expression.Filter is null || expression.Filter.Accept(this, argument)); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitSortElement(SortElementExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.TargetAttribute is null || expression.TargetAttribute.Accept(this, argument); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitSort(SortExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.Elements.All(element => element.Accept(this, argument)); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitCount(CountExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.TargetCollection.Accept(this, argument); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitMatchText(MatchTextExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.TargetAttribute.Accept(this, argument); + + return _isSimpleFilterExpression; + } + + /// + public override bool VisitAny(AnyExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.TargetAttribute.Accept(this, argument); + + return _isSimpleFilterExpression; + } + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs new file mode 100644 index 0000000000..e877781f44 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs @@ -0,0 +1,30 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to provide additional information for a JSON:API relationship with a foreign key + /// (https://jsonapi.org/format/#document-resource-object-relationships). + /// + [PublicAPI] + [AttributeUsage(AttributeTargets.Property)] + public class NoSqlHasForeignKeyAttribute : Attribute + { + /// + /// Gets the name of the foreign key property corresponding to the annotated navigation property. + /// + public string PropertyName { get; private set; } + + /// + /// Gets or sets a value indicating whether the navigation property is on the dependent side of the foreign key relationship. The default is + /// . + /// + public bool IsDependent { get; set; } = true; + + public NoSqlHasForeignKeyAttribute(string propertyName) + { + PropertyName = propertyName; + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs new file mode 100644 index 0000000000..00362fb989 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs @@ -0,0 +1,19 @@ +using System; +using System.Reflection; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// When put on a resource class, marks that resource as being hosted in a NoSQL database. + /// + /// + /// + [PublicAPI] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] + public class NoSqlResourceAttribute : Attribute + { + } +} diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs new file mode 100644 index 0000000000..75eafe28bc --- /dev/null +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -0,0 +1,688 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +#pragma warning disable AV1551 // Method overload should call another overload +#pragma warning disable AV2310 // Code block should not contain inline comment +#pragma warning disable AV2318 // Work-tracking TO DO comment should be removed + +namespace JsonApiDotNetCore.Services +{ + /// + /// Provides the resource services where a NoSQL database such as Cosmos DB or MongoDB is used as a back-end database. + /// + /// + /// Register with the service container as shown in the example. + /// + /// + /// + /// + /// + /// The type of the resource. + /// + /// + /// The type of the resource Id. + /// + [PublicAPI] + public class NoSqlResourceService : IResourceService + where TResource : class, IIdentifiable + where TId : notnull + { + private readonly IResourceRepositoryAccessor _repositoryAccessor; + private readonly INoSqlQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly IJsonApiRequest _request; + private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IResourceGraph _resourceGraph; + private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + + private readonly TraceLogWriter> _traceWriter; + private readonly JsonApiResourceService _resourceService; + + public NoSqlResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer sqlQueryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor, + INoSqlQueryLayerComposer queryLayerComposer, IResourceGraph resourceGraph, IEvaluatedIncludeCache evaluatedIncludeCache) + { + _repositoryAccessor = repositoryAccessor; + _paginationContext = paginationContext; + _options = options; + _request = request; + _resourceChangeTracker = resourceChangeTracker; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + + _queryLayerComposer = queryLayerComposer; + _resourceGraph = resourceGraph; + _evaluatedIncludeCache = evaluatedIncludeCache; + + _traceWriter = new TraceLogWriter>(loggerFactory); + + // Reuse JsonApiResourceService by delegation (rather than inheritance). + _resourceService = new JsonApiResourceService(repositoryAccessor, sqlQueryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceDefinitionAccessor); + } + + /// + public async Task> GetAsync(CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + if (_options.IncludeTotalResourceCount) + { + FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraintsForNoSql(_request.PrimaryResourceType); + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(_request.PrimaryResourceType, topFilter, cancellationToken); + + if (_paginationContext.TotalResourceCount == 0) + { + return Array.Empty(); + } + } + + // Compose a query layer and an include expression, where the query layer can be + // safely used for getting the primary resource because the Include and Projection + // properties are Empty or null, respectively. The IncludeExpression can be used + // to fetch the included elements in separate queries. + var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql(_request.PrimaryResourceType); + + // Get only the primary resource. + IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + + if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) + { + _paginationContext.IsPageFull = true; + } + + // Get the included elements, relying on Entity Framework Core to combine the + // entities it has fetched. + await GetIncludedElementsAsync(resources, include, cancellationToken); + + return resources; + } + + /// + public async Task GetAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get single resource"); + + return await GetPrimaryResourceByIdWithConstraintsAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); + } + + /// + public async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + + // Get the primary resource to (1) ensure it exists and (2) retrieve foreign key values as necessary. + IIdentifiable primary = await GetPrimaryResourceByIdAsync(id, cancellationToken); + + return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Secondary, cancellationToken); + } + + /// + public async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + + // Get the primary resource to (1) ensure it exists and (2) retrieve foreign key values as necessary. + IIdentifiable primary = await GetPrimaryResourceByIdAsync(id, cancellationToken); + + return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Relationship, cancellationToken); + } + + /// + public Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + return _resourceService.CreateAsync(resource, cancellationToken); + } + + /// + public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + RelationshipAttribute? relationship = _request.Relationship; + + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResourceType!.PublicName); + } + + if (!secondaryResourceIds.Any()) + { + return; + } + + ResourceType resourceType = relationship.RightType; + + var targetAttribute = new ResourceFieldChainExpression(resourceType.FindAttributeByPropertyName(nameof(IIdentifiable.Id))!); + + ImmutableHashSet idConstants = + secondaryResourceIds.Select(identifiable => new LiteralConstantExpression(identifiable.StringId!)).ToImmutableHashSet(); + + var queryLayer = new QueryLayer(resourceType) + { + Filter = idConstants.Count > 1 + ? new AnyExpression(targetAttribute, idConstants) + : new ComparisonExpression(ComparisonOperator.Equals, targetAttribute, idConstants.Single()) + }; + + IReadOnlyCollection secondaryResources = await _repositoryAccessor.GetAsync(resourceType, queryLayer, cancellationToken); + + ImmutableHashSet missingResources = secondaryResourceIds + .Where(requestResource => secondaryResources.All(resource => resource.StringId != requestResource.StringId)).ToImmutableHashSet(); + + if (missingResources.Any()) + { + IIdentifiable missingResource = missingResources.First(); + + throw new ResourceNotFoundException(missingResource.StringId!, resourceType.PublicName); + } + + await _resourceService.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + /// + public async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + resource + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); + + _resourceChangeTracker.SetRequestAttributeValues(resource); + + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + // TODO: Revisit. Should we wrap in try-catch clause as JsonApiResourceService? + await _repositoryAccessor.UpdateAsync(resource, resourceFromDatabase, cancellationToken); + + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, cancellationToken); + + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + + return !hasImplicitChanges ? null! : afterResourceFromDatabase; + } + + /// + public async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + leftId, + relationshipName, + rightValue + }); + + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); + + AssertHasRelationship(_request.Relationship, relationshipName); + + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); + + // TODO: Revisit. Should we wrap in try-catch clause as JsonApiResourceService? + await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken); + } + + /// + public Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + return _resourceService.DeleteAsync(id, cancellationToken); + } + + /// + public async Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + leftId, + relationshipName, + rightResourceIds + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); + + TResource primaryResource = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync(primaryResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(primaryResource, rightResourceIds, cancellationToken); + } + + /// + /// Gets the primary resource by ID, specifying only a filter but no other constraints such as include, page, or fields. + /// + /// + /// The primary resource ID. + /// + /// + /// The . + /// + /// + /// If the primary resource does not exist. + /// + /// + /// The primary resource with unpopulated navigation properties. + /// + protected async Task GetPrimaryResourceByIdAsync(TId id, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetByIdForNoSql(id, _request.PrimaryResourceType); + + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + + return AssertPrimaryResourceExists(primaryResources.SingleOrDefault()); + } + + /// + /// Gets the primary resource by ID, observing all other constraints such as include or fields. + /// + /// + /// The primary resource ID. + /// + /// + /// The . + /// + /// + /// The . + /// + /// + /// If the primary resource does not exist. + /// + /// + /// The primary resource with navigation properties populated where such properties represent included resources. + /// + protected async Task GetPrimaryResourceByIdWithConstraintsAsync(TId id, TopFieldSelection fieldSelection, + CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + var (primaryLayer, include) = _queryLayerComposer.ComposeForGetByIdWithConstraintsForNoSql(id, _request.PrimaryResourceType, fieldSelection); + + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + + TResource primaryResource = AssertPrimaryResourceExists(primaryResources.SingleOrDefault()); + + await GetIncludedElementsAsync(primaryResources, include, cancellationToken); + + return primaryResource; + } + + /// + /// Gets the primary resource by ID, with all its fields and included resources. + /// + /// + /// The primary resource ID. + /// + /// + /// The . + /// + /// + /// The primary resource with navigation properties populated where such properties represent included resources. + /// + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + (QueryLayer queryLayer, IncludeExpression include) = _queryLayerComposer.ComposeForUpdateForNoSql(id, _request.PrimaryResourceType); + + TResource primaryResource = AssertPrimaryResourceExists(await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken)); + + await GetIncludedElementsAsync(new[] + { + primaryResource + }, include, cancellationToken); + + return primaryResource; + } + + /// + /// For each primary resource in the collection, gets the secondary resources specified in the given + /// . + /// + /// + /// An specifies one or more relationships. + /// + /// + /// The primary resources. + /// + /// + /// The . + /// + /// + /// The . + /// + /// + /// If any contained in the is a nested expression like "first.second". + /// + /// + /// A representing the asynchronous operation. + /// + protected virtual async Task GetIncludedElementsAsync(IReadOnlyCollection primaryResources, IncludeExpression includeExpression, + CancellationToken cancellationToken) + { + _evaluatedIncludeCache.Set(includeExpression); + + foreach (var includeElementExpression in includeExpression.Elements) + { + await GetIncludedElementAsync(primaryResources, includeElementExpression, cancellationToken); + } + } + + /// + /// For each primary resource in the collection, gets the secondary resources specified in the given + /// . + /// + /// + /// The primary resources. + /// + /// + /// The . + /// + /// + /// The . + /// + /// + /// If the is a nested expression like "first.second". + /// + /// + /// A representing the asynchronous operation. + /// + protected virtual async Task GetIncludedElementAsync(IReadOnlyCollection primaryResources, + IncludeElementExpression includeElementExpression, CancellationToken cancellationToken) + { + if (includeElementExpression.Children.Any()) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Unsupported expression.", + Detail = "Nested include expressions are currently not supported." + }); + } + + string relationshipName = includeElementExpression.Relationship.PublicName; + + foreach (var primaryResource in primaryResources) + { + await GetSecondaryAsync(primaryResource, relationshipName, ResourceKind.Included, cancellationToken); + } + } + + /// + /// For to-many relationships, gets the potentially empty collection of related resources. For to-one relationships, gets zero or one related resource. + /// + /// + /// The primary resource. + /// + /// + /// The name of the relationship between the primary and secondary resources. + /// + /// + /// + /// The . + /// + /// + /// If the relationship specified by does not exist or does not have a . + /// + /// + /// For to-many relationships, an of ; for to-one relationships, an + /// or . + /// + protected async Task GetSecondaryAsync(IIdentifiable primaryResource, string relationshipName, ResourceKind resourceKind, + CancellationToken cancellationToken) + { + // Get the HasMany or HasOne attribute corresponding to the given relationship name. + ResourceType resourceContext = _resourceGraph.GetResourceType(primaryResource.GetType()); + + RelationshipAttribute? relationshipAttribute = + resourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == relationshipName); + + if (relationshipAttribute is null) + { + string message = $"The relationship '{relationshipName}' does not exist."; + _traceWriter.LogMessage(() => message); + + throw new JsonApiException(new ErrorObject(HttpStatusCode.NotFound) + { + Title = "Relationship not found.", + Detail = message + }); + } + + // Get the HasForeignKey attribute corresponding to the relationship, if any. + var foreignKeyAttribute = (NoSqlHasForeignKeyAttribute?)Attribute.GetCustomAttribute( + relationshipAttribute.Property, typeof(NoSqlHasForeignKeyAttribute)); + + if (foreignKeyAttribute is null) + { + string message = $"No foreign key is specified for the relationship '{relationshipName}'."; + _traceWriter.LogMessage(() => message); + + throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid resource definition.", + Detail = message + }); + } + + // Finally, get the secondary resource or resources based on the target (right) type, + // foreign key, and the information on whether the primary resource is on the dependent + // side of the foreign key relationship. + ResourceType type = relationshipAttribute.RightType; + string foreignKey = foreignKeyAttribute.PropertyName; + string stringId = primaryResource.StringId!; + bool isDependent = foreignKeyAttribute.IsDependent; + + return relationshipAttribute switch + { + HasManyAttribute => await GetManySecondaryResourcesAsync(type, foreignKey, stringId, resourceKind, cancellationToken), + + HasOneAttribute when isDependent => await GetOneSecondaryResourceAsync(type, nameof(IIdentifiable.Id), + GetStringValue(primaryResource, foreignKey), resourceKind, cancellationToken), + + HasOneAttribute when !isDependent => throw new JsonApiException(new ErrorObject(HttpStatusCode.NotImplemented) + { + Title = "Unsupported relationship.", + Detail = "One-to-one relationships are not yet supported." + }), + + _ => throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid relationship.", + Detail = $"The relationship '{relationshipName}' is invalid." + }) + }; + } + + /// + /// For to-one relationships (e.g., Parent), gets the secondary resource, if any, filtered by "equals({propertyName},'{propertyValue}')". + /// + /// + /// The resource type. + /// + /// + /// The name of the property used to filter resources, e.g., "Id". + /// + /// + /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// + /// + /// + /// The . + /// + /// + /// The , if it exists, or . + /// + protected async Task GetOneSecondaryResourceAsync(ResourceType resourceType, string propertyName, string? propertyValue, + ResourceKind resourceKind, CancellationToken cancellationToken) + { + if (propertyValue is null) + { + return null; + } + + IReadOnlyCollection items = await GetManySecondaryResourcesAsync( + resourceType, propertyName, propertyValue, resourceKind, cancellationToken); + + return items.SingleOrDefault(); + } + + /// + /// For to-many relationships (e.g., Children), gets the collection of secondary resources, filtered by the filter expressions provided in the request + /// and by equals({propertyName},'{propertyValue}'). + /// + /// + /// The resource type. + /// + /// + /// The name of the property used to filter resources, e.g., "ParentId". + /// + /// + /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// + /// + /// + /// The . + /// + /// + /// The potentially empty collection of secondary resources. + /// + protected async Task> GetManySecondaryResourcesAsync(ResourceType resourceType, string propertyName, + string propertyValue, ResourceKind resourceKind, CancellationToken cancellationToken) + { + bool isIncluded = resourceKind == ResourceKind.Included; + var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql(resourceType, propertyName, propertyValue, isIncluded); + + IReadOnlyCollection items = await _repositoryAccessor.GetAsync(resourceType, queryLayer, cancellationToken); + + if (resourceKind == ResourceKind.Secondary) + { + await GetIncludedElementsAsync(items, include, cancellationToken); + } + + return items; + } + + [AssertionMethod] + private TResource AssertPrimaryResourceExists([SysNotNull] TResource? resource) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + return resource ?? throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); + } + + [AssertionMethod] + private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) + { + if (relationship is null) + { + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); + } + } + + [AssertionMethod] + private static void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType) + { + if (resourceType is null) + { + throw new InvalidOperationException( + $"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.PrimaryResourceType)} not to be null at this point."); + } + } + + /// + /// Gets the value of the named property. + /// + /// + /// The resource. + /// + /// + /// The name of the property. + /// + /// + /// The value of the named property. + /// + protected string? GetStringValue(object resource, string propertyName) + { + Type type = resource.GetType(); + PropertyInfo? property = type.GetProperty(propertyName); + + return property is not null + ? property.GetValue(resource)?.ToString() + : throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid property.", + Detail = $"The '{type.Name}' type does not have a '{propertyName}' property." + }); + } + + protected enum ResourceKind + { + Secondary, + Included, + Relationship + } + } +} diff --git a/start-cosmos-db-emulator.ps1 b/start-cosmos-db-emulator.ps1 new file mode 100644 index 0000000000..67f593bf33 --- /dev/null +++ b/start-cosmos-db-emulator.ps1 @@ -0,0 +1,65 @@ +#Requires -Version 7 + +function StartCosmosDbEmulator { + if ($PSVersionTable.Platform -eq "Unix") { + StartCosmosDbEmulatorOnLinux + } + else { + StartCosmosDbEmulatorOnWindows + } +} + +function StartCosmosDbEmulatorOnLinux { + Remove-Item .\nohup.* + + Write-Host "Running Azure Cosmos Emulator Docker container ..." + Start-Process nohup './run-docker-azure-cosmos-emulator-linux.sh' + Start-Sleep -Seconds 1 + + Write-Host "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + Start-Sleep -Seconds 60 + + Write-Host "--- BEGIN CONTENTS OF NOHUP.OUT ---" + Get-Content .\nohup.out + Write-Host "--- END CONTENTS OF NOHUP.OUT ---" + + Write-Host "Installing Azure Cosmos Emulator SSL certificate ..." + Start-Process bash './install-azure-cosmos-emulator-linux-certificates.sh' -Wait + Write-Host "Installed Azure Cosmos Emulator SSL certificate." +} + +function StartCosmosDbEmulatorOnWindows { + Write-Host "Starting Cosmos DB Emulator for Windows ..." + Start-Process -FilePath "C:\Program Files\Azure Cosmos DB Emulator\Microsoft.Azure.Cosmos.Emulator.exe" -ArgumentList "/DisableRateLimiting /NoUI /NoExplorer" + + Write-Host "Waiting for Azure Cosmos Emulator for Windows to start up ..." + Start-Sleep -Seconds 20 + WaitUntilCosmosSqlApiEndpointIsReady +} + +function WaitUntilCosmosSqlApiEndpointIsReady { + # See https://seddryck.wordpress.com/2020/01/05/running-automated-tests-with-the-azure-cosmos-db-emulator-on-appveyor/ + $attempt = 0; $max = 5 + + do { + $client = New-Object System.Net.Sockets.TcpClient([System.Net.Sockets.AddressFamily]::InterNetwork) + + try { + $client.Connect("localhost", 8081) + Write-Host "Cosmos SQL API endpoint listening. Connection successful." + } + catch { + $client.Close() + if($attempt -eq $max) { + Write-Host "Cosmos SQL API endpoint is not listening. Aborting connection." + throw "Cosmos SQL API endpoint is not listening. Aborting connection." + } else { + [int]$sleepTime = 5 * (++$attempt) + Write-Host "Cosmos SQL API endpoint is not yet listening. Retry after $sleepTime seconds..." + Start-Sleep -Seconds $sleepTime; + } + } + } while(!$client.Connected -and $attempt -lt $max) +} + +StartCosmosDbEmulator diff --git a/test/CosmosDbTests/CosmosDbFixture.cs b/test/CosmosDbTests/CosmosDbFixture.cs new file mode 100644 index 0000000000..76d8c1a492 --- /dev/null +++ b/test/CosmosDbTests/CosmosDbFixture.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CosmosDbExample; +using CosmosDbExample.Data; +using CosmosDbExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +#pragma warning disable AV1115 // Member or local function contains the word 'and', which suggests doing multiple things + +namespace CosmosDbTests +{ + /// + /// Test fixture that deletes and recreates the TodoItemDB database and PeopleAndTodoItems container before all of the test methods are run. It does not + /// delete the database nor the container at the end. + /// + [PublicAPI] + public class CosmosDbFixture : WebApplicationFactory, IAsyncLifetime + { + public Guid BonnieId { get; } = Guid.NewGuid(); + + public Guid ClydeId { get; } = Guid.NewGuid(); + + public Guid TodoItemOwnedByBonnieId { get; } = Guid.NewGuid(); + + public Guid TodoItemOwnedByClydeId { get; } = Guid.NewGuid(); + + public Guid TodoItemWithOwnerAndAssigneeId { get; } = Guid.NewGuid(); + + public async Task InitializeAsync() + { + await RunOnDatabaseAsync(async context => + { + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + Person bonnie = new() + { + Id = BonnieId, + FirstName = "Bonnie", + LastName = "Parker" + }; + + Person clyde = new() + { + Id = ClydeId, + FirstName = "Clyde", + LastName = "Barrow" + }; + + TodoItem todoItemOwnedByBonnie = new() + { + Id = TodoItemOwnedByBonnieId, + Description = "Rob bank", + Priority = TodoItemPriority.High, + OwnerId = bonnie.Id, + Tags = new HashSet + { + new() + { + Name = "Job" + } + } + }; + + TodoItem todoItemOwnedByClyde = new() + { + Id = TodoItemOwnedByClydeId, + Description = "Wash car", + Priority = TodoItemPriority.Low, + OwnerId = clyde.Id + }; + + TodoItem todoItemWithOwnerAndAssignee = new() + { + Id = TodoItemWithOwnerAndAssigneeId, + Description = "Go shopping", + Priority = TodoItemPriority.Medium, + OwnerId = clyde.Id, + AssigneeId = bonnie.Id, + Tags = new HashSet + { + new() + { + Name = "Errands" + }, + new() + { + Name = "Groceries" + } + } + }; + + // @formatter:off + + context.People.AddRange(bonnie, clyde); + context.TodoItems.AddRange(todoItemOwnedByBonnie, todoItemOwnedByClyde, todoItemWithOwnerAndAssignee); + + // @formatter:on + + await context.SaveChangesAsync(); + }); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + public async Task CreateEntityAsync(TEntity entity) + where TEntity : IIdentifiable + { + await RunOnDatabaseAsync(async context => + { + // ReSharper disable once MethodHasAsyncOverload + context.Add(entity); + await context.SaveChangesAsync(); + }); + + return entity; + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + using IServiceScope scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + } +} diff --git a/test/CosmosDbTests/CosmosDbTests.csproj b/test/CosmosDbTests/CosmosDbTests.csproj new file mode 100644 index 0000000000..6f2fdff491 --- /dev/null +++ b/test/CosmosDbTests/CosmosDbTests.csproj @@ -0,0 +1,22 @@ + + + $(NetCoreAppVersion) + + + + + PreserveNewest + + + + + + + + + + + + + + diff --git a/test/CosmosDbTests/PersonTests.cs b/test/CosmosDbTests/PersonTests.cs new file mode 100644 index 0000000000..c5d14d0c4b --- /dev/null +++ b/test/CosmosDbTests/PersonTests.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using CosmosDbExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace CosmosDbTests +{ + public sealed class PersonTests : IntegrationTest, IClassFixture + { + private readonly CosmosDbFixture _fixture; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _fixture.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public PersonTests(CosmosDbFixture fixture) + { + _fixture = fixture; + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/v1/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeNull(); + responseDocument.Data.ManyValue.Should().HaveCountGreaterOrEqualTo(2); + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_get_primary_resources_with_filter() + { + // Arrange + const string fieldName = "firstName"; + const string fieldValue = "Clyde"; + string route = $"/api/v1/people?filter=equals({fieldName},'{fieldValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeNull().ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(fieldName).With(value => value.Should().Be(fieldValue)); + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_primary_resources_including_secondary_resources(string relationshipName) + { + // Arrange + string route = $"/api/v1/people?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeNull().And.NotBeEmpty(); + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetAsync(TId id, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_get_primary_resource_by_id() + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Bonnie")); + } + + /// + /// GetAsync(TId id, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_primary_resource_by_id_including_secondary_resources(string relationshipName) + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Bonnie")); + + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_secondary_resources_related_to_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeNull().And.NotBeEmpty(); + + responseDocument.Included.Should().BeNull(); + } + + /// + /// GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_relationships_of_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}/relationships/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeNull(); + + foreach (ResourceObject resourceObject in responseDocument.Data.ManyValue) + { + resourceObject.Type.ShouldNotBeNull(); + resourceObject.Id.ShouldNotBeNull(); + resourceObject.Attributes.Should().BeNull(); + resourceObject.Relationships.Should().BeNull(); + } + + responseDocument.Included.Should().BeNull(); + } + + /// + /// CreateAsync(TResource resource, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_create_primary_resource() + { + // Arrange + const string route = "/api/v1/people"; + + var person = new Person + { + FirstName = "Mad", + LastName = "Max" + }; + + var requestBody = new + { + data = new + { + type = "people", + attributes = new + { + firstName = person.FirstName, + lastName = person.LastName + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(person.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(person.LastName)); + } + + /// + /// AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet{IIdentifiable} secondaryResourceIds, ...) + /// + [Fact] + public async Task Can_add_existing_secondary_resource_to_many_relationship() + { + // Arrange + TodoItem todoItem = await _fixture.CreateEntityAsync(new TodoItem + { + Id = Guid.NewGuid(), + Description = "Buy booze", + OwnerId = _fixture.ClydeId, + Tags = new HashSet + { + new() + { + Name = "Errand" + } + } + }); + + todoItem.AssigneeId.Should().BeNull(); + + Guid assigneeId = _fixture.BonnieId; + string route = $"/api/v1/people/{assigneeId}/relationships/assignedTodoItems"; + + var requestBody = new + { + Data = new[] + { + new + { + type = "todoItems", + id = todoItem.StringId + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + TodoItem updatedTodoItem = await context.TodoItems.SingleAsync(item => item.Id == todoItem.Id); + updatedTodoItem.AssigneeId.Should().Be(assigneeId); + }); + } + + /// + /// AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet{IIdentifiable} secondaryResourceIds, ...) + /// + [Fact] + public async Task Cannot_add_non_existent_secondary_resource_to_many_relationship() + { + string route = $"/api/v1/people/{_fixture.BonnieId}/relationships/assignedTodoItems"; + + string nonExistentGuid = Guid.NewGuid().ToString(); + + var requestBody = new + { + Data = new[] + { + new + { + type = "todoItems", + Id = nonExistentGuid + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().NotBeNull().And.NotBeEmpty().And.HaveCount(1); + responseDocument.Errors![0].Detail.Should().Contain(nonExistentGuid); + } + + /// + /// UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_update_primary_resource() + { + // Arrange + const string unchangedFirstName = "Roger"; + const string originalLastName = "Rabbit"; + const string updatedLastName = "Rascal"; + + Person person = await _fixture.CreateEntityAsync(new Person + { + Id = Guid.NewGuid(), + FirstName = unchangedFirstName, + LastName = originalLastName + }); + + string route = $"/api/v1/people/{person.StringId}"; + + var requestBody = new + { + data = new + { + type = "people", + id = person.StringId, + attributes = new + { + lastName = updatedLastName + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + Person updatedPerson = await context.People.SingleAsync(item => item.Id == person.Id); + + updatedPerson.FirstName.Should().Be(unchangedFirstName); + updatedPerson.LastName.Should().Be(updatedLastName); + }); + } + + /// + /// SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + /// + [Fact] + public Task Can_set_relationship_of_primary_resource() + { + // The test cannot be performed on people since there is no to-one relationship that can be set. + return Task.CompletedTask; + } + + /// + /// DeleteAsync(TId id, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_delete_primary_resource() + { + // Arrange + Person person = await _fixture.CreateEntityAsync(new Person + { + Id = Guid.NewGuid(), + FirstName = "Bugsy", + LastName = "Malone" + }); + + string route = $"/api/v1/people/{person.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + Person? deletedPerson = await context.People.SingleOrDefaultAsync(item => item.Id == person.Id); + deletedPerson.Should().BeNull(); + }); + } + + /// + /// RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet{IIdentifiable} rightResourceIds, ...) + /// + [Fact] + public async Task Can_remove_existing_secondary_resource_from_to_many_relationship() + { + // Arrange + Guid assigneeId = _fixture.BonnieId; + + TodoItem todoItem = await _fixture.CreateEntityAsync(new TodoItem + { + Id = Guid.NewGuid(), + Description = "Buy gun", + OwnerId = _fixture.ClydeId, + AssigneeId = assigneeId, + Tags = new HashSet + { + new() + { + Name = "Errand" + } + } + }); + + string route = $"/api/v1/people/{assigneeId}/relationships/assignedTodoItems"; + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = todoItem.StringId + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + TodoItem? updatedTodoItem = await context.TodoItems.SingleAsync(item => item.Id == todoItem.Id); + updatedTodoItem.AssigneeId.Should().BeNull(); + }); + } + + /// + /// RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet{IIdentifiable} rightResourceIds, ...) + /// + [Fact] + public async Task Removing_non_existent_secondary_resource_from_to_many_relationship_returns_no_content_status_code() + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}/relationships/assignedTodoItems"; + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = Guid.NewGuid().ToString() + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + protected override HttpClient CreateClient() + { + return _fixture.CreateClient(); + } + } +} diff --git a/test/CosmosDbTests/TodoItemTests.cs b/test/CosmosDbTests/TodoItemTests.cs new file mode 100644 index 0000000000..6ef2921952 --- /dev/null +++ b/test/CosmosDbTests/TodoItemTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using CosmosDbExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace CosmosDbTests +{ + public sealed class TodoItemTests : IntegrationTest, IClassFixture + { + private readonly CosmosDbFixture _fixture; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _fixture.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TodoItemTests(CosmosDbFixture fixture) + { + _fixture = fixture; + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_primary_resources_including_secondary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeNull().And.NotBeEmpty(); + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetAsync(TId id, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_primary_resource_by_id_including_secondary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems/{_fixture.TodoItemWithOwnerAndAssigneeId}?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_secondary_resource_related_to_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems/{_fixture.TodoItemWithOwnerAndAssigneeId}/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + + responseDocument.Included.Should().BeNull(); + } + + /// + /// GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_relationships_of_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems/{_fixture.TodoItemWithOwnerAndAssigneeId}/relationships/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + responseDocument.Included.Should().BeNull(); + } + + /// + /// CreateAsync(TResource resource, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_create_primary_resource() + { + // Arrange + const string route = "/api/v1/todoItems"; + + var todoItem = new TodoItem + { + Description = "To-do created by API", + Tags = new HashSet + { + new() + { + Name = "First" + }, + new() + { + Name = "Second" + } + }, + OwnerId = _fixture.BonnieId + }; + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = todoItem.Description, + tags = todoItem.Tags.Select(tag => new + { + name = tag.Name + }) + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = todoItem.OwnerId.ToString() + } + } + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("tags").As>().Should().BeEquivalentTo(todoItem.Tags); + } + + /// + /// SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_set_relationship_of_primary_resource() + { + // Arrange + Guid todoItemId = _fixture.TodoItemOwnedByBonnieId; + Guid assigneeId = _fixture.ClydeId; + + string route = $"/api/v1/todoItems/{todoItemId}/relationships/assignee"; + + var requestBody = new + { + data = new + { + type = "people", + id = assigneeId.ToString() + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + TodoItem updatedTodoItem = await context.TodoItems.SingleAsync(item => item.Id == todoItemId); + updatedTodoItem.AssigneeId.Should().Be(assigneeId); + }); + } + + protected override HttpClient CreateClient() + { + return _fixture.CreateClient(); + } + } +} diff --git a/test/CosmosDbTests/xunit.runner.json b/test/CosmosDbTests/xunit.runner.json new file mode 100644 index 0000000000..8f5f10571b --- /dev/null +++ b/test/CosmosDbTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +}