feat(photos): GET /photos, PATCH /photos/:id, POST /photos (upload)
- 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:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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;
|
||||||
|
}
|
||||||
+45
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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> {
|
||||||
|
}
|
||||||
+34
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user