기맹기 개발 블로그

[Swagger] Error code enum을 Swagger에 연동시키기 본문

Spring

[Swagger] Error code enum을 Swagger에 연동시키기

기맹기 2023. 2. 14. 14:47

기존 코드에서의 문제점

 

저는 springdoc을 이용해 Swagger로 API 문서를 제공하고 있었습니다.

 

springdoc에서 컨트롤러의 API 메소드에 붙여서 자동으로 문서화를 지원하는 어노테이션은 @ApiResponse입니다.

이는 다음과 같이 구성된 인터페이스입니다.

@Target({METHOD, TYPE, ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Repeatable(ApiResponses.class)
public @interface ApiResponse {
    String description() default "";
    String responseCode() default "default";
    Header[] headers() default {};
    Link[] links() default {};
    Content[] content() default {};
    Extension[] extensions() default {};
    String ref() default "";
    boolean useReturnTypeSchema() default false;
}

 

따라서 단순한 형태의 사용방법은 위와 같습니다.

 

하지만 저는 서비스 단에서 예상할 수 있는 예외들에 ResponseCode enum을 두고, 이를 전역적인 ExceptionHandler에서 관리하여 error code에 맞는 응답을 반환하도록 구성하고 있었습니다.

@Getter
@AllArgsConstructor
public enum ResponseCode {
    OK(20000, HttpStatus.OK, "요청이 완료되었습니다."),
    CREATED(20100, HttpStatus.CREATED, "요청이 완료되었습니다."),

    BAD_REQUEST(-40000, HttpStatus.BAD_REQUEST, "올바르지 않은 요청입니다."),
    VALIDATION_ERROR(-40001, HttpStatus.BAD_REQUEST, "요청 값이 올바르지 않습니다."),
    REQUIRE_VALUE(-40002, HttpStatus.BAD_REQUEST, "누락된 값이 있습니다."),

	...
    
    TOKEN_INVALID(-40100, HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
    TOKEN_EXPIRED(-40101, HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
    TOKEN_IS_NULL(-40102, HttpStatus.UNAUTHORIZED, "토큰이 없습니다."),

	...
    
    private final Integer code;
    private final HttpStatus httpStatus;
    private final String message;
}

ResponseCode에 해당하는 응답을 얻을 수 있도록 다음과 같이 구성되어 있습니다.

@Getter
public class ErrorResponseDto extends ResponseDto {

    private ErrorResponseDto(ResponseCode code) {
        super(false, code.getHttpStatus().value(), code.getCode(), code.getMessage());
    }

    private ErrorResponseDto(ResponseCode code, Exception e) {
        super(false, code.getHttpStatus().value(), code.getCode(), code.getMessage(e));
    }

    private ErrorResponseDto(ResponseCode code, String message) {
        super(false, code.getHttpStatus().value(), code.getCode(), code.getMessage(message));
    }

    public static ErrorResponseDto of(ResponseCode code) {
        return new ErrorResponseDto(code);
    }

    public static ErrorResponseDto of(ResponseCode code, Exception e) {
        return new ErrorResponseDto(code, e);
    }

    public static ErrorResponseDto of(ResponseCode code, String message) {
        return new ErrorResponseDto(code, message);
    }
}

 

이미 잘 정리된 enum이 있는데 API마다 메시지나 에러코드 등을 직접 넣는 것이 재사용성이 떨어진다고 느껴서 개선하고자 시도해본 기록을 정리하고자 합니다.

 

 

1. io.swagger.v3.oas의 OpenAPI를 커스텀

io.swagger.v3.oas에는 Components가 있으며, 필드인 resoponses에 ApiResponse 객체가 저장됩니다.

annotation.ApiResponse가 아닌 model.ApiResponse이며, 어노테이션을 이로 변환하는 기능은 springdoc.core에 구현되어 있습니다. 

저는 이 변환 로직을 수정하고 싶기 때문에 우선 OpenAPI의 components에 접근했습니다.

 

위의 방법으로 ResponseCode enum의 이름들을 @ApiResponse의 ref로 지정하고자 하였습니다.

하지만 다음과 같은 문제가 있습니다.

 

1. ref를 설정하기 위해 ResponseCode에 각 value의 이름을 상수 문자열로 관리해야 합니다.

...
public enum ResponseCode {
    OK(20000, HttpStatus.OK, "요청이 완료되었습니다.", OK_VALUE),
    CREATED(20100, HttpStatus.CREATED, "요청이 완료되었습니다.", CREATED_VALUE),
    
    private final Integer code;
    private final HttpStatus httpStatus;
    private final String message;
    private final String codeName;
    
    public static class Constant {
    	public static final String OK_VALUE = "OK";
        public static finla String CREATED_VALUE = "CREATED";
    }
}

위와 같이 ref에서 상수 문자열을 참조하기 위해서 관리할 코드가 많아집니다.

 

2. 조금 더 큰 문제가 있었습니다.

@ApiResponse의 ref = ResponseCode.Constant.OK_VALUE 이런식으로 지정하고자 하였는데, 어노테이션을 사용하려면 annotation.ApiResponse.responseCode도 반드시 지정해야 합니다.

이를 지정하지 않으면 기본값인 "default"가 됩니다.

 

responseCode 값은 model.ApiResponses에 저장된 model.ApiResponse의 key 값으로 결정됩니다.

즉 어노테이션을 변환하는 springdoc.core에서 맡는 역할이기 때문에 io.swagger.v3.oas에서는 이를 접근할 수 없었습니다.

 

따라서 다음과 같은 방법으로 해결하게 되었습니다.

 

2. springdoc의 OperationCustomizer를 이용

springdoc에서는 OperationCustomizer를 이용하여 이를 커스텀할 수 있습니다.

 

우선 ResponseCode enum에 맞는 어노테이션을 만들어줍니다.

@Target({ANNOTATION_TYPE, METHOD, TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiResponseCode {
    ResponseCode value();
}

 

@Target({METHOD, TYPE, ANNOTATION_TYPE, FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ApiResponseCodes {
    ApiResponseCode[] value() default {};
    ApiResponseCodeGroup[] groups() default {};
}

@ApiResponse와 유사한 구조이지만, ResponseCode로만 이루어져 있습니다.

 

여기서 자주 쓰이는 예외들끼리 묶어서 그룹으로 관리하고자 ApiResponseCodeGroup을 지정해보았습니다.

public enum ApiResponseCodeGroup {
    @ApiResponseCodes(value = {
            @ApiResponseCode(TOKEN_INVALID),
            @ApiResponseCode(TOKEN_EXPIRED),
            @ApiResponseCode(TOKEN_IS_NULL),
            @ApiResponseCode(TOKEN_CAN_NOT_DECODE),
            @ApiResponseCode(AUTHENTICATION_INVALID_USER),
            @ApiResponseCode(REQUIRED_SIGNUP)
    })
    AUTHENTICATED,

    @ApiResponseCodes(value = {
            @ApiResponseCode(PERMISSION_DENIED)
    })
    PERMITTED,

    @ApiResponseCodes(value = {
            @ApiResponseCode(NOT_FOUND)
    })
    OPTIONAL,

    @ApiResponseCodes(value = {
            @ApiResponseCode(WRONG_OAUTH_TOKEN),
            @ApiResponseCode(REQUIRE_OAUTH_EMAIL)
    })
    OAUTH_SIGN_IN
}

 

이제 OperationCustomizer를 이용해 @ApiResponseCodes를 swagger에 적용시킬 수 있습니다.

@Slf4j
@Component
public class OperationCustomizerWithEnums implements OperationCustomizer {

    @SuppressWarnings("rawtypes")
    private final Schema errorEntitySchema = ModelConverters.getInstance()
            .readAllAsResolvedSchema(ErrorResponseDto.class).schema;


    @Override
    public Operation customize(Operation operation, HandlerMethod handlerMethod) {
        ApiResponses apiResponses = operation.getResponses();
        apiResponses.clear();
        Type dtoType = handlerMethod.getReturnType().getGenericParameterType();

        ApiResponseCodes apiResponseCodes = handlerMethod.getMethodAnnotation(ApiResponseCodes.class);
        if (apiResponseCodes != null) {
            Arrays.stream(apiResponseCodes.value()).forEach(
                    code -> putApiResponseCode(apiResponses, code.value(), dtoType)
            );

            Arrays.stream(apiResponseCodes.groups()).forEach(
                    group -> putApiResponseCodeGroup(apiResponses, group, dtoType)
            );
        }

        return operation;
    }

    private void putApiResponseCodeGroup(ApiResponses apiResponses, ApiResponseCodeGroup group, Type dtoType) {
        try {
            Arrays.stream(ApiResponseCodeGroup.class.getField(group.name())
                    .getAnnotation(ApiResponseCodes.class).value()).forEach(apiResponseCode -> {
                            putApiResponseCode(apiResponses, apiResponseCode.value(), dtoType);
            });
        } catch (NoSuchFieldException e) {
            log.error(String.format("Can not put ApiResponseCodeGroup [%s]", e));
        }
    }

    private void putApiResponseCode(ApiResponses apiResponses, ResponseCode code, Type dtoType) {
        if (code.getCode() < 0) {
            apiResponses.put(code.toString(), convertErrorResponse(code));
        } else {
            apiResponses.put(code.toString(), convertDataResponse(code, dtoType));
        }
    }


    private ApiResponse convertErrorResponse(ResponseCode code) {
        return convertResponseInner(
                errorEntitySchema.description(code.getMessage()),
                code,
                ErrorResponseDto.of(code)
        );
    }

    private ApiResponse convertDataResponse(ResponseCode code, Type dtoType) {
        return convertResponseInner(
                customizeSchema(code, dtoType),
                code
        );
    }

    @SuppressWarnings("rawtypes")
    private Schema customizeSchema(ResponseCode responseCode, Type dtoType) {
        Schema schema = ModelConverters.getInstance().readAllAsResolvedSchema(dtoType).schema;
        @SuppressWarnings("unchecked") Map<String, Schema> properties = schema.getProperties();
        Boolean success = responseCode.getHttpStatus().is2xxSuccessful();
        Integer status = responseCode.getHttpStatus().value();
        Integer code = responseCode.getCode();
        String message = responseCode.getMessage();

        properties.get("success").setDefault(success);
        properties.get("status").setDefault(status);
        properties.get("code").setDefault(code);
        properties.get("message").setDefault(message);

        return schema;
    }

    private ApiResponse convertResponseInner(@SuppressWarnings("rawtypes") Schema schema, ResponseCode code) {
        return convertResponseInner(schema, code, null);
    }

    private ApiResponse convertResponseInner(@SuppressWarnings("rawtypes") Schema schema, ResponseCode code, ResponseDto example) {
        MediaType mediaType = new MediaType()
                .schema(schema);

        if (example != null) {
            mediaType.addExamples(code.name(), new Example().value(example));
        }

        return new ApiResponse()
                .content(
                        new Content()
                                .addMediaType(
                                        APPLICATION_JSON_VALUE,
                                        mediaType
                                )
                )
                .description(code.getMessage());
    }

}

 

이를 springdoc의 GruopedOpenApi에 등록해줍니다.

@Bean
public GroupedOpenApi publicApi(OperationCustomizerWithEnums operationCustomizerWithEnums) {
    return GroupedOpenApi.builder()
			...
            
            .addOperationCustomizer(operationCustomizerWithEnums)
            .build();
}

 

이제 컨트롤러에서는 위와 같이 ResponseCode만을 이용해 API 문서화를 할 수 있습니다.

출력되는 결과는 아래와 같습니다.

 

 

끝으로

나름 springdoc과 swagger의 구조를 파악하면서 더 나은 코드를 위해 개선시킨 경험을 공유하고자 작성하였으며, 리플렉션 경험이 없는 상태에서 작성한 코드라 부족할 수 있습니다..! 

만약 더 개선시키게 된다면 추가적으로 포스팅하겠습니다. 피드백은 언제나 환영입니다!