728x90
반응형

참고

24.08.29 기준으로 restdoc-api-spec 라이브러리에 문제가 발견되었는데,

document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    pathParameters(parameterWithName("id").description("user Id")),
                    responseFields(
                    	    fieldWithPath("smtpHost").type(STRING)
                                .description("smtpHost"),
                            fieldWithPath("smtpPort").type(NUMBER)
                                .description("smtpPort")
                    ), // document
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel 조회(ID)")
                        .description("Notification Channel 조회 By ID")
                        .requestSchema(Schema.schema(this.tag + "-Req-Find By ID"))
                        .responseSchema(Schema.schema(this.tag + "-Res-Find By ID"))
                        .responseFields(
                            fieldWithPath("smtpHost").type(STRING)
                                .description("smtpHost"),
                            fieldWithPath("smtpPort").type(NUMBER)
                                .description("smtpPort")
                        ).build()) // resource
                )

 

해당 코드에서 responseFields 가 2번 들어가는 것을 볼 수 있다.

 

document의 인자로 들어가는 responseFields는 snippet을 만들어주며,

resource의 인자로 들어가는 responseFields는 swagger를 켰을 때 해당 schema를 페이지 내부에서 보여준다.

 

둘 중 한 개라도 없을 경우,  restdoc, swagger의 완벽한 문서를 만들 수가 없다.

 

이 때문에 중복되는 FieldsDescriptor[]가 두 번 들어가게 된다. 해당 버그는 아직까지 고쳐지지 않은 상태이다.

question for request/response fields snippet with ResourceSnippetParameters · Issue #206 · ePages-de/restdocs-api-spec (github.com)

 

question for request/response fields snippet with ResourceSnippetParameters · Issue #206 · ePages-de/restdocs-api-spec

Hi. I have a question about generating request/response fields snippets with MockMvcRestDocumentationWrapper. I'd expected documenting with only MockMvcRestDocumentationWrapper and ResourceSnippetP...

github.com

 


 

이를 떠나서 사실 지금 제일 큰 문제가 FieldDescriptor가 많아질 수록 코드가 상당히 길어지고 복잡해지는 것을 볼 수 있다.

    @Test
    public void findById() throws Exception {
        List<FieldDescriptor> responseFields = List.of(
            fieldWithPath("data").type(OBJECT).description("data"),
            fieldWithPath("data.id").type(NUMBER)
                .description("id"),
            fieldWithPath("data.name").type(STRING)
                .description("name"),
            fieldWithPath("data.type").type(STRING)
                .description("type ( EMAIL, TEAMS, KAKAOTALK )"),
            fieldWithPath("data.alarmEnabled").type(BOOLEAN)
                .description("alarmEnabled"),
            fieldWithPath("data.resolveAlarmEnabled").type(BOOLEAN)
                .description("resolveAlarmEnabled"),
            fieldWithPath("data.reminderEnabled").type(BOOLEAN)
                .description("reminderEnabled"),
            fieldWithPath("data.reminder").type(STRING)
                .description("reminder"),
            fieldWithPath("data.smtpHost").type(STRING)
                .description("smtpHost"),
            fieldWithPath("data.smtpPort").type(NUMBER)
                .description("smtpPort"),
            fieldWithPath("data.smtpFrom").type(STRING)
                .description("smtpFrom"),
            fieldWithPath("data.smtpTo").type(STRING)
                .description("smtpTo"),
            fieldWithPath("data.smtpUsername").type(STRING)
                .description("smtpUsername"),
            fieldWithPath("data.smtpPassword").type(STRING)
                .description("smtpPassword"),
            fieldWithPath("data.smtpTlsEnabled").type(BOOLEAN)
                .description("smtpTlsEnabled"),
            fieldWithPath("data.smtpTlsVersion").type(STRING)
                .description("smtpTlsVersion"),
            fieldWithPath("data.teamsWebhookUrl").type(STRING)
                .description("teamsWebhookUrl"),
            fieldWithPath("data.phoneNumbers").type(ARRAY)
                .description("phoneNumbers"),
            fieldWithPath("data.createdAt").type(STRING)
                .description("createdAt"),
            fieldWithPath("data.updatedAt").type(STRING)
                .description("updatedAt"),
            fieldWithPath("data.deletedAt").optional().type(STRING)
                .description("deletedAt"));
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/channel/{id}", channelId)
                .session(this.getSession())
                .with(user("admin")))
            .andExpect(status().isOk())
            .andDo(
                document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    pathParameters(parameterWithName("id").description("user Id")),
                    responseFields(responseFields),
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel 조회(ID)")
                        .description("Notification Channel 조회 By ID")
                        .requestSchema(Schema.schema(this.tag + "-Req-Find By ID"))
                        .responseSchema(Schema.schema(this.tag + "-Res-Find By ID"))
                        .responseFields(responseFields).build())
                )
            );
    }

 

 

 

만약, 

Response, Request Dto를 FieldDescriptor[]로 만들어주는 메소드가 있다면?

우린 상당히 깔끔한 테스트 코드를 만들 수 있을 것이다.

 

우선 생각해보면 Request는 일반 Dto클래스로 받아오기 때문에 상관이 없지만,

API Response 객체는 대부분 CommonResponse로 제네릭 클래스를 사용한다.

 

Sping Boot | Backend Project | 공통 응답 객체 ( Common Response ) :: 티포의개발일지 (tistory.com)

 

Sping Boot | Backend Project | 공통 응답 객체 ( Common Response )

성공 시 { success : true, result : data } 실패 시 { success : false, result : "에러 사유" } 1. CommonResponse backend/response/CommonResponse package web.backend.response; import lombok.AllArgsConstructor; import lombok.Data; @Data @AllArgsConstr

typo.tistory.com

 

때문에 우린 생각해야 한다. 

 

1. 제네릭 클래스로 받아올 경우 ( Response ) 

2. 일반 클래스로 받아올 경우 ( Request )

 

이를 제외하고도 생각해야 될 부분이 먼저 만들어질 fieldWithPath 메소드를 보면

fieldWithPath("data.deletedAt").optional().type(STRING).description("deletedAt"));

 

Dto의 클래스를 순환하여 위와 같은 fieldWithPath 메소드를 만드는 것이다.

 

3. path 

4. optional

5. type

6. desciption

 

해당 필드들을 만들어주어야 한다.

 

먼저 메소드를 만들기 전 RestDoc에서 제공해주는 FieldDescriptor를 살펴보면

public class FieldDescriptor extends IgnorableDescriptor<FieldDescriptor> {
    private final String path;
    private Object type;
    private boolean optional;

    protected FieldDescriptor(String path) {
        this.path = path;
    }

    public final FieldDescriptor type(Object type) {
        this.type = type;
        return this;
    }

    public final FieldDescriptor optional() {
        this.optional = true;
        return this;
    }

    public final String getPath() {
        return this.path;
    }

    public final Object getType() {
        return this.type;
    }

    public final boolean isOptional() {
        return this.optional;
    }
}

 

여기서 확인할 수 있는 것은

1. FieldDescriptor는 생성자가 protected기 때문에 인스턴스를 만들고 사용할 수 없다.

2. Description은 따로 작성해야한다.

이 때문에 FieldDiscriptor[] 배열을 선언해주고, add() 메소드로 FieldDiscriptor를 description과 함께 삽입해 줄 것이다.

 

1. RestDocsFieldGenerator 생성

@Slf4j
public class RestDocsFieldGenerator {

    /*
       외부에서 호출하는 메소드
     */
    public static FieldDescriptor[] generateForType(Type type) {
        List<FieldDescriptor> fieldDescriptors = new ArrayList<>();
        generateFieldDescriptors("", type, fieldDescriptors);
        return fieldDescriptors.toArray(new FieldDescriptor[0]);
    }

    /*
        FieldDescriptors 생성 및 fieldDescriptors 리스트에 추가
     */
    private static void generateFieldDescriptors(
        String prefix,
        Type type,
        List<FieldDescriptor> fieldDescriptors
    ) {
        Class<?> clazz = extractClass(type);
        if (clazz == null) {
            return;
        }

        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {
            if (shouldIgnoreField(field)) {
                continue;
            }

            String fieldPath = buildFieldPath(prefix, field);
            Type fieldType = resolveFieldType(type, field);
            String fieldDescription = resolveFieldDescription(field);
            JsonFieldType jsonFieldType = determineJsonFieldType(fieldType);

            FieldDescriptor fieldDescriptor = createFieldDescriptor(fieldPath, jsonFieldType,
                fieldDescription, field);
            fieldDescriptors.add(fieldDescriptor);

            if (field.isAnnotationPresent(RestDocIgnore.class)) {
                continue;
            }

            // Object or List 경우 재귀 함수로 다시 실행
            if (jsonFieldType == JsonFieldType.ARRAY) {
                handleArrayField(fieldPath, fieldType, fieldDescriptors);
            } else if (jsonFieldType == JsonFieldType.OBJECT) {
                generateFieldDescriptors(fieldPath, fieldType, fieldDescriptors);
            }
        }
    }

    /*
        JsonIgnore 어노테이션이 존재하는지 판단
     */
    private static boolean shouldIgnoreField(Field field) {
        return field.isAnnotationPresent(JsonIgnore.class);
    }

    /*
        Field Path 반환
     */
    private static String buildFieldPath(String prefix, Field field) {
        return prefix.isEmpty() ? field.getName() : prefix + "." + field.getName();
    }

    /*
        Field Type 반환 (제네릭 클래스 판단)
     */
    private static Type resolveFieldType(Type type, Field field) {
        Type fieldType = field.getGenericType();
        if (type instanceof ParameterizedType parameterizedType &&
            Objects.equals(fieldType.getTypeName(), "T") // 필드 타입이 제네릭 일 경우
        ) {
            fieldType = parameterizedType.getActualTypeArguments()[0];
        }
        return fieldType;
    }

    /*
        Field Description 반환 (어노테이션 없을 경우 Field Name 반환)
     */
    private static String resolveFieldDescription(Field field) {
        Description descriptionAnnotation = field.getAnnotation(Description.class);
        return descriptionAnnotation != null ? descriptionAnnotation.value() : field.getName();
    }

    /*
        FieldDescriptor 반환 (Nullable 어노테이션 있을 경우 .optional() 추가)
     */
    private static FieldDescriptor createFieldDescriptor(
        String fieldPath,
        JsonFieldType jsonFieldType,
        String description,
        Field field
    ) {
        FieldDescriptor fieldDescriptor = fieldWithPath(fieldPath).type(jsonFieldType)
            .description(description);
        if (field.isAnnotationPresent(Nullable.class)) {
            fieldDescriptor.optional();
        }
        return fieldDescriptor;
    }

    /*
        JsonFieldType 반환 (FieldDescriptor 사용)
     */
    private static JsonFieldType determineJsonFieldType(Type type) {
        Class<?> clazz = extractClass(type);
        if (clazz == null) {
            return JsonFieldType.OBJECT;
        }

        if (clazz.equals(String.class) || clazz.isEnum() || clazz.equals(LocalDateTime.class)
            || clazz.equals(LocalDate.class) || clazz.equals(LocalTime.class) || clazz.equals(
            DayOfWeek.class) || clazz.isEnum()) {
            return JsonFieldType.STRING;
        } else if (Number.class.isAssignableFrom(clazz) || clazz.isPrimitive()) {
            return JsonFieldType.NUMBER;
        } else if (clazz.equals(Boolean.class) || clazz.equals(boolean.class)) {
            return JsonFieldType.BOOLEAN;
        } else if (List.class.isAssignableFrom(clazz)) {
            return JsonFieldType.ARRAY;
        } else {
            return JsonFieldType.OBJECT;
        }
    }

    /*
        List Type Field Handler
     */
    private static void handleArrayField(
        String fieldPath,
        Type fieldType,
        List<FieldDescriptor> fieldDescriptors
    ) {
        Class<?> listType = extractListGenericType(fieldType);
        if (listType != null && !isSimpleType(listType)) {
            generateFieldDescriptors(fieldPath + "[]", listType, fieldDescriptors);
        }
    }

    /*
        List Generic Type 지정
     */
    private static Class<?> extractListGenericType(Type fieldType) {
        if (fieldType instanceof ParameterizedType parameterizedType) {
            Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
            if (actualTypeArguments.length > 0) {
                return extractClass(actualTypeArguments[0]);
            }
        }
        return null;
    }

    /*
        클래스를 추출 (Generic Class 판단)
     */
    public static Class<?> extractClass(Type type) {
        if (type instanceof ParameterizedType parameterizedType) {
            return (Class<?>) parameterizedType.getRawType();
        } else if (type instanceof Class<?>) {
            return (Class<?>) type;
        } else {
            return null;
        }
    }

    /*
        Simple Type 여부 판단
     */
    private static boolean isSimpleType(Class<?> clazz) {
        return clazz.isPrimitive() || clazz.equals(String.class) ||
            Number.class.isAssignableFrom(clazz) || clazz.equals(Boolean.class);
    }
}

 

주석으로 메소드에 대한 설명은 적어놨고, 질문은 댓글이나 이메일로 받겠습니다.

2. 테스트 코드에서의 사용

    @Test
    public void findById() throws Exception {
        Type responseType = new TypeToken<CommonResponse<NotificationChannelListResponseDto>>() {
        }.getType();
        FieldDescriptor[] responseFields = RestDocsFieldGenerator.generateForType(responseType);
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/channel/{id}", channelId)
                .session(this.getSession())
                .with(user("admin")))
            .andExpect(status().isOk())
            .andDo(
                document("{class-name}/{method-name}",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    pathParameters(parameterWithName("id").description("user Id")),
                    responseFields(responseFields),
                    resource(ResourceSnippetParameters
                        .builder()
                        .tag(this.tag)
                        .summary("Notification Channel 조회(ID)")
                        .description("Notification Channel 조회 By ID")
                        .requestSchema(Schema.schema(this.tag + "-Req-Find By ID"))
                        .responseSchema(Schema.schema(this.tag + "-Res-Find By ID"))
                        .responseFields(responseFields).build())
                )
            );
    }

위의 코드와 같이 필요한 곳에 사용하면 된다.

 

3. 필드 어노테이션

물론 각 프로젝트마다의 조건이 다르겠지만, 필자가 진행하는 프로젝트에서의 조건만 달아둔 상태이며,

필요 시 코드에 추가하면 된다. 해당 어노테이션은 Request, Response Dto 필드에 추가하면 된다. 아래는 예시 Dto이다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class NotificationChannelListResponseDto {

    @RestDocIgnore
    private List<String> phoneNumbers;
    @JsonIgnore
    private String password;
    @Description("이름을 나타내는 필드")
    private String name;
    @Nullable
    private LocalDateTime deletedAt;
}

 

1. @RestDocIgnore

예를 들어 아래와 같은 필드가 있을 때,

    private TreeMap<Integer, Integer> cycleMap;

 

            if (jsonFieldType == JsonFieldType.ARRAY) {
                handleArrayField(fieldPath, field, fieldDescriptors);
            } else if (jsonFieldType == JsonFieldType.OBJECT) {
                // Object일 경우 재귀 함수로 다시 실행
                generateFields(fieldPath, fieldType, fieldDescriptors);
            }

필드가 Object로 판단되어 재귀함수로 들어가는데, 때문에 내가 원하지 않는 하위 필드들이 FieldDescriptor에 추가된다.

이를 방지하기 위한 어노테이션이다.

 

2. @JsonIgnore

Password와 같이 비즈니스 로직에서는 쓰이지만 Response Dto로 사용하면 안되는 필드는 보통 @JsonIgnore로 Client에 전송하는 것을 막는데, 당연히 RestDoc에서도 제거를 해야된다.

            if (shouldIgnoreField(field)) {
                continue;
            }

해당 어노테이션이 존재하는 필드일 경우, 이 부분에서 건너뛰게 되어있다.

 

3. @Description

필자는 FieldDescriptor의 기본 값을 Field의 이름(field.getName())으로 해둔 상태이며, 해당 어노테이션이 존재하는 필드는 어노테이션의 value로 FieldDescriptor가 만들어지게 설계했다.

 

4. @Nullable

Response로 보내는 Dto 중 예를들어 Null이 될 수도, String이 될 수도 있는 값인데, Test에서 Null로 보내는 상태면 

type(STRING) <- 이 메소드쪽에서 에러가 난다. type은 필수로 지정되어야 하므로 추가해주고,

해당 어노테이션이 존재하는 필드는 .optional()이 추가되게 설계했다. 

728x90
반응형

+ Recent posts