diff --git a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj index ffec1a6..7995a27 100644 --- a/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj +++ b/src/AltGen.Console.Tests/AltGen.Console.Tests.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs b/src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs new file mode 100644 index 0000000..bd700f4 --- /dev/null +++ b/src/AltGen.Console.Tests/Unit/AltGenServiceTests.cs @@ -0,0 +1,87 @@ +using System.Net; + +using FluentAssertions; +namespace AltGen.Console.Tests.Unit; + +public class AltGenServiceTests : IDisposable +{ + readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + readonly AltGenService _altGenService; + + public AltGenServiceTests() + { + _altGenService = new AltGenService(_mockHttpMessageHandler.ToHttpClient()); + } + + [Fact] + public async Task GenerateAltTextAsync_WhenCalledAndRequestSucceeds_ItShouldReturnAltText() + { + var req = new GenerateAltTextRequest( + "provider", + "key", + "file.jpg", + [1, 2, 3], + "image/jpeg" + ); + + var altText = "alt text"; + + _mockHttpMessageHandler + .When(HttpMethod.Post, "*/generate") + .Respond( + "application/json", + /*lang=json,strict*/ + $@"{{""AltText"":""{altText}""}}" + ); + + var result = await _altGenService.GenerateAltTextAsync(req); + + result.Should().Be(altText); + } + + [Fact] + public async Task GenerateAltTextAsync_WhenCalledAndRequestFails_ItShouldThrowException() + { + var req = new GenerateAltTextRequest( + "provider", + "key", + "file.jpg", + [1, 2, 3], + "image/jpeg" + ); + + _mockHttpMessageHandler + .When(HttpMethod.Post, "*/generate") + .Respond(HttpStatusCode.InternalServerError); + + var action = async () => await _altGenService.GenerateAltTextAsync(req); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task GenerateAltTextAsync_WhenCalledAndResponseIsNull_ItShouldThrowException() + { + var req = new GenerateAltTextRequest( + "provider", + "key", + "file.jpg", + [1, 2, 3], + "image/jpeg" + ); + + _mockHttpMessageHandler + .When(HttpMethod.Post, "*/generate") + .Respond("application/json", "null"); + + var action = async () => await _altGenService.GenerateAltTextAsync(req); + + await action.Should().ThrowAsync(); + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/AltGen.Console.Tests/UnitTest1.cs b/src/AltGen.Console.Tests/UnitTest1.cs deleted file mode 100644 index 39b4247..0000000 --- a/src/AltGen.Console.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AltGen.Console.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/src/AltGen.Console.Tests/Usings.cs b/src/AltGen.Console.Tests/Usings.cs new file mode 100644 index 0000000..a5fd333 --- /dev/null +++ b/src/AltGen.Console.Tests/Usings.cs @@ -0,0 +1,3 @@ +global using RichardSzalay.MockHttp; + +global using AltGen.Console.Generate; \ No newline at end of file diff --git a/src/AltGen.Console/AltGen.Console.csproj b/src/AltGen.Console/AltGen.Console.csproj index d51b937..f192040 100644 --- a/src/AltGen.Console/AltGen.Console.csproj +++ b/src/AltGen.Console/AltGen.Console.csproj @@ -16,4 +16,34 @@ + + + + + + Generated + $(GeneratedCodeDirectory)\Constants.cs + http://localhost:7297 + + + + + + + + + + + + + diff --git a/src/AltGen.Console/Generate/AltGenService.cs b/src/AltGen.Console/Generate/AltGenService.cs new file mode 100644 index 0000000..9576aee --- /dev/null +++ b/src/AltGen.Console/Generate/AltGenService.cs @@ -0,0 +1,44 @@ +namespace AltGen.Console.Generate; + +sealed class AltGenService(HttpClient client) : IAltGenService +{ + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + readonly HttpClient _client = client; + + public async Task GenerateAltTextAsync(GenerateAltTextRequest req) + { + var byteContent = new ByteArrayContent(req.Image); + byteContent.Headers.ContentType = new MediaTypeHeaderValue(req.ContentType); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{Constants.AltGenApiUri}/generate") + { + Content = new MultipartFormDataContent + { + { new StringContent(req.Provider), "provider" }, + { new StringContent(req.ProviderKey), "providerKey" }, + { new ByteArrayContent(req.Image), "file", req.FileName } + } + }; + + var response = await _client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + throw new AltTextException("Failed to generate alt text."); + } + + var altTextResponse = JsonSerializer.Deserialize(content, JsonOptions) ?? throw new AltTextException("Failed to deserialize response."); + + return altTextResponse.AltText; + } +} + +record AltTextResponse(string AltText); + +class AltTextException(string message) : Exception(message) +{ +} diff --git a/src/AltGen.Console/Generate/GenerateAltTextRequest.cs b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs new file mode 100644 index 0000000..b1f432f --- /dev/null +++ b/src/AltGen.Console/Generate/GenerateAltTextRequest.cs @@ -0,0 +1,9 @@ +namespace AltGen.Console.Generate; + +record GenerateAltTextRequest( + string Provider, + string ProviderKey, + string FileName, + byte[] Image, + string ContentType +); diff --git a/src/AltGen.Console/Generate/GenerateCommand.cs b/src/AltGen.Console/Generate/GenerateCommand.cs new file mode 100644 index 0000000..d4b7259 --- /dev/null +++ b/src/AltGen.Console/Generate/GenerateCommand.cs @@ -0,0 +1,88 @@ +namespace AltGen.Console.Generate; + +sealed class GenerateCommand( + IAnsiConsole console, + IFileSystem fileSystem, + IAltGenService altGenService +) : AsyncCommand +{ + + readonly IAnsiConsole _console = console; + readonly IFileSystem _fileSystem = fileSystem; + readonly IAltGenService _altGenService = altGenService; + + public class Settings(IFileSystem fileSystem) : CommandSettings + { + readonly Dictionary _imageTypes = new() + { + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".png"] = "image/png" + }; + + readonly List _providers = [ + "gemini", + ]; + + readonly IFileSystem _fileSystem = fileSystem; + + [CommandArgument(1, "")] + [Description("The provider to use for generating alt text.")] + public string Provider { get; init; } = string.Empty; + + [CommandArgument(2, "")] + [Description("The key for the provider.")] + public string Key { get; init; } = string.Empty; + + [CommandArgument(3, "")] + [Description("The path to the image to generate alt text for.")] + public string Path { get; init; } = string.Empty; + + public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)]; + + public override ValidationResult Validate() + { + // TODO: We should allow provider and key to be optional + // if they are not passed on the command line then + // we should try to resolve them from configuration + if (_providers.Contains(Provider) is false) + { + return ValidationResult.Error($"The provider '{Provider}' is not supported."); + } + + var pathExists = _fileSystem.File.Exists(Path); + + if (pathExists is false) + { + return ValidationResult.Error($"The path '{Path}' does not exist."); + } + + var extension = _fileSystem.Path.GetExtension(Path); + + if (_imageTypes.ContainsKey(extension) is false) + { + return ValidationResult.Error($"The file '{Path}' is not a valid image file."); + } + + return ValidationResult.Success(); + } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + var fileName = _fileSystem.Path.GetFileName(settings.Path); + var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path); + + var altText = await _altGenService.GenerateAltTextAsync(new( + settings.Provider, + settings.Key, + fileName, + image, + settings.ContentType + )); + + _console.MarkupLine($"[bold]{altText}[/]"); + + return 0; + } +} \ No newline at end of file diff --git a/src/AltGen.Console/Generate/IAltGenService.cs b/src/AltGen.Console/Generate/IAltGenService.cs new file mode 100644 index 0000000..1b2b14b --- /dev/null +++ b/src/AltGen.Console/Generate/IAltGenService.cs @@ -0,0 +1,4 @@ +interface IAltGenService +{ + Task GenerateAltTextAsync(GenerateAltTextRequest req); +} diff --git a/src/AltGen.Console/Generated/Constants.cs b/src/AltGen.Console/Generated/Constants.cs new file mode 100644 index 0000000..c9ed236 --- /dev/null +++ b/src/AltGen.Console/Generated/Constants.cs @@ -0,0 +1,6 @@ +namespace AltGen.Console.Generated; + +static class Constants +{ + public const string AltGenApiUri = "http://localhost:7297"; +} diff --git a/src/AltGen.Console/Program.cs b/src/AltGen.Console/Program.cs index e31f069..ac7c446 100644 --- a/src/AltGen.Console/Program.cs +++ b/src/AltGen.Console/Program.cs @@ -1,18 +1,17 @@ -// TODO: We want to be able -// to provide a path to an image -// read the image -// and then post the image to our API -// get the response and display the alt text - -using System.ComponentModel; -using System.IO.Abstractions; -using System.Net.Http.Headers; - -using AltGen.Console.Common; - -using Spectre.Console; +// TODO: Need to implement config +// command. This should allow +// the user to set default values +// for provider keys and a default +// provider. +// +// For example setting a providers key +// i.e. altgen config gemini mykey +// +// Or setting provider as default +// i.e. altgen config gemini mykey --default await Host.CreateDefaultBuilder(args) + .ConfigureLogging(static l => l.ClearProviders()) .ConfigureServices(static (_, services) => { services.AddSingleton(); @@ -22,141 +21,3 @@ await Host.CreateDefaultBuilder(args) }) .BuildApp() .RunAsync(args); - -sealed class GenerateCommand( - IAnsiConsole console, - IFileSystem fileSystem, - IAltGenService altGenService -) : AsyncCommand -{ - - readonly IAnsiConsole _console = console; - readonly IFileSystem _fileSystem = fileSystem; - readonly IAltGenService _altGenService = altGenService; - - public class Settings(IFileSystem fileSystem) : CommandSettings - { - readonly Dictionary _imageTypes = new() - { - [".jpeg"] = "image/jpeg", - [".jpg"] = "image/jpeg", - [".png"] = "image/png" - }; - - readonly List _providers = [ - "gemini", - ]; - - readonly IFileSystem _fileSystem = fileSystem; - - [CommandArgument(1, "")] - [Description("The provider to use for generating alt text.")] - public string Provider { get; init; } = string.Empty; - - [CommandArgument(2, "")] - [Description("The key for the provider.")] - public string Key { get; init; } = string.Empty; - - [CommandArgument(3, "")] - [Description("The path to the image to generate alt text for.")] - public string Path { get; init; } = string.Empty; - - public string ContentType => _imageTypes[_fileSystem.Path.GetExtension(Path)]; - - public override ValidationResult Validate() - { - if (_providers.Contains(Provider) is false) - { - return ValidationResult.Error($"The provider '{Provider}' is not supported."); - } - - var pathExists = _fileSystem.File.Exists(Path); - - if (pathExists is false) - { - return ValidationResult.Error($"The path '{Path}' does not exist."); - } - - var extension = _fileSystem.Path.GetExtension(Path); - - if (_imageTypes.ContainsKey(extension) is false) - { - return ValidationResult.Error($"The file '{Path}' is not a valid image file."); - } - - return ValidationResult.Success(); - } - } - - public override async Task ExecuteAsync(CommandContext context, Settings settings) - { - var fileName = _fileSystem.Path.GetFileName(settings.Path); - var image = await _fileSystem.File.ReadAllBytesAsync(settings.Path); - - var altText = await _altGenService.GenerateAltTextAsync( - settings.Provider, - settings.Key, - fileName, - image, - settings.ContentType - ); - - _console.MarkupLine($"[bold]Alt Text:[/] {altText}"); - - return 0; - } -} - -interface IAltGenService -{ - Task GenerateAltTextAsync( - string provider, - string key, - string fileName, - byte[] image, - string contentType - ); -} - -sealed class AltGenService(HttpClient client) : IAltGenService -{ - readonly HttpClient _client = client; - - public async Task GenerateAltTextAsync( - string provider, - string key, - string fileName, - byte[] image, - string contentType - ) - { - var byteContent = new ByteArrayContent(image); - byteContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); - - // TODO: Need to have proper URL - // have constants generated at build time - // that point to proper API URL - var request = new HttpRequestMessage(HttpMethod.Post, "altgen") - { - Content = new MultipartFormDataContent - { - { new StringContent(provider), "provider" }, - { new StringContent(key), "providerKey" }, - { new ByteArrayContent(image), "file", fileName } - } - }; - - var response = await _client.SendAsync(request); - - if (response.IsSuccessStatusCode is false) - { - // TODO: Use accurate exception - throw new InvalidDataException("Failed to generate alt text."); - } - - // TODO: Deserialize the response - var altText = await response.Content.ReadAsStringAsync(); - - return altText; - } -} \ No newline at end of file diff --git a/src/AltGen.Console/Usings.cs b/src/AltGen.Console/Usings.cs index fdd2bfc..8dab214 100644 --- a/src/AltGen.Console/Usings.cs +++ b/src/AltGen.Console/Usings.cs @@ -1,4 +1,15 @@ +global using System.ComponentModel; +global using System.IO.Abstractions; +global using System.Net.Http.Headers; +global using System.Text.Json; + +global using AltGen.Console.Common; +global using AltGen.Console.Generated; +global using AltGen.Console.Generate; + global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Spectre.Console; global using Spectre.Console.Cli;