From e27891b05493d99980a745231d071ec586f745ba Mon Sep 17 00:00:00 2001 From: macbook Date: Sat, 5 Oct 2024 17:10:41 +0900 Subject: [PATCH 1/5] [Backend API] Implement endpoint for new resource details #308 --- infra/aspire.bicep | 2 +- .../Endpoints/AdminResourceEndpoints.cs | 17 ++++- src/AzureOpenAIProxy.ApiApp/Program.cs | 6 ++ .../Repositories/AdminResourceRepository.cs | 65 +++++++++++++++++++ .../Services/AdminResourceService.cs | 57 ++++++++++++++++ .../appsettings.Development.sample.json | 5 +- src/AzureOpenAIProxy.ApiApp/appsettings.json | 2 +- .../AdminResourceRepositoryTests.cs | 19 ++++++ .../Services/AdminResourceServiceTests.cs | 18 +++++ 9 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/Repositories/AdminResourceRepository.cs create mode 100644 src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs diff --git a/infra/aspire.bicep b/infra/aspire.bicep index e0f2712d..5f1f0593 100644 --- a/infra/aspire.bicep +++ b/infra/aspire.bicep @@ -24,7 +24,7 @@ param enableRbacAuthorization bool = true // parameters for storage account param storageAccountName string = '' // tableNames passed as a comma separated string from command line -param tableNames string = 'events' +param tableNames string = 'resources' var abbrs = loadJsonContent('./abbreviations.json') diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs index 4dddf5fd..bcfb73a4 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs @@ -19,7 +19,7 @@ public static RouteHandlerBuilder AddNewAdminResource(this WebApplication app) { var builder = app.MapPost(AdminEndpointUrls.AdminResources, async ( [FromBody] AdminResourceDetails payload, - IAdminEventService service, + IAdminResourceService service, ILoggerFactory loggerFactory) => { var logger = loggerFactory.CreateLogger(nameof(AdminResourceEndpoints)); @@ -32,7 +32,20 @@ public static RouteHandlerBuilder AddNewAdminResource(this WebApplication app) return Results.BadRequest("Payload is null"); } - return await Task.FromResult(Results.Ok()); + try + { + var result = await service.CreateResource(payload); + + logger.LogInformation("Created a new resource"); + + return Results.Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create a new resource"); + + return Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } }) .Accepts(contentType: "application/json") .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index f35b83ee..0b579248 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -28,6 +28,12 @@ // Add admin repositories builder.Services.AddAdminEventRepository(); +// Add admin resource services +builder.Services.AddAdminResourceService(); + +// Add admin resource repositories +builder.Services.AddAdminResourceRepository(); + var app = builder.Build(); app.MapDefaultEndpoints(); diff --git a/src/AzureOpenAIProxy.ApiApp/Repositories/AdminResourceRepository.cs b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminResourceRepository.cs new file mode 100644 index 00000000..364778b8 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminResourceRepository.cs @@ -0,0 +1,65 @@ +using Azure.Data.Tables; + +using AzureOpenAIProxy.ApiApp.Configurations; +using AzureOpenAIProxy.ApiApp.Models; + +namespace AzureOpenAIProxy.ApiApp.Repositories; + +/// +/// This provides interfaces to the class. +/// +public interface IAdminResourceRepository +{ + /// + /// Creates a new record of resource details. + /// + /// Resource details instance. + /// Returns the resource details instance created. + Task CreateResource(AdminResourceDetails resourceDetails); +} + +/// +/// This represents the repository entity for the admin resource. +/// +public class AdminResourceRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings) : IAdminResourceRepository +{ + private readonly TableServiceClient _tableServiceClient = tableServiceClient ?? throw new ArgumentNullException(nameof(tableServiceClient)); + private readonly StorageAccountSettings _storageAccountSettings = storageAccountSettings ?? throw new ArgumentNullException(nameof(storageAccountSettings)); + + /// + public async Task CreateResource(AdminResourceDetails resourceDetails) + { + TableClient tableClient = await GetTableClientAsync(); + + await tableClient.AddEntityAsync(resourceDetails).ConfigureAwait(false); + + return resourceDetails; + } + + private async Task GetTableClientAsync() + { + TableClient tableClient = _tableServiceClient.GetTableClient(_storageAccountSettings.TableStorage.TableName); + + await tableClient.CreateIfNotExistsAsync().ConfigureAwait(false); + + return tableClient; + } +} + +/// +/// This represents the extension class for +/// +public static class AdminResourceRepositoryExtensions +{ + /// + /// Adds the instance to the service collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddAdminResourceRepository(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs b/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs new file mode 100644 index 00000000..b31e85bd --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs @@ -0,0 +1,57 @@ +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Repositories; + +namespace AzureOpenAIProxy.ApiApp.Services; + +/// +/// This provides interfaces to the class. +/// +public interface IAdminResourceService +{ + /// + /// Creates a new resource. + /// + /// Resource payload. + /// Returns the resource payload created. + Task CreateResource(AdminResourceDetails resourceDetails); +} + +/// +/// This represents the service entity for admin resource. +/// +public class AdminResourceService : IAdminResourceService +{ + private readonly IAdminResourceRepository _repository; + + public AdminResourceService(IAdminResourceRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + /// + public async Task CreateResource(AdminResourceDetails resourceDetails) + { + resourceDetails.PartitionKey = PartitionKeys.ResourceDetails; + resourceDetails.RowKey = resourceDetails.ResourceId.ToString(); + + var result = await _repository.CreateResource(resourceDetails).ConfigureAwait(false); + return result; + } +} + +/// +/// This represents the extension class for . +/// +public static class AdminResourceServiceExtensions +{ + /// + /// Adds the instance to the service collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddAdminResourceService(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/appsettings.Development.sample.json b/src/AzureOpenAIProxy.ApiApp/appsettings.Development.sample.json index 534af650..b4435354 100644 --- a/src/AzureOpenAIProxy.ApiApp/appsettings.Development.sample.json +++ b/src/AzureOpenAIProxy.ApiApp/appsettings.Development.sample.json @@ -21,7 +21,10 @@ }, "KeyVault": { "VaultUri": "https://{{key-vault-name}}.vault.azure.net/", - "SecretName": "azure-openai-instances" + "SecretNames": { + "OpenAI": "azure-openai-instances", + "Storage": "storage-connection-string" + } } } } diff --git a/src/AzureOpenAIProxy.ApiApp/appsettings.json b/src/AzureOpenAIProxy.ApiApp/appsettings.json index fc890d6e..42ffb967 100644 --- a/src/AzureOpenAIProxy.ApiApp/appsettings.json +++ b/src/AzureOpenAIProxy.ApiApp/appsettings.json @@ -32,7 +32,7 @@ }, "StorageAccount": { "TableStorage": { - "TableName": "events" + "TableName": "resources" } } }, diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs new file mode 100644 index 00000000..ed738347 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs @@ -0,0 +1,19 @@ +using Azure; +using Azure.Data.Tables; + +using AzureOpenAIProxy.ApiApp.Configurations; +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Repositories; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; + +public class AdminResourceRepositoryTests +{ +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs new file mode 100644 index 00000000..96244693 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs @@ -0,0 +1,18 @@ +using Azure; + +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Repositories; +using AzureOpenAIProxy.ApiApp.Services; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Services; + +public class AdminResourceServiceTests +{ +} \ No newline at end of file From b541abf19a3647a88ffd8022767cb0c89d6ad092 Mon Sep 17 00:00:00 2001 From: macbook Date: Sat, 5 Oct 2024 18:10:05 +0900 Subject: [PATCH 2/5] Refactor tableNames parameter to use 'events' instead of 'resources' --- infra/aspire.bicep | 2 +- src/AzureOpenAIProxy.ApiApp/Program.cs | 6 +----- .../Services/AdminResourceService.cs | 9 ++------- src/AzureOpenAIProxy.ApiApp/appsettings.json | 2 +- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/infra/aspire.bicep b/infra/aspire.bicep index 5f1f0593..e0f2712d 100644 --- a/infra/aspire.bicep +++ b/infra/aspire.bicep @@ -24,7 +24,7 @@ param enableRbacAuthorization bool = true // parameters for storage account param storageAccountName string = '' // tableNames passed as a comma separated string from command line -param tableNames string = 'resources' +param tableNames string = 'events' var abbrs = loadJsonContent('./abbreviations.json') diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index 0b579248..c009ed37 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -24,14 +24,10 @@ // Add admin services builder.Services.AddAdminEventService(); +builder.Services.AddAdminResourceService(); // Add admin repositories builder.Services.AddAdminEventRepository(); - -// Add admin resource services -builder.Services.AddAdminResourceService(); - -// Add admin resource repositories builder.Services.AddAdminResourceRepository(); var app = builder.Build(); diff --git a/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs b/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs index b31e85bd..728ab4e4 100644 --- a/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs +++ b/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs @@ -19,14 +19,9 @@ public interface IAdminResourceService /// /// This represents the service entity for admin resource. /// -public class AdminResourceService : IAdminResourceService +public class AdminResourceService(IAdminResourceRepository repository) : IAdminResourceService { - private readonly IAdminResourceRepository _repository; - - public AdminResourceService(IAdminResourceRepository repository) - { - _repository = repository ?? throw new ArgumentNullException(nameof(repository)); - } + private readonly IAdminResourceRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository)); /// public async Task CreateResource(AdminResourceDetails resourceDetails) diff --git a/src/AzureOpenAIProxy.ApiApp/appsettings.json b/src/AzureOpenAIProxy.ApiApp/appsettings.json index 42ffb967..fc890d6e 100644 --- a/src/AzureOpenAIProxy.ApiApp/appsettings.json +++ b/src/AzureOpenAIProxy.ApiApp/appsettings.json @@ -32,7 +32,7 @@ }, "StorageAccount": { "TableStorage": { - "TableName": "resources" + "TableName": "events" } } }, From 69e6eb4ccac148a45a1a08ae732e6cdc4fa19b0c Mon Sep 17 00:00:00 2001 From: macbook Date: Sat, 12 Oct 2024 18:11:21 +0900 Subject: [PATCH 3/5] Add unit tests for AdminResourceRepository and AdminResourceService --- .../AdminResourceRepositoryTests.cs | 78 ++++++++++++++++- .../Services/AdminResourceServiceTests.cs | 87 ++++++++++++++++++- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs index ed738347..41dc6001 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs @@ -1,4 +1,3 @@ -using Azure; using Azure.Data.Tables; using AzureOpenAIProxy.ApiApp.Configurations; @@ -10,10 +9,85 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; public class AdminResourceRepositoryTests { + [Fact] + public void Given_ServiceCollection_When_AddAdminResourceRepository_Invoked_Then_It_Should_Contain_AdminResourceRepository() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAdminResourceRepository(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(IAdminResourceRepository)).Should().NotBeNull(); + } + + [Fact] + public void Given_Null_TableServiceClient_When_Creating_AdminResourceRepository_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = default(TableServiceClient); + + // Act + Action action = () => new AdminResourceRepository(tableServiceClient!, settings); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Null_StorageAccountSettings_When_Creating_AdminResourceRepository_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = default(StorageAccountSettings); + var tableServiceClient = Substitute.For(); + + // Act + Action action = () => new AdminResourceRepository(tableServiceClient, settings!); + + // Assert + action.Should().Throw(); + } + + [Fact] + public async Task Given_Instance_When_CreateResource_Invoked_Then_It_Should_Add_Entity() + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); + var tableClient = Substitute.For(); + tableServiceClient.GetTableClient(Arg.Any()).Returns(tableClient); + + var repository = new AdminResourceRepository(tableServiceClient, settings); + + var resourceId = Guid.NewGuid(); + var resourceDetails = new AdminResourceDetails + { + ResourceId = resourceId, + FriendlyName = "Test Resource", + DeploymentName = "Test Deployment", + ResourceType = ResourceType.Chat, + Endpoint = "https://test.endpoint.com", + ApiKey = "test-api-key", + Region = "test-region", + IsActive = true, + PartitionKey = PartitionKeys.ResourceDetails, + RowKey = resourceId.ToString() + }; + + // Act + var result = await repository.CreateResource(resourceDetails); + + // Assert + await tableClient.Received(1).AddEntityAsync(Arg.Is(x => + x.ResourceId == resourceDetails.ResourceId + )); + result.Should().BeEquivalentTo(resourceDetails); + } } \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs index 96244693..5fae838a 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs @@ -1,5 +1,3 @@ -using Azure; - using AzureOpenAIProxy.ApiApp.Models; using AzureOpenAIProxy.ApiApp.Repositories; using AzureOpenAIProxy.ApiApp.Services; @@ -15,4 +13,89 @@ namespace AzureOpenAIProxy.ApiApp.Tests.Services; public class AdminResourceServiceTests { + [Fact] + public void Given_ServiceCollection_When_AddAdminResourceService_Invoked_Then_It_Should_Contain_AdminResourceService() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAdminResourceService(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(IAdminResourceService)).Should().NotBeNull(); + } + + [Fact] + public void Given_Null_Repository_When_Creating_AdminResourceService_Then_It_Should_Throw_Exception() + { + // Arrange + IAdminResourceRepository? repository = null; + + // Act + Action action = () => new AdminResourceService(repository!); + + // Assert + action.Should().Throw(); + } + + [Fact] + public async Task Given_Instance_When_CreateResource_Invoked_Then_It_Should_Add_Entity() + { + // Arrange + var repository = Substitute.For(); + var service = new AdminResourceService(repository); + + var resourceDetails = new AdminResourceDetails + { + ResourceId = Guid.NewGuid(), + FriendlyName = "Test Resource", + DeploymentName = "Test Deployment", + ResourceType = ResourceType.Chat, + Endpoint = "https://test.endpoint.com", + ApiKey = "test-api-key", + Region = "test-region", + IsActive = true + }; + + repository.CreateResource(resourceDetails).Returns(resourceDetails); + + // Act + var result = await service.CreateResource(resourceDetails); + + // Assert + await repository.Received(1).CreateResource(Arg.Is(x => + x.ResourceId == resourceDetails.ResourceId + )); + + result.Should().BeEquivalentTo(resourceDetails); + } + + [Fact] + public async Task Given_RepositoryFails_When_CreateResource_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var repository = Substitute.For(); + var service = new AdminResourceService(repository); + + var resourceDetails = new AdminResourceDetails + { + ResourceId = Guid.NewGuid(), + FriendlyName = "Test Resource", + DeploymentName = "Test Deployment", + ResourceType = ResourceType.Chat, + Endpoint = "https://test.endpoint.com", + ApiKey = "test-api-key", + Region = "test-region", + IsActive = true + }; + + repository.CreateResource(Arg.Any()).ThrowsAsync(new InvalidOperationException()); + + // Act + Func act = async () => await service.CreateResource(resourceDetails); + + // Assert + await act.Should().ThrowAsync(); + } } \ No newline at end of file From 37a2fe5b982eb8f67c9b3ccd6beead7b3a04fc28 Mon Sep 17 00:00:00 2001 From: macbook Date: Sat, 12 Oct 2024 18:56:05 +0900 Subject: [PATCH 4/5] Rename test methods in AdminResourceServiceTests --- .../Services/AdminResourceServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs index 5fae838a..6d553ff0 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs @@ -72,7 +72,7 @@ await repository.Received(1).CreateResource(Arg.Is(x => } [Fact] - public async Task Given_RepositoryFails_When_CreateResource_Invoked_Then_It_Should_Throw_Exception() + public async Task Given_Failure_In_Add_Entity_When_CreateResource_Invoked_Then_It_Should_Throw_Exception() { // Arrange var repository = Substitute.For(); From 03794364476e31c80f7752c62c2753ff2e92fab5 Mon Sep 17 00:00:00 2001 From: macbook Date: Sat, 12 Oct 2024 19:33:40 +0900 Subject: [PATCH 5/5] Add failure test for CreateResource in AdminResourceRepositoryTests --- .../AdminResourceRepositoryTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs index 41dc6001..e97d43ab 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; @@ -90,4 +91,24 @@ await tableClient.Received(1).AddEntityAsync(Arg.Is(x => )); result.Should().BeEquivalentTo(resourceDetails); } + + [Fact] + public async Task Given_Failure_In_Add_Entity_When_CreateResource_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); + var tableClient = Substitute.For(); + tableServiceClient.GetTableClient(Arg.Any()).Returns(tableClient); + + var repository = new AdminResourceRepository(tableServiceClient, settings); + + tableClient.AddEntityAsync(Arg.Any()).ThrowsAsync(new InvalidOperationException()); + + // Act + Func func = () => repository.CreateResource(new AdminResourceDetails()); + + // Assert + await func.Should().ThrowAsync(); + } } \ No newline at end of file