diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs new file mode 100644 index 00000000..3fdb5d0c --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs @@ -0,0 +1,9 @@ +namespace AzureOpenAIProxy.ApiApp.Endpoints; + +public static class AdminEndpointUrls +{ + /// + /// Declares the admin event details endpoint. + /// + public const string AdminEventDetails = "/admin/events/{eventId}"; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs new file mode 100644 index 00000000..d64ac5f0 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs @@ -0,0 +1,43 @@ +using AzureOpenAIProxy.ApiApp.Models; + +using Microsoft.AspNetCore.Mvc; + +namespace AzureOpenAIProxy.ApiApp.Endpoints; + +/// +/// This represents the endpoint entity for get event details by admin +/// +public static class AdminEventEndpoints +{ + /// + /// Adds the get event details by admin endpoint + /// + /// instance. + /// Returns instance. + public static RouteHandlerBuilder AddAdminEvents(this WebApplication app) + { + // Todo: Issue #19 https://github.com/aliencube/azure-openai-sdk-proxy/issues/19 + // Need authorization by admin + var builder = app.MapGet(AdminEndpointUrls.AdminEventDetails, ( + [FromRoute] string eventId) => + { + // Todo: Issue #208 https://github.com/aliencube/azure-openai-sdk-proxy/issues/208 + return Results.Ok(); + // Todo: Issue #208 + }) + .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") + .Produces(statusCode: StatusCodes.Status401Unauthorized) + .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") + .WithTags("admin") + .WithName("GetAdminEventDetails") + .WithOpenApi(operation => + { + operation.Summary = "Gets event details from the given event ID"; + operation.Description = "This endpoint gets the event details from the given event ID."; + + return operation; + }); + + return builder; + } +} diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs index be5b3e79..a5c6d5d2 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs @@ -14,4 +14,4 @@ public static class EndpointUrls /// Declares the chat completions endpoint. /// public const string ChatCompletions = "/openai/deployments/{deploymentName}/chat/completions"; -} +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs new file mode 100644 index 00000000..19a20eed --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs @@ -0,0 +1,77 @@ +namespace AzureOpenAIProxy.ApiApp.Models; + +/// +/// This represent the event detail data for response by admin event endpoint. +/// +public class AdminEventDetails +{ + /// + /// Gets or sets the event id. + /// + public required string? EventId { get; set; } + + /// + /// Gets or sets the event title name. + /// + public required string? Title { get; set; } + + /// + /// Gets or sets the event summary. + /// + public required string? Summary { get; set; } + + /// + /// Gets or sets the event description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the event start date. + /// + public required DateTimeOffset? DateStart { get; set; } + + /// + /// Gets or sets the event end date. + /// + public required DateTimeOffset? DateEnd { get; set; } + + /// + /// Gets or sets the event start to end date timezone. + /// + public required string? TimeZone { get; set; } + + /// + /// Gets or sets the event active status. + /// + public required bool? IsActive { get; set; } + + /// + /// Gets or sets the event organizer name. + /// + public required string? OrganizerName { get; set; } + + /// + /// Gets or sets the event organizer email. + /// + public required string? OrganizerEmail { get; set; } + + /// + /// Gets or sets the event coorganizer name. + /// + public string? CoorganizerName { get; set; } + + /// + /// Gets or sets the event coorganizer email. + /// + public string? CoorganizerEmail { get; set; } + + /// + /// Gets or sets the Azure OpenAI Service request max token capacity. + /// + public required int? MaxTokenCap { get; set; } + + /// + /// Gets or sets the Azure OpenAI Service daily request capacity. + /// + public required int? DailyRequestCap { get; set; } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index 39464608..2331dc75 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -39,4 +39,7 @@ app.AddWeatherForecast(); app.AddChatCompletions(); +// Admin Endpoints +app.AddAdminEvents(); + await app.RunAsync(); diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs new file mode 100644 index 00000000..73c32654 --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs @@ -0,0 +1,288 @@ +using System.Text.Json; + +using FluentAssertions; + +using IdentityModel.Client; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +namespace AzureOpenAIProxy.AppHost.Tests.ApiApp.Endpoints; + +public class AdminGetEventDetailsOpenApiTests(AspireAppHostFixture host) : IClassFixture +{ + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Path() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .TryGetProperty("/admin/events/{eventId}", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Verb() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .TryGetProperty("get", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("admin")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Tags(string tag) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .GetProperty("get") + .TryGetProperty("tags", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Array); + result.EnumerateArray().Select(p => p.GetString()).Should().Contain(tag); + } + + [Theory] + [InlineData("summary")] + [InlineData("description")] + [InlineData("operationId")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Value(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .GetProperty("get") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.String); + } + + [Theory] + [InlineData("parameters")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Array(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .GetProperty("get") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Array); + } + + [Theory] + [InlineData("eventId")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Path_Parameter(string name) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .GetProperty("get") + .GetProperty("parameters") + .EnumerateArray() + .Where(p => p.GetProperty("in").GetString() == "path") + .Select(p => p.GetProperty("name").ToString()); + result.Should().Contain(name); + } + + [Theory] + [InlineData("responses")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Object(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .GetProperty("get") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("200")] + [InlineData("401")] + [InlineData("500")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Response(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/events/{eventId}") + .GetProperty("get") + .GetProperty("responses") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + public static IEnumerable AttributeData => + [ + ["eventId", true, "string"], + ["title", true, "string"], + ["summary", true, "string"], + ["description", false, "string"], + ["dateStart", true, "string"], + ["dateEnd", true, "string"], + ["timeZone", true, "string"], + ["isActive", true, "boolean"], + ["organizerName", true, "string"], + ["organizerEmail", true, "string"], + ["coorganizerName", false, "string"], + ["coorganizerEmail", false, "string"], + ["maxTokenCap", true, "integer"], + ["dailyRequestCap", true, "integer"] + ]; + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Schemas() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .TryGetProperty("schemas", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Model() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .TryGetProperty("AdminEventDetails", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [MemberData(nameof(AttributeData))] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required + (string attribute, bool isRequired, string type) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + var isReq = isRequired; + var typeStr = type; + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminEventDetails") + .TryGetStringArray("required") + .ToList(); + result.Contains(attribute).Should().Be(isRequired); + } + + [Theory] + [MemberData(nameof(AttributeData))] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property + (string attribute, bool isRequired, string type) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + var isReq = isRequired; + var typeStr = type; + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminEventDetails") + .GetProperty("properties") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [MemberData(nameof(AttributeData))] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type + (string attribute, bool isRequired, string type) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + var isReq = isRequired; + var typeStr = type; + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminEventDetails") + .GetProperty("properties") + .GetProperty(attribute); + result.TryGetString("type").Should().Be(type); + } +} diff --git a/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs index 880bf4d7..50e1a11a 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs @@ -1,10 +1,12 @@ using System.Net; +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + using FluentAssertions; namespace AzureOpenAIProxy.Tests; -public class AppHostProgramTests +public class AppHostProgramTests(AspireAppHostFixture host) : IClassFixture { [Theory] [InlineData("apiapp", "/health", HttpStatusCode.OK)] @@ -12,12 +14,9 @@ public class AppHostProgramTests public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Healthy(string resourceName, string endpoint, HttpStatusCode statusCode) { // Arrange - var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); - await using var app = await appHost.BuildAsync(); - await app.StartAsync(); + using var httpClient = host.App!.CreateHttpClient(resourceName); // Act - var httpClient = app.CreateHttpClient(resourceName); var response = await httpClient.GetAsync(endpoint); // Assert diff --git a/test/AzureOpenAIProxy.AppHost.Tests/Fixtures/AspireAppHostFixture.cs b/test/AzureOpenAIProxy.AppHost.Tests/Fixtures/AspireAppHostFixture.cs new file mode 100644 index 00000000..668bb116 --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/Fixtures/AspireAppHostFixture.cs @@ -0,0 +1,27 @@ +namespace AzureOpenAIProxy.AppHost.Tests.Fixtures; + +/// +/// This class instance is automatically created before run test. +/// To use, inherit IClassFixture and inject on constructor. +/// +public class AspireAppHostFixture : IAsyncLifetime +{ + public DistributedApplication? App { get; private set; } + + public async Task InitializeAsync() + { + var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); + this.App = await appHost.BuildAsync(); + await App.StartAsync(); + + Assert.NotNull(App); + } + + public async Task DisposeAsync() + { + if (App == null) return; + + await App.StopAsync(); + await App.DisposeAsync(); + } +}