Skip to content

Commit

Permalink
Feat: API 공통 핸들러 추가 (#17)
Browse files Browse the repository at this point in the history
* Feat: API 공통 핸들러 추가

* Feat: ErrorType 추가

* Feat: ObjectMapper bean 추가

* Feat: api response 형식을 swagger에 적용

* Feat: null인 응답도 ApiRepsonse 형식으로 반환하도록 수정
  • Loading branch information
Jaewon-pro authored Jul 23, 2024
1 parent ef26f2b commit e648714
Show file tree
Hide file tree
Showing 13 changed files with 399 additions and 0 deletions.
69 changes: 69 additions & 0 deletions src/main/java/com/dnd/runus/global/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package com.dnd.runus.global.config;

import com.dnd.runus.global.exception.type.ApiErrorType;
import com.dnd.runus.global.exception.type.ErrorType;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.media.StringSchema;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.oas.models.parameters.QueryParameter;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.customizers.OperationCustomizer;
import org.springframework.boot.info.BuildProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -21,8 +27,14 @@
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Stream;

import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE;

@RequiredArgsConstructor
@Configuration
Expand All @@ -48,6 +60,63 @@ OpenApiCustomizer projectPageableCustomizer() {
});
}

@Bean
OperationCustomizer successResponseBodyWrapper() {
return (operation, handlerMethod) -> {
if (!operation.getResponses().containsKey("200")) {
return operation;
}
Content content = operation.getResponses().get("200").getContent();
if (content == null) {
return operation;
}
content.forEach((mediaTypeKey, mediaType) -> {
Schema<?> originalSchema = mediaType.getSchema();
Schema<?> wrappedSchema = new Schema<>();
wrappedSchema.addProperty("success", new Schema<>().type("bool").example(true));
wrappedSchema.addProperty("data", originalSchema);
mediaType.setSchema(wrappedSchema);
});
return operation;
};
}

@Bean
OperationCustomizer apiErrorTypeCustomizer() {
Function<ErrorType, Schema<?>> getErrorSchema = type -> {
Schema<?> errorSchema = new Schema<>();
errorSchema.properties(Map.of(
"statusCode",
new Schema<>().type("int").example(type.httpStatus().value()),
"code", new Schema<>().type("string").example(type.code()),
"message", new Schema<>().type("string").example(type.message())));
return errorSchema;
};

return (operation, handlerMethod) -> {
ApiErrorType apiErrorType = handlerMethod.getMethodAnnotation(ApiErrorType.class);
if (apiErrorType == null) {
return operation;
}

Stream.of(apiErrorType.value())
.sorted(Comparator.comparingInt(t -> t.httpStatus().value()))
.forEach(type -> {
Content content = new Content()
.addMediaType(
APPLICATION_JSON_VALUE, new MediaType().schema(getErrorSchema.apply(type)));
operation
.getResponses()
.put(
type.httpStatus().value() + " " + type.name(),
new ApiResponse()
.description(type.message())
.content(content));
});
return operation;
};
}

private Info info() {
String activeProfile = ObjectUtils.isEmpty(environment.getActiveProfiles())
? environment.getDefaultProfiles()[0]
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/dnd/runus/global/exception/BaseException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dnd.runus.global.exception;

import com.dnd.runus.global.exception.type.ErrorType;
import lombok.Getter;

@Getter
public abstract class BaseException extends RuntimeException {
private final ErrorType type;
private final String message;

public BaseException(ErrorType type, String message) {
super(message);
this.type = type;
this.message = message;
}

@Override
public String toString() {
return "에러 타입: " + type + ", 사유: " + message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dnd.runus.global.exception;

import com.dnd.runus.global.exception.type.ErrorType;

public class BusinessException extends BaseException {
public BusinessException(ErrorType type, String message) {
super(type, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dnd.runus.global.exception;

import com.dnd.runus.global.exception.type.ErrorType;

public class NotFoundException extends BaseException {
public NotFoundException(String message) {
super(ErrorType.ENTITY_NOT_FOUND, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.dnd.runus.global.exception.type;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.METHOD;

@Target({METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorType {
ErrorType[] value();
}
30 changes: 30 additions & 0 deletions src/main/java/com/dnd/runus/global/exception/type/ErrorType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.dnd.runus.global.exception.type;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
import org.springframework.http.HttpStatus;

import static lombok.AccessLevel.PRIVATE;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.*;

@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor(access = PRIVATE)
public enum ErrorType {
// WebErrorType
UNHANDLED_EXCEPTION(INTERNAL_SERVER_ERROR, "WEB_001", "직접적으로 처리되지 않은 예외, 문의해주세요"),
FAILED_VALIDATION(BAD_REQUEST, "WEB_002", "Request 요청에서 올바르지 않은 값이 있습니다"),
FAILED_PARSING(BAD_REQUEST, "WEB_003", "Request JSON body를 파싱하지 못했습니다"),
UNSUPPORTED_API(BAD_REQUEST, "WEB_004", "지원하지 않는 API입니다"),
COOKIE_NOT_FOND(BAD_REQUEST, "WEB_005", "요청에 쿠키가 필요합니다"),

// DatabaseErrorType
ENTITY_NOT_FOUND(NOT_FOUND, "DB_001", "해당 엔티티를 찾을 수 없습니다"),
VIOLATION_OCCURRED(NOT_ACCEPTABLE, "DB_002", "저장할 수 없는 값입니다"),
;
private final HttpStatus httpStatus;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dnd.runus.presentation.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ObjectMapperConfig {
@Bean
ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/dnd/runus/presentation/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.dnd.runus.presentation.config;

import com.dnd.runus.presentation.handler.NullResponseHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final NullResponseHandler nullResponseHandler;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(nullResponseHandler);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.dnd.runus.presentation.dto.response;

import com.dnd.runus.global.exception.type.ErrorType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

import static lombok.AccessLevel.PRIVATE;

@Schema(description = "API 애러 응답 형식")
@Getter
@ToString
@Builder(access = PRIVATE)
public class ApiErrorDto {
@Schema(description = "응답 상태 코드", example = "400")
private final int statusCode;
@Schema(description = "응답 코드 이름 (디버깅용)", example = "FAILED_AUTHENTICATION")
private final String code;
@Schema(description = "응답 메시지 (디버깅용)", example = "인증에 실패했습니다")
private final String message;

public static ApiErrorDto of(
@NotNull ErrorType type,
String message
) {
return ApiErrorDto.builder()
.statusCode(type.httpStatus().value())
.code(type.code())
.message(type.message() + ", " + message)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dnd.runus.presentation.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;

import static java.util.Collections.emptyMap;
import static java.util.Objects.requireNonNullElse;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(
boolean success,
T data,
ApiErrorDto error
) {
public static <T> ApiResponse<?> success(T data) {
return new ApiResponse<>(true, requireNonNullElse(data, emptyMap()), null);
}

public static ApiResponse<?> fail(ApiErrorDto error) {
return new ApiResponse<>(false, null, error);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.dnd.runus.presentation.handler;

import com.dnd.runus.presentation.dto.response.ApiErrorDto;
import com.dnd.runus.presentation.dto.response.ApiResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Nonnull;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@Slf4j
@RequiredArgsConstructor
@RestControllerAdvice(basePackages = "com.dnd.runus")
public class ApiResponseHandler implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper;

@Override
public boolean supports(@Nonnull MethodParameter returnType, @Nonnull Class converterType) {
return true;
}

@Override
public Object beforeBodyWrite(Object body, @Nonnull MethodParameter returnType,
@Nonnull MediaType selectedContentType, @Nonnull Class selectedConverterType,
@Nonnull ServerHttpRequest request, @Nonnull ServerHttpResponse response) {
HttpServletResponse servletResponse = ((ServletServerHttpResponse) response).getServletResponse();

HttpStatus resolve = HttpStatus.resolve(servletResponse.getStatus());
if (resolve == null || !resolve.is2xxSuccessful()) {
if (body instanceof ApiErrorDto error) {
return ApiResponse.fail(error);
}
log.error("Unreachable response handling! request: {}, response: {}, body: {}", request, response, body);
throw new UnsupportedOperationException("Unreachable response handling!" + body);
}

if (body instanceof String) {
// String 타입을 Wrapper로 감싸면 StringConverter에서 처리할 수 없음
// 따라서 ObjectMapper를 통해 직렬화하여 반환
ApiResponse<?> res = ApiResponse.success(body);
try {
String stringRes = objectMapper.writeValueAsString(res);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return stringRes;
} catch (JsonProcessingException err){
throw new RuntimeException("Failed to convert BaseResponse to JSON");
}
}
return ApiResponse.success(body);
}
}
Loading

0 comments on commit e648714

Please sign in to comment.