Wire SeaweedFS credentials through to the api.core dev container

api.core now waits on seaweedfs and receives its service URL and credentials so the file storage provider can connect.
This commit is contained in:
Aaron Po
2026-08-28 00:33:11 -04:00
parent d2d78b4323
commit 1c2a96c6f2
7 changed files with 64 additions and 53 deletions

View File

@@ -61,6 +61,7 @@ WEBSITE_BASE_URL=http://localhost:3000
# SeaweedFS (S3) Configuration
# ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket

View File

@@ -19,7 +19,10 @@ public class UploadPhotoHandlerTests
_handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
}
private static Mock<IFormFile> ValidFile(string content = "fake-image-bytes", string contentType = "image/png")
private static Mock<IFormFile> ValidFile(
string content = "fake-image-bytes",
string contentType = "image/png"
)
{
Mock<IFormFile> file = new();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
@@ -38,16 +41,21 @@ public class UploadPhotoHandlerTests
string? uploadedKey = null;
string? uploadedContentType = null;
_fileStorageProviderMock
.Setup(p => p.UploadAsync(
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<string, Stream, string, CancellationToken>((key, _, contentType, _) =>
It.IsAny<CancellationToken>()
)
)
.Callback<string, Stream, string, CancellationToken>(
(key, _, contentType, _) =>
{
uploadedKey = key;
uploadedContentType = contentType;
})
}
)
.Returns(Task.CompletedTask);
Photo? persisted = null;
@@ -68,9 +76,19 @@ public class UploadPhotoHandlerTests
persisted.UploadedById.Should().Be(command.UploadedById);
_fileStorageProviderMock.Verify(
p => p.UploadAsync(uploadedKey!, It.IsAny<Stream>(), "image/png", It.IsAny<CancellationToken>()),
Times.Once);
_repoMock.Verify(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()), Times.Once);
p =>
p.UploadAsync(
uploadedKey!,
It.IsAny<Stream>(),
"image/png",
It.IsAny<CancellationToken>()
),
Times.Once
);
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Once
);
}
[Fact]
@@ -80,16 +98,22 @@ public class UploadPhotoHandlerTests
UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
_fileStorageProviderMock
.Setup(p => p.UploadAsync(
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
It.IsAny<CancellationToken>()
)
)
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
_repoMock.Verify(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
}

View File

@@ -6,8 +6,10 @@ using MediatR;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Handles <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhotoUploadRepository photoUploadRepository)
: IRequestHandler<UploadPhotoCommand, Guid>
public class UploadPhotoHandler(
IFileStorageProvider fileStorageProvider,
IPhotoUploadRepository photoUploadRepository
) : IRequestHandler<UploadPhotoCommand, Guid>
{
public async Task<Guid> Handle(UploadPhotoCommand request, CancellationToken cancellationToken)
{
@@ -16,7 +18,6 @@ public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhoto
await using Stream stream = request.File.OpenReadStream();
await fileStorageProvider.UploadAsync(
key,
stream,
@@ -24,17 +25,10 @@ public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhoto
cancellationToken
);
Photo photo = new()
{
Hyperlink = key,
UploadedById = request.UploadedById,
};
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById };
await photoUploadRepository.CreateAsync(photo, cancellationToken);
return photoId;
}
}

View File

@@ -7,18 +7,11 @@ public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
{
public UploadPhotoValidator()
{
RuleFor(x => x.UploadedById)
.NotEmpty()
.WithMessage("UploadedById is required.");
RuleFor(x => x.UploadedById).NotEmpty().WithMessage("UploadedById is required.");
RuleFor(x => x.File)
.NotNull()
.WithMessage("File is required.");
RuleFor(x => x.Key)
.NotEmpty()
.WithMessage("Key is required.");
RuleFor(x => x.File).NotNull().WithMessage("File is required.");
RuleFor(x => x.Key).NotEmpty().WithMessage("Key is required.");
RuleFor(x => x.File.Length)
.GreaterThan(0)
@@ -26,7 +19,9 @@ public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
.WithMessage("File cannot be empty.");
RuleFor(x => x.File.ContentType)
.Must(contentType => contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
.Must(contentType =>
contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
)
.When(x => x.File is not null)
.WithMessage("File must be an image.");
}

View File

@@ -4,7 +4,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.ImageUploads</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -19,6 +18,4 @@
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -17,7 +17,6 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
{
public async Task CreateAsync(Photo photo, CancellationToken cancellationToken)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
@@ -48,11 +47,7 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
INSERT INTO dbo.Photo (Hyperlink, UploadedByID)
VALUES (@Hyperlink, @UploadedById)
""",
new
{
photo.Hyperlink,
photo.UploadedById
},
new { photo.Hyperlink, photo.UploadedById },
transaction
)
);
@@ -65,5 +60,4 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
throw;
}
}
}

View File

@@ -80,6 +80,8 @@ services:
depends_on:
database.seed:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
dockerfile: API/API.Core/Dockerfile
@@ -101,6 +103,10 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: unless-stopped
networks:
- devnet