diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln index 30283eb..8eab026 100644 --- a/Datasync.Toolkit.sln +++ b/Datasync.Toolkit.sln @@ -64,6 +64,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server", "samples\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{A9967817-2A2C-4C6D-A133-967A6062E9B3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb", "src\CommunityToolkit.Datasync.Server.CosmosDb\CommunityToolkit.Datasync.Server.CosmosDb.csproj", "{D9356867-0A30-4B17-BD4C-0F7EF70984C6}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB", "src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj", "{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB.Test", "tests\CommunityToolkit.Datasync.Server.MongoDB.Test\CommunityToolkit.Datasync.Server.MongoDB.Test.csproj", "{4FC45D20-0BA9-484B-9040-641687659AF6}" @@ -72,6 +74,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.OpenApi", "src\CommunityToolkit.Datasync.Server.OpenApi\CommunityToolkit.Datasync.Server.OpenApi.csproj", "{505421EC-2598-4114-B2EC-997959B89E74}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb.Test", "tests\CommunityToolkit.Datasync.Server.CosmosDb.Test\CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj", "{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -162,6 +166,10 @@ Global {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.Build.0 = Release|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Release|Any CPU.Build.0 = Release|Any CPU {DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.Build.0 = Debug|Any CPU {DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -178,6 +186,10 @@ Global {505421EC-2598-4114-B2EC-997959B89E74}.Debug|Any CPU.Build.0 = Debug|Any CPU {505421EC-2598-4114-B2EC-997959B89E74}.Release|Any CPU.ActiveCfg = Release|Any CPU {505421EC-2598-4114-B2EC-997959B89E74}.Release|Any CPU.Build.0 = Release|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -204,8 +216,10 @@ Global {D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838} {A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2} + {D9356867-0A30-4B17-BD4C-0F7EF70984C6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838} + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2} = {D59F1489-5D74-4F52-B78B-88037EAB2838} {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE} = {D59F1489-5D74-4F52-B78B-88037EAB2838} {505421EC-2598-4114-B2EC-997959B89E74} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} EndGlobalSection diff --git a/Directory.Packages.props b/Directory.Packages.props index f9129f2..b4b77a5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + @@ -19,12 +20,14 @@ + + diff --git a/samples/datasync-server-cosmosdb-singlecontainer/Datasync.Server.CosmosDb.SingleContainer.sln b/samples/datasync-server-cosmosdb-singlecontainer/Datasync.Server.CosmosDb.SingleContainer.sln new file mode 100644 index 0000000..24ce5ea --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/Datasync.Server.CosmosDb.SingleContainer.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35728.132 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server.SingleContainer", "src\Sample.Datasync.Server.SingleContainer.csproj", "{5C9AB15D-EBE4-4F60-939C-B24E73F4174E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Projects", "Projects", "{2410E728-3A10-4E61-BCE0-64031E9ABC1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb", "..\..\src\CommunityToolkit.Datasync.Server.CosmosDb\CommunityToolkit.Datasync.Server.CosmosDb.csproj", "{2CA5DC15-69DE-47EA-9FF3-595E6D6D9FB5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.Swashbuckle", "..\..\src\CommunityToolkit.Datasync.Server.Swashbuckle\CommunityToolkit.Datasync.Server.Swashbuckle.csproj", "{98195FB2-DABA-48F6-8D52-50CF920B4F72}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5C9AB15D-EBE4-4F60-939C-B24E73F4174E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C9AB15D-EBE4-4F60-939C-B24E73F4174E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C9AB15D-EBE4-4F60-939C-B24E73F4174E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C9AB15D-EBE4-4F60-939C-B24E73F4174E}.Release|Any CPU.Build.0 = Release|Any CPU + {2CA5DC15-69DE-47EA-9FF3-595E6D6D9FB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CA5DC15-69DE-47EA-9FF3-595E6D6D9FB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CA5DC15-69DE-47EA-9FF3-595E6D6D9FB5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CA5DC15-69DE-47EA-9FF3-595E6D6D9FB5}.Release|Any CPU.Build.0 = Release|Any CPU + {98195FB2-DABA-48F6-8D52-50CF920B4F72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98195FB2-DABA-48F6-8D52-50CF920B4F72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98195FB2-DABA-48F6-8D52-50CF920B4F72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98195FB2-DABA-48F6-8D52-50CF920B4F72}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2CA5DC15-69DE-47EA-9FF3-595E6D6D9FB5} = {2410E728-3A10-4E61-BCE0-64031E9ABC1C} + {98195FB2-DABA-48F6-8D52-50CF920B4F72} = {2410E728-3A10-4E61-BCE0-64031E9ABC1C} + EndGlobalSection +EndGlobal diff --git a/samples/datasync-server-cosmosdb-singlecontainer/azure.yaml b/samples/datasync-server-cosmosdb-singlecontainer/azure.yaml new file mode 100644 index 0000000..47e51be --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/azure.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: community-toolkit-datasync-server +metadata: + template: community-toolkit-datasync-server@8.0.0 + +workflows: + up: + steps: + - azd: provision + - azd: deploy --all + +services: + backend: + project: ./src + language: csharp + host: appservice \ No newline at end of file diff --git a/samples/datasync-server-cosmosdb-singlecontainer/infra/main.bicep b/samples/datasync-server-cosmosdb-singlecontainer/infra/main.bicep new file mode 100644 index 0000000..67b4707 --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/infra/main.bicep @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the the environment which is used to generate a short unique hash used in all resources.') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +@description('Optional - the name of the App Service to create. If not provided, a unique name will be generated.') +param appServiceName string = '' + +@description('Optional - the name of the App Service Plan to create. If not provided, a unique name will be generated.') +param appServicePlanName string = '' + +@description('Optional - the name of the Resource Group to create. If not provided, a unique name will be generated.') +param resourceGroupName string = '' + +@description('Optional - the name for the CosmosDB account. If not provided, a unique name will be generated.') +param accountName string = '' + +@description('Optional - the name for the database. default is TodoDb') +param databaseName string = 'TodoDb' + +@description('Optional - the name for the container. default is TodoContainer.') +param containerName string = 'TodoContainer' + +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) +var tags = { 'azd-env-name': environmentName } + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : 'rg-${environmentName}' + location: location + tags: tags +} + +module resources './resources.bicep' = { + name: 'resources' + scope: resourceGroup + params: { + location: location + tags: tags + appServiceName: !empty(appServiceName) ? appServiceName : 'app-${resourceToken}' + appServicePlanName: !empty(appServicePlanName) ? appServicePlanName : 'asp-${resourceToken}' + accountName: !empty(accountName) ? accountName : 'cdb-${resourceToken}' + databaseName: databaseName + containerName: containerName + } +} + +output SERVICE_ENDPOINT string = resources.outputs.SERVICE_ENDPOINT + diff --git a/samples/datasync-server-cosmosdb-singlecontainer/infra/main.parameters.json b/samples/datasync-server-cosmosdb-singlecontainer/infra/main.parameters.json new file mode 100644 index 0000000..8f7787b --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/infra/main.parameters.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + } + } +} \ No newline at end of file diff --git a/samples/datasync-server-cosmosdb-singlecontainer/infra/resources.bicep b/samples/datasync-server-cosmosdb-singlecontainer/infra/resources.bicep new file mode 100644 index 0000000..73e8c6f --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/infra/resources.bicep @@ -0,0 +1,139 @@ +targetScope = 'resourceGroup' + +@minLength(1) +@description('Primary location for all resources') +param location string + +@description('The name of the App Service to create.') +param appServiceName string + +@description('The name of the App Service Plan to create.') +param appServicePlanName string + +@description('The list of tags to apply to all resources.') +param tags object = {} + +@description('The name for the CosmosDB account') +param accountName string + +@description('The name for the database') +param databaseName string + +@description('The name for the container') +param containerName string + +resource account 'Microsoft.DocumentDB/databaseAccounts@2022-05-15' = { + name: toLower(accountName) + kind: 'GlobalDocumentDB' + location: location + properties: { + consistencyPolicy: { + defaultConsistencyLevel: 'Session'} + locations: [ + { + locationName: location + } + ] + databaseAccountOfferType: 'Standard' + } +} + +resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15' = { + parent: account + name: databaseName + properties: { + resource: { + id: databaseName + } + options: { + throughput: 1000 + } + } +} + +resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2022-05-15' = { + parent: database + name: containerName + properties: { + resource: { + id: containerName + partitionKey: { + paths: [ + '/entity' + ] + kind: 'Hash' + } + indexingPolicy: { + indexingMode: 'consistent' + includedPaths: [ + { + path: '/*' + } + ] + excludedPaths: [ + { + path: '/_etag/?' + } + ] + compositeIndexes: [ + [ + { + path: '/updatedAt' + order: 'ascending' + } + { + path: '/id' + order: 'ascending' + } + ] + ] + } + } + } +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2022-09-01' = { + name: appServicePlanName + location: location + tags: tags + sku: { + name: 'B1' + capacity: 1 + } +} + +resource appService 'Microsoft.Web/sites@2022-09-01' = { + name: appServiceName + location: location + tags: union(tags, { + 'azd-service-name': 'backend' + 'hidden-related:${appServicePlan.id}': 'empty' + }) + properties: { + httpsOnly: true + serverFarmId: appServicePlan.id + siteConfig: { + ftpsState: 'Disabled' + minTlsVersion: '1.2' + } + } + + resource configLogs 'config' = { + name: 'logs' + properties: { + applicationLogs: { fileSystem: { level: 'Verbose' } } + detailedErrorMessages: { enabled: true } + failedRequestsTracing: { enabled: true } + httpLogs: { fileSystem: { retentionInMb: 35, retentionInDays: 3, enabled: true } } + } + } + + resource connectionStrings 'config' = { + name: 'appsettings' + properties: { + ConnectionStrings__DefaultConnection: account.listConnectionStrings().connectionStrings[0].connectionString + } + } +} + +output SERVICE_ENDPOINT string = 'https://${appService.properties.defaultHostName}' diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Controllers/TodoItemController.cs b/samples/datasync-server-cosmosdb-singlecontainer/src/Controllers/TodoItemController.cs new file mode 100644 index 0000000..86c7aa4 --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Controllers/TodoItemController.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using Microsoft.AspNetCore.Mvc; +using Sample.Datasync.Server.SingleContainer.Models; + +namespace Sample.Datasync.Server.Controllers; + +[Route("tables/[controller]")] +public class TodoItemController : TableController +{ + public TodoItemController(IRepository repository) + : base(repository) + { + } +} \ No newline at end of file diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Controllers/TodoListController.cs b/samples/datasync-server-cosmosdb-singlecontainer/src/Controllers/TodoListController.cs new file mode 100644 index 0000000..1795e77 --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Controllers/TodoListController.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using Microsoft.AspNetCore.Mvc; +using Sample.Datasync.Server.SingleContainer.Models; + +namespace Sample.Datasync.Server.Controllers; + +[Route("tables/[controller]")] +public class TodoListController : TableController +{ + public TodoListController(IRepository repository) : base(repository) + { + Options = new TableControllerOptions { EnableSoftDelete = true }; + } +} \ No newline at end of file diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Models/TodoItem.cs b/samples/datasync-server-cosmosdb-singlecontainer/src/Models/TodoItem.cs new file mode 100644 index 0000000..d26663f --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Models/TodoItem.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb; +using System.ComponentModel.DataAnnotations; + +namespace Sample.Datasync.Server.SingleContainer.Models; + +public class TodoItem : CosmosTableData +{ + [Required, MinLength(1)] + public string Title { get; set; } = string.Empty; + + public bool IsComplete { get; set; } +} diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Models/TodoList.cs b/samples/datasync-server-cosmosdb-singlecontainer/src/Models/TodoList.cs new file mode 100644 index 0000000..0c9c296 --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Models/TodoList.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb; +using System.ComponentModel.DataAnnotations; + +namespace Sample.Datasync.Server.SingleContainer.Models; + +public class TodoList : CosmosTableData +{ + [Required, MinLength(1)] + public string Title { get; set; } = string.Empty; + + public string? ListId { get; set; } +} diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Program.cs b/samples/datasync-server-cosmosdb-singlecontainer/src/Program.cs new file mode 100644 index 0000000..3584c1e --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Program.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.CosmosDb; +using CommunityToolkit.Datasync.Server.Swashbuckle; +using Microsoft.Azure.Cosmos; +using Sample.Datasync.Server.SingleContainer.Models; +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new ApplicationException("DefaultConnection is not set"); + +CosmosClient cosmosClient = new CosmosClient(connectionString, + new CosmosClientOptions() + { + UseSystemTextJsonSerializerWithOptions = new() + { + Converters = + { + new JsonStringEnumConverter(), + new DateTimeOffsetConverter(), + new DateTimeConverter(), + new TimeOnlyConverter(), + new SpatialGeoJsonConverter() + }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | JsonIgnoreCondition.WhenWritingDefault, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + ReferenceHandler = ReferenceHandler.Preserve + } + }); + +builder.Services.AddSingleton(cosmosClient); +builder.Services.AddSingleton>(new CosmosSharedTableOptions("TodoDb", "TodoContainer")); +builder.Services.AddSingleton>(new CosmosSharedTableOptions("TodoDb", "TodoContainer")); +builder.Services.AddSingleton(typeof(IRepository<>), typeof(CosmosTableRepository<>)); +// Add services to the container. + +builder.Services.AddDatasyncServices(); + +builder.Services.AddControllers(); + +_ = builder.Services + .AddEndpointsApiExplorer() + .AddSwaggerGen(options => options.AddDatasyncControllers()); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + _ = app.UseSwagger().UseSwaggerUI(); + _ = app.UseDeveloperExceptionPage(); + + Database database = await cosmosClient.CreateDatabaseIfNotExistsAsync("TodoDb"); + + _ = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("TodoContainer", "/entity") + { + IndexingPolicy = new() + { + CompositeIndexes = + { + new Collection() + { + new CompositePath() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending }, + new CompositePath() { Path = "/id", Order = CompositePathSortOrder.Ascending } + } + } + } + }); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Properties/launchSettings.json b/samples/datasync-server-cosmosdb-singlecontainer/src/Properties/launchSettings.json new file mode 100644 index 0000000..5db3ef9 --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7284;http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Sample.Datasync.Server.SingleContainer.csproj b/samples/datasync-server-cosmosdb-singlecontainer/src/Sample.Datasync.Server.SingleContainer.csproj new file mode 100644 index 0000000..499b21b --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Sample.Datasync.Server.SingleContainer.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + 4fb4ad03-5b6a-43b8-8e71-90b9d8252f94 + + + + + + + + diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/Sample.Datasync.Server.SingleContainer.http b/samples/datasync-server-cosmosdb-singlecontainer/src/Sample.Datasync.Server.SingleContainer.http new file mode 100644 index 0000000..f8c3d5e --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/Sample.Datasync.Server.SingleContainer.http @@ -0,0 +1,33 @@ +@Sample.Datasync.Server.SingleContainer_HostAddress = https://localhost:7284 + +POST {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoItem +Content-Type: application/json + +{ + "id": "2", + "title": "Second item", + "isComplete": false +} + +### + +GET {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoItem +Accept: application/json + +### + +POST {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoList +Content-Type: application/json + +{ + "id": "1", + "title": "First List", + "listId": "My List" +} + +### + +GET {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoList +Accept: application/json + +### \ No newline at end of file diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/appsettings.Development.json b/samples/datasync-server-cosmosdb-singlecontainer/src/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/datasync-server-cosmosdb-singlecontainer/src/appsettings.json b/samples/datasync-server-cosmosdb-singlecontainer/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/datasync-server-cosmosdb-singlecontainer/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj b/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj new file mode 100644 index 0000000..442f541 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj @@ -0,0 +1,19 @@ + + + + enable + enable + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs new file mode 100644 index 0000000..93ce806 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Implementation of that supports storing multiple entity types in the same container. +/// Defaults the partition key to the entity type name. +/// +/// The that these options applys to. +public class CosmosSharedTableOptions : CosmosTableOptions where TEntity : CosmosTableData +{ + /// + /// Creates a new instance of the class. + /// + /// The ID of the database that the container is in. + /// The ID of the container that the entities are stored in. + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + public CosmosSharedTableOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp) + { + Entity = typeof(TEntity).Name; + } + /// + /// The entity type for the data. Used as the default partition key for shared containers. defaults to the entity type name. + /// + public virtual string Entity { get; } + /// + /// Gets the partition key for the entity from the entity. Defaults to the entity type name. + /// + /// The to retrieve the partition key from. + /// A containing the type name. + /// The original id of the entity. + + public override string GetPartitionKey(TEntity entity, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(Entity); + return entity.Id; + } + /// + /// Parses the partition key from the id. Defaults to the entity type name. + /// + /// The id of the entity + /// A containing the entity type name. + /// The id passed in the id parameter. + public override string ParsePartitionKey(string id, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(Entity); + return id; + } + /// + /// Returns a predicate that will filters the data by the entity name. + /// + /// + public override Expression> QueryablePredicate() => (e) => e.Entity == Entity; +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs new file mode 100644 index 0000000..6d503ec --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Implementation of for a single table. Defaults the partition key to the entity's ID. +/// +/// The that these options apply to. +public class CosmosSingleTableOptions : CosmosTableOptions where TEntity : CosmosTableData +{ + /// + /// Creates a new instance of the class. + /// + /// The ID of the database that the container is in. + /// The ID of the container that the entities are stored in. + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + public CosmosSingleTableOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp) + { + } + + /// + /// Gets a containing the entities Id. + /// + /// The to retrieve the partition key from. + /// A containing the entities Id. + /// The original id of the entity. + + public override string GetPartitionKey(TEntity entity, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(entity.Id); + + return entity.Id; + } + /// + /// + /// + /// + /// + /// + public override string ParsePartitionKey(string id, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(id); + return id; + } + /// + /// + /// + /// + public override Expression> QueryablePredicate() => (_) => true; +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableData.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableData.cs new file mode 100644 index 0000000..781956b --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableData.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text; +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Base implementation of for entities that are stored in a CosmosDB collection. +/// +public abstract class CosmosTableData : ITableData +{ + /// + public virtual string Id { get; set; } = string.Empty; + /// + public virtual bool Deleted { get; set; } = false; + /// + public virtual DateTimeOffset? UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch; + /// + public virtual byte[] Version + { + get => Encoding.UTF8.GetBytes(ETag); + set => ETag = Encoding.UTF8.GetString(value); + } + /// + /// The ETag value for the entity. + /// + [JsonPropertyName("_etag")] + public string ETag { get; set; } = string.Empty; + + /// + /// Equality comparison for instances. + /// + + public bool Equals(ITableData? other) + { + return other != null && Id == other.Id && Version.SequenceEqual(other.Version); + } +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableDataOfT.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableDataOfT.cs new file mode 100644 index 0000000..3ed18f7 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableDataOfT.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Base implementation of for entities that are stored in a shared CosmosDB collection. +/// +/// The type of the Table Data +public abstract class CosmosTableData : CosmosTableData where T : CosmosTableData +{ + /// + /// The entity type for the data. Used as the default partition key for shared containers. + /// + public virtual string Entity => typeof(T).Name; +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs new file mode 100644 index 0000000..a032417 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; + +/// +/// Base implementation of +/// +/// The that these options apply to. +public abstract class CosmosTableOptions : ICosmosTableOptions where TEntity : CosmosTableData +{ + /// + public virtual string DatabaseId { get; } + /// + public virtual string ContainerId { get; } + /// + public virtual bool ShouldUpdateTimestamp { get; } + + /// + /// Creates a new instance of the class. + /// + /// The ID of the database that the container is in. + /// The ID of the container that the entities are stored in. + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + /// Thrown when or are null or whitespace." + public CosmosTableOptions( + string databaseId, + string containerId, + bool shouldUpdateTimestamp = true) + { + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException($"'{nameof(databaseId)}' cannot be null or whitespace.", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException($"'{nameof(containerId)}' cannot be null or whitespace.", nameof(containerId)); + } + + DatabaseId = databaseId; + ContainerId = containerId; + ShouldUpdateTimestamp = shouldUpdateTimestamp; + } + /// + public virtual Func IdGenerator => (_) => Guid.NewGuid().ToString(); + /// + public virtual bool TryParsePartitionKey(string entityId, out string id, out PartitionKey partitionKey) + { + try + { + id = ParsePartitionKey(entityId, out PartitionKey pk); + partitionKey = pk; + return true; + } + catch + { + id = string.Empty; + partitionKey = default; + return false; + } + } + /// + public abstract string ParsePartitionKey(string id, out PartitionKey partitionKey); + /// + public abstract string GetPartitionKey(TEntity entity, out PartitionKey partitionKey); + /// + public abstract Expression> QueryablePredicate(); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs new file mode 100644 index 0000000..41b39fb --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb.Extensions; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; +using System.Net; +using System.Text; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; + +/// +/// An implementation of the interface that +/// stores data via an Cosmos DB Container. +/// +/// The type of entity to store in the database. +public class CosmosTableRepository : IRepository where TEntity : CosmosTableData +{ + /// + /// The for the entity set. + /// + protected ICosmosTableOptions Options { get; } + /// + /// The used for saving changes to the entity set. + /// + protected Container Container { get; } + + /// + public async ValueTask CountAsync(IQueryable queryable, CancellationToken cancellationToken = default) + => await queryable.CountAsync(cancellationToken); + /// + public async ValueTask> ToListAsync(IQueryable queryable, CancellationToken cancellationToken = default) + => await queryable.ToListAsync(cancellationToken); + + /// + /// Creates a new instance of the class, using the provided + /// to store the entities." + /// + /// The to access the container for this repository. + /// The for this repository. + /// Thrown if the or is null. + public CosmosTableRepository(CosmosClient client, ICosmosTableOptions options) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + Options = options ?? throw new ArgumentNullException(nameof(options)); + + Container = client.GetContainer(options.DatabaseId, options.ContainerId); + + } + + /// + /// Retrieves an untracked version of an entity from the database. + /// + /// The ID of the entity to retrieve. + /// The partition key for the entity. + /// A to observe. + /// A task that returns an untracked version of the entity when complete. + /// Thrown if an error in the backend occurs. + protected async Task GetEntityAsync(string entityId, PartitionKey partitionKey, CancellationToken cancellationToken = default) + { + try + { + return await Container.ReadItemAsync(entityId, partitionKey, cancellationToken: cancellationToken); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + /// + /// Updates the managed properties for this entity if required. + /// + /// The entity to be updated. + internal void UpdateManagedProperties(TEntity entity) + { + if (Options.ShouldUpdateTimestamp) + { + entity.UpdatedAt = DateTimeOffset.UtcNow; + } + } + + /// + /// Runs the inner part of an operation on the database, catching all the normal exceptions and reformatting them + /// as appropriate. + /// + /// The ID of the entity being operated on. + /// The partition key for the entity. + /// The operation to execute. + /// A to observe. + /// A task that completes when the operation is finished. + /// Thrown if a concurrency exception occurs. + /// Thrown if an error in the backend occurs. + internal async Task WrapExceptionAsync(string id, PartitionKey partitionKey, Func action, CancellationToken cancellationToken = default) + { + try + { + await action.Invoke().ConfigureAwait(false); + } + catch (CosmosException ex) + { + throw new HttpException((int)ex.StatusCode, ex.Message, ex) { Payload = await GetEntityAsync(id, partitionKey, cancellationToken).ConfigureAwait(false) }; + } + } + + #region IRepository implementation + /// + public virtual ValueTask> AsQueryableAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult(Container.GetItemLinqQueryable().Where(Options.QueryablePredicate()).AsQueryable()); + + /// + public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(entity.Id)) + { + entity.Id = Options.IdGenerator.Invoke(entity); + } + + string id = Options.GetPartitionKey(entity, out PartitionKey partitionKey); + + await WrapExceptionAsync(id, partitionKey, async () => + { + UpdateManagedProperties(entity); + + ItemResponse response = await Container.CreateItemAsync(entity, partitionKey, cancellationToken: cancellationToken); + + entity.ETag = response.Resource.ETag; + entity.UpdatedAt = response.Resource.UpdatedAt; + + }, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); + } + + if (Options.TryParsePartitionKey(id, out string entityId, out PartitionKey partitionKey) == false) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is not in the correct format"); + } + + await WrapExceptionAsync(id, partitionKey, async () => + { + ItemRequestOptions? requestOptions = null; + + if (version?.Length > 0) + { + requestOptions = new ItemRequestOptions() + { + IfMatchEtag = Encoding.UTF8.GetString(version) + }; + } + + _ = await Container.DeleteItemAsync(entityId, partitionKey, requestOptions, cancellationToken); + + }, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual async ValueTask ReadAsync(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); + } + + if(Options.TryParsePartitionKey(id, out string entityId, out PartitionKey partitionKey) == false) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is not in the correct format"); + } + + return await GetEntityAsync(entityId, partitionKey, cancellationToken).ConfigureAwait(false) ?? + throw new HttpException((int)HttpStatusCode.NotFound, $"Entity with id {id} not found"); + } + + /// + public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(entity.Id)) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); + } + + string id = Options.GetPartitionKey(entity, out PartitionKey partitionKey); + + await WrapExceptionAsync(id, partitionKey, async () => + { + UpdateManagedProperties(entity); + + ItemRequestOptions? requestOptions = null; + + if (version?.Length > 0) + { + requestOptions = new ItemRequestOptions() + { + IfMatchEtag = Encoding.UTF8.GetString(version) + }; + } + + _ = await Container.ReplaceItemAsync(entity, id, partitionKey, requestOptions, cancellationToken); + + }, cancellationToken).ConfigureAwait(false); + } + #endregion +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/Extensions/IQueryableExtensions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/Extensions/IQueryableExtensions.cs new file mode 100644 index 0000000..2393a11 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/Extensions/IQueryableExtensions.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Extensions; + +/// +/// CosmosDB specific extensions for IQueryable +/// +public static class IQueryableExtensions +{ + /// + /// Converts an IQueryable to a using a + /// + /// The queryable to convert + /// The cancellation token to use + /// The type of entity in the queryable + public static async Task> ToListAsync(this IQueryable queryable, CancellationToken cancellationToken = default) + where TEntity : class + { + List list = []; + using (FeedIterator iterator = queryable.ToFeedIterator()) + { + + while (iterator.HasMoreResults) + { + FeedResponse response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + + list.AddRange(response); + } + } + + return list; + } +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs new file mode 100644 index 0000000..e4de5a3 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// +/// +/// +public interface ICosmosTableOptions where TEntity : CosmosTableData +{ + /// + /// The name of the cosmsos database the entities are stored in + /// + string DatabaseId { get; } + /// + /// The name of the cosmos container the entities are stored in + /// + string ContainerId { get; } + /// + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + /// Should be false if there are triggers to update the timestamp on the container. + /// + bool ShouldUpdateTimestamp { get; } + /// + /// Function to attempt to parse the partition key from the entity + /// + /// The entity passed from the controller to the repository ID + /// The entity ID after the entity has been parsed + /// The partition key after the entity has been parsed + bool TryParsePartitionKey(string entityId, out string id, out PartitionKey partitionKey); + /// + /// Function to parse the partition key from the entity + /// + /// The entity id passed from the controller to the repository + /// The partition key after the entity has been parsed + /// The entity ID after the entity has been parsed + string ParsePartitionKey(string id, out PartitionKey partitionKey); + /// + /// Get the id and partition key for the entity from the entity + /// + /// The entity passed from the controller to the repository + /// The partition key after the entity has been parsed + /// The entity ID after the entity has been parsed + string GetPartitionKey(TEntity entity, out PartitionKey partitionKey); + /// + /// Function to generate a new ID for the entity + /// + /// The function to generate a new ID for the entity + Func IdGenerator { get; } + /// + /// Function to filter the dataset for the entity required for shared containers + /// + Expression> QueryablePredicate(); +} diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj new file mode 100644 index 0000000..03b77ad --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj @@ -0,0 +1,23 @@ + + + + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosDbRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosDbRepository_Tests.cs new file mode 100644 index 0000000..74d0c6f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosDbRepository_Tests.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; +using CommunityToolkit.Datasync.TestCommon; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; +using System.Collections.ObjectModel; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Test; + +[ExcludeFromCodeCoverage] +public class CosmosDbRepository_Tests : RepositoryTests, IDisposable, IAsyncLifetime +{ + #region Setup + private readonly Random random = new(); + private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); + private readonly List movies = []; + + private CosmosClient _client; + private Container _container; + private CosmosTableRepository _repository; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this._client?.Dispose(); + } + } + + override protected bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); + protected override async Task GetEntityAsync(string id) + { + try + { + return await this._container.ReadItemAsync(id, new PartitionKey(id)); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + protected override async Task GetEntityCountAsync() + { + return await this._container.GetItemLinqQueryable().CountAsync(); + } + + protected override Task> GetPopulatedRepositoryAsync() + { + return Task.FromResult>(this._repository); + } + + protected override Task GetRandomEntityIdAsync(bool exists) + { + return Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); + } + + public async Task InitializeAsync() + { + if (!string.IsNullOrEmpty(this.connectionString)) + { + this._client = new CosmosClient( + this.connectionString, + new CosmosClientOptions() + { + UseSystemTextJsonSerializerWithOptions = new(JsonSerializerDefaults.Web) + { + AllowTrailingCommas = true, + Converters = + { + new JsonStringEnumConverter(), + new DateTimeOffsetConverter(), + new DateTimeConverter(), + new TimeOnlyConverter(), + new SpatialGeoJsonConverter() + }, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + IgnoreReadOnlyFields = true, + IgnoreReadOnlyProperties = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip + } + }); + + Database database = await this._client.CreateDatabaseIfNotExistsAsync("Movies"); + + this._container = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("Movies", "/id") + { + IndexingPolicy = new IndexingPolicy() + { + CompositeIndexes = + { + new Collection() + { + new() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/id", Order = CompositePathSortOrder.Ascending } + }, + new Collection() + { + new() { Path = "/releaseDate", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/id", Order = CompositePathSortOrder.Ascending } + } + } + } + }); + + foreach (CosmosDbMovie movie in TestCommon.TestData.Movies.OfType()) + { + movie.UpdatedAt = DateTimeOffset.UtcNow; + movie.Version = Guid.NewGuid().ToByteArray(); + _ = await this._container.CreateItemAsync(movie, new PartitionKey(movie.Id)); + this.movies.Add(movie); + } + + this._repository = new CosmosTableRepository( + this._client, + new CosmosSingleTableOptions("Movies", "Movies") + ); + + } + } + + public async Task DisposeAsync() + { + if (this._client != null) + { + try + { + await this._client.GetDatabase("Movies").DeleteAsync(); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Ignore + } + } + } + #endregion +} diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Models/CosmosDbMovie.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Models/CosmosDbMovie.cs new file mode 100644 index 0000000..76d7d06 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Models/CosmosDbMovie.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.TestCommon.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; +public class CosmosDbMovie : CosmosTableData, IMovie +{ + public bool BestPictureWinner { get; set; } + + public int Duration { get; set; } + + public MovieRating Rating { get; set; } + + public DateOnly ReleaseDate { get; set; } + + public string Title { get; set; } + + public int Year { get; set; } +} diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Options/PackedKeyOptions.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Options/PackedKeyOptions.cs new file mode 100644 index 0000000..00827d9 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Options/PackedKeyOptions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; +using Microsoft.Azure.Cosmos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Test.Options; + +public class PackedKeyOptions : CosmosSingleTableOptions +{ + public PackedKeyOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp) + { + } + + public override Func IdGenerator => (entity) => $"{Guid.NewGuid()}:{entity.Year}"; + public override string GetPartitionKey(CosmosDbMovie entity, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(entity.Year); + return entity.Id; + } + + public override string ParsePartitionKey(string id, out PartitionKey partitionKey) + { + string[] parts = id.Split(':'); + + if (parts.Length != 2) + { + throw new ArgumentException("Invalid ID format"); + } + + if (!int.TryParse(parts[1], out int year)) + throw new ArgumentException("Invalid ID Part"); + + partitionKey = new PartitionKey(year); + return id; + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs new file mode 100644 index 0000000..c71abb4 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs @@ -0,0 +1,197 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; +using CommunityToolkit.Datasync.Server.CosmosDb.Test.Options; +using CommunityToolkit.Datasync.TestCommon; +using CommunityToolkit.Datasync.TestCommon.Databases; +using FluentAssertions; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; +using System.Collections.ObjectModel; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Test; + +[ExcludeFromCodeCoverage] +public class PackedKeyRepository_Tests : RepositoryTests, IDisposable, IAsyncLifetime +{ + #region Setup + private readonly Random random = new(); + private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); + private readonly List movies = []; + + private CosmosClient _client; + private Container _container; + private CosmosTableRepository _repository; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this._client?.Dispose(); + } + } + + override protected bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); + protected override async Task GetEntityAsync(string id) + { + try + { + string[] parts = id.Split(':'); + + if (parts.Length != 2) + { + throw new ArgumentException("Invalid ID format"); + } + + if (!int.TryParse(parts[1], out int year)) + throw new ArgumentException("Invalid ID Part"); + + return await this._container.ReadItemAsync(id, new PartitionKey(year)); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + protected override async Task GetEntityCountAsync() + { + return await this._container.GetItemLinqQueryable().CountAsync(); + } + + protected override Task> GetPopulatedRepositoryAsync() + { + return Task.FromResult>(this._repository); + } + + protected override Task GetRandomEntityIdAsync(bool exists) + { + return Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : $"{Guid.NewGuid()}:2018"); + } + + public async Task InitializeAsync() + { + if (!string.IsNullOrEmpty(this.connectionString)) + { + this._client = new CosmosClient( + this.connectionString, + new CosmosClientOptions() + { + UseSystemTextJsonSerializerWithOptions = new(JsonSerializerDefaults.Web) + { + AllowTrailingCommas = true, + Converters = + { + new JsonStringEnumConverter(), + new DateTimeOffsetConverter(), + new DateTimeConverter(), + new TimeOnlyConverter(), + new SpatialGeoJsonConverter() + }, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + IgnoreReadOnlyFields = true, + IgnoreReadOnlyProperties = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip + } + }); + + Database database = await this._client.CreateDatabaseIfNotExistsAsync("Movies"); + + this._container = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("Movies", "/year") + { + IndexingPolicy = new IndexingPolicy() + { + CompositeIndexes = + { + new Collection() + { + new() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/id", Order = CompositePathSortOrder.Ascending } + }, + new Collection() + { + new() { Path = "/releaseDate", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/id", Order = CompositePathSortOrder.Ascending } + } + } + } + }); + + foreach (CosmosDbMovie movie in TestData.Movies.OfType()) + { + movie.Id = $"{Guid.NewGuid()}:{movie.Year}"; + movie.UpdatedAt = DateTimeOffset.UtcNow; + movie.Version = Guid.NewGuid().ToByteArray(); + _ = await this._container.CreateItemAsync(movie, new PartitionKey(movie.Year)); + this.movies.Add(movie); + } + + this._repository = new CosmosTableRepository( + this._client, + new PackedKeyOptions("Movies", "Movies") + ); + + } + } + + public async Task DisposeAsync() + { + if (this._client != null) + { + try + { + await this._client.GetDatabase("Movies").DeleteAsync(); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Ignore + } + } + } + #endregion + + [SkippableTheory] + [InlineData("BadId")] + [InlineData("12345-12345")] + public async Task ReadAsync_Throws_OnMalformedId(string id) + { + Skip.IfNot(CanRunLiveTests()); + + IRepository Repository = await GetPopulatedRepositoryAsync(); + Func act = async () => _ = await Repository.ReadAsync(id); + + (await act.Should().ThrowAsync()).WithStatusCode(400); + } + [SkippableTheory] + [InlineData("BadId")] + [InlineData("12345-12345")] + public async Task DeleteAsync_Throws_OnMalformedIds(string id) + { + Skip.IfNot(CanRunLiveTests()); + + IRepository Repository = await GetPopulatedRepositoryAsync(); + Func act = async () => await Repository.DeleteAsync(id); + + (await act.Should().ThrowAsync()).WithStatusCode(400); + (await GetEntityCountAsync()).Should().Be(TestData.Movies.Count()); + } + +}