Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backend API] Implement endpoint for new resource details #308 #332

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
17 changes: 15 additions & 2 deletions src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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<AdminResourceDetails>(contentType: "application/json")
.Produces<AdminResourceDetails>(statusCode: StatusCodes.Status200OK, contentType: "application/json")
2 changes: 2 additions & 0 deletions src/AzureOpenAIProxy.ApiApp/Program.cs
Original file line number Diff line number Diff line change
@@ -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();
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
@@ -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>();
}
}

Unchanged files with check annotations Beta

string timeZone = GetIanaTimezoneId();
// Act
string inputTimezoneValue = await inputTimezone.GetAttributeAsync("current-value");

Check warning on line 61 in test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/NewEventDetailsPageTests.cs

GitHub Actions / build-test

Converting null literal or possible null value to non-nullable type.
// Assert
inputTimezoneValue.Should().Be(timeZone);
{
private readonly IEventRepository _eventRepository = eventRepository ?? throw new ArgumentNullException(nameof(eventRepository));
/// <inheritdoc/>
public async Task<List<DeploymentModelDetails>> GetDeploymentModels(string eventId)

Check warning on line 27 in src/AzureOpenAIProxy.ApiApp/Services/PlaygroundService.cs

GitHub Actions / build-test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
throw new NotImplementedException();
}
private readonly StorageAccountSettings _storageAccountSettings = storageAccountSettings ?? throw new ArgumentNullException(nameof(storageAccountSettings));
/// <inheritdoc />
public async Task<AdminEventDetails> CreateEvent(AdminEventDetails eventDetails)

Check warning on line 52 in src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs

GitHub Actions / build-test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
throw new NotImplementedException();
}
/// <inheritdoc />
public async Task<List<AdminEventDetails>> GetEvents()

Check warning on line 58 in src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs

GitHub Actions / build-test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
throw new NotImplementedException();
}
}
/// <inheritdoc />
public async Task<AdminEventDetails> UpdateEvent(Guid eventId, AdminEventDetails eventDetails)

Check warning on line 77 in src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs

GitHub Actions / build-test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
throw new NotImplementedException();
}
var tableServiceClient = default(TableServiceClient);
// Act
Action action = () => new EventRepository(tableServiceClient, settings);

Check warning on line 41 in test/AzureOpenAIProxy.ApiApp.Tests/Repositories/EventRepositoryTests.cs

GitHub Actions / build-test

Possible null reference argument for parameter 'tableServiceClient' in 'EventRepository.EventRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings)'.
// Assert
action.Should().Throw<ArgumentNullException>();
var tableServiceClient = Substitute.For<TableServiceClient>();
// Act
Action action = () => new EventRepository(tableServiceClient, settings);

Check warning on line 55 in test/AzureOpenAIProxy.ApiApp.Tests/Repositories/EventRepositoryTests.cs

GitHub Actions / build-test

Possible null reference argument for parameter 'storageAccountSettings' in 'EventRepository.EventRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings)'.
// Assert
action.Should().Throw<ArgumentNullException>();
var tableServiceClient = default(TableServiceClient);
// Act
Action action = () => new AdminEventRepository(tableServiceClient, settings);

Check warning on line 40 in test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs

GitHub Actions / build-test

Possible null reference argument for parameter 'tableServiceClient' in 'AdminEventRepository.AdminEventRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings)'.
// Assert
action.Should().Throw<ArgumentNullException>();
var tableServiceClient = Substitute.For<TableServiceClient>();
// Act
Action action = () => new AdminEventRepository(tableServiceClient, settings);

Check warning on line 54 in test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs

GitHub Actions / build-test

Possible null reference argument for parameter 'storageAccountSettings' in 'AdminEventRepository.AdminEventRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings)'.
// Assert
action.Should().Throw<ArgumentNullException>();
var services = new ServiceCollection();
var dict = new Dictionary<string, string>()
{
{ "Azure:KeyVault:SecretNames:Storage", secretName },

Check warning on line 317 in test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs

GitHub Actions / build-test

Possible null reference argument for parameter 'value' in 'void Dictionary<string, string>.Add(string key, string value)'.
};
#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build();