Skip to content

Commit 0b430b2

Browse files
authored
Merge pull request #7 from SoongSilComputingClub/feat/#3-api-response
[FEAT] API 공통 응답구조 / Swagger 초기 설정
2 parents a36f167 + 839ef6a commit 0b430b2

10 files changed

Lines changed: 302 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ build/
55
!**/src/main/**/build/
66
!**/src/test/**/build/
77

8+
### Security / Local Config ###
9+
application-local.yml
10+
application-local.yaml
11+
812
### STS ###
913
.apt_generated
1014
.classpath

build.gradle

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ def coverageExcludePackages = [
1919
'**/config/**', // Config 클래스 제외
2020
'**/exception/**', // Exception 클래스 제외
2121
'**/dto/**', // DTO 클래스 제외
22+
'**/apipayload/**', // API 공통 응답 및 예외 처리 제외
2223
]
2324

2425
// JaCoCo용 제외 패턴 (클래스 파일)
2526
def jacocoCoverageExcludes = coverageExcludePackages + ['**/*Application.class']
2627

2728
// Sonar용 제외 패턴 (소스 파일)
28-
def sonarCoverageExcludes = coverageExcludePackages.collect { it + '/*.java' } + ['**/Application.java']
29+
def sonarCoverageExcludes = coverageExcludePackages.collect { it + '/*.java' } + ['**/*Application.java']
2930

3031
java {
3132
toolchain {
@@ -59,6 +60,9 @@ dependencies {
5960
runtimeOnly 'com.mysql:mysql-connector-j'
6061
runtimeOnly 'com.h2database:h2'
6162

63+
// Swagger
64+
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
65+
6266
// Lombok
6367
compileOnly 'org.projectlombok:lombok'
6468
annotationProcessor 'org.projectlombok:lombok'
@@ -165,5 +169,6 @@ sonar {
165169
property 'sonar.java.coveragePlugin', 'jacoco'
166170
property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml'
167171
property 'sonar.coverage.exclusions', sonarCoverageExcludes.join(',')
172+
property 'sonar.exclusions', sonarCoverageExcludes.join(',')
168173
}
169174
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.example.ssccwebbe.global.apipayload;
2+
3+
import com.example.ssccwebbe.global.apipayload.code.error.ErrorCode;
4+
import com.example.ssccwebbe.global.apipayload.code.success.CommonSuccessCode;
5+
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
6+
7+
import lombok.AccessLevel;
8+
import lombok.AllArgsConstructor;
9+
import lombok.Getter;
10+
11+
@Getter
12+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
13+
@JsonPropertyOrder({"success", "code", "message", "data"})
14+
public class ApiResponse<T> {
15+
16+
private final boolean success;
17+
private final String code;
18+
private final String message;
19+
private final T data;
20+
21+
public static <T> ApiResponse<T> success(T data) {
22+
return new ApiResponse<>(
23+
true,
24+
CommonSuccessCode.SUCCESS.getCode(),
25+
CommonSuccessCode.SUCCESS.getMessage(),
26+
data);
27+
}
28+
29+
public static ApiResponse<Void> successWithNoData() {
30+
return new ApiResponse<>(
31+
true,
32+
CommonSuccessCode.SUCCESS.getCode(),
33+
CommonSuccessCode.SUCCESS.getMessage(),
34+
null);
35+
}
36+
37+
public static <T> ApiResponse<T> created(T data) {
38+
return new ApiResponse<>(
39+
true,
40+
CommonSuccessCode.CREATED.getCode(),
41+
CommonSuccessCode.CREATED.getMessage(),
42+
data);
43+
}
44+
45+
public static ApiResponse<Void> createdWithNoData() {
46+
return new ApiResponse<>(
47+
true,
48+
CommonSuccessCode.CREATED.getCode(),
49+
CommonSuccessCode.CREATED.getMessage(),
50+
null);
51+
}
52+
53+
public static ApiResponse<?> fail(ErrorCode errorCode) {
54+
return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null);
55+
}
56+
57+
public static ApiResponse<?> fail(ErrorCode errorCode, String message) {
58+
return new ApiResponse<>(false, errorCode.getCode(), message, null);
59+
}
60+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.example.ssccwebbe.global.apipayload.code.error;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
8+
@Getter
9+
@AllArgsConstructor
10+
public enum CommonErrorCode implements ErrorCode {
11+
12+
// COMMON 4XX
13+
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "COMMON400", "파라미터가 올바르지 않습니다."),
14+
INVALID_BODY(HttpStatus.BAD_REQUEST, "COMMON400", "요청 본문이 올바르지 않습니다."),
15+
BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."),
16+
FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),
17+
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "찾을 수 없는 리소스입니다."),
18+
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON405", "허용되지 않는 HTTP Method입니다."),
19+
20+
// COMMON 5XX
21+
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 내부 오류입니다.");
22+
23+
private final HttpStatus httpStatus;
24+
private final String code;
25+
private final String message;
26+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.example.ssccwebbe.global.apipayload.code.error;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
public interface ErrorCode {
6+
HttpStatus getHttpStatus();
7+
8+
String getCode();
9+
10+
String getMessage();
11+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.example.ssccwebbe.global.apipayload.code.success;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
8+
@Getter
9+
@RequiredArgsConstructor
10+
public enum CommonSuccessCode implements SuccessCode {
11+
SUCCESS(HttpStatus.OK, "COMMON200", "요청이 성공적으로 처리되었습니다."),
12+
CREATED(HttpStatus.CREATED, "COMMON201", "리소스가 성공적으로 생성되었습니다.");
13+
14+
private final HttpStatus httpStatus;
15+
private final String code;
16+
private final String message;
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.example.ssccwebbe.global.apipayload.code.success;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
public interface SuccessCode {
6+
HttpStatus getHttpStatus();
7+
8+
String getCode();
9+
10+
String getMessage();
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.example.ssccwebbe.global.apipayload.exception;
2+
3+
import com.example.ssccwebbe.global.apipayload.code.error.ErrorCode;
4+
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
8+
@Getter
9+
@RequiredArgsConstructor
10+
public class GeneralException extends RuntimeException {
11+
private final ErrorCode errorCode;
12+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.example.ssccwebbe.global.apipayload.handler;
2+
3+
import org.springframework.http.HttpHeaders;
4+
import org.springframework.http.HttpStatusCode;
5+
import org.springframework.http.ResponseEntity;
6+
import org.springframework.http.converter.HttpMessageNotReadableException;
7+
import org.springframework.validation.ObjectError;
8+
import org.springframework.web.HttpRequestMethodNotSupportedException;
9+
import org.springframework.web.bind.MethodArgumentNotValidException;
10+
import org.springframework.web.bind.annotation.ExceptionHandler;
11+
import org.springframework.web.bind.annotation.RestController;
12+
import org.springframework.web.bind.annotation.RestControllerAdvice;
13+
import org.springframework.web.context.request.WebRequest;
14+
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
15+
16+
import com.example.ssccwebbe.global.apipayload.ApiResponse;
17+
import com.example.ssccwebbe.global.apipayload.code.error.CommonErrorCode;
18+
import com.example.ssccwebbe.global.apipayload.code.error.ErrorCode;
19+
import com.example.ssccwebbe.global.apipayload.exception.GeneralException;
20+
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
21+
22+
import lombok.extern.slf4j.Slf4j;
23+
24+
@Slf4j
25+
@RestControllerAdvice(annotations = {RestController.class})
26+
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
27+
28+
@ExceptionHandler(GeneralException.class)
29+
public ResponseEntity<Object> handleGeneralException(GeneralException ex) {
30+
ErrorCode errorCode = ex.getErrorCode();
31+
return handleExceptionInternal(errorCode);
32+
}
33+
34+
@ExceptionHandler(IllegalArgumentException.class)
35+
public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException ex) {
36+
log.warn("IllegalArgumentException: {}", ex.getMessage());
37+
ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
38+
return handleExceptionInternal(errorCode, ex.getMessage());
39+
}
40+
41+
@Override
42+
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
43+
HttpRequestMethodNotSupportedException ex,
44+
HttpHeaders headers,
45+
HttpStatusCode status,
46+
WebRequest request) {
47+
log.warn("HttpRequestMethodNotSupportedException: {}", ex.getMessage());
48+
ErrorCode errorCode = CommonErrorCode.METHOD_NOT_ALLOWED;
49+
return handleExceptionInternal(errorCode);
50+
}
51+
52+
@Override
53+
protected ResponseEntity<Object> handleMethodArgumentNotValid(
54+
MethodArgumentNotValidException ex,
55+
HttpHeaders headers,
56+
HttpStatusCode status,
57+
WebRequest request) {
58+
log.warn("MethodArgumentNotValidException");
59+
ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
60+
return handleExceptionInternal(errorCode, getDefaultMessage(ex));
61+
}
62+
63+
@Override
64+
protected ResponseEntity<Object> handleHttpMessageNotReadable(
65+
HttpMessageNotReadableException ex,
66+
HttpHeaders headers,
67+
HttpStatusCode status,
68+
WebRequest request) {
69+
// Jackson이 DTO로 변환하다 실패(예: enum/숫자 타입 변환 실패)한 경우 상세 메시지 제공
70+
Throwable cause = ex.getCause();
71+
if (cause instanceof InvalidFormatException ife && !ife.getPath().isEmpty()) {
72+
String fieldName = ife.getPath().get(0).getFieldName();
73+
String value = String.valueOf(ife.getValue());
74+
String targetType =
75+
ife.getTargetType() != null ? ife.getTargetType().getSimpleName() : "Unknown";
76+
77+
String message =
78+
String.format(
79+
"'%s'는 %s 필드에 유효하지 않은 값입니다. (%s 타입)", value, fieldName, targetType);
80+
ErrorCode errorCode = CommonErrorCode.INVALID_BODY;
81+
return handleExceptionInternal(errorCode, message);
82+
}
83+
84+
ErrorCode errorCode = CommonErrorCode.INVALID_BODY;
85+
return handleExceptionInternal(errorCode, CommonErrorCode.INVALID_BODY.getMessage());
86+
}
87+
88+
@ExceptionHandler(Exception.class)
89+
public ResponseEntity<Object> handleAll(Exception ex) {
90+
log.error("Unhandled Exception", ex);
91+
ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
92+
return handleExceptionInternal(errorCode);
93+
}
94+
95+
private static String getDefaultMessage(MethodArgumentNotValidException ex) {
96+
StringBuilder message = new StringBuilder();
97+
for (ObjectError error : ex.getBindingResult().getAllErrors()) {
98+
message.append(error.getDefaultMessage()).append(" ");
99+
}
100+
return message.toString().trim();
101+
}
102+
103+
private ResponseEntity<Object> handleExceptionInternal(final ErrorCode errorCode) {
104+
return ResponseEntity.status(errorCode.getHttpStatus()).body(ApiResponse.fail(errorCode));
105+
}
106+
107+
private ResponseEntity<Object> handleExceptionInternal(
108+
final ErrorCode errorCode, final String message) {
109+
return ResponseEntity.status(errorCode.getHttpStatus())
110+
.body(ApiResponse.fail(errorCode, message));
111+
}
112+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.example.ssccwebbe.global.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
6+
import io.swagger.v3.oas.models.Components;
7+
import io.swagger.v3.oas.models.OpenAPI;
8+
import io.swagger.v3.oas.models.info.Info;
9+
import io.swagger.v3.oas.models.security.SecurityRequirement;
10+
import io.swagger.v3.oas.models.security.SecurityScheme;
11+
import io.swagger.v3.oas.models.servers.Server;
12+
13+
@Configuration
14+
public class SwaggerConfig {
15+
16+
@Bean
17+
public OpenAPI openApi() {
18+
String jwtSchemeName = "JWT TOKEN";
19+
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
20+
Components components =
21+
new Components()
22+
.addSecuritySchemes(
23+
jwtSchemeName,
24+
new SecurityScheme()
25+
.name(jwtSchemeName)
26+
.type(SecurityScheme.Type.HTTP)
27+
.scheme("bearer")
28+
.bearerFormat("JWT"));
29+
30+
return new OpenAPI()
31+
.addServersItem(new Server().url("/"))
32+
.info(apiInfo())
33+
.addSecurityItem(securityRequirement)
34+
.components(components);
35+
}
36+
37+
private Info apiInfo() {
38+
return new Info()
39+
.title("SSCC Web BE API")
40+
.description("SSCC 웹서비스 API 명세서")
41+
.version("1.0.0");
42+
}
43+
}

0 commit comments

Comments
 (0)