Spring에서 API 문서를 만들 때 Swegger, Restdocs를 쓴다. 가장 큰 차이는 Restdocs는 테스트코드가 필수라는 점이다.
TDD가 처음엔 귀찮은 작업일 순 있지만 향후 유지보수를 위해선 오히려 나를 편하게 해주는 작업이기 때문에 테스트코드 기반의 RestDocs를 사용하기로 했다.
RestDocs 공식 사이트
1. 환경설정
Spring Boot를 이용할 땐 RestDocs를 가져오면 알아서 build gradle에 추가해준다.
아닐 경우엔 구글에 검색하면 최신 버전에 맞게끔 설정하는 내용이 많을테니 검색해보자.
아래는 추가되어야 할 것들 ( 버전에 따라 다를 수도 있습니다. )
plugins {
id "org.asciidoctor.jvm.convert" version "3.3.2"//RestDoc
}
ext {
set('snippetsDir', file("build/generated-snippets"))
}
configurations {
asciidoctorExt
}
asciidoctor {//RestDoc
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
dependencies {
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' //RestDoc
}
ext {
snippetsDir = file('build/generated-snippets')//RestDoc
}
test {
outputs.dir snippetsDir
useJUnitPlatform()
}
// asccidoctor 작업 이후 생성된 HTML 파일을 static/docs 로 copy
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
// 참고사항 //
// 공식 문서에서는 위의 ascidoctor.doFirst부터 아래 내용은 없고 이와 같은 내용만 있습니다.
// 이렇게 하면 jar로 만들어 질때 옮겨지는 것으로 IDE로 돌릴 때는 build 폴더에서만 확인이 가능합니다.
// 위 방법을 사용하면 IDE에서도 static으로 옮겨진 것을 확인할 수 있습니다.
// 위에 방법을 사용하든 아래 방법을 사용하든 편한 선택지를 사용하시면 됩니다.
//bootJar {//RestDoc
// dependsOn asciidoctor
// from("${asciidoctor.outputDir}/html5") {
// into 'static/docs'
// }
//}
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
tasks.named('asciidoctor') {
inputs.dir snippetsDir
dependsOn test
}
2. 테스트 코드 작성하기
아래 코드가 일반적인 mockMvc를 사용한 테스트 코드이다.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
@SpringBootTest
@Slf4j
@AutoConfigureRestDocs
@AutoConfigureMockMvc
class EmsConfigControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@Transactional
void test() throws Exception {
this.mockMvc.perform(MockMvcRequestBuilders.get("/user"))
.andExpect(status().isOk());
.andDo(
document("user-get")
)
}
}
만약 Spring Sequrity RBAC를 사용 중이라면 authentication 을 위해 다음 메서드를 추가해주자.
ROLE이 "ROLE_ADMIN" 이라면 "ADMIN" 을 추가하면 된다. 여러 개를 콤마로 사용할 수도 있다.
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
void test() throws Exception {
this.mockMvc.perform(MockMvcRequestBuilders.get("/user").with(user("admin").password("password").roles("ADMIN","USER")))
.andExpect(status().isOk());
.andDo(
document("user-get")
)
}
먼저 Roles에 따라서 api에 접근이 잘 되는지 확인해보자.
3. Request Response Document Description
Request와 Response를 명세해주기 위한 메서드를 사용해준다. 필요에 따라서 작성해주면 된다.
.andDo( // rest docs 문서 작성 시작
document("member-get", // 문서 조각 디렉토리 명
pathParameters( // path 파라미터 정보 입력
parameterWithName("id").description("Member ID")
),
responseFields( // response 필드 정보 입력
fieldWithPath("id").description("ID"),
fieldWithPath("name").description("name"),
fieldWithPath("email").description("email")
)
)
)
pathParameter 나 requestFields는 다음과 같이 할 수 있다.
- Path Parameters
spring에서는 Path value를 사용할 때 MockMvcRequestBuilders 보다 RestDocumentationRequestBuilders를 선호한다.
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
this.mockMvc.perform(RestDocumentationRequestBuilders.get("/user/{id}",1L).with(user("admin").roles("CONFIG_READ")))
.andExpect(status().isOk())
.andDo(
document("findUserByIndexId",
pathParameters(parameterWithName("id").description("User Id")),
- Query Parameters
Spring Rest Doc 3.0에 Query Parameter를 적용할 수 있는 방법이 추가되었다
this.mockMvc.perform(get("/users?page=2&per_page=100"))
.andExpect(status().isOk()).andDo(document("users", queryParameters(
parameterWithName("page").description("The page to retrieve"),
parameterWithName("per_page").description("Entries per page")
)));
- RequestFields
requestFields(fieldWithPath("id").description("EmsConfig Id")),
- Post Method
Post Method를 사용할 땐 다음과 같이 쓰자. ContentType과 content를 이용해 map을 String으로 넣어주면 된다.
Map<String, Object> map = new HashMap<>();
map.put("name", "홍길동");
map.put("phone", "010-5424-6542");
map.put("address", address);
ObjectMapper objectMapper = new ObjectMapper();
this.mockMvc.perform(MockMvcRequestBuilders.post("/ems_config")
.with(user("admin").roles("CONFIG_READ", "CONFIG_WRITE"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(map))
type을 지정해주고 배열이나 Json Object를 아래와 같이 사용할 수도 있다.
fieldWithPath("result").type(JsonFieldType.ARRAY).description("result"), // 배열
fieldWithPath("result.id").type(JsonFieldType.NUMBER).description("User Id"), // JSON 요소
fieldWithPath("result[].item").type(JsonFieldType.STRING).description("User Items"), // 배열 안의 JSON요소
만약 숨기고 싶은 필드가 있다면 아래와 같이 하자.
fieldWithPath("id").description("사용자 id").ignored()
이제 빌드를 해보면 기본적으로 다음과 같은 조각들이 default로 생성된다.
- curl-request.adoc
- http-request.adoc
- httpie-request.adoc
- http-response.adoc
- request body
- response body
테스트 코드에 따라 추가적인 조각이 생성될 수 있다.
- response-fields.adoc
- request-parameters.adoc
- request-parts.adoc
- path-parameters.adoc
- request-parts.adoc
이제 우린 이 조각들을 가져다 쓰면서 문서를 작성 할 것이다.
4. index.adoc
adoc 파일을 쉽게 작성하기 위해 아래 플러그인을 설치해준다.
src/docs/index.adoc 파일을 만들고 다음과 같이 작성해보자.
= REST Docs
backtony.github.io(부제)
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
[[User-API]]
== User API
[[User-단일-조회]]
=== User 단일 조회
operation::user-get[]
plugin을 통해 확인해보면 잘 나오는 것을 볼 수 있다.
operation을 사용할 때
operation::user-get[]
위와 같은 방법으로 빈 배열을 할당하면 모든 것들이 보이지만,
operation::user-get[snippets='response-fields']
이렇게 하면 원하는 스니펫만을 보여줄 수도 있다.
또한 문서를 더 깔끔하게 작성하려면 아래 사이트를 참고해보자.
Asciidoc 기본 사용법 (narusas.github.io)
5. index.html
문서를 html로 보는 방법은 몇 가지가 있는데, 위에서 설치했던 plugin으로 볼 수 있다
html버튼을 누르면 브라우저가 뜨고 파일이 생성되는 것을 볼 수 있다.
23.06.28 추가 내용
6. 리팩토링
테스트 코드의 andDo(document()) 부분의 중복을 삭제하는 리팩토링을 진행해보자.
먼저 디렉토리 이름을 따로 지정해주지 않아도 되도록 아래 Configuration을 선언해준다.
utils/RestDocsConfig.java
@TestConfiguration
public class RestDocsConfig {
@Bean
public RestDocumentationResultHandler write() {
return MockMvcRestDocumentation.document(
"{class-name}/{method-name}",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
);
}
public static final Attribute field(
final String key,
final String value) {
return new Attribute(key, value);
}
}
테스트 코드에서 선언되는 값들의 중복을 제거하기 위해 다음을 만든다.
Utils/ControllerTest.java
@Disabled
@WebMvcTest({
MemberController.class,
CommonDocController.class
})
public abstract class ControllerTest {
@Autowired protected ObjectMapper objectMapper;
@Autowired protected MockMvc mockMvc;
@MockBean protected MemberRepository memberRepository;
protected String createJson(Object dto) throws JsonProcessingException {
return objectMapper.writeValueAsString(dto);
}
}
이젠 andDo(document()) 부분을 삭제하는 코드를 만들어준다.
이 코드는 모든 테스트에서 extends 될 것이다.
@SpringBootTest
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@Disabled
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
public class RestDocsTestSupport {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected JwtUtil jwtUtil;
@Autowired
protected RestDocumentationResultHandler restDocs;
@BeforeEach
void setUp(final WebApplicationContext context,
final RestDocumentationContextProvider provider) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(
MockMvcRestDocumentation.documentationConfiguration(provider)) // rest docs 설정 주입
.alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함
.alwaysDo(restDocs) // pretty 패턴과 문서 디렉토리 명 정해준것 적용
.addFilters(new CharacterEncodingFilter("UTF-8", true)) // 한글 깨짐 방지
.build();
}
}
여기서 만약 Spring Security를 적용하면 다음과같이 써야한다.
@BeforeEach
void setUp(final WebApplicationContext context,
final RestDocumentationContextProvider provider) throws ServletException {
DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
delegateProxyFilter.init(
new MockFilterConfig(context.getServletContext(), BeanIds.SPRING_SECURITY_FILTER_CHAIN));
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(
MockMvcRestDocumentation.documentationConfiguration(provider)) // rest docs 설정 주입
.alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함
.alwaysDo(restDocs) // pretty 패턴과 문서 디렉토리 명 정해준것 적용
.addFilters(
new CharacterEncodingFilter("UTF-8", true),
delegateProxyFilter
)
.build();
}
아래는 예시 코드이다.
class MemberControllerTest extends RestDocsTestSupport {
@Test
public void member_page_test() throws Exception {
Member member = new Member("backtony@gmail.com", 27, MemberStatus.NORMAL);
PageImpl<Member> memberPage = new PageImpl<>(List.of(member), PageRequest.of(0, 10), 1);
given(memberRepository.findAll(ArgumentMatchers.any(Pageable.class))).willReturn(memberPage);
mockMvc.perform(
get("/api/members")
.param("size", "10")
.param("page", "0")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(
restDocs.document(
requestParameters(
parameterWithName("size").optional().description("size"), // 필수여부 false
parameterWithName("page").optional().description("page") // 필수여부 false
)
)
)
;
}
@Test
public void member_create() throws Exception {
MemberSignUpRequest dto = MemberSignUpRequest.builder()
.name("name")
.email("hhh@naver.com")
.status(MemberStatus.BAN)
.build();
mockMvc.perform(
post("/api/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))
)
.andExpect(status().isOk())
.andDo(
restDocs.document(
requestFields(
// 앞서 작성한 RestDocsConfig의 field 메서드로 constraints를 명시
fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하")),
fieldWithPath("email").description("email").attributes(field("constraints", "길이 30 이하")),
fieldWithPath("status").description("Code Member Status 참조")
)
)
)
;
}
}
7. Request, Response Fields 커스터마이징
/test/resources/org/springframework/restdocs/templates/asciidoctor/ 경로에 snippet 파일을 추가하면 default 표를 만들 수 있다.
/default-request-fields.snippet
|===
|필드|타입|필수값|설명|제한
{{#fields}}
|{{path}}
|{{type}}
|{{^optional}}true{{/optional}}
|{{description}}
|{{#constraint}}{{constraint}}{{/constraint}}
{{/fields}}
|===
constraints 같은 경우는 다음과 같이 추가할 수 있다.
fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하"))
/default-response-fields.snippet
|===
|필드|타입|설명
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
{{/fields}}
|===
/default-path-parameters.snippet, /default-query-parameters.snippet
|===
|파라미터|설명
{{#parameters}}
|{{name}}
|{{description}}
{{/parameters}}
|===
8. Enum 코드 문서화
Enum 타입을 사용하기 위해선 로직에 사용하던 enum을 다음 interface에 implement 해주어야 한다.
Enumtype.java
public interface RestDocsEnumType {
String getName();
String getDescription();
}
아래는 예시 코드.
MemberStatus.java
@AllArgsConstructor
public enum MemberStatus implements EnumType {
LOCK("일시 정지"),
NORMAL("정상"),
BAN("영구 정지");
private final String description;
@Override
public String getDescription() {
return this.description;
}
@Override
public String getName() {
return this.name();
}
}
위는 참고했던 사이트에서 만든 파일들이고,
내가 직접 만들었던 파일들이다.
Enum에 관련한 snippet인 custom-response-fields.snippet을 만들어준다.
|===
|필드|타입|설명
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
{{/fields}}
|===
위 패키지 경로대로 하나씩 클래스를 만들어준다.
utils/CustomResponseFieldsSnippet
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();
}
}
utils/ApiResponseDto
@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {
private T data;
private ApiResponseDto(T data){
this.data=data;
}
public static <T> ApiResponseDto<T> of(T data) {
return new ApiResponseDto<>(data);
}
}
utils/consts/EnumDocs
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnumDocs {
// 문서화하고 싶은 모든 enum값을 명시
Map<String,String> Sex;
Map<String,String> memberStatus;
}
utils/consts/CommonDocController
@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {
private T data;
private ApiResponseDto(T data){
this.data=data;
}
public static <T> ApiResponseDto<T> of(T data) {
return new ApiResponseDto<>(data);
}
}
utils/consts/CommonDocControllerTest
// restdocs의 get 이 아님을 주의!!
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
class CommonDocControllerTest extends RestDocsTestSupport {
@Test
public void enums() throws Exception {
// 요청
ResultActions result = this.mockMvc.perform(
get("/test/enums")
.contentType(MediaType.APPLICATION_JSON)
);
// 결과값
MvcResult mvcResult = result.andReturn();
// 데이터 파싱
EnumDocs enumDocs = getData(mvcResult);
// 문서화 진행
result.andExpect(status().isOk())
.andDo(restDocs.document(
customResponseFields("custom-response", beneathPath("data.memberStatus").withSubsectionId("memberStatus"), // (1)
attributes(key("title").value("memberStatus")),
enumConvertFieldDescriptor((enumDocs.getMemberStatus()))
),
customResponseFields("custom-response", beneathPath("data.sex").withSubsectionId("sex"),
attributes(key("title").value("sex")),
enumConvertFieldDescriptor((enumDocs.getSex()))
)
));
}
// 커스텀 템플릿 사용을 위한 함수
public static CustomResponseFieldsSnippet customResponseFields
(String type,
PayloadSubsectionExtractor<?> subsectionExtractor,
Map<String, Object> attributes, FieldDescriptor... descriptors) {
return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
, true);
}
// Map으로 넘어온 enumValue를 fieldWithPath로 변경하여 리턴
private static FieldDescriptor[] enumConvertFieldDescriptor(Map<String, String> enumValues) {
return enumValues.entrySet().stream()
.map(x -> fieldWithPath(x.getKey()).description(x.getValue()))
.toArray(FieldDescriptor[]::new);
}
// mvc result 데이터 파싱
private EnumDocs getData(MvcResult result) throws IOException {
ApiResponseDto<EnumDocs> apiResponseDto = objectMapper
.readValue(result.getResponse().getContentAsByteArray(),
new TypeReference<ApiResponseDto<EnumDocs>>() {}
);
return apiResponseDto.getData();
}
}
이렇게 만들고 빌드를 하면 아래와 같은 adoc 파일이 생기는데
Enum을 클릭해서 새 창을 띄우도록 하려면 아래와 같이 사용할 수 있다.
queryParameters(
parameterWithName("memberStatus").description(
"link:#enum-memberStatus[memberStatus,window=\"_blank\"]"),
'Back-End > Spring Boot' 카테고리의 다른 글
Spring boot | Spring Apache Kafka 사용법 ( with Docker Container ) | Kafka 설치 (0) | 2023.03.02 |
---|---|
Spring boot | Jacoco로 테스트 커버리지 확인하기 (0) | 2023.02.23 |
Spring Boot | Mysql 연동하는 방법 (0) | 2022.11.03 |
Sping Boot | Backend Project | AWS S3 upload 구현 (0) | 2022.11.01 |
Sping Boot | Backend Project | File Upload (0) | 2022.11.01 |