feat(photos): GET /photos, PATCH /photos/:id, POST /photos (upload)
CI — Tests & Docker Build / Tests (push) Failing after 19m22s
CI — Tests & Docker Build / Build & push image Docker (push) Has been skipped

- Domaine : Photo avec statuts todo/ok/flag, valDate automatique au passage ok
- Ports in : GetPhotos, UpdatePhoto, UploadPhotos
- Ports out : PhotoRepository, PhotoStoragePort
- Infrastructure : PhotoEntity, mapper, adapter JPA, LocalPhotoStorage
- Controller : liste, patch, upload multipart, endpoint image
- Migration V3 : table photos
- Tests unitaires UpdatePhotoService
- application.yml : multipart 50MB, photos.storage-path

Closes #2, #4, #5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 09:16:55 +02:00
parent 6c8c01a90e
commit 647f10ff8a
24 changed files with 729 additions and 0 deletions
@@ -0,0 +1,9 @@
package com.olhar.olharapi.application.port.in;
import com.olhar.olharapi.domain.model.Photo;
import java.util.List;
public interface GetPhotosUseCase {
List<Photo> getAll();
}
@@ -0,0 +1,21 @@
package com.olhar.olharapi.application.port.in;
import com.olhar.olharapi.domain.model.Photo;
import java.util.List;
import java.util.UUID;
public interface UpdatePhotoUseCase {
Photo update(UUID id, Command command);
record Command(
String title,
String date,
String place,
List<String> people,
String comment,
String status,
String flagReason,
String flagBy
) {}
}
@@ -0,0 +1,12 @@
package com.olhar.olharapi.application.port.in;
import com.olhar.olharapi.domain.model.Photo;
import java.io.InputStream;
import java.util.List;
public interface UploadPhotosUseCase {
List<Photo> upload(List<FileEntry> files, String uploadedBy);
record FileEntry(String originalFilename, String contentType, InputStream inputStream) {}
}
@@ -0,0 +1,13 @@
package com.olhar.olharapi.application.port.out;
import com.olhar.olharapi.domain.model.Photo;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PhotoRepository {
List<Photo> findAll();
Optional<Photo> findById(UUID id);
Photo save(Photo photo);
}
@@ -0,0 +1,9 @@
package com.olhar.olharapi.application.port.out;
import java.io.InputStream;
import java.util.UUID;
public interface PhotoStoragePort {
String store(UUID photoId, String filename, InputStream data);
InputStream load(UUID photoId);
}
@@ -0,0 +1,21 @@
package com.olhar.olharapi.application.usecase;
import com.olhar.olharapi.application.port.in.GetPhotosUseCase;
import com.olhar.olharapi.application.port.out.PhotoRepository;
import com.olhar.olharapi.domain.model.Photo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class GetPhotosService implements GetPhotosUseCase {
private final PhotoRepository photoRepository;
@Override
public List<Photo> getAll() {
return photoRepository.findAll();
}
}
@@ -0,0 +1,53 @@
package com.olhar.olharapi.application.usecase;
import com.olhar.olharapi.application.port.in.UpdatePhotoUseCase;
import com.olhar.olharapi.application.port.out.PhotoRepository;
import com.olhar.olharapi.domain.exception.PhotoNotFoundException;
import com.olhar.olharapi.domain.model.Photo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class UpdatePhotoService implements UpdatePhotoUseCase {
private final PhotoRepository photoRepository;
@Override
@Transactional
public Photo update(UUID id, Command command) {
Photo existing = photoRepository.findById(id)
.orElseThrow(() -> new PhotoNotFoundException(id));
Photo.Status newStatus = command.status() != null
? Photo.Status.fromApiStatus(command.status())
: existing.status();
Instant valDate = switch (newStatus) {
case OK -> existing.valDate() != null ? existing.valDate() : Instant.now();
default -> null;
};
Photo updated = new Photo(
existing.id(),
command.title() != null ? command.title() : existing.title(),
command.date() != null ? command.date() : existing.date(),
command.place() != null ? command.place() : existing.place(),
command.people() != null ? command.people() : existing.people(),
command.comment() != null ? command.comment() : existing.comment(),
newStatus,
existing.by(),
valDate,
command.flagReason() != null ? command.flagReason() : existing.flagReason(),
command.flagBy() != null ? command.flagBy() : existing.flagBy(),
existing.imageUrl(),
existing.createdAt()
);
return photoRepository.save(updated);
}
}
@@ -0,0 +1,57 @@
package com.olhar.olharapi.application.usecase;
import com.olhar.olharapi.application.port.in.UploadPhotosUseCase;
import com.olhar.olharapi.application.port.out.PhotoRepository;
import com.olhar.olharapi.application.port.out.PhotoStoragePort;
import com.olhar.olharapi.domain.model.Photo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class UploadPhotosService implements UploadPhotosUseCase {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_LOCAL_DATE;
private final PhotoRepository photoRepository;
private final PhotoStoragePort photoStorage;
@Override
@Transactional
public List<Photo> upload(List<FileEntry> files, String uploadedBy) {
return files.stream()
.map(file -> importOne(file, uploadedBy))
.toList();
}
private Photo importOne(FileEntry file, String uploadedBy) {
UUID id = UUID.randomUUID();
String imageUrl = photoStorage.store(id, file.originalFilename(), file.inputStream());
String today = LocalDate.now().format(DATE_FMT);
Photo photo = new Photo(
id,
null,
today,
null,
List.of(),
null,
Photo.Status.TODO,
uploadedBy,
null,
null,
null,
imageUrl,
Instant.now()
);
return photoRepository.save(photo);
}
}
@@ -0,0 +1,9 @@
package com.olhar.olharapi.domain.exception;
import java.util.UUID;
public class PhotoNotFoundException extends RuntimeException {
public PhotoNotFoundException(UUID id) {
super("Photo introuvable : " + id);
}
}
@@ -0,0 +1,33 @@
package com.olhar.olharapi.domain.model;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record Photo(
UUID id,
String title,
String date,
String place,
List<String> people,
String comment,
Status status,
String by,
Instant valDate,
String flagReason,
String flagBy,
String imageUrl,
Instant createdAt
) {
public enum Status {
TODO, OK, FLAG;
public String toApiStatus() {
return name().toLowerCase();
}
public static Status fromApiStatus(String value) {
return valueOf(value.toUpperCase());
}
}
}
@@ -0,0 +1,34 @@
package com.olhar.olharapi.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
return "[]";
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return List.of();
try {
return MAPPER.readValue(dbData, new TypeReference<>() {});
} catch (Exception e) {
return List.of();
}
}
}
@@ -0,0 +1,56 @@
package com.olhar.olharapi.infrastructure.persistence.entity;
import com.olhar.olharapi.infrastructure.persistence.converter.StringListConverter;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "photos")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PhotoEntity {
@Id
private UUID id;
private String title;
private String date;
private String place;
@Convert(converter = StringListConverter.class)
@Column(columnDefinition = "text")
private List<String> people;
@Column(columnDefinition = "text")
private String comment;
@Column(nullable = false)
private String status;
@Column(name = "by_user", nullable = false)
private String by;
@Column(name = "val_date")
private Instant valDate;
@Column(name = "flag_reason")
private String flagReason;
@Column(name = "flag_by")
private String flagBy;
@Column(name = "image_url", nullable = false)
private String imageUrl;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
}
@@ -0,0 +1,45 @@
package com.olhar.olharapi.infrastructure.persistence.mapper;
import com.olhar.olharapi.domain.model.Photo;
import com.olhar.olharapi.infrastructure.persistence.entity.PhotoEntity;
import org.springframework.stereotype.Component;
@Component
public class PhotoPersistenceMapper {
public Photo toDomain(PhotoEntity entity) {
return new Photo(
entity.getId(),
entity.getTitle(),
entity.getDate(),
entity.getPlace(),
entity.getPeople(),
entity.getComment(),
Photo.Status.valueOf(entity.getStatus()),
entity.getBy(),
entity.getValDate(),
entity.getFlagReason(),
entity.getFlagBy(),
entity.getImageUrl(),
entity.getCreatedAt()
);
}
public PhotoEntity toEntity(Photo photo) {
return PhotoEntity.builder()
.id(photo.id())
.title(photo.title())
.date(photo.date())
.place(photo.place())
.people(photo.people())
.comment(photo.comment())
.status(photo.status().name())
.by(photo.by())
.valDate(photo.valDate())
.flagReason(photo.flagReason())
.flagBy(photo.flagBy())
.imageUrl(photo.imageUrl())
.createdAt(photo.createdAt())
.build();
}
}
@@ -0,0 +1,9 @@
package com.olhar.olharapi.infrastructure.persistence.repository;
import com.olhar.olharapi.infrastructure.persistence.entity.PhotoEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface JpaPhotoRepository extends JpaRepository<PhotoEntity, UUID> {
}
@@ -0,0 +1,34 @@
package com.olhar.olharapi.infrastructure.persistence.repository;
import com.olhar.olharapi.application.port.out.PhotoRepository;
import com.olhar.olharapi.domain.model.Photo;
import com.olhar.olharapi.infrastructure.persistence.mapper.PhotoPersistenceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class PhotoRepositoryAdapter implements PhotoRepository {
private final JpaPhotoRepository jpa;
private final PhotoPersistenceMapper mapper;
@Override
public List<Photo> findAll() {
return jpa.findAll().stream().map(mapper::toDomain).toList();
}
@Override
public Optional<Photo> findById(UUID id) {
return jpa.findById(id).map(mapper::toDomain);
}
@Override
public Photo save(Photo photo) {
return mapper.toDomain(jpa.save(mapper.toEntity(photo)));
}
}
@@ -0,0 +1,62 @@
package com.olhar.olharapi.infrastructure.storage;
import com.olhar.olharapi.application.port.out.PhotoStoragePort;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Component
public class LocalPhotoStorage implements PhotoStoragePort {
private final Path storageRoot;
public LocalPhotoStorage(@Value("${photos.storage-path:/app/uploads}") String storagePath) {
this.storageRoot = Path.of(storagePath);
try {
Files.createDirectories(storageRoot);
} catch (IOException e) {
throw new UncheckedIOException("Impossible de créer le répertoire de stockage", e);
}
}
@Override
public String store(UUID photoId, String filename, InputStream data) {
String ext = extractExtension(filename);
Path target = storageRoot.resolve(photoId + ext);
try {
Files.copy(data, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException("Échec de l'écriture du fichier", e);
}
return "/api/v1/photos/" + photoId + "/image";
}
@Override
public InputStream load(UUID photoId) {
try {
return Files.list(storageRoot)
.filter(p -> p.getFileName().toString().startsWith(photoId.toString()))
.findFirst()
.map(p -> {
try { return Files.newInputStream(p); }
catch (IOException e) { throw new UncheckedIOException(e); }
})
.orElseThrow(() -> new IllegalArgumentException("Fichier introuvable : " + photoId));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private String extractExtension(String filename) {
if (filename == null) return ".jpg";
int dot = filename.lastIndexOf('.');
return dot >= 0 ? filename.substring(dot) : ".jpg";
}
}
@@ -1,6 +1,7 @@
package com.olhar.olharapi.interfaces.exception; package com.olhar.olharapi.interfaces.exception;
import com.olhar.olharapi.domain.exception.EmailAlreadyUsedException; import com.olhar.olharapi.domain.exception.EmailAlreadyUsedException;
import com.olhar.olharapi.domain.exception.PhotoNotFoundException;
import com.olhar.olharapi.domain.exception.UserNotFoundException; import com.olhar.olharapi.domain.exception.UserNotFoundException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail; import org.springframework.http.ProblemDetail;
@@ -26,6 +27,11 @@ public class GlobalExceptionHandler {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
} }
@ExceptionHandler(PhotoNotFoundException.class)
public ProblemDetail handlePhotoNotFound(PhotoNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(BadCredentialsException.class) @ExceptionHandler(BadCredentialsException.class)
public ProblemDetail handleBadCredentials(BadCredentialsException ex) { public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
@@ -0,0 +1,82 @@
package com.olhar.olharapi.interfaces.rest.controller;
import com.olhar.olharapi.application.port.in.GetPhotosUseCase;
import com.olhar.olharapi.application.port.in.UpdatePhotoUseCase;
import com.olhar.olharapi.application.port.in.UploadPhotosUseCase;
import com.olhar.olharapi.application.port.out.PhotoStoragePort;
import com.olhar.olharapi.interfaces.rest.dto.request.UpdatePhotoRequest;
import com.olhar.olharapi.interfaces.rest.dto.response.PhotoResponse;
import com.olhar.olharapi.interfaces.rest.mapper.PhotoRestMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/photos")
@RequiredArgsConstructor
@Tag(name = "Photos", description = "Gestion des photos")
public class PhotoController {
private final GetPhotosUseCase getPhotosUseCase;
private final UpdatePhotoUseCase updatePhotoUseCase;
private final UploadPhotosUseCase uploadPhotosUseCase;
private final PhotoStoragePort photoStorage;
private final PhotoRestMapper mapper;
@GetMapping
@Operation(summary = "Lister toutes les photos")
public List<PhotoResponse> getAll() {
return getPhotosUseCase.getAll().stream().map(mapper::toResponse).toList();
}
@PatchMapping("/{id}")
@Operation(summary = "Mettre à jour les métadonnées ou le statut d'une photo")
public PhotoResponse update(@PathVariable UUID id, @RequestBody UpdatePhotoRequest request) {
return mapper.toResponse(updatePhotoUseCase.update(id,
new UpdatePhotoUseCase.Command(
request.title(), request.date(), request.place(), request.people(),
request.comment(), request.status(), request.flagReason(), request.flagBy()
)));
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Importer des photos depuis la galerie")
public List<PhotoResponse> upload(
@RequestParam("files[]") List<MultipartFile> files,
Authentication authentication) throws IOException {
List<UploadPhotosUseCase.FileEntry> entries = files.stream()
.map(f -> {
try {
return new UploadPhotosUseCase.FileEntry(
f.getOriginalFilename(), f.getContentType(), f.getInputStream());
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.toList();
return uploadPhotosUseCase.upload(entries, authentication.getName())
.stream().map(mapper::toResponse).toList();
}
@GetMapping("/{id}/image")
@Operation(summary = "Télécharger l'image d'une photo")
public ResponseEntity<byte[]> getImage(@PathVariable UUID id) throws IOException {
byte[] data = photoStorage.load(id).readAllBytes();
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG)
.body(data);
}
}
@@ -0,0 +1,14 @@
package com.olhar.olharapi.interfaces.rest.dto.request;
import java.util.List;
public record UpdatePhotoRequest(
String title,
String date,
String place,
List<String> people,
String comment,
String status,
String flagReason,
String flagBy
) {}
@@ -0,0 +1,18 @@
package com.olhar.olharapi.interfaces.rest.dto.response;
import java.util.List;
public record PhotoResponse(
String id,
String title,
String date,
String place,
List<String> people,
String comment,
String status,
String by,
String valDate,
String flagReason,
String flagBy,
String imageUrl
) {}
@@ -0,0 +1,36 @@
package com.olhar.olharapi.interfaces.rest.mapper;
import com.olhar.olharapi.domain.model.Photo;
import com.olhar.olharapi.interfaces.rest.dto.response.PhotoResponse;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class PhotoRestMapper {
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_INSTANT;
public PhotoResponse toResponse(Photo photo) {
return new PhotoResponse(
photo.id().toString(),
photo.title(),
photo.date(),
photo.place(),
photo.people(),
photo.comment(),
photo.status().toApiStatus(),
photo.by(),
formatInstant(photo.valDate()),
photo.flagReason(),
photo.flagBy(),
photo.imageUrl()
);
}
private String formatInstant(Instant instant) {
return instant != null ? ISO.format(instant.atOffset(ZoneOffset.UTC)) : null;
}
}
+7
View File
@@ -19,6 +19,10 @@ spring:
server: server:
port: 8080 port: 8080
spring.servlet.multipart:
max-file-size: 50MB
max-request-size: 200MB
springdoc: springdoc:
api-docs: api-docs:
path: /api-docs path: /api-docs
@@ -33,3 +37,6 @@ security:
cors: cors:
allowed-origin: ${CORS_ALLOWED_ORIGIN_PROD:http://localhost:4200} allowed-origin: ${CORS_ALLOWED_ORIGIN_PROD:http://localhost:4200}
photos:
storage-path: ${PHOTOS_STORAGE_PATH:/app/uploads}
@@ -0,0 +1,15 @@
CREATE TABLE photos (
id UUID PRIMARY KEY,
title VARCHAR(255),
date VARCHAR(100),
place VARCHAR(255),
people TEXT,
comment TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'TODO',
by_user VARCHAR(255) NOT NULL,
val_date TIMESTAMPTZ,
flag_reason TEXT,
flag_by VARCHAR(255),
image_url VARCHAR(500) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,74 @@
package com.olhar.olharapi.application.usecase;
import com.olhar.olharapi.application.port.out.PhotoRepository;
import com.olhar.olharapi.application.port.in.UpdatePhotoUseCase;
import com.olhar.olharapi.domain.exception.PhotoNotFoundException;
import com.olhar.olharapi.domain.model.Photo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class UpdatePhotoServiceTest {
private PhotoRepository photoRepository;
private UpdatePhotoService service;
@BeforeEach
void setUp() {
photoRepository = mock(PhotoRepository.class);
service = new UpdatePhotoService(photoRepository);
}
@Test
void update_shouldSetValDate_whenStatusBecomesOk() {
UUID id = UUID.randomUUID();
Photo existing = photoWithStatus(id, Photo.Status.TODO);
when(photoRepository.findById(id)).thenReturn(Optional.of(existing));
when(photoRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Photo result = service.update(id, new UpdatePhotoUseCase.Command(
null, null, null, null, null, "ok", null, null));
assertThat(result.status()).isEqualTo(Photo.Status.OK);
assertThat(result.valDate()).isNotNull();
}
@Test
void update_shouldClearValDate_whenStatusBecomesFlag() {
UUID id = UUID.randomUUID();
Photo existing = photoWithStatus(id, Photo.Status.OK);
when(photoRepository.findById(id)).thenReturn(Optional.of(existing));
when(photoRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Photo result = service.update(id, new UpdatePhotoUseCase.Command(
null, null, null, null, null, "flag", "Bad photo", "gato"));
assertThat(result.status()).isEqualTo(Photo.Status.FLAG);
assertThat(result.valDate()).isNull();
}
@Test
void update_shouldThrow_whenPhotoNotFound() {
UUID id = UUID.randomUUID();
when(photoRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.update(id, new UpdatePhotoUseCase.Command(
null, null, null, null, null, null, null, null)))
.isInstanceOf(PhotoNotFoundException.class);
}
private Photo photoWithStatus(UUID id, Photo.Status status) {
return new Photo(id, "title", "2026-06-07", "Paris", List.of(),
null, status, "gato", null, null, null,
"/api/v1/photos/" + id + "/image", Instant.now());
}
}