Syncope.T-*
article thumbnail
728x90

1. Springdoc?

1) 구성

Springdoc은 아래와 같이 구성되어있다.

그래서 타 블로그처럼 openapi-ui를 설치하게 되면, Swagger-ui까지 같이 사용하게 된다.

UI를 사용하지 않아도 OpenAPI JSON이 제공된다.

그래서 코어 기능인 webmvc-core만 사용하려고 한다. UI에 관한건 Redoc을 쓰기로 했기 때문이다.

2) Default Version의 문제점

기본적으로 Spring에서 Swagger는 Description 남발은 피할수가 없다.

Springdoc에서 제공하는 Example이 바로 Petstore인데 Petstore Swagger-UI로 열어보면 내용이 별로 없다는걸 알 수 있다.

Front 개발자가 참고 할 만한 설명이 모두 들어가려면 @Tag, @Operation 등등 Summary, Description 필드를 채워 넣어야 하는데 Class 파일에다 그것도 어노테이션의 한 필드에다 내용을 모두 채우려면 힘이 든다.

위 사진은 Redoc으로 뽑은 결과물 중 한 편을 캡쳐하였는데, 위 정도의 내용만 추가하려 해도 Annotation이 복잡해질 거라는 상상이 들것이다.

그럼 어떻게 간단히 편집하게끔 할 수 있을까?
그러다 설명 부분은 모두 파일(Markdown File)로 따로 뺄 수가 있을까?라는 의문에 봉착하게 된다.
이를 해결 해보려 본인은 도전을 시작했다.

2. Springdoc 설치

1) build.gradle

implementation 'org.springdoc:springdoc-openapi-webmvc-core:1.6.12'

gradle에 의존성을 추가하자.

2) package 구성

swagger는 따로 설정해줄게 조금 많다.

위의 사진처럼 구성을 해둔다면 아래 진행하는데 별 무리는 없을 것이다.

3. Swagger의 설명과 문제

1) Swagger Config

@Configuration
@AllArgsConstructor
@Slf4j
public class Swagger2Config {

    @Bean
    public OpenAPI swaggerConfig() {
        Contact contact = new Contact()
                .name("API Support")
                .email("https://syncope.tistory.com")
                .url("https://github.com/krPlatypus");

        License license = new License()
                .name("Apache 2.0")
                .url("http://www.apache.org/licenses/LICENSE-2.0.html");

        Info info = new Info()
                .title("Swagger Markdown 전용 Project")
                .version("0.0.1")
                .extensions(extensions())
                .contact(contact)
                .license(license)
                .description(SwaggerMarkdown.INFO_DESCRIPTION.asString());

        return new OpenAPI()
                .components(new Components())
                .info(info);
    }

    private Map<String, Object> extensions() {
        Map<String, Object> ext = new HashMap<>();

        xLogo xLogo = new xLogo();
        xLogo.setUrl("https://syncope.tistory.com/redoc/next.png");
        xLogo.setAltText("Test Logo");

        ext.put("x-logo", xLogo);
        return ext;
    }

    @Getter
    @Setter
    class xLogo{
        private String url;
        private String altText;
    }
}

Info의 .description()에 들어가는 String이 개인적으로 만든 Markdown으로 적용할 수 있게끔 되어있다. 이는 후술 하겠다.

우선 위 클래스를 만들어 두어야 Swagger를 적용할 수 있다. 본인은 섹션이 구분될 필요가 없어서 OpenAPI 객체 하나만 생성하였다.

2) Tag와 Operation

API 문서를 작성하다 보면 Tag와 Operation 에는 설명이 많이 들어가지만, 이하 부분에서는 그렇게 많이 작성되지 않는다.

Controller Class에서 작성하는 양도 적을뿐더러 DTO 단에도 설명을 적는 일이 적다.

@Tag(name = "사용자 인증", description = "기다란 설명~~~~")

Tag는 보통 클래스에 적용하여 항목을 나누게 되는데. PetStore의 User, Pet과 같은 엔티티 급으로 나눈다.

Redoc에서는 아래 사진에 빨간 박스를 칠한 부분이 Tag 영역이 된다. 이곳의 내용이 길어지면 길어질수록 description 필드에 쓰는 것은 편집이 힘들어질뿐더러, 소스 정리가 되지 않는다.

@Operation(summary = "사용자 정보 조회", description = "사용자 전체 정보를 조회 합니다.")

Operation은 메서드 단위를 target으로 잡는다. 아래 사진에서 파란 박스 부분이 Operation 영역이다.

물론 Request, Response 정보들은 따로 어노테이션으로 존재한다. 여기 부분도 설명이 많이 들어가면 Tag와 같은 문제가 발생한다.

3) Controller 부터 작성해보자

@Tag(name = "사용자 인증", description = "설명~~~")
public interface SwaggerAuthController {

    @Operation(summary = "사용자 정보 조회", description = "사용자 전체 정보를 조회 합니다.")
    @Parameters(
        value = {
                @Parameter(name = "Authorization", description = "AccessToken", in = ParameterIn.HEADER, schema = @Schema(implementation = String.class), required = true)
        })
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = ResponseAccount.class))),
            @ApiResponse(responseCode = "400", description = "실패", content = @Content(schema = @Schema(implementation = ErrorBody.class)))
    })
    ResponseEntity info(Authentication auth);
}

Springdoc으로 작성하면 위와 같이 작성할 수 있다.

위 코드처럼 Swagger 전용 Interface를 만들고, 이를 implement 한 것이 우리가 Spring에서 작성하는 Controller로 쓰이게 하면 된다.

그러나 서두처럼 궁극적으로는 아래와 같이 간단히 진행하고 싶다.

@MarkdownDescription(markdown = SwaggerMarkdown.AUTH_DESCRIPTION)
public interface SwaggerAuthController {
    @MarkdownDescription(markdown = SwaggerMarkdown.AUTH_INFO_DESCRIPTION)
    @AccessToken
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = ResponseAccount.class))),
            @ApiResponse(responseCode = "400", description = "실패", content = @Content(schema = @Schema(implementation = ErrorBody.class)))
    })
    ResponseEntity info(Authentication auth);
}

Description 상당 부분이 날아가니 보기가 깔끔하다. 예시로 든 것이 너무 짧아서 그럴 수 있으니 효과는 미미해 보일 수 있다.

위처럼 만들려면 어떻게 진행해야 할까? 우선은 저 MarkdownDescription을 살펴보기보다는

나는 Markdown파일을 Enum으로 관리할래요! 하는 SwaggerMarkdown부터 살펴보자.

4. 파일 기반 Swagger 만들기

1) Enum - SwaggerMarkdown

@Getter
public enum SwaggerMarkdown {
    INFO_DESCRIPTION("Description.md", "INFO", SwaggerType.Info),

    AUTH_DESCRIPTION("Auth.md", "사용자 인증", SwaggerType.Tag),
    AUTH_INFO_DESCRIPTION("Auth_Info.md", "사용자 정보 조회", SwaggerType.Operation),
    AUTH_LOGIN("Auth_Login.md", "로그인", SwaggerType.Operation),
    AUTH_LOGOUT("Auth_Logout.md", "로그아웃", SwaggerType.Operation),
    REFRESH_TOKEN("Auth_Refresh_Token.md", "토큰 재발급", SwaggerType.Operation),

    ACCOUNT_DESCRIPTION("Account.md", "계정", SwaggerType.Tag),
    ACCOUNT_GET("Account_Get.md", "ID로 조회", SwaggerType.Operation),
    ACCOUNT_JOIN("Account_Join.md", "생성", SwaggerType.Operation),

    VIDEO_DESCRIPTION("Video.md", "비디오", SwaggerType.Tag),
    VIDEO_LIST("Video_List.md", "검색", SwaggerType.Operation),
    VIDEO_CREATE("Video_Create.md", "컨텐츠 생성", SwaggerType.Operation),
    VIDEO_UPDATE("Video_Update.md", "컨텐츠 수정", SwaggerType.Operation),
    ;

    private final String path;
    private final String name;
    private final SwaggerType type;

    SwaggerMarkdown(String path, String name, SwaggerType tag) {
        this.path = path;
        this.name = name;
        this.type = tag;
    }

    private Resource getResource() {
        ResourceLoader resourceLoader = new DefaultResourceLoader();
        return resourceLoader.getResource(String.format("classpath:swagger/%s", path));
    }

    public String asString() {

        try (Reader reader = new InputStreamReader(getResource().getInputStream(), UTF_8)) {
            return FileCopyUtils.copyToString(reader);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public static SwaggerMarkdown ofPath(String path) {
        return Arrays.stream(values())
                .filter(x -> x.getPath().equals(path))
                .findFirst()
                .orElseThrow(() -> new RuntimeException("찾을 수 없는 MarkDown 파일입니다."));
    }
}
@Getter
public enum SwaggerType {
    Info,
    Tag,
    Operation,
    Parameter,
    Schema
    ;
}

Enum과 Annotation의 조합이면 어떤 Swagger Annotation에 알맞은 Markdown File을 불러와 String으로 주겠다의 선언부를 작성할 수 있다. 위처럼 선언을 했다면, 이제 동작 부분을 어떻게 만들까를 고민해봐야 했다.

2) Tag - Reflection

Swagger에서는 Tag의 수정부를 참 피곤하게 만들었다. Tag를 수정할 수 있는 Customizer를 제공해주지 않기 때문이다.

그래서 나는 Reflection을 사용할 수밖에 없었다. 참고로 RuntimeAnnotations는 여기를 참고하였다.

@Slf4j
@Configuration
@AllArgsConstructor
public class MarkdownPostProcessor implements BeanPostProcessor {

    private final ApplicationContext applicationContext;

    private final MarkdownTagCustomizer markdownTagCustomizer;

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName)
            throws BeansException {
        try {
            MarkdownDescription classDescription = applicationContext.findAnnotationOnBean(beanName, MarkdownDescription.class);
            if (classDescription != null) {
                SwaggerMarkdown markdown = classDescription.markdown();
                markdownTagCustomizer.replaceClass(bean, markdown);
            }
        } catch (
                BeansException e) {
            return bean;
        }
        return bean;
    }
}
@Slf4j
@Component
@AllArgsConstructor
public class MarkdownTagCustomizer implements MarkdownCustomizer {

    @Override
    public void replaceClass(Object bean, SwaggerMarkdown markdown) {
        if (markdown.getType() == SwaggerType.Tag) {
            Map<String, Object> valuesMap = new HashMap<>();
            valuesMap.put("name", markdown.getName());
            valuesMap.put("description", markdown.asString());
            valuesMap.put("externalDocs", externalDocumentation());
            valuesMap.put("extensions", externalDocumentation().extensions());
            RuntimeAnnotations.putAnnotation(bean.getClass(), Tag.class, valuesMap);
        }
    }

    private ExternalDocumentation externalDocumentation(){
        return new ExternalDocumentation(){
            @Override
            public Class<? extends Annotation> annotationType() {
                return ExternalDocumentation.class;
            }

            @Override
            public String description() {
                return "";
            }

            @Override
            public String url() {
                return "";
            }

            @Override
            public Extension[] extensions() {
                return new Extension[0];
            }
        };
    }
}

Spring Bean이 생성후의 시점에, MarkdownDescription이라는 Annotation들을 찾아

MarkdownDescription Annotation 정보를 Tag 정보로 수정하는 작업을 한다. 수정 시, 마크다운 내용을 Description으로 삼게 한다.

그리고 따로 Tag 간의 정렬을 하고 싶다면,

springdoc.swagger-ui.tags-sorter=alpha

alphabetic이 아닌 numeric 등을 써 볼 수야 있지만 나는 따로 아래와 같이 설정하였다.

@Component
public class TagSorter implements OpenApiCustomiser {

    @Getter
    @Setter
    @AllArgsConstructor
    private static class TagOrder{
        private SwaggerMarkdown tag;
        private int order = 0;
    }

    @Override
    public void customise(OpenAPI openApi) {
        List<TagOrder> orders = Arrays.asList(
                new TagOrder(SwaggerMarkdown.AUTH_DESCRIPTION, 1),
                new TagOrder(SwaggerMarkdown.ACCOUNT_DESCRIPTION, 2),
                new TagOrder(SwaggerMarkdown.VIDEO_DESCRIPTION, 3)
        );
        Map<String, Integer> orderMap = new HashMap<>();
        for (TagOrder order : orders) {
            orderMap.put(order.getTag().getName(), order.getOrder());
        }

        openApi.setTags(openApi.getTags().stream().sorted((a, b) -> {
            int aOrder = orderMap.get(a.getName());
            int bOrder = orderMap.get(b.getName());
            return aOrder - bOrder;
        }).collect(Collectors.toList()));
    }
}

3) Annotation

MarkdownDescription 어노테이션은 Tag와 거의 비슷하게 만들었다.

Repeatable 때문에 복수형도 하나 만들어 주었다.

AccessToken은 공통으로 쓰이는 것이라 묶음용으로 하나 만들어 둔다.

@Target({METHOD, TYPE, ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(MarkdownDescriptions.class)
@Inherited
public @interface MarkdownDescription {
    SwaggerMarkdown markdown();
}
@Target({METHOD, TYPE, ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface MarkdownDescriptions {
    MarkdownDescription[] value() default {};
}
@Parameters(
        value = {
                @Parameter(name = "Authorization", description = "AccessToken", in = ParameterIn.HEADER, schema = @Schema(implementation = String.class), required = true)
        })
public @interface AccessToken {
}

4) Operation - Customizer

Opertaion은 조금 더 간단하게 진행이 가능했는데, Springdoc에서 Operation 관련 Customizer를 지원해 주었기 때문이다.

HandlerMethod를 파라미터로 주어서 어노테이션을 바로 수정할 수가 있었다.

@Slf4j
@Component
public class MarkdownOperationCustomizer implements OperationCustomizer {
    @Override
    public Operation customize(Operation operation, HandlerMethod handlerMethod) {
        /* your code */
        MarkdownDescription markdownDescription = handlerMethod.getMethodAnnotation(MarkdownDescription.class);
        if(markdownDescription != null){
            SwaggerMarkdown markdown = markdownDescription.markdown();
            if(markdown != null){
                operation.setSummary(markdown.getName());
                operation.setDescription(markdown.asString());
            }
        }
        return operation;
    }
}

5) MD 작성

Markdown 파일이야 작성법은 나보다 읽는 분들이 더 잘 아실 거라 본다.

나는 /resources/swagger에 무작정 넣었는데 디렉터리화 하실 분들은 따로 설정하시면 좋을 듯하다.

위 사진 내용들이 어떻게 보이는지 한번 보자.

설정들이 모두 끝난 후에 Redoc에다 JSON을 연결해서 보면 아래 사진과 같다.

결과적으로 Controller Class 에다가 Summary, Description을 때려 박아 코드를 더럽히는 일을 없애 보았다.

꽤나 만족스러운 설계 작업이었다.

 

profile

Syncope.T-*

@Syncope

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

profile on loading

Loading...