-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
문의하기 구글 스프레드시트 API 요청 책임을 클라이언트에서 서버로 변경 #923
Changes from all commits
3c88d2b
6a6a38f
4c36b14
9dcf0f0
c43f3b9
431e92f
3e12905
a6872f3
703dab8
f61eda7
3ff8b59
0f15c67
b543c9a
4eeca8a
1ccfc72
b56f614
060d855
54a6c8f
01d0337
1110c8a
b0c247e
4d7ebd0
978eda2
d8cbf75
da384a8
dbaa316
9136fc8
8344d20
3654b93
38734d1
448eb21
86343e9
e824906
412bc3b
2ab6769
514995e
08634b1
95100a5
948c8be
016bf9f
e01242b
86001cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package codezap.voc.config; | ||
|
||
import java.io.IOException; | ||
import java.net.SocketTimeoutException; | ||
|
||
import org.springframework.http.HttpRequest; | ||
import org.springframework.http.client.ClientHttpRequestExecution; | ||
import org.springframework.http.client.ClientHttpRequestInterceptor; | ||
import org.springframework.http.client.ClientHttpResponse; | ||
|
||
import codezap.global.exception.CodeZapException; | ||
import codezap.global.exception.ErrorCode; | ||
import lombok.extern.slf4j.Slf4j; | ||
|
||
@Slf4j | ||
public class VocClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { | ||
|
||
@Override | ||
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) { | ||
try { | ||
return execution.execute(request, body); | ||
} catch (SocketTimeoutException e) { | ||
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청 시간이 초과되었습니다: " + e.getMessage()); | ||
} catch (IOException e) { | ||
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다: " + e.getMessage()); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package codezap.voc.config; | ||
|
||
import static org.springframework.http.HttpHeaders.CONTENT_TYPE; | ||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; | ||
|
||
import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.http.client.BufferingClientHttpRequestFactory; | ||
import org.springframework.http.client.ClientHttpRequestFactory; | ||
import org.springframework.http.client.SimpleClientHttpRequestFactory; | ||
import org.springframework.web.client.RestClient; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
|
||
@Configuration | ||
@EnableConfigurationProperties(VocProperties.class) | ||
@RequiredArgsConstructor | ||
public class VocConfiguration { | ||
|
||
private final VocProperties properties; | ||
|
||
@Bean | ||
public RestClient.Builder vocRestClientBuilder() { | ||
return RestClient.builder() | ||
.baseUrl(properties.getBaseUrl()) | ||
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) | ||
.defaultStatusHandler(new VocResponseErrorHandler()) | ||
.requestInterceptor(new VocClientHttpRequestInterceptor()) | ||
.requestFactory(requestFactory()); | ||
} | ||
|
||
private ClientHttpRequestFactory requestFactory() { | ||
var httpRequestFactory = new SimpleClientHttpRequestFactory(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제우스 이 PR과 조금 다른 이야기일수도 있긴 한데요! var 변수에 대해 다같이 컨벤션을 논의해보면 좋을 것 같아요. 어떤 변수는 타입 추론이고 어떤 변수는 객체 지정이면 같은 프로젝트 내에서 조금 일관성이 깨질 수도 있을 것 같아요 🥲 |
||
httpRequestFactory.setConnectTimeout(properties.getConnectTimeout()); | ||
httpRequestFactory.setReadTimeout(properties.getReadTimeout()); | ||
jminkkk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return new BufferingClientHttpRequestFactory(httpRequestFactory); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package codezap.voc.config; | ||
|
||
import java.time.Duration; | ||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
||
import lombok.Getter; | ||
import lombok.RequiredArgsConstructor; | ||
|
||
@ConfigurationProperties("voc.http.client") | ||
@RequiredArgsConstructor | ||
@Getter | ||
public class VocProperties { | ||
private final String baseUrl; | ||
private final Duration connectTimeout; | ||
private final Duration readTimeout; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
package codezap.voc.config; | ||
|
||
import java.io.IOException; | ||
|
||
import org.springframework.http.HttpStatusCode; | ||
import org.springframework.http.client.ClientHttpResponse; | ||
import org.springframework.web.client.ResponseErrorHandler; | ||
|
||
import codezap.global.exception.CodeZapException; | ||
import codezap.global.exception.ErrorCode; | ||
import lombok.extern.slf4j.Slf4j; | ||
|
||
@Slf4j | ||
public class VocResponseErrorHandler implements ResponseErrorHandler { | ||
kyum-q marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
@Override | ||
public boolean hasError(ClientHttpResponse response) { | ||
try { | ||
HttpStatusCode statusCode = response.getStatusCode(); | ||
return statusCode.isError(); | ||
} catch (IOException e) { | ||
log.error(e.getMessage(), e); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아무리 생각해도 지금 코드에서 이거 없앨 수 있는 방법이 생각이 안나네요...😭 |
||
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다."); | ||
} | ||
} | ||
|
||
@Override | ||
public void handleError(ClientHttpResponse response) { | ||
try { | ||
HttpStatusCode statusCode = response.getStatusCode(); | ||
log.error("{}", statusCode); | ||
} catch (IOException e) { | ||
log.error(e.getMessage(), e); | ||
} | ||
throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다."); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package codezap.voc.controller; | ||
|
||
import org.springframework.http.HttpStatus; | ||
import org.springframework.http.ResponseEntity; | ||
|
||
import codezap.global.swagger.error.ApiErrorResponse; | ||
import codezap.global.swagger.error.ErrorCase; | ||
import codezap.voc.dto.VocRequest; | ||
import io.swagger.v3.oas.annotations.Operation; | ||
import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||
import io.swagger.v3.oas.annotations.tags.Tag; | ||
|
||
@Tag(name = "문의하기") | ||
public interface SpringDocVocController { | ||
|
||
@Operation(summary = "문의하기") | ||
@ApiResponse(responseCode = "201", description = "VOC 스프레드시트 데이터 추가 성공") | ||
@ApiErrorResponse( | ||
status = HttpStatus.BAD_REQUEST, | ||
instance = "/contact", | ||
errorCases = { | ||
@ErrorCase(description = "문의 내용을 입력하지 않은 경우.", exampleMessage = "문의 내용을 입력해주세요."), | ||
@ErrorCase(description = "문의 내용이 20자 미만인 경우.", exampleMessage = "문의 내용을 20자 이상 입력해주세요."), | ||
@ErrorCase(description = "문의 내용이 10,000 글자를 초과한 경우.", exampleMessage = "문의 내용은 최대 10,000 글자까지 입력할 수 있습니다.") | ||
} | ||
) | ||
ResponseEntity<Void> create(VocRequest request); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package codezap.voc.controller; | ||
|
||
import jakarta.validation.Valid; | ||
|
||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.PostMapping; | ||
import org.springframework.web.bind.annotation.RequestBody; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
import codezap.voc.dto.VocRequest; | ||
import codezap.voc.service.VocService; | ||
import lombok.RequiredArgsConstructor; | ||
|
||
@RestController | ||
@RequiredArgsConstructor | ||
public class VocController implements SpringDocVocController { | ||
|
||
private final VocService vocService; | ||
|
||
@PostMapping("/contact") | ||
public ResponseEntity<Void> create(@Valid @RequestBody VocRequest request) { | ||
vocService.create(request); | ||
return ResponseEntity.ok().build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
package codezap.voc.dto; | ||
|
||
import jakarta.annotation.Nullable; | ||
import jakarta.validation.constraints.Email; | ||
import jakarta.validation.constraints.Min; | ||
import jakarta.validation.constraints.NotEmpty; | ||
import jakarta.validation.constraints.Size; | ||
|
||
import io.swagger.v3.oas.annotations.media.Schema; | ||
|
||
public record VocRequest( | ||
|
||
@Schema(description = "문의 내용", example = "코드잽 정말 잘 사용하고 있어요. 우테코 6기 코드잽 화이팅!") | ||
@NotEmpty(message = "문의 내용은 비어있을 수 없습니다.") | ||
@Size(min = 20, max = 10_000, message = "문의 내용은 최소 20자, 최대 10,000 자 입력할 수 있습니다.") | ||
String message, | ||
|
||
@Nullable | ||
@Schema(description = "이메일(선택)", example = "[email protected]") | ||
@Email(regexp = "[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$", message = "올바른 형식의 이메일 주소여야 합니다.") // Regex from RFC 5322 | ||
jminkkk marked this conversation as resolved.
Show resolved
Hide resolved
jminkkk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
String email, | ||
|
||
@Nullable | ||
@Schema(description = "사용자 db id(선택)", example = "1") | ||
@Min(value = 1, message = "1 이상이어야 합니다.") | ||
Long memberId, | ||
|
||
@Nullable | ||
@Schema(description = "사용자 아이디(선택)", example = "만두") | ||
@Size(max = 255, message = "아이디는 255자 이하로 입력해주세요.") | ||
String name | ||
) { | ||
public VocRequest(String message) { | ||
this(message, null); | ||
} | ||
|
||
public VocRequest(String message, String email) { | ||
this(message, email, null, null); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package codezap.voc.service; | ||
|
||
import org.springframework.stereotype.Service; | ||
import org.springframework.web.client.RestClient; | ||
|
||
import codezap.voc.dto.VocRequest; | ||
|
||
@Service | ||
public class VocService { | ||
|
||
private final RestClient restClient; | ||
|
||
public VocService(RestClient.Builder builder) { | ||
restClient = builder.build(); | ||
} | ||
|
||
public void create(VocRequest request) { | ||
restClient.post() | ||
.body(request) | ||
.retrieve() | ||
.toBodilessEntity(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
voc: | ||
http: | ||
client: | ||
base-url: ${VOC_SPREADSHEET_API_BASE_URL} | ||
connect-timeout: 5s | ||
read-timeout: 5s | ||
Comment on lines
+5
to
+6
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 각각 5, 5초로 설정한 이유가 궁금해요! sheet api 를 사용한 것이 맞다면, 다음 부분을 참고해보면 좋을 것 같아요 :)
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이게 좀 헷갈리는 부분인데, 우리는 Google Sheets API를 사용하지 않아요! 대신 Google Apps Script를 사용해요. Google Apps Script는 Sheets뿐 아니라 Docs와 Slides에서도 개발자가 미리 배포한 자바스크립트를 실행할 수 있게 해줘요. Google Apps Script문서에는 timeout 권장 시간을 명시한 문서는 없고 Google 서비스 할당량에 따른 6분의 실행 제한만 있어요. 그러나 우리의 Apps Script는 매우 간단하고, 제한을 초과할 일이 거의 없어요. 그래서 제 임의로 너무 길지도 너무 짧지도 않게 5초로 설정했어요. 몰리는 어떻게 설정하는 게 좋다고 생각하나요? 좋은 아이디어가 있다면 얼마든지 제안해주세요~! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 음 Read Timeout이 5초면 너무 짧지 않을까 싶었어요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제가 직접 수차례 테스트했을 땐, 1초 이상의 속도가 걸렸전 적은 없어요. 몰리 말대로 일시적 부하가 있다면 응답에 문제가 있을 수 있지만, 구체적으로 어떤 값으로 정할지 모르겠어서 일단 넘어갈게요! 개인적으로는 관리자 기능을 개발하며 스프레드시트 대신 DB를 사용하도록 바뀌어야 한다고 생각해요 😁 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
spring: | ||
profiles: | ||
group: | ||
default: local | ||
dev: db, actuator, swagger, cors | ||
prod: db, actuator, swagger, cors | ||
default: local, voc | ||
dev: db, actuator, swagger, cors, voc | ||
prod: db, actuator, swagger, cors, voc |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package codezap.voc.config; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.junit.jupiter.api.Assertions.assertAll; | ||
|
||
import java.time.Duration; | ||
|
||
import org.junit.jupiter.api.DisplayName; | ||
import org.junit.jupiter.api.Test; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.boot.test.context.SpringBootTest; | ||
|
||
@SpringBootTest(classes = VocConfiguration.class) | ||
class VocPropertiesTest { | ||
|
||
@Autowired | ||
private VocProperties sut; | ||
|
||
@Test | ||
@DisplayName("yml 파일로부터 http client 관련 설정 값을 가져오는지 확인") | ||
void getAllowedOrigins() { | ||
assertAll( | ||
() -> assertThat(sut.getBaseUrl()).isEqualTo("http://localhost:8080"), | ||
() -> assertThat(sut.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5L)), | ||
() -> assertThat(sut.getReadTimeout()).isEqualTo(Duration.ofSeconds(5L)) | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
package codezap.voc.controller; | ||
|
||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | ||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
|
||
import org.junit.jupiter.api.DisplayName; | ||
import org.junit.jupiter.api.Test; | ||
import org.springframework.context.annotation.Import; | ||
import org.springframework.http.MediaType; | ||
|
||
import codezap.global.MockMvcTest; | ||
import codezap.voc.dto.VocRequest; | ||
|
||
@Import(VocController.class) | ||
class VocControllerTest extends MockMvcTest { | ||
|
||
@Test | ||
@DisplayName("문의하기 요청 성공") | ||
void create() throws Exception { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DisplayName 써주세요! |
||
var request = new VocRequest("lorem ipsum dolor sit amet consectetur adipiscing elit", null); | ||
|
||
mvc.perform(post("/contact") | ||
.content(objectMapper.writeValueAsString(request)) | ||
.contentType(MediaType.APPLICATION_JSON)) | ||
.andExpect(status().isOk()); | ||
} | ||
|
||
@Test | ||
@DisplayName("문의하기 요청 실패 : 요청 본문 없음") | ||
void create_error() throws Exception { | ||
mvc.perform(post("/contact")) | ||
.andExpect(status().isBadRequest()); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아래와 같이 return 타입을
RestClient
가 아닌RestClient.Builder
로 한 이유가 있나요??There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확장과 테스트를 위해서 열어 놓았어요.
혹시 제가 모르는 게 있다면 알려주세요~!