Spring Rest Doc??
- 기존에 사용하던 Swagger는 소스코드의 작동과는 연관이 거의 없기 때문에, 로직이 변경 되더라도 api 명세 어노테이션 등을 변경해주지 않으면 최신화가 되지 않는 문제가 있다.
- Spring Rest Docs 를 이용하면 반드시 테스트코드가 통과 해야 문서가 생성된다.(asciiDoc 이 생성) 따라서 api 문서의 정확성이 보장된다.
asciiDoc??
markdown 과 같이 특정 표현식을 통해 문서를 작성하는 도구이다. 개인적인 생각으론 markdown 에 비해 사용성은 불편하지만, include와 같은 기능을 통해 api명세를 만들기엔 좋은 것 같다.
(markdown이 include를 지원하게 되면 markdown 이 더 좋을듯...)
RestDocs를 사용하기 위한 의존 추가
build.gradle
plugins {
...
//asciiDoc -> html 변환을 위한 플러그인
id "org.asciidoctor.convert" version "1.5.9.2"
...
}
...
ext {
...
//스니펫이 생성될 기본 경로 변수 설정
snippetsDir = file('build/generated-snippets')
...
}
...
//asciidoctor 실행을 위한 설정
asciidoctor {
inputs.dir snippetsDir
dependsOn test
}
...
dependencies {
...
//asciidoctor 디펜던시 추가
asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.5.RELEASE'
//test compile 시에 mockMvc 설정을 추가
testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc:2.0.5.RELEASE'
...
}
...
task copyDocument(type: Copy) {
//asciidoctor 먼저 실행 후 해당 테스크 실행
dependsOn asciidoctor
//경로 설정
from file("build/asciidoc/html5/")
into file("src/main/resources/static/docs")
}
//빌드시 태스크 먼저 실행 하도록 설정
build{
dependsOn copyDocument
}
...
- asciidoctor: asciidoc(.adoc) 파일을 웹에서 볼 수 있도록 html파일로 변환 해주는 플러그인 이다.
- 스니펫: restDocs 성공시 http-request.adoc, response-body.adoc과 같은 파일들이 각 테스트별 폴더 하위에 생성되는데(정확히는 테스트 실행 객체의 identifier로 넣어준 문자열) 저러한 .adoc파일들을 스니펫 이라고 한다.
- 스니펫들을 조합하여 전체적인 Index.adoc 을 만들고, 그 것을 asciidoctor로 html로 파싱하여 문서로 제공한다.
학습테스트용 소스
테스트 코드는 MockMvc를 이용해 작성되었다. RestAssured 객체 또는 다른 스프링 테스트 클래스도 사용 가능하다.
@RestController
@RequestMapping("/api/member")
public class MemberController {
@GetMapping("{memberId}")
public ResponseEntity getMember(@PathVariable int memberId) {
Member member = new Member();
member.setMemberId(memberId);
member.setName("꿀똥벌레");
member.setAge(29);
return new ResponseEntity<>(member, HttpStatus.OK);
}
@PostMapping
public ResponseEntity postMember(@RequestBody Member member) {
return new ResponseEntity<>(member, HttpStatus.OK);
}
}
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest({MemberController.class})
@ExtendWith(RestDocumentationExtension.class)
public class MemberControllerTest {
private MockMvc mockMvc;
@MockBean
MemberService memberService;
@BeforeEach
public void setUp(WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
@Test
public void findMemberById() throws Exception {
Member member = new Member();
member.setMemberId(1);
member.setName("꿀똥벌레");
member.setAge(29);
when(memberService.getMemberById(1)).thenReturn(member);
mockMvc.perform(get("/api/member/{memberId}", 1)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("index",
pathParameters(
parameterWithName("memberId").description("멤버 id")
),
responseFields(
fieldWithPath("memberId").description("멤버 이름"),
fieldWithPath("name").description("멤버 이름"),
fieldWithPath("age").description("멤버 이름")
)));
}
}
- build.gradle 에서 설정한 스니펫 디렉토리인 build/generated-snippets 와 andDo(document("") 로 설정한 index로 인해 build/generated-snippets/index 하위에 스니펫들이 생성 된다.
- @WebMvcTest 를 이용해 테스트를 작성하면 mvc클래스만 로딩 되므로 service 의존성 주입에서 에러가 발생한다. 주입 에러가 발생되지 않도록 @MockBean을 사용한다.
- junit5의 기능으로 @ExtendWith(RestDocumentationExtension.class) 를 사용하면 @BeforEach의 WebApplicationContext, RestDocumentationContextProvider를 받아올 수 있다.
스프링부트에서 @AutoConfigurationRestDocs 어노테이션을 테스트 클래스에 적용하면 아래를 자동으로 적용해 준다.
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
.apply(documentationConfiguration(this.restDocumentation))
.build();
}
MockMvc와 RestDocs 커스터마이징
import static capital.scalable.restdocs.response.ResponseModifyingPreprocessors.*;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
@TestConfiguration
public class RestDocsConfiguration {
@Bean
public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
return configurer -> configurer.snippets()
.withDefaults(
CliDocumentation.curlRequest(),
HttpDocumentation.httpRequest(),
HttpDocumentation.httpResponse(),
PayloadDocumentation.requestBody(),
PayloadDocumentation.responseBody());
}
@Bean
public RestDocumentationResultHandler restDocumentationResultHandler(final Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder) {
return document("{class-name}/{method-name}",
preprocessRequest(prettyPrint()),
preprocessResponse(replaceBinaryContent(),
limitJsonArrayLength(jackson2ObjectMapperBuilder.build()),
replaceMultipartBinaryContent(),
prettyPrint()));
}
}
@WebMvcTest({MemberController.class})
@Import(RestDocsConfiguration.class)
@AutoConfigureRestDocs
public class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
...
}
테스트가 성공하면 생성되는 스니펫
build/generated-snippets/.. 에 생성된다.
curl-request.adoc
[source,bash]
----
$ curl 'http://localhost:8080/api/member/1' -i -X GET \
-H 'Accept: application/json'
----
http-request.adoc
[source,http,options="nowrap"]
----
GET /api/member/1 HTTP/1.1
Accept: application/json
Host: localhost:8080
----
등등이 있다.
MockMvc RestDoc 커스터마이징 설정
Configuration 클래스
import static capital.scalable.restdocs.response.ResponseModifyingPreprocessors.*;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
@TestConfiguration
public class RestDocsConfiguration {
@Bean
public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
return configurer -> configurer.snippets()
.withDefaults(
CliDocumentation.curlRequest(),//curl스니펫
HttpDocumentation.httpRequest(),//http-request스니펫
HttpDocumentation.httpResponse(),//http-response스니펫
PayloadDocumentation.requestBody(),//request-body스니펫
PayloadDocumentation.responseBody());//response-body스니펫
}
@Bean
public RestDocumentationResultHandler restDocumentationResultHandler(final Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder) {
return document("{class-name}/{method-name}",
preprocessRequest(prettyPrint()),
preprocessResponse(replaceBinaryContent(),
limitJsonArrayLength(jackson2ObjectMapperBuilder.build()),
replaceMultipartBinaryContent(),
prettyPrint()));
}
}
- RestDocsMockMvcConfigurationCustomizer 를 빈으로 생성하면, 스프링에서 MockMvcRestDocumentationConfigurer 를 커스터마이징 하는데 사용된다. 단@AutoConfigureRestDocs 어노테이션이 제공하지 않는 기능에 대해서만 추가적으로 사용된다.
- configurer.snippets() 로 스니펫 설정 오브젝트를 가져온다.
- withDefaults(): 디폴트로 생성되는 스니펫들을 설정한다.(http-requeset.adoc등..)
- CliDocumentation.curlRequest(param) param으로 설정에 맞는 map을 넣어주면 추가 설정이 가능하다.
- MockMvcRestDocumentation.document(): rest api 문서화 하는 팩토리 메서드 이다.
- {class-name}/{method-name}경로에 스니펫들이 생성된다(스네이크 표기법)
- prettyPrint: json 정렬되어 표기
- testImplementation("capital.scalable:spring-auto-restdocs-core:2.0.11") 의존성 추가로 사용할 수 있다.
- limitJsonArrayLength: 제이슨 배열 표기의 경우 3개까지 표기되도록 설정
- replaceBinaryContent, replaceMultipartBinaryContent: binary와 multipart 콘텐츠를 replace??
테스트 클래스
@WebMvcTest({MemberController.class})
@Import(RestDocsConfiguration.class)
@AutoConfigureRestDocs
public class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
MemberService memberService;
@Autowired
RestDocumentationResultHandler restDocumentationResultHandler;
@Test
public void findMemberById() throws Exception {
//Given
Member member = new Member();
member.setMemberId(1);
member.setName("꿀똥벌레");
member.setAge(29);
//When
when(memberService.getMemberById(1)).thenReturn(member);
//Then
mockMvc.perform(get("/api/member/{memberId}", 1)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(restDocumentationResultHandler.document(
pathParameters(
parameterWithName("memberId").description("멤버 id")
),
responseFields(
fieldWithPath("memberId").description("멤버 이름"),
fieldWithPath("name").description("멤버 이름"),
fieldWithPath("age").description("멤버 이름")
)
));
}
}
커스텀 스니펫 사용법
추상 클래스인 TemplateSnippet 상속받은 클래스를 정의한다.
public class CustomSnippet extends TemplatedSnippet {
public CustomSnippet(Map<String, Object> attributes) {
super("custom", attributes);
}
@Override
protected Map<String, Object> createModel(Operation operation) {
Map<String, Object> model = new HashMap<>();
model.put("넣을데이터", "데이터 입니다.");
return model;
}
}
- TemplateSnippet을 상속 받는 경우 createModel을 반드시 구현 해야한다.
//HttpRequestSnippet의 createModel 의 메소드 @Override protected Map<String, Object> createModel(Operation operation) { Map<String, Object> model = new HashMap<>(); model.put("method", operation.getRequest().getMethod()); model.put("path", getPath(operation.getRequest())); model.put("headers", getHeaders(operation.getRequest())); model.put("requestBody", getRequestBody(operation.getRequest())); return model; }
- createModel에서 return 하는 map은 스니펫 파일을 렌더링 할 데이터로 사용된다.
- {{넣을데이터}} → 데이터 입니다. 로 렌더링 됨.
- super("custom", attributes)로 선언됨에 따라 custom.snippet 파일을 템플릿으로 사용한다.
- classpath 하위의 org/springframework/restdocs/templates/custom.snippet 파일을 사용한다.
사용 소스 (학습용으로 급하게 만든 소스이므로 다듬을 필요가 있음)
@TestConfiguration
public class RestDocsConfiguration {
@Bean
public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
return configurer -> configurer.snippets()
.withDefaults(
CliDocumentation.curlRequest(),
HttpDocumentation.httpRequest(),
HttpDocumentation.httpResponse(),
PayloadDocumentation.requestBody(),
PayloadDocumentation.responseBody(),
new CustomSnippet(null));
}
...
}
제약조건 표기 추가
java validation annotation을 api문서에 추가 하려 한다.
@Data
public class Member {
@NotNull
private int memberId;
@NotNull
@Size(max=10)
private String name;
private int age;
}
private static class ConstrainedFields {
private final ConstraintDescriptions constraintDescriptions;
ConstrainedFields(Class<?> input) {
this.constraintDescriptions = new ConstraintDescriptions(input);
}
private FieldDescriptor withPath(String path) {
return fieldWithPath(path)
.attributes(key("constraints")
.value(StringUtils.collectionToDelimitedString(
this.constraintDescriptions.descriptionsForProperty(path), "\n")));
}
}
- input파라미터로 받은 클래스의 제약조건을 가져와서, 메세지로 변환시킨다.
- 메시지는 /src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties
파일을 만들고, 각 제약조건에 해당하는 메세지를 재정의 할 수 있다.
javax.validation.constraints.NotNull.description=NOT NULL
javax.validation.constraints.Size.description=SIZE ${min} ~ ${max}
테스트 코드
//Given
Member member = new Member();
member.setMemberId(1);
member.setName("꿀똥벌레");
member.setAge(29);
//When
when(memberService.getMemberById(1)).thenReturn(member);
ConstrainedFields field = new ConstrainedFields(Member.class);
//Then
mockMvc.perform(get("/api/member/{memberId}", 1)
.accept(MediaType.APPLICATION\_JSON))
.andExpect(status().isOk())
.andDo(restDocumentationResultHandler.document(
pathParameters(
parameterWithName("memberId").description("멤버 id")
),
responseFields(
//기본 PayloadDocumentation.fieldWithPath를 사용하지 않고 새로 정의한 ConstrainedFields
//클래스의 withPath 를 사용하였다.
field.withPath("memberId").description("멤버 아이디"),
field.withPath("name").description("멤버 이름"),
field.withPath("age").description("나이")
)
));
추가한 constraints 속성을 사용하도록 response-fields.snippet 을 재정의
/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet
===== Response Fields
|===
|필드명|타입|필수여부|설명|제약조건
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
|{{#tableCellContent}}{{constraints}}{{/tableCellContent}}
{{/fields}}
|===
'SPRING' 카테고리의 다른 글
Spring Event (0) | 2021.05.27 |
---|---|
Spring 메소드 실행 실패시 재실행 (0) | 2021.05.24 |
Spring Batch (0) | 2021.04.28 |
Spring RestTemplate (0) | 2021.04.20 |
스프링 비동기 타임아웃 설정 변경 (0) | 2020.11.23 |