티스토리 뷰
최근 회사에서 API 문서를 Spring Restdocs 로 작성하고 있습니다. 이를 사용하여 일반적인 요청,응답은 작성을 하고 있었는데 추가로 요청 사항이 있었고 이를 개선한 과정에 대해 적어보려고 합니다.
(Spring Restdocs 적용을 위한 초기 설정 + 아래 내용 코드는 여기를 참고해주세요!)
가장 큰 요청 사항은 Enum 값을 확인하기 어렵다는 것이었습니다. 현재 진행하는 프로젝트에서는 다양한 코드값들을 Enum으로 사용하고 있었는데 일반적인 요청, 응답을 보면 description 영역에 모든 값들이 표시되지 않아 별도 문서를 확인해야 하는 불편함이 있었습니다.
이를 해결하기 위해 enum 값을 별도로 확인할 수 있는 api를 생성하고 조회할 수 있도록 하였습니다. 프로젝트 내 모든 enum을 찾아서 반환하는 api를 생성하기 위해 reflection을 사용하였습니다.
Enum 조회 API 생성
1. reflection 사용을 위해 build.gradle에 dependency 추가합니다.
// reflection
implementation 'org.reflections:reflections:0.10.2'
2. Annotation 추가
reflection에서 해당 애노테이션이 붙은 클래스를 찾기 위해 사용합니다. 따라서 enum 생성 후 해당 애노테이션을 클래스 위에 달아주면 reflection 시 해당 클래스를 찾을 수 있습니다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CodeClass {
Class value() default BaseCode.class;
String methodName() default "of";
}
여기서 기본 값으로 BaseCode.class를 지정하였는데 이는 추후 해당 애노테이션이 붙은 클래스에서 메소드를 찾을 때 발생할 수 있는 에러를 방지하기 위해 기본값을 지정한 것입니다.
최종적으로 Reflection을 통해 찾은 Enum들을 BaseCode 형태로 반환할 것이기 때문에 미리 해당 클래스를 생성해둡니다. (아주아주 마지막에 최종 결과를 위에 Map으로 변환하긴 하지만... 여기서는 좀더 형식적으로 객체를 구성하기 위해 사용하였습니다... 😅)
@Getter
@ToString
public class BaseCode {
@NonNull
private String code;
@NonNull
private String desc;
protected BaseCode(CommonCode commonCode) {
this.code = commonCode.getCode();
this.desc = commonCode.getDesc();
}
public static BaseCode of(CommonCode commonCode) {
return new BaseCode(commonCode);
}
}
3. Enum에 공통으로 구현해야할 메서드 지정을 위해 interface 추가
추후 Reflection을 통해 찾은 Enum들에서 공통으로 사용하는 메서드를 미리 지정하기 위해 인터페이스를 추가하였습니다. 해당 인터페이스를 구현한 클래스를 찾기도 하기 때문에 찾고자 하는 Enum 생성 후 아래 인터페이스를 구현해줍니다.
public interface CommonCode {
String getCode();
String getDesc();
}
4. enum 생성 후 애노테이션 추가 및 인터페이스 구현
Reflection을 통해 찾을 Enum에 위에서 작성한 애노테이션을 추가하고 인터페이스를 구현합니다.
@Getter
@RequiredArgsConstructor
@CodeClass
public enum Gender implements CommonCode {
M("M", "남자"),
F("F", "여자");
private final String code;
private final String name;
@Override
public String getDesc() {
return getName();
}
}
getCode()의 경우 롬복 애노테이션으로 getter를 추가하였기 때문에 별도로 오버라이딩하지 않았습니다. 만약 code 값에 추가로 더 값을 넣어 반환하는 경우에는 getCode() 메서드를 오버라이딩하여 사용하면 됩니다.
5. 이제 reflection을 통해 전체 enum 목록을 조회하는 컨트롤러와 서비스를 구현해보도록 하겠습니다.
@RestController
@RequestMapping("/v1/codes")
@RequiredArgsConstructor
public class CodeRestController {
private final CodeQueryService codeQueryService;
@GetMapping
public ResponseEntity<CodeQueryResponse> findAll() {
return ResponseEntity.ok(CodeQueryResponse.make(codeQueryService.getCodeQuery()));
}
}
@Slf4j
@Service
@Transactional(rollbackFor = Exception.class)
public class CodeQueryService {
private final CodeQuery codeQuery = createCodeQuery();
private static CodeQuery createCodeQuery() {
// 직접 package 입력되지 않아 workaround (참고 - https://github.com/ronmamo/reflections/issues/373)
Reflections reflections = new Reflections(new ConfigurationBuilder()
.setUrls(ClasspathHelper.forPackage(CommonCode.class.getPackageName()))
.addScanners(Scanners.TypesAnnotated)
);
Set<Class<?>> classes = reflections.getTypesAnnotatedWith(CodeClass.class);
return CodeQuery.of(classes
.stream()
.filter(Class::isEnum)
.filter(clazz -> Arrays.asList(clazz.getInterfaces()).contains(CommonCode.class))
.collect(Collectors.toMap(
Class::getSimpleName,
it -> getCodeSet(it, getFactoryMethod(it.getAnnotation(CodeClass.class))))));
}
private static Set<BaseCode> getCodeSet(Class enumClass, Function of) {
return Arrays.stream(enumClass.getEnumConstants())
.map(it -> (BaseCode) of.apply(it))
.collect(Collectors.toSet());
}
private static Function getFactoryMethod(CodeClass codeClass) {
return (it) -> {
try {
// static mathod 이므로 첫번째 null
return codeClass.value().getMethod(codeClass.methodName(), CommonCode.class).invoke(null, it);
} catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
log.error("Reflection errors {}", e);
return null;
}
};
}
public CodeQuery getCodeQuery() {
return codeQuery;
}
}
컨트롤러에서는 서비스에서 받은 클래스 받아서 Restdocs 문서에서 사용할 객체 형태로 변환하고 있습니다.
서비스에서 반환된 enum 클래스의 경우 다음과 같은 형태를 가지는데
{
"codeMap": [
"Gender": [
{
"code": "M",
"desc": "남자"
},
{
"code": "F",
"desc": "여자"
}
]
]
}
이를 테스트에서 사용할 Map 형태로 다음과 같이 변경합니다.
{
"codeMap": [
"Gender": {
"M": "남자",
"F": "여자"
}
]
}
이렇게 변경된 최종 응답 객체를 만드는 코드는 다음과 같습니다.
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class CodeQueryResponse {
private final Map<String, Map<String, String>> codeMap;
public static CodeQueryResponse make(CodeQuery codes) {
Map<String, Map<String, String>> codeMap = new HashMap<>();
codes.getCodeMap().forEach((k, v) -> {
Map<String, String> values = new HashMap<>();
v.forEach(baseCode -> values.put(baseCode.getCode(), baseCode.getDesc()));
codeMap.put(k, values);
});
return new CodeQueryResponse(codeMap);
}
}
여기까지가 테스트 작성을 위해 Enum 목록을 조회하는 API를 만드는 과정이었습니다...
Restdocs 문서 생성을 위한 테스트 코드 작성
이제 테스트 코드를 작성해보도록 하겠습니다. 해당 응답을 snippet으로 표현하기 위해 기존과 다른 새로운 커스텀 snippet을 생성하였습니다.
test/resources/org/springframework/restdocs/templates/common-response-field.snippet
{{title}}
|===
|코드|코드명
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/fields}}
|===
test/**/CustomResponseFieldsSnippet.class
public class CustomResponseFieldsSnippet extends AbstractFieldsSnippet {
public CustomResponseFieldsSnippet(String type, PayloadSubsectionExtractor<?> subsectionExtractor,
List<FieldDescriptor> descriptors, Map<String, Object> attributes,
boolean ignoreUndocumentedFields) {
super(type, descriptors, attributes, ignoreUndocumentedFields,
subsectionExtractor);
}
@Override
protected MediaType getContentType(Operation operation) {
return operation.getResponse().getHeaders().getContentType();
}
@Override
protected byte[] getContent(Operation operation) throws IOException {
return operation.getResponse().getContent();
}
}
이제 테스트 코드에서 enum 목록을 조회하는 api 테스트를 작성하고 문서의 응답 값으로 위에서 작성한 커스텀 스니펫을 사용할 수 있도록 합니다.
CommonDocumentationTest.class
@WebMvcTest(CodeRestController.class)
@AutoConfigureRestDocs
public class CommonDocumentationTest {
protected static final String API_V1 = "/v1";
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
private CodeQueryService codeQueryService;
private final CodeQueryService _codeQueryService = new CodeQueryService();
@Test
@DisplayName("enum 목록 조회")
public void enums() throws Exception {
// given
String url = API_V1 + "/codes";
String documentPath = "code/getCodes";
given(codeQueryService.getCodeQuery()).willReturn(_codeQueryService.getCodeQuery());
// when
ResultActions resultActions = mockMvc.perform(get(url).accept(MediaType.APPLICATION_JSON));
resultActions.andExpect(status().isOk())
.andDo(document(documentPath,
_codeQueryService.getCodeQuery().getCodeMap().keySet().stream().map((k) -> customResponseFields("enum-response", beneathPath("codeMap." + k).withSubsectionId(k), // (2)
attributes(key("title").value(k)),
enumConvertFieldDescriptor(_codeQueryService.getCodeQuery().getCodeMap().get(k).toArray(BaseCode[]::new))
)).toArray(Snippet[]::new)
));
}
private FieldDescriptor[] enumConvertFieldDescriptor(BaseCode[] enumTypes) {
return Arrays.stream(enumTypes)
.map(enumType -> fieldWithPath(enumType.getCode()).description(enumType.getDesc()))
.toArray(FieldDescriptor[]::new);
}
public static CustomResponseFieldsSnippet customResponseFields(String type,
PayloadSubsectionExtractor<?> subsectionExtractor,
Map<String, Object> attributes, FieldDescriptor... descriptors) {
return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
, true);
}
}
이렇게 하면 지정한 위치에 build/generated-snippets/code/getCodes/common-response-field-Gender.adoc 파일이 생기고 해당 파일을 표시하고자 하는 (ex. index.adoc)에 추가하면 아래와 같은 형태를 확인할 수 있습니다.
이렇게 해서 enum을 표시할 수 있었지만 enum 확인을 위해 기존 문서에서 enum으로 이동했다 돌아오기가 불편한 점들이 있어서 description을 선택하면 해당 enum 타입이 팝업으로 뜰 수 있도록 수정해보았습니다.
먼저 index.adoc 에 다음 설정을 추가합니다.
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
= API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectlinks:
:docinfo: shared-head // 여기 추가!!!!!!
다음으로 index.adoc 파일이 있는 동일 디렉토리에 docinfo.html 파일을 생성합니다.
<script>
function ready(callbackFunc) {
if (document.readyState !== 'loading') {
// Document is already ready, call the callback directly
callbackFunc();
} else if (document.addEventListener) {
// All modern browsers to register DOMContentLoaded
document.addEventListener('DOMContentLoaded', callbackFunc);
} else {
// Old IE browsers
document.attachEvent('onreadystatechange', function () {
if (document.readyState === 'complete') {
callbackFunc();
}
});
}
}
function openPopup(event) {
const target = event.target;
if (target.className !== "popup") { //(1) class 가 popup 인 경우 팝업 생성
return;
}
event.preventDefault();
const screenX = event.screenX;
const screenY = event.screenY;
window.open(target.href, target.text, `left=$, top=$, width=500, height=600, status=no, menubar=no, toolbar=no, resizable=no`);
}
ready(function () {
const el = document.getElementById("content");
el.addEventListener("click", event => openPopup(event), false);
});
</script>
이제 팝업 내에 띄울 코드 내용을 작성합니다. (저는 codes/gender.adoc 과 같이 작성하였습니다.)
include::{snippets}/code/getCodes/enum-response-fields-Gender.adoc[]
이제 enum을 사용한 응답값 쪽으로 가서 description에 다음과 같이 작성해줍니다.
link:codes/gender.html[성별 코드,role=\"popup\"]에서 link 다음에 오는 html 경로는 위에서 작성한 adoc 경로와 동일하게 적어주면 됩니다.
@WebMvcTest(MemberController.class)
class MemberControllerTest extends ApiDocumentationTest {
...
@Test
@DisplayName("회원 단건 조회")
public void findOne() throws Exception {
...
// then
resultActions.andExpect(status().isOk())
.andDo(document("members/find-by-id",
getDocumentRequest(),
getDocumentResponse(),
pathParameters(
parameterWithName("memberId").description("회원 아이디")
),
responseFields(
...
fieldWithPath("gender").type(JsonFieldType.STRING).description("link:codes/gender.html[성별 코드,role=\"popup\"]"),
...
)
))
.andDo(print());
}
}
최종적으로 문서를 확인하면 다음과 같습니다.
참고
- https://techblog.woowahan.com/2597/
- https://techblog.woowahan.com/2678/
- https://github.com/hojinDev/restdocs-sample
- https://htmlpreview.github.io/?https://github.com/hojinDev/restdocs-sample/blob/master/html/step3.html
'Spring' 카테고리의 다른 글
[Jackson DataFormat] String To LocalDateTime Serialize (0) | 2023.11.16 |
---|---|
[Spring Error] SpringBoot 예외 처리 (1) | 2023.10.15 |
[Annotation] @JsonFormat 과 @Builder 동시 사용 이슈 (0) | 2022.07.01 |
[Validation] @Valid 사용하기 (1) | 2022.06.28 |
[Redis] SpringBoot + Redis 연동하기 (0) | 2022.06.08 |
- Total
- Today
- Yesterday
- map
- java
- CodeCommit
- EC2
- DFS
- programmers
- string
- Dynamic Programming
- AWS
- 순열
- BFS
- array
- ECR
- 수학
- ionic
- search
- Baekjoon
- spring
- cloudfront
- permutation
- CodeDeploy
- 조합
- 소수
- sort
- Combination
- SWIFT
- Algorithm
- CodePipeline
- 프로그래머스
- 에라토스테네스의 체
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |