Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ dependencies {
annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0")

// MinIO S3-compatible object storage
implementation("io.minio:minio:8.5.7")

// Tracing (Zipkin)
implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave")
implementation("org.springframework.boot:spring-boot-starter-zipkin")
Expand All @@ -58,6 +61,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.testcontainers:junit-jupiter:1.20.4")
testImplementation("org.testcontainers:postgresql:1.20.4")
testImplementation("org.testcontainers:minio:1.20.4")
testImplementation("io.rest-assured:rest-assured:5.5.0")

testCompileOnly("org.projectlombok:lombok")
Expand Down
6 changes: 6 additions & 0 deletions environment/.local.env
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ POSTGRES_HOST=devoops-postgres
POSGTES_PORT=5432
DB_USERNAME=accommodation-service
DB_PASSWORD=accommodation-service-pass

# MinIO configuration
MINIO_ENDPOINT=http://devoops-minio:9000
MINIO_ACCESS_KEY=devoops
MINIO_SECRET_KEY=devoops123
MINIO_BUCKET=accommodation-photos
27 changes: 27 additions & 0 deletions src/main/java/com/devoops/accommodation/config/MinioConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.devoops.accommodation.config;

import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MinioConfig {

@Value("${minio.endpoint}")
private String endpoint;

@Value("${minio.access-key}")
private String accessKey;

@Value("${minio.secret-key}")
private String secretKey;

@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.devoops.accommodation.controller;

import com.devoops.accommodation.config.RequireRole;
import com.devoops.accommodation.config.UserContext;
import com.devoops.accommodation.dto.response.AccommodationPhotoResponse;
import com.devoops.accommodation.entity.AccommodationPhoto;
import com.devoops.accommodation.service.AccommodationPhotoService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.io.InputStream;
import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/accommodation/{accommodationId}/photos")
@RequiredArgsConstructor
public class AccommodationPhotoController {

private final AccommodationPhotoService photoService;

@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequireRole("HOST")
public ResponseEntity<AccommodationPhotoResponse> uploadPhoto(
@PathVariable UUID accommodationId,
@RequestPart("file") MultipartFile file,
@RequestParam(value = "displayOrder", required = false) Integer displayOrder,
UserContext userContext) {
AccommodationPhotoResponse response = photoService.uploadPhoto(accommodationId, file, displayOrder, userContext);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@GetMapping
public ResponseEntity<List<AccommodationPhotoResponse>> listPhotos(@PathVariable UUID accommodationId) {
List<AccommodationPhotoResponse> photos = photoService.listPhotos(accommodationId);
return ResponseEntity.ok(photos);
}

@GetMapping("/{photoId}")
public ResponseEntity<StreamingResponseBody> getPhoto(
@PathVariable UUID accommodationId,
@PathVariable UUID photoId) {
AccommodationPhoto metadata = photoService.getPhotoMetadata(accommodationId, photoId);
InputStream inputStream = photoService.getPhotoFile(accommodationId, photoId);

StreamingResponseBody responseBody = outputStream -> {
try (inputStream) {
inputStream.transferTo(outputStream);
}
};

return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(metadata.getContentType()))
.contentLength(metadata.getFileSize())
.body(responseBody);
}

@DeleteMapping("/{photoId}")
@RequireRole("HOST")
public ResponseEntity<Void> deletePhoto(
@PathVariable UUID accommodationId,
@PathVariable UUID photoId,
UserContext userContext) {
photoService.deletePhoto(accommodationId, photoId, userContext);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.devoops.accommodation.dto.response;

import java.time.LocalDateTime;
import java.util.UUID;

public record AccommodationPhotoResponse(
UUID id,
UUID accommodationId,
String originalFilename,
String contentType,
Long fileSize,
Integer displayOrder,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.devoops.accommodation.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.SQLRestriction;

import java.util.UUID;

@Entity
@Table(name = "accommodation_photos")
@SQLRestriction("is_deleted = false")
@Getter
@Setter
@NoArgsConstructor
@SuperBuilder
public class AccommodationPhoto extends BaseEntity {

@Column(name = "accommodation_id", nullable = false)
private UUID accommodationId;

@Column(name = "storage_filename", nullable = false, unique = true)
private String storageFilename;

@Column(name = "original_filename", nullable = false)
private String originalFilename;

@Column(name = "content_type", nullable = false)
private String contentType;

@Column(name = "file_size", nullable = false)
private Long fileSize;

@Column(name = "display_order", nullable = false)
private Integer displayOrder;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

import java.util.Map;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -45,4 +46,29 @@ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}

@ExceptionHandler(PhotoNotFoundException.class)
public ProblemDetail handlePhotoNotFound(PhotoNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

@ExceptionHandler(PhotoLimitExceededException.class)
public ProblemDetail handlePhotoLimitExceeded(PhotoLimitExceededException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}

@ExceptionHandler(PhotoStorageException.class)
public ProblemDetail handlePhotoStorage(PhotoStorageException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
}

@ExceptionHandler(InvalidContentTypeException.class)
public ProblemDetail handleInvalidContentType(InvalidContentTypeException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}

@ExceptionHandler(MaxUploadSizeExceededException.class)
public ProblemDetail handleMaxUploadSize(MaxUploadSizeExceededException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the maximum allowed limit");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.accommodation.exception;

public class InvalidContentTypeException extends RuntimeException {
public InvalidContentTypeException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.accommodation.exception;

public class PhotoLimitExceededException extends RuntimeException {
public PhotoLimitExceededException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.accommodation.exception;

public class PhotoNotFoundException extends RuntimeException {
public PhotoNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.devoops.accommodation.exception;

public class PhotoStorageException extends RuntimeException {
public PhotoStorageException(String message) {
super(message);
}

public PhotoStorageException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.devoops.accommodation.mapper;

import com.devoops.accommodation.dto.response.AccommodationPhotoResponse;
import com.devoops.accommodation.entity.AccommodationPhoto;
import org.mapstruct.Mapper;

import java.util.List;

@Mapper(componentModel = "spring")
public interface AccommodationPhotoMapper {

AccommodationPhotoResponse toResponse(AccommodationPhoto photo);

List<AccommodationPhotoResponse> toResponseList(List<AccommodationPhoto> photos);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.devoops.accommodation.repository;

import com.devoops.accommodation.entity.AccommodationPhoto;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface AccommodationPhotoRepository extends JpaRepository<AccommodationPhoto, UUID> {

List<AccommodationPhoto> findByAccommodationIdOrderByDisplayOrderAsc(UUID accommodationId);

Optional<AccommodationPhoto> findByIdAndAccommodationId(UUID id, UUID accommodationId);

long countByAccommodationId(UUID accommodationId);

@Query("SELECT COALESCE(MAX(p.displayOrder), -1) FROM AccommodationPhoto p WHERE p.accommodationId = :accommodationId")
Integer findMaxDisplayOrder(@Param("accommodationId") UUID accommodationId);
}
Loading