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 969e9caa..46f45fca 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -24,9 +24,11 @@ // Add admin services builder.Services.AddAdminEventService(); +builder.Services.AddAdminResourceService(); // Add admin repositories builder.Services.AddAdminEventRepository(); +builder.Services.AddAdminResourceRepository(); // Add playground services builder.Services.AddPlaygroundService(); 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..728ab4e4 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs @@ -0,0 +1,52 @@ +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(IAdminResourceRepository repository) : IAdminResourceService +{ + private readonly IAdminResourceRepository _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/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs new file mode 100644 index 00000000..e97d43ab --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminResourceRepositoryTests.cs @@ -0,0 +1,114 @@ +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 +{ + [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); + } + + [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 diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs new file mode 100644 index 00000000..6d553ff0 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminResourceServiceTests.cs @@ -0,0 +1,101 @@ +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 +{ + [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_Failure_In_Add_Entity_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