diff --git a/.github/workflows/publish-containers.yml b/.github/workflows/publish-containers.yml index b27800d..7addc4c 100644 --- a/.github/workflows/publish-containers.yml +++ b/.github/workflows/publish-containers.yml @@ -6,7 +6,6 @@ on: env: REGISTRY: ghcr.io - ORGANIZATION: baklanov-soft jobs: publish-containers: @@ -20,9 +19,9 @@ jobs: - linux/arm64/v8 include: - dockerfile: ./src/ImageHosting.Storage.WebApi/Dockerfile - image: ${{ env.REGISTRY }}/${{ env.ORGANIZATION }}/image-hosting-storage-webapi + image: ghcr.io/baklanov-soft/image-hosting-storage-webapi - dockerfile: ./src/ImageHosting.Storage.Tagger/Dockerfile - image: ${{ env.REGISTRY }}/${{ env.ORGANIZATION }}/image-hosting-storage-tagger + image: ghcr.io/baklanov-soft/image-hosting-storage-tagger permissions: contents: read diff --git a/launchSettings.json b/launchSettings.json index 895bb3f..086c2ad 100644 --- a/launchSettings.json +++ b/launchSettings.json @@ -11,7 +11,7 @@ "minio": "StartWithoutDebugging", "postgres": "StartWithoutDebugging", "resizer": "StartWithoutDebugging", - "image-tagger": "StartWithoutDebugging", + "image-tagger": "DoNotStart", "recognizer": "StartWithoutDebugging", "storage-webapi": "DoNotStart" } diff --git a/src/ImageHosting.Storage.Domain/Messages/CategorizedNewImage.cs b/src/ImageHosting.Storage.Domain/Messages/CategorizedNewImage.cs index 3bda9a0..33a11b1 100644 --- a/src/ImageHosting.Storage.Domain/Messages/CategorizedNewImage.cs +++ b/src/ImageHosting.Storage.Domain/Messages/CategorizedNewImage.cs @@ -1,10 +1,9 @@ using System.Text.Json.Serialization; -using ImageHosting.Storage.Domain.ValueTypes; namespace ImageHosting.Storage.Domain.Messages; public class CategorizedNewImage { - [JsonPropertyName("imageId")] public required ImageId ImageId { get; init; } + [JsonPropertyName("image")] public required NewImage Image { get; init; } [JsonPropertyName("categories")] public required Dictionary Categories { get; init; } } \ No newline at end of file diff --git a/src/ImageHosting.Storage.Domain/Messages/NewImage.cs b/src/ImageHosting.Storage.Domain/Messages/NewImage.cs index 04f3965..0fad9db 100644 --- a/src/ImageHosting.Storage.Domain/Messages/NewImage.cs +++ b/src/ImageHosting.Storage.Domain/Messages/NewImage.cs @@ -1,13 +1,11 @@ using ImageHosting.Storage.Domain.ValueTypes; -using System.Text.Json.Serialization; namespace ImageHosting.Storage.Domain.Messages; public class NewImage { - [JsonPropertyName("bucket")] public UserId Bucket { get; set; } - [JsonIgnore] public ImageId ImageId { get; set; } - - [JsonPropertyName("prefix")] public string Prefix => ImageId.ToString(); - [JsonPropertyName("image")] public string ImageName => "original.jpg"; + public required UserId Bucket { get; init; } + public string Prefix => ImageId.ToString(); + public required string Name { get; init; } + public required ImageId ImageId { get; init; } } diff --git a/src/ImageHosting.Storage.Domain/Serialization/NewImageJsonConverter.cs b/src/ImageHosting.Storage.Domain/Serialization/NewImageJsonConverter.cs new file mode 100644 index 0000000..ad8e7c4 --- /dev/null +++ b/src/ImageHosting.Storage.Domain/Serialization/NewImageJsonConverter.cs @@ -0,0 +1,77 @@ +using ImageHosting.Storage.Domain.Messages; +using ImageHosting.Storage.Domain.ValueTypes; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ImageHosting.Storage.Domain.Serialization; + +public class NewImageJsonConverter : JsonConverter +{ + public override NewImage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is not JsonTokenType.StartObject) + throw new JsonException("Expected start of object"); + + UserId? bucket = null; + ImageId? prefix = null; + string? name = null; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndObject) + break; + + if (reader.TokenType is JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); // Move to the value + + switch (propertyName) + { + case "bucket": + bucket = JsonSerializer.Deserialize(ref reader, options); + break; + + case "prefix": + prefix = JsonSerializer.Deserialize(ref reader, options); + break; + + case "name": + name = reader.GetString(); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + if (!bucket.HasValue || name == null || !prefix.HasValue) + { + throw new JsonException("Missing required properties"); + } + + return new NewImage + { + Bucket = bucket.Value, + Name = name, + ImageId = prefix.Value, + }; + } + + public override void Write(Utf8JsonWriter writer, NewImage value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WritePropertyName("bucket"); + JsonSerializer.Serialize(writer, value.Bucket, options); + + writer.WritePropertyName("prefix"); + writer.WriteStringValue(value.Prefix); + + writer.WritePropertyName("name"); + writer.WriteStringValue(value.Name); + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/ImageHosting.Storage.Tagger/AssignTagsConsumer.cs b/src/ImageHosting.Storage.Tagger/AssignTagsConsumer.cs index 86a1ef9..8b5b496 100644 --- a/src/ImageHosting.Storage.Tagger/AssignTagsConsumer.cs +++ b/src/ImageHosting.Storage.Tagger/AssignTagsConsumer.cs @@ -9,7 +9,7 @@ public class AssignTagsConsumer(IAssignTagsService assignTagsService) : IConsume public Task Consume(ConsumeContext> context) { var messages = context.Message - .GroupBy(consumeContext => consumeContext.Message.ImageId, + .GroupBy(consumeContext => consumeContext.Message.Image.ImageId, consumeContext => consumeContext.Message.Categories) .ToDictionary(grouping => grouping.Key, grouping => grouping.SelectMany(categories => categories) diff --git a/src/ImageHosting.Storage.Tagger/Program.cs b/src/ImageHosting.Storage.Tagger/Program.cs index 79e1a50..56c5b66 100644 --- a/src/ImageHosting.Storage.Tagger/Program.cs +++ b/src/ImageHosting.Storage.Tagger/Program.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Diagnostics; using Confluent.Kafka; using ImageHosting.Storage.Domain.Messages; +using ImageHosting.Storage.Domain.Serialization; using ImageHosting.Storage.Infrastructure.Extensions.DependencyInjection; using ImageHosting.Storage.Tagger; using ImageHosting.Storage.Tagger.Options; @@ -15,7 +16,14 @@ builder.Services.AddMassTransit(massTransit => { - massTransit.UsingInMemory(); + massTransit.UsingInMemory((context, configurator) => + { + configurator.ConfigureJsonSerializerOptions(jsonSerializerOptions => + { + jsonSerializerOptions.Converters.Add(new NewImageJsonConverter()); + return jsonSerializerOptions; + }); + }); massTransit.AddRider(rider => { diff --git a/src/ImageHosting.Storage.WebApi/Features/Images/Handlers/UploadFileHandler.cs b/src/ImageHosting.Storage.WebApi/Features/Images/Handlers/UploadFileHandler.cs index ccca30d..c6987a0 100644 --- a/src/ImageHosting.Storage.WebApi/Features/Images/Handlers/UploadFileHandler.cs +++ b/src/ImageHosting.Storage.WebApi/Features/Images/Handlers/UploadFileHandler.cs @@ -27,7 +27,7 @@ public async Task UploadAsync(UserId userId, ImageId imageId, fileUploadCommandFactory.CreateCommand(userId, imageId, formFile.Length, formFile.ContentType, stream); var metadataUploadCommand = metadataUploadCommandFactory.CreateCommand(userId, imageId, formFile.FileName, hidden, uploadedAt); - var publishNewMessageCommand = publishNewMessageCommandFactory.CreateCommand(userId, imageId); + var publishNewMessageCommand = publishNewMessageCommandFactory.CreateCommand(userId, imageId, formFile.FileName); var commands = new RollbackCommands(); commands.Add(fileUploadCommand); diff --git a/src/ImageHosting.Storage.WebApi/Features/Images/Services/PublishNewMessageCommand.cs b/src/ImageHosting.Storage.WebApi/Features/Images/Services/PublishNewMessageCommand.cs index 30e3be3..57176f9 100644 --- a/src/ImageHosting.Storage.WebApi/Features/Images/Services/PublishNewMessageCommand.cs +++ b/src/ImageHosting.Storage.WebApi/Features/Images/Services/PublishNewMessageCommand.cs @@ -7,22 +7,22 @@ namespace ImageHosting.Storage.WebApi.Features.Images.Services; public interface IPublishNewMessageCommandFactory { - IRollbackCommand CreateCommand(UserId userId, ImageId imageId); + IRollbackCommand CreateCommand(UserId userId, ImageId imageId, string imageName); } public class PublishNewMessageCommandFactory(INewImageProducer newImageProducer) : IPublishNewMessageCommandFactory { - public IRollbackCommand CreateCommand(UserId userId, ImageId imageId) + public IRollbackCommand CreateCommand(UserId userId, ImageId imageId, string name) { - return new PublishNewMessageCommand(newImageProducer, userId, imageId); + return new PublishNewMessageCommand(newImageProducer, userId, imageId, name); } } -public class PublishNewMessageCommand(INewImageProducer newImageProducer, UserId userId, ImageId imageId) : IRollbackCommand +public class PublishNewMessageCommand(INewImageProducer newImageProducer, UserId userId, ImageId imageId, string name) : IRollbackCommand { public Task ExecuteAsync(CancellationToken cancellationToken = default) { - return newImageProducer.SendAsync(new NewImage { Bucket = userId, ImageId = imageId }, cancellationToken); + return newImageProducer.SendAsync(new NewImage { Bucket = userId, ImageId = imageId, Name = name }, cancellationToken); } public Task RollbackAsync(CancellationToken cancellationToken = default) diff --git a/src/ImageHosting.Storage.WebApi/Program.cs b/src/ImageHosting.Storage.WebApi/Program.cs index b8cc0c4..a69c8c3 100644 --- a/src/ImageHosting.Storage.WebApi/Program.cs +++ b/src/ImageHosting.Storage.WebApi/Program.cs @@ -4,6 +4,7 @@ using Hellang.Middleware.ProblemDetails.Mvc; using ImageHosting.Storage.Application.Services; using ImageHosting.Storage.Domain.Messages; +using ImageHosting.Storage.Domain.Serialization; using ImageHosting.Storage.Domain.ValueTypes; using ImageHosting.Storage.Infrastructure.DbContexts; using ImageHosting.Storage.Infrastructure.Extensions.DependencyInjection; @@ -58,7 +59,14 @@ builder.Services.AddMassTransit(massTransit => { - massTransit.UsingInMemory(); + massTransit.UsingInMemory((context, configurator) => + { + configurator.ConfigureJsonSerializerOptions(jsonSerializerOptions => + { + jsonSerializerOptions.Converters.Add(new NewImageJsonConverter()); + return jsonSerializerOptions; + }); + }); massTransit.AddRider(rider => { diff --git a/tests/ImageHosting.Storage.UnitTests/Extensions/ObjectExtensions.cs b/tests/ImageHosting.Storage.UnitTests/Extensions/ObjectExtensions.cs new file mode 100644 index 0000000..a0e3168 --- /dev/null +++ b/tests/ImageHosting.Storage.UnitTests/Extensions/ObjectExtensions.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using Xunit.Abstractions; + +namespace ImageHosting.Storage.UnitTests.Extensions; + +public static class ObjectExtensions +{ + public static void PrintProperties(this ITestOutputHelper outputHelper, object? obj) + { + if (obj == null) + { + outputHelper.WriteLine("null"); + return; + } + + var type = obj.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + var value = property.GetValue(obj) ?? "null"; + outputHelper.WriteLine($"{property.Name}: {value}"); + } + } +} diff --git a/tests/ImageHosting.Storage.UnitTests/Serialization/NewImageJsonConverterTests.cs b/tests/ImageHosting.Storage.UnitTests/Serialization/NewImageJsonConverterTests.cs new file mode 100644 index 0000000..2356673 --- /dev/null +++ b/tests/ImageHosting.Storage.UnitTests/Serialization/NewImageJsonConverterTests.cs @@ -0,0 +1,75 @@ +namespace ImageHosting.Storage.UnitTests.Serialization; + +using ImageHosting.Storage.Domain.Messages; +using ImageHosting.Storage.Domain.Serialization; +using ImageHosting.Storage.Domain.ValueTypes; +using ImageHosting.Storage.UnitTests.Extensions; +using System.Text.Json; +using Xunit; +using Xunit.Abstractions; + +public class NewImageJsonConverterTests +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public NewImageJsonConverterTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _jsonSerializerOptions = new JsonSerializerOptions + { + Converters = { new NewImageJsonConverter() }, + WriteIndented = false + }; + } + + [Fact] + public void Serialize_NewImage_returns_correct_JSON() + { + var newImage = new NewImage + { + Bucket = new UserId(Guid.Empty), + ImageId = new ImageId(Guid.Empty), + Name = "image.jpg", + }; + + var json = JsonSerializer.Serialize(newImage, _jsonSerializerOptions); + + _testOutputHelper.WriteLine("JSON string: {0}", json); + json.Should().Contain("\"bucket\":\"00000000-0000-0000-0000-000000000000\""); + json.Should().Contain("\"prefix\":\"00000000-0000-0000-0000-000000000000\""); + json.Should().Contain("\"name\":\"image.jpg\""); + json.Should().NotContain("imageId"); // Ensure ImageId is ignored + } + + [Fact] + public void Deserialize_valid_JSON_returns_NewImage() + { + var json = @"{ + ""bucket"": ""00000000-0000-0000-0000-000000000000"", + ""prefix"": ""00000000-0000-0000-0000-000000000000"", + ""name"": ""image.jpg"" + }"; + + var newImage = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + _testOutputHelper.PrintProperties(newImage); + Assert.NotNull(newImage); + Assert.Equal(new UserId(Guid.Empty), newImage.Bucket); + Assert.Equal("image.jpg", newImage.Name); + Assert.Equal(new ImageId(Guid.Empty), newImage.ImageId); + } + + [Fact] + public void Deserialize_invalid_JSON_throws_JSON_exception() + { + // Missing required prefix field + var invalidJson = @"{ + ""bucket"": ""00000000-0000-0000-0000-000000000000"", + ""image"": ""image.jpg"" + }"; + + _testOutputHelper.WriteLine("JSON string: {0}", invalidJson); + Assert.Throws(() => JsonSerializer.Deserialize(invalidJson, _jsonSerializerOptions)); + } +}