Uploading returns a locator that is opaque to the domain: it is persisted as {@code File.uri} + * and handed back to the very same adapter to read or delete the content. Each adapter picks its + * own format — an absolute path for local storage, an object key for S3 — so the domain never has + * to interpret it. + */ public interface FileStorageService { - byte[] get(String path) throws FileStorageException; - String upload(FileResource fileResource) throws FileStorageException; - void delete(UUID fileId) throws FileStorageException; + byte[] get(String locator) throws FileStorageException; - void deleteByPath(String path) throws FileStorageException; + void delete(String locator) throws FileStorageException; } diff --git a/src/main/java/fr/avenirsesr/portfolio/file/domain/service/FileResourceServiceImpl.java b/src/main/java/fr/avenirsesr/portfolio/file/domain/service/FileResourceServiceImpl.java index f66f0d44b..89ceaf519 100644 --- a/src/main/java/fr/avenirsesr/portfolio/file/domain/service/FileResourceServiceImpl.java +++ b/src/main/java/fr/avenirsesr/portfolio/file/domain/service/FileResourceServiceImpl.java @@ -84,7 +84,7 @@ public FileDownload download(UUID fileId) { @Override public void delete(UUID fileId) { var file = fileRepository.findById(fileId).orElseThrow(FileNotFoundException::new); - fileStorageService.deleteByPath(file.getUri()); + fileStorageService.delete(file.getUri()); fileRepository.removeFromDatabase(file); log.info("File deleted: {}", file); } diff --git a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceImpl.java b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceImpl.java index a692375ee..76350bdf7 100644 --- a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceImpl.java +++ b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceImpl.java @@ -8,14 +8,22 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.util.UUID; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; +/** + * Stores files on the local filesystem. The locator handed back to the domain is the absolute path + * of the written file. + * + *
Default backend: it stays active as long as {@code file.storage.type} is not set to {@code + * s3}. + */ @Slf4j @Component @Primary +@ConditionalOnProperty(name = "file.storage.type", havingValue = "local", matchIfMissing = true) public class FileStorageServiceImpl implements FileStorageService { @Override @@ -39,8 +47,8 @@ public String upload(FileResource fileResource) { } @Override - public byte[] get(String path) { - File file = new File(path); + public byte[] get(String locator) { + File file = new File(locator); if (!file.exists()) { throw new FileNotFoundException(); @@ -49,45 +57,23 @@ public byte[] get(String path) { try { return java.nio.file.Files.readAllBytes(file.toPath()); } catch (IOException e) { - throw new FileStorageException("Failed to read file at path " + path, e); + throw new FileStorageException("Failed to read file at path " + locator, e); } } @Override - public void delete(UUID id) { - String uploadDir = System.getProperty("user.dir") + FileStorageConstants.STORAGE_PATH; - - File dir = new File(uploadDir); - if (!dir.exists()) { - throw new IllegalStateException("File storage directory does not exist"); - } - - File[] matchingFiles = dir.listFiles((d, name) -> name.startsWith(id.toString() + ".")); - if (matchingFiles == null || matchingFiles.length == 0) { - log.error("No file with id {} found", id); - throw new FileNotFoundException(); - } - - File fileToDelete = matchingFiles[0]; - if (!fileToDelete.delete()) { - throw new FileStorageException("Failed to delete file with id " + id, null); - } - log.info("File with id {} has been deleted", id); - } - - @Override - public void deleteByPath(String path) { - File file = new File(path); + public void delete(String locator) { + File file = new File(locator); if (!file.exists()) { - log.error("No file found at path {}", path); + log.error("No file found at path {}", locator); throw new FileNotFoundException(); } if (!file.delete()) { - throw new FileStorageException("Failed to delete file at path " + path, null); + throw new FileStorageException("Failed to delete file at path " + locator, null); } - log.info("File at path {} has been deleted", path); + log.info("File at path {} has been deleted", locator); } } diff --git a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceMock.java b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceMock.java index ab3a8f304..67fd427c6 100644 --- a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceMock.java +++ b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/FileStorageServiceMock.java @@ -9,7 +9,6 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -65,9 +64,9 @@ private boolean ensurePlaceholderExists(String path) { } @Override - public byte[] get(String path) { - log.debug("Mocking get file resource {} return placeholder file", path); - File file = new File(path); + public byte[] get(String locator) { + log.debug("Mocking get file resource {} return placeholder file", locator); + File file = new File(locator); if (!file.exists()) { throw new FileNotFoundException(); @@ -81,12 +80,7 @@ public byte[] get(String path) { } @Override - public void delete(UUID id) { - log.debug("Mocking delete file resource {}", id); - } - - @Override - public void deleteByPath(String path) throws FileStorageException { - log.debug("Mocking delete file resource by path {}", path); + public void delete(String locator) { + log.debug("Mocking delete file resource {}", locator); } } diff --git a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/S3FileStorageService.java b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/S3FileStorageService.java new file mode 100644 index 000000000..9bf8bd728 --- /dev/null +++ b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/S3FileStorageService.java @@ -0,0 +1,84 @@ +package fr.avenirsesr.portfolio.file.infrastructure.adapter.service; + +import fr.avenirsesr.portfolio.file.domain.exception.FileNotFoundException; +import fr.avenirsesr.portfolio.file.domain.exception.FileStorageException; +import fr.avenirsesr.portfolio.file.domain.model.FileResource; +import fr.avenirsesr.portfolio.file.domain.port.output.service.FileStorageService; +import fr.avenirsesr.portfolio.file.infrastructure.configuration.S3StorageProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Stores files in an S3-compatible bucket. The locator handed back to the domain is the object key. + */ +@Slf4j +@Component +@Primary +@RequiredArgsConstructor +@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3") +public class S3FileStorageService implements FileStorageService { + private final S3Client s3Client; + private final S3StorageProperties properties; + + @Override + public String upload(FileResource fileResource) { + var key = fileResource.id() + "." + fileResource.fileType().name().toLowerCase(); + + try { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .contentType(fileResource.fileType().getMimeType()) + .build(), + RequestBody.fromBytes(fileResource.content())); + } catch (S3Exception e) { + throw new FileStorageException("Failed to upload file " + fileResource.fileName(), e); + } + + log.info("File {} has been uploaded as {}", fileResource.fileName(), key); + return key; + } + + @Override + public byte[] get(String locator) { + try { + ResponseBytes> object = + s3Client.getObjectAsBytes( + GetObjectRequest.builder().bucket(properties.getBucket()).key(locator).build()); + return object.asByteArray(); + } catch (NoSuchKeyException e) { + log.error("No object found for key {}", locator); + throw new FileNotFoundException(); + } catch (S3Exception e) { + throw new FileStorageException("Failed to read object with key " + locator, e); + } + } + + /** + * Deletes the object. S3 answers successfully for a key that does not exist, so unlike the local + * adapter this never reports a missing file. + */ + @Override + public void delete(String locator) { + try { + s3Client.deleteObject( + DeleteObjectRequest.builder().bucket(properties.getBucket()).key(locator).build()); + } catch (S3Exception e) { + throw new FileStorageException("Failed to delete object with key " + locator, e); + } + + log.info("Object with key {} has been deleted", locator); + } +} diff --git a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/FileStorageConstants.java b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/FileStorageConstants.java index 68b96cfed..a3691e235 100644 --- a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/FileStorageConstants.java +++ b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/FileStorageConstants.java @@ -45,6 +45,16 @@ public class FileStorageConstants { public static String DEFAULT_PROFILE_FILE_URL; public static String DEFAULT_COVER_FILE_URL; + /** + * Builds the URL serving the content of the given file, handled by {@code StorageController}. + * + *
This is the only supported way to expose a file: {@code File.uri} holds a storage locator
+ * that is meaningful to the storage adapter alone, never to a client.
+ */
+ public static String publicUrlOf(UUID fileId) {
+ return PHOTO_ENDPOINT_PREFIX + "/" + fileId;
+ }
+
@PostConstruct
private void init() {
STORAGE_PATH = storagePath;
diff --git a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/S3ClientConfig.java b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/S3ClientConfig.java
new file mode 100644
index 000000000..dc1150860
--- /dev/null
+++ b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/S3ClientConfig.java
@@ -0,0 +1,32 @@
+package fr.avenirsesr.portfolio.file.infrastructure.configuration;
+
+import java.net.URI;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+
+@Configuration
+@RequiredArgsConstructor
+@EnableConfigurationProperties(S3StorageProperties.class)
+@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3")
+public class S3ClientConfig {
+ private final S3StorageProperties properties;
+
+ @Bean
+ public S3Client s3Client() {
+ return S3Client.builder()
+ .endpointOverride(URI.create(properties.getEndpoint()))
+ .region(Region.of(properties.getRegion()))
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
+ .forcePathStyle(properties.isPathStyleAccess())
+ .build();
+ }
+}
diff --git a/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/S3StorageProperties.java b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/S3StorageProperties.java
new file mode 100644
index 000000000..3720be136
--- /dev/null
+++ b/src/main/java/fr/avenirsesr/portfolio/file/infrastructure/configuration/S3StorageProperties.java
@@ -0,0 +1,35 @@
+package fr.avenirsesr.portfolio.file.infrastructure.configuration;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/** Connection settings of the S3-compatible backend, read when {@code file.storage.type=s3}. */
+@Getter
+@Setter
+@ConfigurationProperties(prefix = "file.storage.s3")
+public class S3StorageProperties {
+
+ /** Base URL of the S3 API, for instance {@code https://s3.example.org}. */
+ private String endpoint;
+
+ /**
+ * Region sent along with the request signature. The AWS SDK refuses to build a client without one
+ * even when the backend ignores it, so this defaults to the region the hosted bucket lives in. A
+ * backend that does validate it reports the value it expects in the error message.
+ */
+ private String region = "eu-west-3";
+
+ /** Bucket holding every file of this environment. */
+ private String bucket;
+
+ private String accessKey;
+
+ private String secretKey;
+
+ /**
+ * Addresses buckets as {@code Unlike the controller integration tests, this one does not extend {@code
+ * ContainerConfigurationTest}: the adapter is instantiated directly, so booting the application
+ * context and seeding the database would only slow the test down without covering anything more.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class S3FileStorageServiceIT {
+
+ private static final String BUCKET = "avenirs-portfolio-it";
+ private static final String REGION = "eu-west-3";
+ private static final String ACCESS_KEY = "avenirs-it-access-key";
+ private static final String SECRET_KEY = "avenirs-it-secret-key";
+ private static final int MINIO_PORT = 9000;
+
+ private GenericContainer> minio;
+ private S3Client s3Client;
+ private S3FileStorageService storageService;
+
+ @BeforeAll
+ void startBackend() {
+ minio =
+ new GenericContainer<>(DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
+ .withEnv("MINIO_ROOT_USER", ACCESS_KEY)
+ .withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
+ .withCommand("server", "/data")
+ .withExposedPorts(MINIO_PORT)
+ .waitingFor(
+ Wait.forHttp("/minio/health/ready")
+ .forPort(MINIO_PORT)
+ .withStartupTimeout(Duration.ofMinutes(2)));
+ minio.start();
+
+ var properties = new S3StorageProperties();
+ properties.setBucket(BUCKET);
+ properties.setRegion(REGION);
+ properties.setAccessKey(ACCESS_KEY);
+ properties.setSecretKey(SECRET_KEY);
+
+ s3Client =
+ S3Client.builder()
+ .endpointOverride(
+ URI.create(
+ "http://%s:%d".formatted(minio.getHost(), minio.getMappedPort(MINIO_PORT))))
+ .region(Region.of(properties.getRegion()))
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
+ .forcePathStyle(true)
+ .build();
+ s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
+
+ storageService = new S3FileStorageService(s3Client, properties);
+ }
+
+ @AfterAll
+ void stopBackend() {
+ if (s3Client != null) {
+ s3Client.close();
+ }
+ if (minio != null) {
+ minio.stop();
+ }
+ }
+
+ @Test
+ void shouldRoundTripAFileThroughTheBucket() {
+ BddLogger.given("a file resource");
+ var id = UUID.randomUUID();
+ byte[] content = "Contenu du fichier de test".getBytes(StandardCharsets.UTF_8);
+ var resource = new FileResource(id, "retour.pdf", EFileType.PDF, content.length, content);
+
+ BddLogger.when("uploading it and reading it back");
+ String locator = storageService.upload(resource);
+ byte[] read = storageService.get(locator);
+
+ BddLogger.then("the stored bytes should be the ones that were uploaded");
+ assertEquals(id + ".pdf", locator);
+ assertArrayEquals(content, read);
+ }
+
+ @Test
+ void shouldReportAMissingKeyAsFileNotFound() {
+ BddLogger.given("a key that was never uploaded");
+ String locator = UUID.randomUUID() + ".pdf";
+
+ BddLogger.when("reading it");
+ BddLogger.then("the service should throw FileNotFoundException");
+ assertThrows(FileNotFoundException.class, () -> storageService.get(locator));
+ }
+
+ @Test
+ void shouldMakeAFileUnreadableOnceDeleted() {
+ BddLogger.given("an uploaded file");
+ var id = UUID.randomUUID();
+ byte[] content = "a supprimer".getBytes(StandardCharsets.UTF_8);
+ String locator =
+ storageService.upload(
+ new FileResource(id, "trace.pdf", EFileType.PDF, content.length, content));
+
+ BddLogger.when("deleting it");
+ storageService.delete(locator);
+
+ BddLogger.then("reading it back should report a missing file");
+ assertThrows(FileNotFoundException.class, () -> storageService.get(locator));
+ }
+
+ @Test
+ void shouldSucceedWhenDeletingAKeyThatDoesNotExist() {
+ BddLogger.given("a key that was never uploaded");
+ String locator = UUID.randomUUID() + ".pdf";
+
+ BddLogger.when("deleting it");
+ BddLogger.then("the backend should report success, unlike the local adapter");
+ assertDoesNotThrow(() -> storageService.delete(locator));
+ }
+}
diff --git a/src/test/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/S3FileStorageServiceTest.java b/src/test/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/S3FileStorageServiceTest.java
new file mode 100644
index 000000000..ffce28074
--- /dev/null
+++ b/src/test/java/fr/avenirsesr/portfolio/file/infrastructure/adapter/service/S3FileStorageServiceTest.java
@@ -0,0 +1,192 @@
+package fr.avenirsesr.portfolio.file.infrastructure.adapter.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import fr.avenirsesr.portfolio.common.testutils.BddLogger;
+import fr.avenirsesr.portfolio.file.domain.exception.FileNotFoundException;
+import fr.avenirsesr.portfolio.file.domain.exception.FileStorageException;
+import fr.avenirsesr.portfolio.file.domain.model.FileResource;
+import fr.avenirsesr.portfolio.file.domain.model.enums.EFileType;
+import fr.avenirsesr.portfolio.file.infrastructure.configuration.S3StorageProperties;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+class S3FileStorageServiceTest {
+
+ private static final String BUCKET = "avenirs-portfolio-test";
+
+ @Mock private S3Client s3Client;
+
+ private S3FileStorageService storageService;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ var properties = new S3StorageProperties();
+ properties.setBucket(BUCKET);
+ storageService = new S3FileStorageService(s3Client, properties);
+ }
+
+ private FileResource aFileResource(UUID id, EFileType fileType) {
+ return new FileResource(
+ id, "consigne.pdf", fileType, 3L, "abc".getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Nested
+ class GivenAnS3StorageService {
+
+ @BeforeEach
+ void setupGiven() {
+ BddLogger.given("an S3 storage service");
+ }
+
+ @Nested
+ class WhenUploadingAFile {
+
+ UUID fileId;
+
+ @BeforeEach
+ void setupWhen() {
+ BddLogger.when("uploading a file");
+ fileId = UUID.randomUUID();
+ }
+
+ @Test
+ void thenItShouldStoreTheObjectUnderTheFileIdKey() {
+ BddLogger.then("the object should be stored in the configured bucket under {id}.{type}");
+ when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+ .thenReturn(PutObjectResponse.builder().build());
+
+ String locator = storageService.upload(aFileResource(fileId, EFileType.PDF));
+
+ assertEquals(fileId + ".pdf", locator);
+
+ ArgumentCaptor