참고
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[]가 두 번 들어가게 된다. 해당 버그는 아직까지 고쳐지지 않은 상태이다.
이를 떠나서 사실 지금 제일 큰 문제가 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)
때문에 우린 생각해야 한다.
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()이 추가되게 설계했다.