Skip to content
17 changes: 15 additions & 2 deletions src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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<AdminResourceDetails>(contentType: "application/json")
.Produces<AdminResourceDetails>(statusCode: StatusCodes.Status200OK, contentType: "application/json")
Expand Down
2 changes: 2 additions & 0 deletions src/AzureOpenAIProxy.ApiApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Azure.Data.Tables;

using AzureOpenAIProxy.ApiApp.Configurations;
using AzureOpenAIProxy.ApiApp.Models;

namespace AzureOpenAIProxy.ApiApp.Repositories;

/// <summary>
/// This provides interfaces to the <see cref="AdminResourceRepository"/> class.
/// </summary>
public interface IAdminResourceRepository
{
/// <summary>
/// Creates a new record of resource details.
/// </summary>
/// <param name="resourceDetails">Resource details instance.</param>
/// <returns>Returns the resource details instance created.</returns>
Task<AdminResourceDetails> CreateResource(AdminResourceDetails resourceDetails);
}

/// <summary>
/// This represents the repository entity for the admin resource.
/// </summary>
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));

/// <inheritdoc />
public async Task<AdminResourceDetails> CreateResource(AdminResourceDetails resourceDetails)
{
TableClient tableClient = await GetTableClientAsync();

await tableClient.AddEntityAsync(resourceDetails).ConfigureAwait(false);

return resourceDetails;
}

private async Task<TableClient> GetTableClientAsync()
{
TableClient tableClient = _tableServiceClient.GetTableClient(_storageAccountSettings.TableStorage.TableName);

await tableClient.CreateIfNotExistsAsync().ConfigureAwait(false);

return tableClient;
}
}

/// <summary>
/// This represents the extension class for <see cref="IServiceCollection"/>
/// </summary>
public static class AdminResourceRepositoryExtensions
{
/// <summary>
/// Adds the <see cref="AdminResourceRepository"/> instance to the service collection.
/// </summary>
/// <param name="services"><see cref="IServiceCollection"/> instance.</param>
/// <returns>Returns <see cref="IServiceCollection"/> instance.</returns>
public static IServiceCollection AddAdminResourceRepository(this IServiceCollection services)
{
services.AddScoped<IAdminResourceRepository, AdminResourceRepository>();

return services;
}
}
52 changes: 52 additions & 0 deletions src/AzureOpenAIProxy.ApiApp/Services/AdminResourceService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using AzureOpenAIProxy.ApiApp.Models;
using AzureOpenAIProxy.ApiApp.Repositories;

namespace AzureOpenAIProxy.ApiApp.Services;

/// <summary>
/// This provides interfaces to the <see cref="AdminResourceService"/> class.
/// </summary>
public interface IAdminResourceService
{
/// <summary>
/// Creates a new resource.
/// </summary>
/// <param name="resourceDetails">Resource payload.</param>
/// <returns>Returns the resource payload created.</returns>
Task<AdminResourceDetails> CreateResource(AdminResourceDetails resourceDetails);
}

/// <summary>
/// This represents the service entity for admin resource.
/// </summary>
public class AdminResourceService(IAdminResourceRepository repository) : IAdminResourceService
{
private readonly IAdminResourceRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));

/// <inheritdoc />
public async Task<AdminResourceDetails> CreateResource(AdminResourceDetails resourceDetails)
{
resourceDetails.PartitionKey = PartitionKeys.ResourceDetails;
resourceDetails.RowKey = resourceDetails.ResourceId.ToString();

var result = await _repository.CreateResource(resourceDetails).ConfigureAwait(false);
return result;
}
}

/// <summary>
/// This represents the extension class for <see cref="IServiceCollection"/>.
/// </summary>
public static class AdminResourceServiceExtensions
{
/// <summary>
/// Adds the <see cref="AdminResourceService"/> instance to the service collection.
/// </summary>
/// <param name="services"><see cref="IServiceCollection"/> instance.</param>
/// <returns>Returns <see cref="IServiceCollection"/> instance.</returns>
public static IServiceCollection AddAdminResourceService(this IServiceCollection services)
{
services.AddScoped<IAdminResourceService, AdminResourceService>();
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<StorageAccountSettings>();
var tableServiceClient = default(TableServiceClient);

// Act
Action action = () => new AdminResourceRepository(tableServiceClient!, settings);

// Assert
action.Should().Throw<ArgumentNullException>();
}

[Fact]
public void Given_Null_StorageAccountSettings_When_Creating_AdminResourceRepository_Then_It_Should_Throw_Exception()
{
// Arrange
var settings = default(StorageAccountSettings);
var tableServiceClient = Substitute.For<TableServiceClient>();

// Act
Action action = () => new AdminResourceRepository(tableServiceClient, settings!);

// Assert
action.Should().Throw<ArgumentNullException>();
}

[Fact]
public async Task Given_Instance_When_CreateResource_Invoked_Then_It_Should_Add_Entity()
{
// Arrange
var settings = Substitute.For<StorageAccountSettings>();
var tableServiceClient = Substitute.For<TableServiceClient>();
var tableClient = Substitute.For<TableClient>();
tableServiceClient.GetTableClient(Arg.Any<string>()).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<AdminResourceDetails>(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<StorageAccountSettings>();
var tableServiceClient = Substitute.For<TableServiceClient>();
var tableClient = Substitute.For<TableClient>();
tableServiceClient.GetTableClient(Arg.Any<string>()).Returns(tableClient);

var repository = new AdminResourceRepository(tableServiceClient, settings);

tableClient.AddEntityAsync(Arg.Any<AdminResourceDetails>()).ThrowsAsync(new InvalidOperationException());

// Act
Func<Task> func = () => repository.CreateResource(new AdminResourceDetails());

// Assert
await func.Should().ThrowAsync<InvalidOperationException>();
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>();
}

[Fact]
public async Task Given_Instance_When_CreateResource_Invoked_Then_It_Should_Add_Entity()
{
// Arrange
var repository = Substitute.For<IAdminResourceRepository>();
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<AdminResourceDetails>(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<IAdminResourceRepository>();
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<AdminResourceDetails>()).ThrowsAsync(new InvalidOperationException());

// Act
Func<Task> act = async () => await service.CreateResource(resourceDetails);

// Assert
await act.Should().ThrowAsync<InvalidOperationException>();
}
}
Loading