From d6fdf1b9c8702f03a59cf113c2a8096ee0c39eae Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Wed, 20 May 2026 08:01:44 +0200 Subject: [PATCH] Refactored the S3 services to properly upload the file --- LiteCharms.Features.Tests/CommonFixture.cs | 6 ++- .../LiteCharms.Features.Tests.csproj | 10 ++-- .../S3ServiceFeatureTests.cs | 28 ++++++++++ LiteCharms.Features.Tests/appsettings.json | 12 +++++ LiteCharms.Features/Extensions/S3.cs | 54 ++++++++++--------- .../S3/Abstractions/S3ServiceBase.cs | 38 ++++++++++++- .../S3/BookshopInvoicesS3Service.cs | 11 ++++ .../S3/BookshopQuotesS3Service.cs | 11 ++++ LiteCharms.Features/S3/BookshopS3Service.cs | 11 ++++ .../S3/BookstoreInvoicesS3Service.cs | 38 ------------- .../S3/BookstoreQuotesS3Service.cs | 38 ------------- LiteCharms.Features/S3/BookstoreS3Service.cs | 38 ------------- 12 files changed, 148 insertions(+), 147 deletions(-) create mode 100644 LiteCharms.Features.Tests/S3ServiceFeatureTests.cs create mode 100644 LiteCharms.Features/S3/BookshopInvoicesS3Service.cs create mode 100644 LiteCharms.Features/S3/BookshopQuotesS3Service.cs create mode 100644 LiteCharms.Features/S3/BookshopS3Service.cs delete mode 100644 LiteCharms.Features/S3/BookstoreInvoicesS3Service.cs delete mode 100644 LiteCharms.Features/S3/BookstoreQuotesS3Service.cs delete mode 100644 LiteCharms.Features/S3/BookstoreS3Service.cs diff --git a/LiteCharms.Features.Tests/CommonFixture.cs b/LiteCharms.Features.Tests/CommonFixture.cs index a73bed2..b9085c4 100644 --- a/LiteCharms.Features.Tests/CommonFixture.cs +++ b/LiteCharms.Features.Tests/CommonFixture.cs @@ -14,18 +14,20 @@ public class CommonFixture : IDisposable { Configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json") .AddUserSecrets() + .AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"), optional: true, reloadOnChange: true) .AddEnvironmentVariables() .Build(); - Services = new ServiceCollection() + Services = new ServiceCollection() .AddMediator() .AddLogging() .AddShopServices() .AddEmailServiceBus() + .AddGarageS3(Configuration) .AddShopDatabase(Configuration) .AddEmailServices(Configuration) + .AddSingleton(Configuration) .BuildServiceProvider(); Mediator = Services.GetRequiredService(); diff --git a/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj b/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj index 7070851..55f1b2e 100644 --- a/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj +++ b/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj @@ -27,10 +27,10 @@ - - - - + + + + @@ -43,7 +43,7 @@ - PreserveNewest + Always diff --git a/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs b/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs new file mode 100644 index 0000000..b7f7ac4 --- /dev/null +++ b/LiteCharms.Features.Tests/S3ServiceFeatureTests.cs @@ -0,0 +1,28 @@ +using LiteCharms.Features.S3.Abstractions; + +namespace LiteCharms.Features.Tests; + +public class S3ServiceFeatureTests(CommonFixture fixture, ITestOutputHelper output) : IClassFixture +{ + [Fact] + public async Task BookshopS3Service_MustReturnUrl() + { + var service = fixture.Services.GetKeyedService(S3.Constants.BookshopQuotesBucketName); + + var fileName = "appsettings.json"; + + string path = Path.Combine(Directory.GetCurrentDirectory(), fileName); + + Assert.True(File.Exists(path)); + + var stream = File.OpenRead(path); + + var result = await service!.UploadFileAsync(fileName, stream, MimeKit.MimeTypes.GetMimeType(fileName)); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value); + Assert.NotEmpty(result.Value); + + output.WriteLine(result.Value); + } +} diff --git a/LiteCharms.Features.Tests/appsettings.json b/LiteCharms.Features.Tests/appsettings.json index aec5c2e..1066af9 100644 --- a/LiteCharms.Features.Tests/appsettings.json +++ b/LiteCharms.Features.Tests/appsettings.json @@ -1,4 +1,16 @@ { + "BookshopS3Settings": { + "ServiceUrl": "http://192.168.1.177:30900", + "Region": "garage", + "BucketName": "bookshop", + "CdnBaseUrl": "https://bookshop.cdn.khongisa.co.za" + }, + "BookshopQuotesS3Settings": { + "ServiceUrl": "http://192.168.1.177:30900", + "Region": "garage", + "BucketName": "bookshop.quotes", + "CdnBaseUrl": "https://bookshop.quotes.cdn.khongisa.co.za" + }, "Email": { "Credentials": { "Username": "shop@litecharms.co.za" diff --git a/LiteCharms.Features/Extensions/S3.cs b/LiteCharms.Features/Extensions/S3.cs index 5518b7d..2b2fd1e 100644 --- a/LiteCharms.Features/Extensions/S3.cs +++ b/LiteCharms.Features/Extensions/S3.cs @@ -7,8 +7,8 @@ namespace LiteCharms.Features.Extensions; public static class S3 { public static IServiceCollection AddGarageS3(this IServiceCollection services, IConfiguration configuration) - { - if (configuration.GetSection(BookshopBucketName) is not null) + { + if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value)) { services.AddKeyedSingleton(BookshopBucketName, (provider, client) => new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopS3SettingsSection}:AccessKey").Value, @@ -18,39 +18,45 @@ public static class S3 ServiceURL = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value, AuthenticationRegion = configuration.GetSection($"{BookshopS3SettingsSection}:Region").Value, ForcePathStyle = true, + EndpointDiscoveryEnabled = true, + UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://") })); - services.AddKeyedScoped(BookshopBucketName); + services.AddKeyedScoped(BookshopBucketName); } - if (configuration.GetSection(BookshopInvoicesBucketName) is not null) + if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:ServiceUrl").Value)) { services.AddKeyedSingleton(BookshopInvoicesBucketName, (provider, client) => - new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopInvoicesBucketName}:AccessKey").Value, - configuration.GetSection($"{BookshopInvoicesBucketName}:SecretKey").Value), + new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:AccessKey").Value, + configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:SecretKey").Value), new AmazonS3Config { - ServiceURL = configuration.GetSection($"{BookshopInvoicesBucketName}:ServiceUrl").Value, - AuthenticationRegion = configuration.GetSection($"{BookshopInvoicesBucketName}:Region").Value, - ForcePathStyle = true, - })); - - services.AddKeyedScoped(BookshopInvoicesBucketName); - } - - if (configuration.GetSection(BookshopQuotesBucketName) is not null) - { - services.AddKeyedSingleton(BookshopQuotesBucketName, (provider, client) => - new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopQuotesBucketName}:AccessKey").Value, - configuration.GetSection($"{BookshopQuotesBucketName}:SecretKey").Value), - new AmazonS3Config - { - ServiceURL = configuration.GetSection($"{BookshopQuotesBucketName}:ServiceUrl").Value, - AuthenticationRegion = configuration.GetSection($"{BookshopQuotesBucketName}:Region").Value, + ServiceURL = configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:ServiceUrl").Value, + AuthenticationRegion = configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:Region").Value, ForcePathStyle = true, + EndpointDiscoveryEnabled = true, + UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://") })); - services.AddKeyedScoped(BookshopQuotesBucketName); + services.AddKeyedScoped(BookshopInvoicesBucketName); + } + + if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{BookshopQuotesS3SettingsSection}:ServiceUrl").Value)) + { + services.AddKeyedSingleton(BookshopQuotesBucketName, (provider, client) => + new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopQuotesS3SettingsSection}:AccessKey").Value, + configuration.GetSection($"{BookshopQuotesS3SettingsSection}:SecretKey").Value), + new AmazonS3Config + { + ServiceURL = configuration.GetSection($"{BookshopQuotesS3SettingsSection}:ServiceUrl").Value, + AuthenticationRegion = configuration.GetSection($"{BookshopQuotesS3SettingsSection}:Region").Value, + ForcePathStyle = true, + EndpointDiscoveryEnabled = true, + UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://") + })); + + services.AddKeyedScoped(BookshopQuotesBucketName); } return services; diff --git a/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs b/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs index 7b9f147..1b232fc 100644 --- a/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs +++ b/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs @@ -2,7 +2,41 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) { - protected readonly IAmazonS3 client = amazonS3; + protected readonly IAmazonS3 Client = amazonS3; - public abstract Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default); + protected abstract string BucketName { get; } + protected abstract string CdnBaseUrl { get; } + + public virtual async Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(BucketName)) + return Result.Fail("Bucket name is not configured."); + + if (string.IsNullOrWhiteSpace(CdnBaseUrl)) + return Result.Fail("CDN base URL is not configured."); + + var fileKey = $"{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; + + var putRequest = new PutObjectRequest + { + BucketName = BucketName, + Key = fileKey, + InputStream = fileStream, + ContentType = contentType, + UseChunkEncoding = false + }; + + var response = await Client.PutObjectAsync(putRequest, cancellationToken); + + return response.HttpStatusCode != System.Net.HttpStatusCode.OK + ? Result.Fail($"Failed to upload {fileName} to S3.") + : Result.Ok($"{CdnBaseUrl}/{fileKey}"); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex)); + } + } } diff --git a/LiteCharms.Features/S3/BookshopInvoicesS3Service.cs b/LiteCharms.Features/S3/BookshopInvoicesS3Service.cs new file mode 100644 index 0000000..8093591 --- /dev/null +++ b/LiteCharms.Features/S3/BookshopInvoicesS3Service.cs @@ -0,0 +1,11 @@ +using LiteCharms.Features.S3.Abstractions; +using static LiteCharms.Features.S3.Constants; + +namespace LiteCharms.Features.S3; + +public class BookshopInvoicesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopInvoicesBucketName)] IAmazonS3 amazonS3) : + S3ServiceBase(amazonS3), IS3Service +{ + protected override string BucketName => configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:BucketName").Value ?? ""; + protected override string CdnBaseUrl => configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:CdnBaseUrl").Value ?? ""; +} diff --git a/LiteCharms.Features/S3/BookshopQuotesS3Service.cs b/LiteCharms.Features/S3/BookshopQuotesS3Service.cs new file mode 100644 index 0000000..0f87fa0 --- /dev/null +++ b/LiteCharms.Features/S3/BookshopQuotesS3Service.cs @@ -0,0 +1,11 @@ +using LiteCharms.Features.S3.Abstractions; +using static LiteCharms.Features.S3.Constants; + +namespace LiteCharms.Features.S3; + +public class BookshopQuotesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopQuotesBucketName)] IAmazonS3 amazonS3) : + S3ServiceBase(amazonS3), IS3Service +{ + protected override string BucketName => configuration.GetSection($"{BookshopQuotesS3SettingsSection}:BucketName").Value ?? ""; + protected override string CdnBaseUrl => configuration.GetSection($"{BookshopQuotesS3SettingsSection}:CdnBaseUrl").Value ?? ""; +} diff --git a/LiteCharms.Features/S3/BookshopS3Service.cs b/LiteCharms.Features/S3/BookshopS3Service.cs new file mode 100644 index 0000000..aff9cf5 --- /dev/null +++ b/LiteCharms.Features/S3/BookshopS3Service.cs @@ -0,0 +1,11 @@ +using LiteCharms.Features.S3.Abstractions; +using static LiteCharms.Features.S3.Constants; + +namespace LiteCharms.Features.S3; + +public class BookshopS3Service(IConfiguration configuration, [FromKeyedServices(BookshopBucketName)] IAmazonS3 amazonS3) : + S3ServiceBase(amazonS3), IS3Service +{ + protected override string BucketName => configuration.GetSection($"{BookshopS3SettingsSection}:BucketName").Value ?? ""; + protected override string CdnBaseUrl => configuration.GetSection($"{BookshopS3SettingsSection}:CdnBaseUrl").Value ?? ""; +} diff --git a/LiteCharms.Features/S3/BookstoreInvoicesS3Service.cs b/LiteCharms.Features/S3/BookstoreInvoicesS3Service.cs deleted file mode 100644 index 9614d6f..0000000 --- a/LiteCharms.Features/S3/BookstoreInvoicesS3Service.cs +++ /dev/null @@ -1,38 +0,0 @@ -using LiteCharms.Features.S3.Abstractions; - -namespace LiteCharms.Features.S3; - -public class BookstoreInvoicesS3Service(IConfiguration configuration, [FromKeyedServices(Constants.BookshopInvoicesBucketName)] IAmazonS3 amazonS3) : - S3ServiceBase(amazonS3), IS3Service -{ - public override async Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) - { - try - { - var bucketName = configuration.GetSection($"{Constants.BookshopInvoicesS3SettingsSection}:BucketName").Value!; - var cdnBaseUrl = configuration.GetSection($"{Constants.BookshopInvoicesS3SettingsSection}:CdnBaseUrl").Value!; - - if(string.IsNullOrWhiteSpace(bucketName)) - return Result.Fail("Bucket name is not configured."); - - if (string.IsNullOrWhiteSpace(cdnBaseUrl)) - return Result.Fail("CDN base URL is not configured."); - - var response = await client.PutObjectAsync(new PutObjectRequest - { - BucketName = bucketName, - Key = fileName, - InputStream = fileStream, - ContentType = contentType - }, cancellationToken); - - return response.HttpStatusCode != System.Net.HttpStatusCode.OK - ? Result.Fail($"Failed to upload {fileName} to S3.") - : Result.Ok(string.Format(cdnBaseUrl, bucketName, fileName)); - } - catch (Exception ex) - { - return Result.Fail(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex)); - } - } -} diff --git a/LiteCharms.Features/S3/BookstoreQuotesS3Service.cs b/LiteCharms.Features/S3/BookstoreQuotesS3Service.cs deleted file mode 100644 index 79b1abc..0000000 --- a/LiteCharms.Features/S3/BookstoreQuotesS3Service.cs +++ /dev/null @@ -1,38 +0,0 @@ -using LiteCharms.Features.S3.Abstractions; - -namespace LiteCharms.Features.S3; - -public class BookstoreQuotesS3Service(IConfiguration configuration, [FromKeyedServices(Constants.BookshopQuotesBucketName)] IAmazonS3 amazonS3) : - S3ServiceBase(amazonS3), IS3Service -{ - public override async Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) - { - try - { - var bucketName = configuration.GetSection($"{Constants.BookshopQuotesS3SettingsSection}:BucketName").Value!; - var cdnBaseUrl = configuration.GetSection($"{Constants.BookshopQuotesS3SettingsSection}:CdnBaseUrl").Value!; - - if(string.IsNullOrWhiteSpace(bucketName)) - return Result.Fail("Bucket name is not configured."); - - if (string.IsNullOrWhiteSpace(cdnBaseUrl)) - return Result.Fail("CDN base URL is not configured."); - - var response = await client.PutObjectAsync(new PutObjectRequest - { - BucketName = bucketName, - Key = fileName, - InputStream = fileStream, - ContentType = contentType - }, cancellationToken); - - return response.HttpStatusCode != System.Net.HttpStatusCode.OK - ? Result.Fail($"Failed to upload {fileName} to S3.") - : Result.Ok(string.Format(cdnBaseUrl, bucketName, fileName)); - } - catch (Exception ex) - { - return Result.Fail(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex)); - } - } -} diff --git a/LiteCharms.Features/S3/BookstoreS3Service.cs b/LiteCharms.Features/S3/BookstoreS3Service.cs deleted file mode 100644 index 0ac895b..0000000 --- a/LiteCharms.Features/S3/BookstoreS3Service.cs +++ /dev/null @@ -1,38 +0,0 @@ -using LiteCharms.Features.S3.Abstractions; - -namespace LiteCharms.Features.S3; - -public class BookstoreS3Service(IConfiguration configuration, [FromKeyedServices(Constants.BookshopBucketName)] IAmazonS3 amazonS3) : - S3ServiceBase(amazonS3), IS3Service -{ - private readonly string bucketName = configuration.GetSection($"{Constants.BookshopS3SettingsSection}:BucketName").Value ?? ""; - private readonly string cdnBaseUrl = configuration.GetSection($"{Constants.BookshopS3SettingsSection}:CdnBaseUrl").Value ?? ""; - - public override async Task> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) - { - try - { - if(string.IsNullOrWhiteSpace(bucketName)) - return Result.Fail("Bucket name is not configured."); - - if (string.IsNullOrWhiteSpace(cdnBaseUrl)) - return Result.Fail("CDN base URL is not configured."); - - var response = await client.PutObjectAsync(new PutObjectRequest - { - BucketName = bucketName, - Key = fileName, - InputStream = fileStream, - ContentType = contentType - }, cancellationToken); - - return response.HttpStatusCode != System.Net.HttpStatusCode.OK - ? Result.Fail($"Failed to upload {fileName} to S3.") - : Result.Ok(string.Format(cdnBaseUrl, bucketName, fileName)); - } - catch (Exception ex) - { - return Result.Fail(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex)); - } - } -}