diff --git a/backend/.gitignore b/backend/.gitignore index 5de39c9e..cb978bdf 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -39,3 +39,5 @@ out/ .vscode/ /src/main/generated/ + +/src/main/resources/*.json diff --git a/backend/build.gradle b/backend/build.gradle index 3bc15416..44feb95f 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -56,6 +56,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + implementation 'org.springframework.cloud:spring-cloud-gcp-starter:1.2.5.RELEASE' + implementation 'org.springframework.cloud:spring-cloud-gcp-storage:1.2.5.RELEASE' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' diff --git a/backend/src/docs/image.adoc b/backend/src/docs/image.adoc new file mode 100644 index 00000000..3e26f3be --- /dev/null +++ b/backend/src/docs/image.adoc @@ -0,0 +1,9 @@ +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Image +=== 프로필 이미지 업로드 +operation::post upload image[snippets='http-request,http-response'] \ No newline at end of file diff --git a/backend/src/docs/index.adoc b/backend/src/docs/index.adoc index 57b5b5fb..adfcbf50 100644 --- a/backend/src/docs/index.adoc +++ b/backend/src/docs/index.adoc @@ -15,3 +15,4 @@ include::place.adoc[] include::member.adoc[] include::friend.adoc[] include::group.adoc[] +include::image.adoc[] diff --git a/backend/src/main/java/com/twtw/backend/config/properties/PropertiesConfig.java b/backend/src/main/java/com/twtw/backend/config/properties/PropertiesConfig.java index 6d86810b..c3b25f46 100644 --- a/backend/src/main/java/com/twtw/backend/config/properties/PropertiesConfig.java +++ b/backend/src/main/java/com/twtw/backend/config/properties/PropertiesConfig.java @@ -11,6 +11,7 @@ KakaoProperties.class, TmapProperties.class, RabbitMQProperties.class, - RedisProperties.class + RedisProperties.class, + StorageProperties.class }) public class PropertiesConfig {} diff --git a/backend/src/main/java/com/twtw/backend/config/storage/StorageConfig.java b/backend/src/main/java/com/twtw/backend/config/storage/StorageConfig.java new file mode 100644 index 00000000..aed2922a --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/config/storage/StorageConfig.java @@ -0,0 +1,31 @@ +package com.twtw.backend.config.storage; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import com.twtw.backend.global.properties.StorageProperties; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; + +@Configuration +@RequiredArgsConstructor +public class StorageConfig { + + private final StorageProperties storageProperties; + + @Bean + public Storage storage() throws IOException { + final GoogleCredentials googleCredentials = + GoogleCredentials.fromStream( + new ClassPathResource(storageProperties.getCredentialsFile()) + .getInputStream()); + + return StorageOptions.newBuilder().setCredentials(googleCredentials).build().getService(); + } +} diff --git a/backend/src/main/java/com/twtw/backend/domain/friend/controller/advice/FriendControllerAdvice.java b/backend/src/main/java/com/twtw/backend/domain/friend/controller/advice/FriendControllerAdvice.java index ec9df366..c22d9a5b 100644 --- a/backend/src/main/java/com/twtw/backend/domain/friend/controller/advice/FriendControllerAdvice.java +++ b/backend/src/main/java/com/twtw/backend/domain/friend/controller/advice/FriendControllerAdvice.java @@ -3,7 +3,6 @@ import com.twtw.backend.domain.friend.exception.InvalidFriendMemberException; import com.twtw.backend.global.advice.ErrorResponse; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -13,7 +12,6 @@ public class FriendControllerAdvice { @ExceptionHandler(InvalidFriendMemberException.class) public ResponseEntity invalidFriendMember(final InvalidFriendMemberException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } } diff --git a/backend/src/main/java/com/twtw/backend/domain/group/controller/advice/GroupControllerAdvice.java b/backend/src/main/java/com/twtw/backend/domain/group/controller/advice/GroupControllerAdvice.java index ba6bb154..bfea72f0 100644 --- a/backend/src/main/java/com/twtw/backend/domain/group/controller/advice/GroupControllerAdvice.java +++ b/backend/src/main/java/com/twtw/backend/domain/group/controller/advice/GroupControllerAdvice.java @@ -3,7 +3,6 @@ import com.twtw.backend.domain.group.exception.IllegalGroupMemberException; import com.twtw.backend.global.advice.ErrorResponse; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -13,7 +12,6 @@ public class GroupControllerAdvice { @ExceptionHandler(IllegalGroupMemberException.class) public ResponseEntity invalidFriendMember(final IllegalGroupMemberException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } } diff --git a/backend/src/main/java/com/twtw/backend/domain/image/controller/ImageController.java b/backend/src/main/java/com/twtw/backend/domain/image/controller/ImageController.java new file mode 100644 index 00000000..91cf5ee5 --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/domain/image/controller/ImageController.java @@ -0,0 +1,27 @@ +package com.twtw.backend.domain.image.controller; + +import com.twtw.backend.domain.image.dto.ImageResponse; +import com.twtw.backend.domain.image.service.ImageService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("images") +public class ImageController { + + private final ImageService imageService; + + @PostMapping + public ResponseEntity uploadImage(final MultipartFile image) throws IOException { + return ResponseEntity.ok(imageService.uploadImage(image)); + } +} diff --git a/backend/src/main/java/com/twtw/backend/domain/image/controller/advice/ImageControllerAdvice.java b/backend/src/main/java/com/twtw/backend/domain/image/controller/advice/ImageControllerAdvice.java new file mode 100644 index 00000000..311fd9fd --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/domain/image/controller/advice/ImageControllerAdvice.java @@ -0,0 +1,24 @@ +package com.twtw.backend.domain.image.controller.advice; + +import com.twtw.backend.domain.image.exception.InvalidFileTypeException; +import com.twtw.backend.global.advice.ErrorResponse; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.io.IOException; + +@RestControllerAdvice +public class ImageControllerAdvice { + + @ExceptionHandler(InvalidFileTypeException.class) + public ResponseEntity handleInvalidFileType(final InvalidFileTypeException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(IOException.class) + public ResponseEntity io(final IOException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } +} diff --git a/backend/src/main/java/com/twtw/backend/domain/image/dto/ImageResponse.java b/backend/src/main/java/com/twtw/backend/domain/image/dto/ImageResponse.java new file mode 100644 index 00000000..78d7adfe --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/domain/image/dto/ImageResponse.java @@ -0,0 +1,10 @@ +package com.twtw.backend.domain.image.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ImageResponse { + private String imageUrl; +} diff --git a/backend/src/main/java/com/twtw/backend/domain/image/exception/InvalidFileTypeException.java b/backend/src/main/java/com/twtw/backend/domain/image/exception/InvalidFileTypeException.java new file mode 100644 index 00000000..4cdcf242 --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/domain/image/exception/InvalidFileTypeException.java @@ -0,0 +1,10 @@ +package com.twtw.backend.domain.image.exception; + +public class InvalidFileTypeException extends IllegalArgumentException { + + private static final String MESSAGE = "파일 형식이 잘못되었습니다."; + + public InvalidFileTypeException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/twtw/backend/domain/image/service/ImageService.java b/backend/src/main/java/com/twtw/backend/domain/image/service/ImageService.java new file mode 100644 index 00000000..1988f68d --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/domain/image/service/ImageService.java @@ -0,0 +1,60 @@ +package com.twtw.backend.domain.image.service; + +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.twtw.backend.domain.image.dto.ImageResponse; +import com.twtw.backend.domain.image.exception.InvalidFileTypeException; +import com.twtw.backend.domain.member.entity.Member; +import com.twtw.backend.domain.member.service.AuthService; +import com.twtw.backend.global.properties.StorageProperties; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private static final String IMAGE_FILE_TYPE = "image"; + private final AuthService authService; + private final Storage storage; + private final StorageProperties storageProperties; + + public ImageResponse uploadImage(final MultipartFile image) throws IOException { + final Member member = authService.getMemberByJwt(); + final String contentType = image.getContentType(); + validate(contentType); + + final String uuid = UUID.randomUUID().toString(); + upload(image, contentType, uuid); + + final String imagePath = getImageUrl(uuid); + member.updateProfileImage(imagePath); + + return new ImageResponse(imagePath); + } + + private String getImageUrl(final String uuid) { + return storageProperties.getStorageUrl() + uuid; + } + + private void upload(final MultipartFile image, final String contentType, final String uuid) + throws IOException { + storage.create( + BlobInfo.newBuilder(storageProperties.getBucket(), uuid) + .setContentType(contentType) + .build(), + image.getInputStream()); + } + + private void validate(final String contentType) { + if (contentType == null || !contentType.startsWith(IMAGE_FILE_TYPE)) { + throw new InvalidFileTypeException(); + } + } +} diff --git a/backend/src/main/java/com/twtw/backend/domain/member/controller/advice/AuthControllerAdvice.java b/backend/src/main/java/com/twtw/backend/domain/member/controller/advice/AuthControllerAdvice.java index cb70fed8..76bcfc44 100644 --- a/backend/src/main/java/com/twtw/backend/domain/member/controller/advice/AuthControllerAdvice.java +++ b/backend/src/main/java/com/twtw/backend/domain/member/controller/advice/AuthControllerAdvice.java @@ -5,7 +5,6 @@ import com.twtw.backend.domain.member.exception.RefreshTokenValidationException; import com.twtw.backend.global.advice.ErrorResponse; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -16,20 +15,17 @@ public class AuthControllerAdvice { @ExceptionHandler(RefreshTokenInfoMismatchException.class) public ResponseEntity refreshTokenInfoMismatch( final RefreshTokenInfoMismatchException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(RefreshTokenValidationException.class) public ResponseEntity refreshTokenValidation( final RefreshTokenValidationException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(NicknameExistsException.class) public ResponseEntity nicknameExists(final NicknameExistsException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } } diff --git a/backend/src/main/java/com/twtw/backend/domain/member/entity/Member.java b/backend/src/main/java/com/twtw/backend/domain/member/entity/Member.java index 3696c930..99b76e6b 100644 --- a/backend/src/main/java/com/twtw/backend/domain/member/entity/Member.java +++ b/backend/src/main/java/com/twtw/backend/domain/member/entity/Member.java @@ -71,4 +71,8 @@ public void addGroupMember(final GroupMember groupMember) { public boolean hasNoGroupMember() { return this.groupMembers.isEmpty(); } + + public void updateProfileImage(final String profileImage) { + this.profileImage = profileImage; + } } diff --git a/backend/src/main/java/com/twtw/backend/domain/plan/controller/advice/PlanControllerAdvice.java b/backend/src/main/java/com/twtw/backend/domain/plan/controller/advice/PlanControllerAdvice.java index 96b6ad98..67f86032 100644 --- a/backend/src/main/java/com/twtw/backend/domain/plan/controller/advice/PlanControllerAdvice.java +++ b/backend/src/main/java/com/twtw/backend/domain/plan/controller/advice/PlanControllerAdvice.java @@ -4,7 +4,6 @@ import com.twtw.backend.domain.plan.exception.PlanMakerNotExistsException; import com.twtw.backend.global.advice.ErrorResponse; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -15,14 +14,12 @@ public class PlanControllerAdvice { @ExceptionHandler(InvalidPlanMemberException.class) public ResponseEntity refreshTokenInfoMismatch( final InvalidPlanMemberException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(PlanMakerNotExistsException.class) public ResponseEntity refreshTokenInfoMismatch( final PlanMakerNotExistsException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } } diff --git a/backend/src/main/java/com/twtw/backend/global/advice/GlobalErrorAdvice.java b/backend/src/main/java/com/twtw/backend/global/advice/GlobalErrorAdvice.java index 92da3486..50631494 100644 --- a/backend/src/main/java/com/twtw/backend/global/advice/GlobalErrorAdvice.java +++ b/backend/src/main/java/com/twtw/backend/global/advice/GlobalErrorAdvice.java @@ -4,7 +4,6 @@ import com.twtw.backend.global.exception.EntityNotFoundException; import com.twtw.backend.global.exception.WebClientResponseException; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -14,19 +13,16 @@ public class GlobalErrorAdvice { @ExceptionHandler(WebClientResponseException.class) public ResponseEntity webClientResponse(final WebClientResponseException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity entityNotFound(final EntityNotFoundException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(AuthorityException.class) public ResponseEntity authority(final AuthorityException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(e.getMessage())); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } } diff --git a/backend/src/main/java/com/twtw/backend/global/properties/StorageProperties.java b/backend/src/main/java/com/twtw/backend/global/properties/StorageProperties.java new file mode 100644 index 00000000..10e4b4ef --- /dev/null +++ b/backend/src/main/java/com/twtw/backend/global/properties/StorageProperties.java @@ -0,0 +1,15 @@ +package com.twtw.backend.global.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "spring.cloud.gcp.storage") +public class StorageProperties { + private final String bucket; + private final String credentialsFile; + private final String storageUrl; +} diff --git a/backend/src/test/java/com/twtw/backend/domain/image/controller/ImageControllerTest.java b/backend/src/test/java/com/twtw/backend/domain/image/controller/ImageControllerTest.java new file mode 100644 index 00000000..ea8e0266 --- /dev/null +++ b/backend/src/test/java/com/twtw/backend/domain/image/controller/ImageControllerTest.java @@ -0,0 +1,56 @@ +package com.twtw.backend.domain.image.controller; + +import static com.twtw.backend.support.docs.ApiDocsUtils.getDocumentRequest; +import static com.twtw.backend.support.docs.ApiDocsUtils.getDocumentResponse; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.twtw.backend.domain.image.dto.ImageResponse; +import com.twtw.backend.domain.image.service.ImageService; +import com.twtw.backend.support.docs.RestDocsTest; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("ImageController의") +@WebMvcTest(ImageController.class) +class ImageControllerTest extends RestDocsTest { + + @MockBean private ImageService imageService; + + @Test + @DisplayName("이미지 업로드 API가 수행되는가") + void uploadImage() throws Exception { + // given + final ImageResponse expected = + new ImageResponse("https://storage.googleapis.com/bucket-name/some-file-id"); + given(imageService.uploadImage(any())).willReturn(expected); + + // when + final ResultActions perform = + mockMvc.perform( + post("/images") + .contentType(MediaType.MULTIPART_FORM_DATA) + .content(toRequestBody("image를 request시 넣어주세요")) + .header( + "Authorization", + "Bearer wefa3fsdczf32.gaoiuergf92.gb5hsa2jgh")); + + // then + perform.andExpect(status().isOk()).andExpect(jsonPath("$.imageUrl").isString()); + + // docs + perform.andDo(print()) + .andDo(document("post upload image", getDocumentRequest(), getDocumentResponse())); + } +}