Skip to content

Commit

Permalink
feat/LS-33: 이미지 업로드 API 구현하기 (#40)
Browse files Browse the repository at this point in the history
* feat: Ncp Object Storage 연결

* feat: Exnternal API 구축하기

* feat: Space 생성 시 배너 url 추가하기

* clean: 미사용 코드 제거

* fix: 누락된 MemberId 어노테이션 추가
  • Loading branch information
raymondanythings authored Jul 24, 2024
1 parent 6ce1eb1 commit 6e07516
Show file tree
Hide file tree
Showing 19 changed files with 254 additions and 8 deletions.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,11 @@ project(":layer-external") {
implementation project(path: ':layer-common')
implementation project(path: ':layer-domain')

// NCP Object Storage
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'

testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'

}
}
4 changes: 3 additions & 1 deletion layer-api/src/main/java/org/layer/LayerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@OpenAPIDefinition(servers = {@Server(url = "https://api.layerapp.io", description = "운영서버")})
@OpenAPIDefinition(servers = {
@Server(url = "https://api.layerapp.io", description = "운영서버"),
@Server(url = "http://localhost:8080", description = "개발서버")})
@SpringBootApplication
@EnableJpaAuditing
public class LayerApplication {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ private void setHttp(HttpSecurity http) throws Exception {
.requestMatchers(new AntPathRequestMatcher("/api/test")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/auth/test")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/external/image/presigned")).permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.layer.domain.external.api;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.layer.common.annotation.MemberId;
import org.layer.domain.external.dto.ExternalRequest;
import org.layer.domain.external.dto.ExternalResponse;
import org.springframework.http.ResponseEntity;

@Tag(name = "외부 API")
public interface ExternalApi {
@Operation(summary = "Presigned URL 발급받기",
method = "POST", description = """
이미지 업로드를 위한 Presigned URL 발급합니다.
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ExternalResponse.GetPreSignedURLResponse.class)
)
}
)
})
ResponseEntity<ExternalResponse.GetPreSignedURLResponse> getPresignedURL(@MemberId Long memberId, ExternalRequest.GetPreSignedURLRequest getPreSignedURLRequest);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.layer.domain.external.controller;

import lombok.RequiredArgsConstructor;
import org.layer.common.annotation.MemberId;
import org.layer.domain.external.api.ExternalApi;
import org.layer.domain.external.dto.ExternalRequest;
import org.layer.domain.external.dto.ExternalResponse;
import org.layer.external.ncp.service.NcpService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/external")
public class ExternalController implements ExternalApi {

private final NcpService ncpService;

@Override
@GetMapping("/image/presigned")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ExternalResponse.GetPreSignedURLResponse> getPresignedURL(@MemberId Long memberId, ExternalRequest.GetPreSignedURLRequest getPreSignedURLRequest) {
String url = ncpService.getPreSignedUrl(memberId, getPreSignedURLRequest.domain());

return ResponseEntity.ok(ExternalResponse.GetPreSignedURLResponse.toResponse(url));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.layer.domain.external.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import org.layer.external.ncp.enums.ImageDomain;

public class ExternalRequest {

@Schema(description = "Presigned URL 발급받기")
public record GetPreSignedURLRequest(
@Schema(description = "발급받을 이미지의 활용 도메인")
ImageDomain domain
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.layer.domain.external.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import org.layer.external.ncp.exception.ExternalExeption;

import java.util.Optional;

import static org.layer.common.exception.ExternalExceptionType.INTERNAL_SERVER_ERROR;

public class ExternalResponse {

@Builder
@Schema(description = "Presigned URL 응답")
public record GetPreSignedURLResponse(
@Schema(description = "생성된 Presigned URL")
@NotNull
String url
) {
public static GetPreSignedURLResponse toResponse(String url) {
return Optional.ofNullable(url).map(it -> GetPreSignedURLResponse.builder().url(url).build()).orElseThrow(() -> new ExternalExeption(INTERNAL_SERVER_ERROR));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public interface SpaceApi {
)
ResponseEntity<SpaceResponse.SpacePage> getMySpaceList(@MemberId Long memberId, @ModelAttribute @Validated SpaceRequest.GetSpaceRequest getSpaceRequest);

@Operation(summary = "스페이스 생성하기", method = "PUT", description = """
@Operation(summary = "스페이스 생성하기", method = "POST", description = """
스페이스를 생성합니다. <br />
생성 성공 시 아무것도 반환하지 않습니다.
""")
Expand All @@ -52,7 +52,7 @@ public interface SpaceApi {
)
ResponseEntity<Void> createSpace(@MemberId Long memberId, @RequestBody @Validated SpaceRequest.CreateSpaceRequest createSpaceRequest);

@Operation(summary = "스페이스 수정하기", method = "POST", description = """
@Operation(summary = "스페이스 수정하기", method = "PUT", description = """
스페이스를 수정합니다. <br />
생성 성공 시 아무것도 반환하지 않습니다.
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ public ResponseEntity<SpaceResponse.SpacePage> getMySpaceList(@MemberId Long mem
}

@Override
@PutMapping("")
@PostMapping("")
public ResponseEntity<Void> createSpace(@MemberId Long memberId, @RequestBody @Validated SpaceRequest.CreateSpaceRequest createSpaceRequest) {
spaceService.createSpace(memberId, createSpaceRequest);
return ResponseEntity.ok().build();
}

@Override
@PostMapping("")
@PutMapping("")
@ResponseStatus(HttpStatus.ACCEPTED)
public ResponseEntity<Void> updateSpace(@MemberId Long memberId, @RequestBody @Validated SpaceRequest.UpdateSpaceRequest updateSpaceRequest) {
spaceService.updateSpace(memberId, updateSpaceRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public class SpaceRequest {

@Schema(description = "스페이스 생성하기")
public record CreateSpaceRequest(

@Schema(description = "스페이스 이미지 주소")
String bannerUrl,
@Schema(description = "프로젝트 유형 카테고리", example = "INDIVIDUAL")
@NotNull
SpaceCategory category,
Expand All @@ -36,13 +39,16 @@ public Space toEntity(Long memberId) {
.name(name)
.introduction(introduction)
.leaderId(memberId)
.bannerUrl(bannerUrl)
.build();
}
}

@AtLeastNotNull(min = 2)
@Schema(description = "스페이스 수정하기")
public record UpdateSpaceRequest(
@Schema(description = "수정하고자 하는 배너 주소")
String bannerUrl,

@Schema(description = "수정하고자 하는 스페이스 아이디")
@NotNull
Expand All @@ -69,6 +75,7 @@ public Space toEntity(Long memberId) {
.name(name)
.introduction(introduction)
.leaderId(memberId)
.bannerUrl(bannerUrl)
.build();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public void createSpace(Long memberId, SpaceRequest.CreateSpaceRequest mutateSpa
@Transactional
public void updateSpace(Long memberId, SpaceRequest.UpdateSpaceRequest updateSpaceRequest) {
spaceRepository.findByIdAndJoinedMemberId(updateSpaceRequest.id(), memberId).orElseThrow(() -> new SpaceException(SPACE_NOT_FOUND));
spaceRepository.updateSpace(updateSpaceRequest.id(), updateSpaceRequest.category(), updateSpaceRequest.field(), updateSpaceRequest.name(), updateSpaceRequest.introduction());
spaceRepository.updateSpace(updateSpaceRequest.id(), updateSpaceRequest.category(), updateSpaceRequest.field(), updateSpaceRequest.name(), updateSpaceRequest.introduction(), updateSpaceRequest.bannerUrl());
}

public SpaceResponse.SpaceWithMemberCountInfo getSpaceById(Long memberId, Long spaceId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.layer.common.exception;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@RequiredArgsConstructor
public enum ExternalExceptionType implements ExceptionType {
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알수 없는 에러에요.");

private final HttpStatus status;
private final String message;

@Override
public HttpStatus httpStatus() {
return null;
}

@Override
public String message() {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Space extends BaseEntity {

private String bannerUrl;

@NotNull
@Enumerated(EnumType.STRING)
private SpaceCategory category;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ public interface SpaceCustomRepository {

Optional<SpaceWithMemberCount> findByIdAndJoinedMemberId(Long spaceId, Long memberId);

public Long updateSpace(Long spaceId, SpaceCategory category, SpaceField field, String name, String introduction);
Long updateSpace(Long spaceId, SpaceCategory category, SpaceField field, String name, String introduction, String bannerUrl);

}
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ public Optional<SpaceWithMemberCount> findByIdAndJoinedMemberId(Long spaceId, Lo
}

@Override
public Long updateSpace(Long spaceId, SpaceCategory category, SpaceField field, String name, String introduction) {
public Long updateSpace(Long spaceId, SpaceCategory category, SpaceField field, String name, String introduction, String bannerUrl) {
var query = queryFactory.update(space);

// null 값 제거
Optional.ofNullable(category).ifPresent(it -> query.set(space.category, it));
Optional.ofNullable(field).ifPresent(it -> query.set(space.field, it));
Optional.ofNullable(name).ifPresent(it -> query.set(space.name, it));
Optional.ofNullable(introduction).ifPresent(it -> query.set(space.introduction, it));
Optional.ofNullable(bannerUrl).ifPresent(it -> query.set(space.bannerUrl, it));

return query.where(space.id.eq(spaceId)).execute();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.layer.external.ncp.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class NcpConfig {
@Value("${ncp.storage.accessKey}")
private String accessKey;

@Value("${ncp.storage.secretKey}")
private String secretKey;

@Value("${ncp.storage.region}")
private String region;

@Value("${ncp.storage.endpoint}")
private String endPoint;


@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey,
secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endPoint,
region))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.layer.external.ncp.enums;

public enum ImageDomain {
SPACE,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.layer.external.ncp.exception;

import org.layer.common.exception.BaseCustomException;
import org.layer.common.exception.ExceptionType;

public class ExternalExeption extends BaseCustomException {
public ExternalExeption(ExceptionType exceptionType) {
super(exceptionType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.layer.external.ncp.service;

import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.Headers;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.layer.external.ncp.enums.ImageDomain;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.UUID;

@Service
@Slf4j
@RequiredArgsConstructor
public class NcpService {

@Value("${ncp.storage.bucketName}")
private String bucket;

private final AmazonS3Client amazonS3Client;

public String getPreSignedUrl(Long memberId, ImageDomain imageDomain) {
String fileName = imageDomain + "/" + memberId.toString() + "/" + UUID.randomUUID();

GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePreSignedUrlRequest(bucket, fileName);

return amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest).toString();
}

private GeneratePresignedUrlRequest getGeneratePreSignedUrlRequest(String bucket, String fileName) {
GeneratePresignedUrlRequest generatePresignedUrlRequest =
new GeneratePresignedUrlRequest(bucket, fileName)
.withMethod(HttpMethod.PUT)
.withExpiration(getPreSignedUrlExpiration());
generatePresignedUrlRequest.addRequestParameter(
Headers.S3_CANNED_ACL,
CannedAccessControlList.PublicRead.toString());
return generatePresignedUrlRequest;
}

private Date getPreSignedUrlExpiration() {
Date expiration = new Date();
long expTimeMillis = expiration.getTime();
expTimeMillis += 1000 * 60 * 2;
expiration.setTime(expTimeMillis);
return expiration;
}
}

0 comments on commit 6e07516

Please sign in to comment.