diff --git a/Directory.Packages.props b/Directory.Packages.props index 233d1feee..cb6536ed8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + diff --git a/docs/docs/Installation/azure.md b/docs/docs/Installation/azure.md index 1733c96ff..4ba65c12e 100644 --- a/docs/docs/Installation/azure.md +++ b/docs/docs/Installation/azure.md @@ -9,21 +9,25 @@ This page is a work in progress! ::: -Use Azure to scale BaGetter. You can store metadata on [Azure SQL Database](https://azure.microsoft.com/products/azure-sql/database/), upload packages to [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs/), and soon provide powerful search using [Azure Search](https://azure.microsoft.com/en-us/services/search/). +Use Azure to scale BaGetter. You can store metadata on Azure [SQL Database](https://azure.microsoft.com/products/azure-sql/database/) or [Table Storage](https://azure.microsoft.com/products/storage/tables/), upload packages to [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs/), and soon provide powerful search using [Azure Search](https://azure.microsoft.com/services/search/). ## TODO - App Service -- Table Storage - High availability setup ## Configure BaGetter -You can modify BaGetter's configurations by editing the `appsettings.json` file or through [environment variables](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-8.0#non-prefixed-environment-variables). For the full list of configurations, please refer to [BaGetter's configuration](../configuration.md) guide. +You can modify BaGetter's configurations by editing the `appsettings.json` file or through [environment variables](https://learn.microsoft.com/aspnet/core/fundamentals/configuration/?view=aspnetcore-8.0#non-prefixed-environment-variables). For the full list of configurations, please refer to [BaGetter's configuration](../configuration.md) guide. -### Azure SQL database +### Package Metadata Database -Set the database type to `SqlServer` and provide a [connection string](https://learn.microsoft.com/ef/core/miscellaneous/connection-strings): +To storage the package metadata, you can use any [Azure SQL database](https://azure.microsoft.com/products/category/databases/) (as any normal SQL DB), but also [Azure Table Storage](https://azure.microsoft.com/products/storage/tables/) in a Storage Account or Azure Cosmod DB in Table API mode. + + + + +To use a SQL database, set the database type to the SQL provider of your choice (e.g. `SqlServer`) and provide a [connection string](https://learn.microsoft.com/ef/core/miscellaneous/connection-strings): ```json { @@ -38,6 +42,30 @@ Set the database type to `SqlServer` and provide a [connection string](https://l } ``` + + + + +To use Azure Table Storage or Azure Cosmos DB in Table API mode, set the database type to `AzureTable` and provide a connection string and optionally a custom table name. If no table name is set, the default table name `Packages` will be used. +If it doesn't exist, it will be created automatically. + +```json +{ + ... + + "Database": { + "Type": "AzureTable", + "ConnectionString": "...", + "TableName": "my-nuget-packages" + }, + + ... +} +``` + + + + ### Azure Blob Storage Set the storage type to `AzureBlobStorage` and provide a container name to use and credentials: diff --git a/src/BaGetter.Azure/AzureApplicationExtensions.cs b/src/BaGetter.Azure/AzureApplicationExtensions.cs index 71a832616..bca4bf413 100644 --- a/src/BaGetter.Azure/AzureApplicationExtensions.cs +++ b/src/BaGetter.Azure/AzureApplicationExtensions.cs @@ -1,75 +1,60 @@ using System; +using Azure.Data.Tables; using Azure.Storage; using Azure.Storage.Blobs; using BaGetter.Azure; using BaGetter.Core; -//using Azure.Cosmos.Table; -//using Azure.Search; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; -//using Azure.Storage.Blobs; namespace BaGetter { - //using CloudStorageAccount = Azure.Storage.CloudStorageAccount; - //using StorageCredentials = Azure.Storage.Auth.StorageCredentials; - - //using TableStorageAccount = Azure.Cosmos.Table.CloudStorageAccount; - public static class AzureApplicationExtensions { public static BaGetterApplication AddAzureTableDatabase(this BaGetterApplication app) { - throw new NotImplementedException(); - - //app.Services.AddBaGetterOptions(nameof(BaGetterOptions.Database)); + app.Services.AddBaGetterOptions(nameof(BaGetterOptions.Database)); - //app.Services.AddTransient(); - //app.Services.AddTransient(); - //app.Services.AddTransient(); - //app.Services.TryAddTransient(provider => provider.GetRequiredService()); - //app.Services.TryAddTransient(provider => provider.GetRequiredService()); - //app.Services.TryAddTransient(provider => provider.GetRequiredService()); + app.Services.AddTransient(); + app.Services.AddTransient(); + app.Services.TryAddTransient(provider => provider.GetRequiredService()); + app.Services.TryAddTransient(provider => provider.GetRequiredService()); + app.Services.TryAddTransient(provider => provider.GetRequiredService()); - //app.Services.AddSingleton(provider => - //{ - // var options = provider.GetRequiredService>().Value; - - // return TableStorageAccount.Parse(options.ConnectionString); - //}); - - //app.Services.AddTransient(provider => - //{ - // var account = provider.GetRequiredService(); + app.Services.AddSingleton(provider => + { + var options = provider.GetRequiredService>().Value; - // return account.CreateCloudTableClient(); - //}); + var tableServiceClient = new TableServiceClient(options.ConnectionString); + tableServiceClient.CreateTableIfNotExists(options.TableName); + return tableServiceClient; + }); - //app.Services.AddProvider((provider, config) => - //{ - // if (!config.HasDatabaseType("AzureTable")) return null; + app.Services.AddProvider((provider, config) => + { + if (!config.HasDatabaseType("AzureTable")) return null; - // return provider.GetRequiredService(); - //}); + return provider.GetRequiredService(); + }); - //app.Services.AddProvider((provider, config) => - //{ - // if (!config.HasSearchType("Database")) return null; - // if (!config.HasDatabaseType("AzureTable")) return null; + app.Services.AddProvider((provider, config) => + { + if (!config.HasSearchType("Database")) return null; + if (!config.HasDatabaseType("AzureTable")) return null; - // return provider.GetRequiredService(); - //}); + return provider.GetRequiredService(); + }); - //app.Services.AddProvider((provider, config) => - //{ - // if (!config.HasSearchType("Database")) return null; - // if (!config.HasDatabaseType("AzureTable")) return null; + app.Services.AddProvider((provider, config) => + { + if (!config.HasSearchType("Database")) return null; + if (!config.HasDatabaseType("AzureTable")) return null; - // return provider.GetRequiredService(); - //}); + return provider.GetRequiredService(); + }); - //return app; + return app; } public static BaGetterApplication AddAzureTableDatabase( diff --git a/src/BaGetter.Azure/BaGetter.Azure.csproj b/src/BaGetter.Azure/BaGetter.Azure.csproj index a88d1d3e1..c13748c08 100644 --- a/src/BaGetter.Azure/BaGetter.Azure.csproj +++ b/src/BaGetter.Azure/BaGetter.Azure.csproj @@ -8,10 +8,10 @@ + - @@ -21,6 +21,5 @@ - diff --git a/src/BaGetter.Azure/Configuration/AzureTableOptions.cs b/src/BaGetter.Azure/Configuration/AzureTableOptions.cs index 9090cff0d..60351e1e5 100644 --- a/src/BaGetter.Azure/Configuration/AzureTableOptions.cs +++ b/src/BaGetter.Azure/Configuration/AzureTableOptions.cs @@ -6,5 +6,6 @@ public class AzureTableOptions { [Required] public string ConnectionString { get; set; } + public string TableName { get; set; } = "Packages"; } } diff --git a/src/BaGetter.Azure/Extensions/StorageExceptionExtensions.cs b/src/BaGetter.Azure/Extensions/StorageExceptionExtensions.cs index 4a06c1f80..115c808f8 100644 --- a/src/BaGetter.Azure/Extensions/StorageExceptionExtensions.cs +++ b/src/BaGetter.Azure/Extensions/StorageExceptionExtensions.cs @@ -21,9 +21,9 @@ public static bool IsAlreadyExistsException(this RequestFailedException e) // return e?.RequestInformation?.HttpStatusCode == (int?)HttpStatusCode.Conflict; //} - //public static bool IsPreconditionFailedException(this TableStorageException e) - //{ - // return e?.RequestInformation?.HttpStatusCode == (int?)HttpStatusCode.PreconditionFailed; - //} + public static bool IsPreconditionFailedException(this RequestFailedException e) + { + return e?.Status == (int?)HttpStatusCode.PreconditionFailed; + } } } diff --git a/src/BaGetter.Azure/Table/PackageEntity.cs b/src/BaGetter.Azure/Table/PackageEntity.cs index f5bbc0af3..6e2daed84 100644 --- a/src/BaGetter.Azure/Table/PackageEntity.cs +++ b/src/BaGetter.Azure/Table/PackageEntity.cs @@ -1,20 +1,17 @@ using System; +using Azure; +using Azure.Data.Tables; using BaGetter.Core; -using Microsoft.Azure.Cosmos.Table; namespace BaGetter.Azure { /// /// The Azure Table Storage entity that maps to a . - /// The is the and - /// the is the . + /// The is the and + /// the is the . /// - public class PackageEntity : TableEntity, IDownloadCount, IListed + public class PackageEntity : ITableEntity, IDownloadCount, IListed { - public PackageEntity() - { - } - public string Id { get; set; } public string NormalizedVersion { get; set; } public string OriginalVersion { get; set; } @@ -45,6 +42,10 @@ public PackageEntity() public string Dependencies { get; set; } public string PackageTypes { get; set; } public string TargetFrameworks { get; set; } + public string PartitionKey { get; set; } + public string RowKey { get; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } } /// @@ -68,30 +69,30 @@ public class PackageTypeModel /// /// The Azure Table Storage entity to update the column. - /// The is the and - /// the is the . + /// The is the and + /// the is the . /// - public class PackageListingEntity : TableEntity, IListed + public class PackageListingEntity : ITableEntity, IListed { - public PackageListingEntity() - { - } - public bool Listed { get; set; } + public string PartitionKey { get; set; } + public string RowKey { get; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } } /// /// The Azure Table Storage entity to update the column. - /// The is the and - /// the is the . + /// The is the and + /// the is the . /// - public class PackageDownloadsEntity : TableEntity, IDownloadCount + public class PackageDownloadsEntity : ITableEntity, IDownloadCount { - public PackageDownloadsEntity() - { - } - public long Downloads { get; set; } + public string PartitionKey { get; set; } + public string RowKey { get; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } } internal interface IListed diff --git a/src/BaGetter.Azure/Table/PackageEntityExtensions.cs b/src/BaGetter.Azure/Table/PackageEntityExtensions.cs index 94c9c8d03..8a23231b6 100644 --- a/src/BaGetter.Azure/Table/PackageEntityExtensions.cs +++ b/src/BaGetter.Azure/Table/PackageEntityExtensions.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using BaGetter.Core; -using Newtonsoft.Json; namespace BaGetter.Azure { @@ -12,12 +12,10 @@ public static Package AsPackage(this PackageEntity entity) { return new Package { - Id = entity.Id, + Id = entity.PartitionKey, NormalizedVersionString = entity.NormalizedVersion, OriginalVersionString = entity.OriginalVersion, - - // TODO: Convert to System.Text.Json - Authors = JsonConvert.DeserializeObject(entity.Authors), + Authors = JsonSerializer.Deserialize(entity.Authors), Description = entity.Description, Downloads = entity.Downloads, HasReadme = entity.HasReadme, @@ -37,7 +35,7 @@ public static Package AsPackage(this PackageEntity entity) ProjectUrl = ParseUri(entity.ProjectUrl), RepositoryUrl = ParseUri(entity.RepositoryUrl), RepositoryType = entity.RepositoryType, - Tags = JsonConvert.DeserializeObject(entity.Tags), + Tags = JsonSerializer.Deserialize(entity.Tags), Dependencies = ParseDependencies(entity.Dependencies), PackageTypes = ParsePackageTypes(entity.PackageTypes), TargetFrameworks = ParseTargetFrameworks(entity.TargetFrameworks), @@ -51,8 +49,7 @@ private static Uri ParseUri(string input) private static List ParseDependencies(string input) { - // TODO: Convert to System.Text.Json - return JsonConvert.DeserializeObject>(input) + return JsonSerializer.Deserialize>(input) .Select(e => new PackageDependency { Id = e.Id, @@ -64,8 +61,7 @@ private static List ParseDependencies(string input) private static List ParsePackageTypes(string input) { - // TODO: Convert to System.Text.Json - return JsonConvert.DeserializeObject>(input) + return JsonSerializer.Deserialize>(input) .Select(e => new PackageType { Name = e.Name, @@ -76,8 +72,7 @@ private static List ParsePackageTypes(string input) private static List ParseTargetFrameworks(string targetFrameworks) { - // TODO: Convert to System.Text.Json - return JsonConvert.DeserializeObject>(targetFrameworks) + return JsonSerializer.Deserialize>(targetFrameworks) .Select(f => new TargetFramework { Moniker = f }) .ToList(); } diff --git a/src/BaGetter.Azure/Table/TableOperationBuilder.cs b/src/BaGetter.Azure/Table/TableOperationBuilder.cs index 6637ea048..979edba8c 100644 --- a/src/BaGetter.Azure/Table/TableOperationBuilder.cs +++ b/src/BaGetter.Azure/Table/TableOperationBuilder.cs @@ -1,20 +1,16 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using BaGetter.Core; -using Microsoft.Azure.Cosmos.Table; using Newtonsoft.Json; -using NuGet.Versioning; namespace BaGetter.Azure { - [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Would be a breaking change since it's part of the public API")] public class TableOperationBuilder { - public TableOperation AddPackage(Package package) + public static PackageEntity GetEntity(Package package) { - if (package == null) throw new ArgumentNullException(nameof(package)); + ArgumentNullException.ThrowIfNull(package); var version = package.Version; var normalizedVersion = version.ToNormalizedString(); @@ -53,57 +49,10 @@ public TableOperation AddPackage(Package package) TargetFrameworks = SerializeList(package.TargetFrameworks, f => f.Moniker) }; - return TableOperation.Insert(entity); + return entity; } - public TableOperation UpdateDownloads(string packageId, NuGetVersion packageVersion, long downloads) - { - var entity = new PackageDownloadsEntity(); - - entity.PartitionKey = packageId.ToLowerInvariant(); - entity.RowKey = packageVersion.ToNormalizedString().ToLowerInvariant(); - entity.Downloads = downloads; - entity.ETag = "*"; - - return TableOperation.Merge(entity); - } - - public TableOperation HardDeletePackage(string packageId, NuGetVersion packageVersion) - { - var entity = new PackageEntity(); - - entity.PartitionKey = packageId.ToLowerInvariant(); - entity.RowKey = packageVersion.ToNormalizedString().ToLowerInvariant(); - entity.ETag = "*"; - - return TableOperation.Delete(entity); - } - - public TableOperation UnlistPackage(string packageId, NuGetVersion packageVersion) - { - var entity = new PackageListingEntity(); - - entity.PartitionKey = packageId.ToLowerInvariant(); - entity.RowKey = packageVersion.ToNormalizedString().ToLowerInvariant(); - entity.Listed = false; - entity.ETag = "*"; - - return TableOperation.Merge(entity); - } - - public TableOperation RelistPackage(string packageId, NuGetVersion packageVersion) - { - var entity = new PackageListingEntity(); - - entity.PartitionKey = packageId.ToLowerInvariant(); - entity.RowKey = packageVersion.ToNormalizedString().ToLowerInvariant(); - entity.Listed = true; - entity.ETag = "*"; - - return TableOperation.Merge(entity); - } - - private static string SerializeList(IReadOnlyList objects, Func map) + private static string SerializeList(IEnumerable objects, Func map) { var data = objects.Select(map).ToList(); diff --git a/src/BaGetter.Azure/Table/TablePackageDatabase.cs b/src/BaGetter.Azure/Table/TablePackageDatabase.cs index 0adacf525..73337254c 100644 --- a/src/BaGetter.Azure/Table/TablePackageDatabase.cs +++ b/src/BaGetter.Azure/Table/TablePackageDatabase.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading; using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; using BaGetter.Core; -using Microsoft.Azure.Cosmos.Table; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NuGet.Versioning; namespace BaGetter.Azure @@ -15,32 +18,32 @@ namespace BaGetter.Azure /// public class TablePackageDatabase : IPackageDatabase { - private const string TableName = "Packages"; private const int MaxPreconditionFailures = 5; - private readonly TableOperationBuilder _operationBuilder; - private readonly CloudTable _table; + private readonly TableClient _table; private readonly ILogger _logger; public TablePackageDatabase( - TableOperationBuilder operationBuilder, - CloudTableClient client, - ILogger logger) + TableServiceClient client, + ILogger logger, + IOptions options) { - _operationBuilder = operationBuilder ?? throw new ArgumentNullException(nameof(operationBuilder)); - _table = client?.GetTableReference(TableName) ?? throw new ArgumentNullException(nameof(client)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(logger); + + _table = client.GetTableClient(options.Value.TableName); + _logger = logger; } public async Task AddAsync(Package package, CancellationToken cancellationToken) { try { - var operation = _operationBuilder.AddPackage(package); + var entity = TableOperationBuilder.GetEntity(package); - await _table.ExecuteAsync(operation, cancellationToken); + await _table.AddEntityAsync(entity, cancellationToken); } - catch (StorageException e) when (e.IsAlreadyExistsException()) + catch (RequestFailedException e) when (e.IsAlreadyExistsException()) { return PackageAddResult.PackageAlreadyExists; } @@ -59,24 +62,32 @@ public async Task AddDownloadAsync( { try { - var operation = TableOperation.Retrieve( - id.ToLowerInvariant(), - version.ToNormalizedString().ToLowerInvariant()); - - var result = await _table.ExecuteAsync(operation, cancellationToken); - var entity = result.Result as PackageDownloadsEntity; + var result = await _table.GetEntityIfExistsAsync(id, version.ToNormalizedString().ToLowerInvariant(), cancellationToken: cancellationToken); - if (entity == null) + if (!result.HasValue) { return; } + var entity = result.Value; + entity.Downloads += 1; - await _table.ExecuteAsync(TableOperation.Merge(entity), cancellationToken); + var updateResponse = await _table.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Merge, cancellationToken); + + // Not sure if there's gonna be an exception here so check both ways just in case + if(updateResponse.Status == (int?)HttpStatusCode.PreconditionFailed && attempt < MaxPreconditionFailures) + { + attempt++; + _logger.LogWarning( + "Retrying due to precondition failure, attempt {Attempt} of {MaxPreconditionFailures}", + attempt, MaxPreconditionFailures); + continue; + } + return; } - catch (StorageException e) + catch (RequestFailedException e) when (attempt < MaxPreconditionFailures && e.IsPreconditionFailedException()) { attempt++; @@ -90,15 +101,18 @@ public async Task AddDownloadAsync( public async Task ExistsAsync(string id, CancellationToken cancellationToken) { - var filter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, id.ToLowerInvariant()); - var query = new TableQuery() - .Select(MinimalColumnSet) - .Where(filter) - .Take(1); + var query = _table.QueryAsync(p => + p.PartitionKey.Equals(id, StringComparison.InvariantCultureIgnoreCase), + 1, + MinimalColumnSet, + cancellationToken); - var result = await _table.ExecuteQuerySegmentedAsync(query, token: null, cancellationToken); + await foreach(var _ in query) + { + return true; + } - return result.Results.Any(); + return false; } public async Task ExistsAsync( @@ -106,44 +120,34 @@ public async Task ExistsAsync( NuGetVersion version, CancellationToken cancellationToken) { - var operation = TableOperation.Retrieve( - id.ToLowerInvariant(), - version.ToNormalizedString().ToLowerInvariant(), - MinimalColumnSet); - - var execution = await _table.ExecuteAsync(operation, cancellationToken); + var query = _table.QueryAsync(p => + p.PartitionKey.Equals(id, StringComparison.InvariantCultureIgnoreCase) && + p.RowKey.Equals(version.ToNormalizedString(), StringComparison.InvariantCultureIgnoreCase), + 1, + MinimalColumnSet, + cancellationToken); + + await foreach (var _ in query) + { + return true; + } - return execution.Result is PackageEntity; + return false; } public async Task> FindAsync(string id, bool includeUnlisted, CancellationToken cancellationToken) { - var filter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, id.ToLowerInvariant()); - if (!includeUnlisted) - { - filter = TableQuery.CombineFilters( - filter, - TableOperators.And, - TableQuery.GenerateFilterConditionForBool(nameof(PackageEntity.Listed), QueryComparisons.Equal, true)); - } + const int maxPerPage = 500; + var query = + includeUnlisted + ? _table.QueryAsync(p => p.PartitionKey.Equals(id, StringComparison.InvariantCultureIgnoreCase), maxPerPage, cancellationToken: cancellationToken) + : _table.QueryAsync(p => p.PartitionKey.Equals(id, StringComparison.InvariantCultureIgnoreCase) && p.Listed, maxPerPage, cancellationToken: cancellationToken); - var query = new TableQuery().Where(filter); var results = new List(); - - // Request 500 results at a time from the server. - TableContinuationToken token = null; - query.TakeCount = 500; - - do + await foreach (var entity in query) { - var segment = await _table.ExecuteQuerySegmentedAsync(query, token, cancellationToken); - - token = segment.ContinuationToken; - - // Write out the properties for each entity returned. - results.AddRange(segment.Results.Select(r => r.AsPackage())); + results.Add(entity.AsPackage()); } - while (token != null); return results.OrderBy(p => p.Version).ToList(); } @@ -154,18 +158,15 @@ public async Task FindOrNullAsync( bool includeUnlisted, CancellationToken cancellationToken) { - var operation = TableOperation.Retrieve( - id.ToLowerInvariant(), - version.ToNormalizedString().ToLowerInvariant()); + var result = await _table.GetEntityIfExistsAsync(id, version.ToNormalizedString(), cancellationToken: cancellationToken); - var result = await _table.ExecuteAsync(operation, cancellationToken); - var entity = result.Result as PackageEntity; - - if (entity == null) + if (!result.HasValue) { return null; } + var entity = result.Value; + // Filter out the package if it's unlisted. if (!includeUnlisted && !entity.Listed) { @@ -177,39 +178,46 @@ public async Task FindOrNullAsync( public async Task HardDeletePackageAsync(string id, NuGetVersion version, CancellationToken cancellationToken) { - return await TryUpdatePackageAsync( - _operationBuilder.HardDeletePackage(id, version), - cancellationToken); + var result = await _table.DeleteEntityAsync(id, version.ToNormalizedString().ToLowerInvariant(), cancellationToken: cancellationToken); + return !result.IsError; } public async Task RelistPackageAsync(string id, NuGetVersion version, CancellationToken cancellationToken) { - return await TryUpdatePackageAsync( - _operationBuilder.RelistPackage(id, version), - cancellationToken); + var result = await _table.GetEntityIfExistsAsync(id, version.ToNormalizedString().ToLowerInvariant(), cancellationToken: cancellationToken); + + if (!result.HasValue) + { + return false; + } + + var entity = result.Value; + + entity.Listed = true; + + await _table.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Merge, cancellationToken); + + return true; } public async Task UnlistPackageAsync(string id, NuGetVersion version, CancellationToken cancellationToken) { - return await TryUpdatePackageAsync( - _operationBuilder.UnlistPackage(id, version), - cancellationToken); - } + var result = await _table.GetEntityIfExistsAsync(id, version.ToNormalizedString().ToLowerInvariant(), cancellationToken: cancellationToken); - private static List MinimalColumnSet => new List { "PartitionKey" }; - - private async Task TryUpdatePackageAsync(TableOperation operation, CancellationToken cancellationToken) - { - try - { - await _table.ExecuteAsync(operation, cancellationToken); - } - catch (StorageException e) when (e.IsNotFoundException()) + if (!result.HasValue) { return false; } + var entity = result.Value; + + entity.Listed = false; + + await _table.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Merge, cancellationToken); + return true; } + + private static List MinimalColumnSet => ["PartitionKey"]; } } diff --git a/src/BaGetter.Azure/Table/TableSearchService.cs b/src/BaGetter.Azure/Table/TableSearchService.cs index 2e00cdf03..4f2d0508d 100644 --- a/src/BaGetter.Azure/Table/TableSearchService.cs +++ b/src/BaGetter.Azure/Table/TableSearchService.cs @@ -3,9 +3,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; using BaGetter.Core; using BaGetter.Protocol.Models; -using Microsoft.Azure.Cosmos.Table; namespace BaGetter.Azure { @@ -13,15 +14,18 @@ public class TableSearchService : ISearchService { private const string TableName = "Packages"; - private readonly CloudTable _table; + private readonly TableClient _table; private readonly ISearchResponseBuilder _responseBuilder; public TableSearchService( - CloudTableClient client, + TableServiceClient client, ISearchResponseBuilder responseBuilder) { - _table = client?.GetTableReference(TableName) ?? throw new ArgumentNullException(nameof(client)); - _responseBuilder = responseBuilder ?? throw new ArgumentNullException(nameof(responseBuilder)); + ArgumentNullException.ThrowIfNull(client, nameof(client)); + ArgumentNullException.ThrowIfNull(responseBuilder, nameof(responseBuilder)); + + _table = client.GetTableClient(TableName); + _responseBuilder = responseBuilder; } public async Task SearchAsync( @@ -82,11 +86,9 @@ private async Task> SearchAsync( bool includeSemVer2, CancellationToken cancellationToken) { - var query = new TableQuery(); - query = query.Where(GenerateSearchFilter(searchText, includePrerelease, includeSemVer2)); - query.TakeCount = 500; + var query = _table.QueryAsync(GenerateSearchFilter(searchText, includePrerelease, includeSemVer2), cancellationToken: cancellationToken); - var results = await LoadPackagesAsync(query, maxPartitions: skip + take, cancellationToken); + var results = await LoadPackagesAsync(query, maxPartitions: skip + take); return results .GroupBy(p => p.Id, StringComparer.OrdinalIgnoreCase) @@ -96,39 +98,30 @@ private async Task> SearchAsync( .ToList(); } - private async Task> LoadPackagesAsync( - TableQuery query, - int maxPartitions, - CancellationToken cancellationToken) + private static async Task> LoadPackagesAsync( + AsyncPageable query, + int maxPartitions) { var results = new List(); var partitions = 0; string lastPartitionKey = null; - TableContinuationToken token = null; - do - { - var segment = await _table.ExecuteQuerySegmentedAsync(query, token, cancellationToken); - - token = segment.ContinuationToken; - foreach (var result in segment.Results) + await foreach (var result in query) + { + if (lastPartitionKey != result.PartitionKey) { - if (lastPartitionKey != result.PartitionKey) - { - lastPartitionKey = result.PartitionKey; - partitions++; + lastPartitionKey = result.PartitionKey; + partitions++; - if (partitions > maxPartitions) - { - break; - } + if (partitions > maxPartitions) + { + break; } - - results.Add(result.AsPackage()); } + + results.Add(result.AsPackage()); } - while (token != null); return results; } @@ -139,45 +132,27 @@ private static string GenerateSearchFilter(string searchText, bool includePrerel if (!string.IsNullOrWhiteSpace(searchText)) { - // Filter to rows where the "searchText" prefix matches on the partition key. var prefix = searchText.TrimEnd().Split(separator: null).Last(); var prefixLower = prefix; var prefixUpper = prefix + "~"; - var partitionLowerFilter = TableQuery.GenerateFilterCondition( - "PartitionKey", - QueryComparisons.GreaterThanOrEqual, - prefixLower); - - var partitionUpperFilter = TableQuery.GenerateFilterCondition( - "PartitionKey", - QueryComparisons.LessThanOrEqual, - prefixUpper); + var partitionLowerFilter = $"PartitionKey ge '{prefixLower}'"; + var partitionUpperFilter = $"PartitionKey le '{prefixUpper}'"; result = GenerateAnd(partitionLowerFilter, partitionUpperFilter); } - // Filter to rows that are listed. - result = GenerateAnd( - result, - GenerateIsTrue(nameof(PackageEntity.Listed))); + result = GenerateAnd(result, "Listed eq true"); if (!includePrerelease) { - result = GenerateAnd( - result, - GenerateIsFalse(nameof(PackageEntity.IsPrerelease))); + result = GenerateAnd(result, "IsPrerelease eq false"); } if (!includeSemVer2) { - result = GenerateAnd( - result, - TableQuery.GenerateFilterConditionForInt( - nameof(PackageEntity.SemVerLevel), - QueryComparisons.Equal, - 0)); + result = GenerateAnd(result, "SemVerLevel eq 0"); } return result; @@ -186,23 +161,7 @@ string GenerateAnd(string left, string right) { if (string.IsNullOrEmpty(left)) return right; - return TableQuery.CombineFilters(left, TableOperators.And, right); - } - - string GenerateIsTrue(string propertyName) - { - return TableQuery.GenerateFilterConditionForBool( - propertyName, - QueryComparisons.Equal, - givenValue: true); - } - - string GenerateIsFalse(string propertyName) - { - return TableQuery.GenerateFilterConditionForBool( - propertyName, - QueryComparisons.Equal, - givenValue: false); + return $"({left}) and ({right})"; } } } diff --git a/src/BaGetter.Core/Content/IPackageContentService.cs b/src/BaGetter.Core/Content/IPackageContentService.cs index fb221e14e..1c4018bda 100644 --- a/src/BaGetter.Core/Content/IPackageContentService.cs +++ b/src/BaGetter.Core/Content/IPackageContentService.cs @@ -28,8 +28,8 @@ Task GetPackageVersionsOrNullAsync( /// Download a package, or null if the package does not exist. /// See: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg /// - /// The package ID. - /// The package's version. + /// The package ID, e.g. "BaGetter.Protocol". + /// The package's version, e.g. "1.2.0". /// A token to cancel the task. /// /// The package's content stream, or null if the package does not exist. The stream may not be seekable. diff --git a/src/BaGetter.Web/Controllers/PackageContentController.cs b/src/BaGetter.Web/Controllers/PackageContentController.cs index b8a17e988..2d995ee1b 100644 --- a/src/BaGetter.Web/Controllers/PackageContentController.cs +++ b/src/BaGetter.Web/Controllers/PackageContentController.cs @@ -10,7 +10,7 @@ namespace BaGetter.Web; /// /// The Package Content resource, used to download content from packages. -/// See: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource +/// See: https://docs.microsoft.com/nuget/api/package-base-address-resource /// public class PackageContentController : Controller { @@ -18,7 +18,9 @@ public class PackageContentController : Controller public PackageContentController(IPackageContentService content) { - _content = content ?? throw new ArgumentNullException(nameof(content)); + ArgumentNullException.ThrowIfNull(content); + + _content = content; } public async Task> GetPackageVersionsAsync(string id, CancellationToken cancellationToken) @@ -32,6 +34,13 @@ public async Task> GetPackageVersionsAsync return versions; } + /// + /// Download a specific package version. + /// + /// Package id, e.g. "BaGetter.Protocol". + /// Package version, e.g. "1.2.0". + /// A token to cancel the task. + /// The requested package in an octet stream, or 404 not found if the package isn't found. public async Task DownloadPackageAsync(string id, string version, CancellationToken cancellationToken) { if (!NuGetVersion.TryParse(version, out var nugetVersion)) diff --git a/src/BaGetter/Startup.cs b/src/BaGetter/Startup.cs index a87533dc2..e6b63ba57 100644 --- a/src/BaGetter/Startup.cs +++ b/src/BaGetter/Startup.cs @@ -52,7 +52,7 @@ private void ConfigureBaGetterApplication(BaGetterApplication app) app.AddStatistics(); // Add database providers. - //app.AddAzureTableDatabase(); + app.AddAzureTableDatabase(); app.AddMySqlDatabase(); app.AddPostgreSqlDatabase(); app.AddSqliteDatabase();