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 # SeaweedFS (S3) Configuration
# ====================== # ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage # Credentials and default bucket for the local SeaweedFS S3-compatible storage
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_ACCESS_KEY_ID=admin SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -19,7 +19,10 @@ public class UploadPhotoHandlerTests
_handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object); _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(); Mock<IFormFile> file = new();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content); byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
@@ -38,16 +41,21 @@ public class UploadPhotoHandlerTests
string? uploadedKey = null; string? uploadedKey = null;
string? uploadedContentType = null; string? uploadedContentType = null;
_fileStorageProviderMock _fileStorageProviderMock
.Setup(p => p.UploadAsync( .Setup(p =>
It.IsAny<string>(), p.UploadAsync(
It.IsAny<Stream>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<Stream>(),
It.IsAny<CancellationToken>())) It.IsAny<string>(),
.Callback<string, Stream, string, CancellationToken>((key, _, contentType, _) => It.IsAny<CancellationToken>()
{ )
uploadedKey = key; )
uploadedContentType = contentType; .Callback<string, Stream, string, CancellationToken>(
}) (key, _, contentType, _) =>
{
uploadedKey = key;
uploadedContentType = contentType;
}
)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
Photo? persisted = null; Photo? persisted = null;
@@ -68,9 +76,19 @@ public class UploadPhotoHandlerTests
persisted.UploadedById.Should().Be(command.UploadedById); persisted.UploadedById.Should().Be(command.UploadedById);
_fileStorageProviderMock.Verify( _fileStorageProviderMock.Verify(
p => p.UploadAsync(uploadedKey!, It.IsAny<Stream>(), "image/png", It.IsAny<CancellationToken>()), p =>
Times.Once); p.UploadAsync(
_repoMock.Verify(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()), Times.Once); 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] [Fact]
@@ -80,16 +98,22 @@ public class UploadPhotoHandlerTests
UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object); UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
_fileStorageProviderMock _fileStorageProviderMock
.Setup(p => p.UploadAsync( .Setup(p =>
It.IsAny<string>(), p.UploadAsync(
It.IsAny<Stream>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<Stream>(),
It.IsAny<CancellationToken>())) It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.ThrowsAsync(new InvalidOperationException("storage unavailable")); .ThrowsAsync(new InvalidOperationException("storage unavailable"));
Func<Task> act = () => _handler.Handle(command, CancellationToken.None); Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>(); 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; namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Handles <see cref="UploadPhotoCommand" />.</summary> /// <summary>Handles <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhotoUploadRepository photoUploadRepository) public class UploadPhotoHandler(
: IRequestHandler<UploadPhotoCommand, Guid> IFileStorageProvider fileStorageProvider,
IPhotoUploadRepository photoUploadRepository
) : IRequestHandler<UploadPhotoCommand, Guid>
{ {
public async Task<Guid> Handle(UploadPhotoCommand request, CancellationToken cancellationToken) 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 using Stream stream = request.File.OpenReadStream();
await fileStorageProvider.UploadAsync( await fileStorageProvider.UploadAsync(
key, key,
stream, stream,
@@ -24,16 +25,9 @@ public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhoto
cancellationToken cancellationToken
); );
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById };
Photo photo = new() await photoUploadRepository.CreateAsync(photo, cancellationToken);
{
Hyperlink = key,
UploadedById = request.UploadedById,
};
await photoUploadRepository.CreateAsync(photo,cancellationToken);
return photoId; return photoId;
} }

View File

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

View File

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

View File

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

View File

@@ -80,6 +80,8 @@ services:
depends_on: depends_on:
database.seed: database.seed:
condition: service_completed_successfully condition: service_completed_successfully
seaweedfs:
condition: service_started
build: build:
context: ./backend context: ./backend
dockerfile: API/API.Core/Dockerfile dockerfile: API/API.Core/Dockerfile
@@ -101,6 +103,10 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" 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 restart: unless-stopped
networks: networks:
- devnet - devnet