API 문서화
보통 서버 개발을 진행할 때, API Spec을 먼저 작성하고 Client에게 공유해서
같이 작업하는 방향을 맞춰나가는 식으로 많이 하죠?ㅎㅎ
하지만 작업이 진행되면 진행될수록 API Spec이 처음에 나왔던 Spec하고 비슷하면 정말 다행이겠지만.!
대게는 많이 같지가 않죠ㅠㅠ
그래서 Production Code를 바꾸면, Spec도 바꿔야 하는데...
우리의 실상은?
잘 까먹더라구요ㅠㅠ
그래서 자동화할 수 있는 방법에 대해 알아보도록 할게요~!
Swagger vs REST Docs
보통 Spring 진영에서 API Spec을 자동화하는 Tool로는
- Swagger
- REST Docs
크게 2개로 나뉘어지는데요, Swagger의 경우에는 Production Code에 어노테이션을 기반으로 해서 작성하게 되고,
REST Docs는 Test를 기반으로 해서 작성되게 됩니다.
한번 비교해볼까요?
먼저 Swagger 입니다
@Operation(summary = "Get a foo by foo id")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "found the foo", content = {
@Content(mediaType = "application/json", schema = @Schema(implementation = Foo.class))}),
@ApiResponse(responseCode = "400", description = "Invalid id supplied", content = @Content),
@ApiResponse(responseCode = "404", description = "Foo not found", content = @Content) })
@GetMapping(value = "{id}")
public ResponseEntity getFooById(@Parameter(description = "id of foo to be searched")
@PathVariable("id") String id) {
// implementation omitted for brevity
}
나는 Production Code를 읽는건지, 문서를 읽는 건지 모르겠다
너무나 난잡한 모습이에요..
반면에 REST Docs는?
@Test
public void whenGetFooById_thenSuccessful() throws Exception {
ConstraintDescriptions desc = new ConstraintDescriptions(Foo.class);
this.mockMvc.perform(get("/foo/{id}", 1))
.andExpect(status().isOk())
.andDo(document("getAFoo", preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(parameterWithName("id").description("id of foo to be searched")),
responseFields(fieldWithPath("id")
.description("The id of the foo" +
collectionToDelimitedString(desc.descriptionsForProperty("id"), ". ")),
fieldWithPath("title").description("The title of the foo"),
fieldWithPath("body").description("The body of the foo"))));
}
둘다 읽기는 매한가지군요
굳이 2개중의 하나를 선택하자면, 저는 REST Docs를 선택할 것 같습니다
왜냐하면?
Production Code는 정말 많이 읽는 반면, Test Code는 그만큼 많이 읽지 않습니다.
그리고 API Spec을 작성할 거면, Test를 강제화하는 효과도 있죠
또한, 필드가 추가되고 변경될 때 Test Code를 돌리는데,
Documentation에 명시하지 않으면 Test Case가 실패합니다
정말 너무나 좋군요..ㅎㅎ
테스트를 강제화 할 수 있고, 문서화도 강제화 할 수 있다니
그럼 한번 알아보러 가보도록 하겠습니다~!
Spring REST Docs
어떻게 하면 사용할 수 있을까요?
먼저 gradle로 설명드리겠습니다~!
plugins {
id 'org.springframework.boot' version '2.3.1.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'org.asciidoctor.convert' version '1.5.8' // asciidoctor plugin
id 'java'
}
group = 'com.huisam'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '14'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('snippetsDir', file("build/generated-snippets")) // 문서 저장위치
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'io.projectreactor:reactor-test'
testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc' // mock용
testCompile 'org.springframework.restdocs:spring-restdocs-webtestclient' // webtestclient용
}
test {
outputs.dir snippetsDir // 선언한 디렉토리에 생성
useJUnitPlatform()
}
asciidoctor {
attributes 'snippets': snippetsDir // adoc 파일 생성시 올바르게 include하기 위함
inputs.dir snippetsDir
dependsOn test
}
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") { // gradle은 src/docs/asciidoc 에 메인 adoc생성!
into 'static/docs' // asciidoctor로 만든 문서는 static/docs 디렉토리로.!
}
}
task copyDocument(type: Copy) { // 생성된 docs 파일을 build시 static 아래에 docs로 복사!
dependsOn bootJar
from file("build/asciidoc/html5/")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument // build시 copy 실행
}
의존성 설정만 잘 해주면, 생각보다 구현하는 것은 쉽습니다~!
한가지 꼭 명심해야 될 것은 html로 변환할 adoc 파일을
꼭 꼭 src/docs/asciidoc 으로 해주셔야 정상적으로 생성됩니다~!
이제 차근차근 설정해보러갈까요~?
의외로 되게 간단하게 끝납니다!
package com.huisam.documentation;
import org.springframework.boot.test.autoconfigure.restdocs.RestDocsMockMvcConfigurationCustomizer;
import org.springframework.boot.test.autoconfigure.restdocs.RestDocsWebTestClientConfigurationCustomizer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
@TestConfiguration
public class RestDocConfiguration {
@Bean
public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
return configurer -> configurer.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint());
}
@Bean
public RestDocsWebTestClientConfigurationCustomizer restDocsWebTestClientConfigurationCustomizer() {
return configurer -> configurer.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint());
}
}
먼저 Request Format과 Response 포맷을 예쁘게 꾸며주기 위한 설정파일을 만들고,
(1) MockMvc
package com.huisam.documentation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
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.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.restdocs.headers.HeaderDocumentation.*;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.snippet.Attributes.key;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@WebMvcTest(PersonController.class)
@Import(RestDocConfiguration.class) // 테스트 설정 import
@AutoConfigureRestDocs // 알아서 설정해준다~!
class PersonControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("Person Get Test")
void Person_Get_Test() throws Exception {
/* given when then */
this.mockMvc.perform(get("/v1/person").accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andDo(document("person-get",
requestHeaders( // 요청 헤더
headerWithName(HttpHeaders.ACCEPT).description(MediaType.APPLICATION_JSON_VALUE)
),
responseHeaders( // 응답 헤더
headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.APPLICATION_JSON_VALUE)
),
responseFields( // 응답 필드
fieldWithPath("name").description("Name of Person"),
fieldWithPath("age").description("Age of Person")
))
)
.andExpect(jsonPath("$.name").value(containsString("person")))
.andExpect(jsonPath("$.age").isNumber())
;
}
@Test
@DisplayName("Person Post Test")
void person_post_test() throws Exception {
/* given */
ClientRequest clientRequest = new ClientRequest("hi");
/* when & then */
this.mockMvc.perform(post("/v1/person")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(clientRequest))
)
.andDo(print())
.andDo(document("person-post",
requestHeaders(
headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.APPLICATION_JSON_VALUE),
headerWithName(HttpHeaders.ACCEPT).description(MediaType.APPLICATION_JSON_VALUE)
),
requestFields(
fieldWithPath("name").optional().description("Name of Person")
),
responseHeaders(
headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.APPLICATION_JSON_VALUE)
),
responseFields(
fieldWithPath("name").type(JsonFieldType.STRING).description("Name of Person")
.attributes(key("constraint").value("3자 이내")),
fieldWithPath("age").type(JsonFieldType.NUMBER).description("Age of Person").optional()
))
)
.andExpect(jsonPath("$.name").value(containsString("hi")))
.andExpect(jsonPath("$.age").isNumber())
;
}
}
(2) WebTestClient
package com.huisam.restdoc;
import org.junit.jupiter.api.DisplayName;
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.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.BodyInserters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.restdocs.headers.HeaderDocumentation.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document;
@WebFluxTest(PersonController.class)
@Import(RestDocConfiguration.class)
@AutoConfigureRestDocs
public class WebClientPersonControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
@DisplayName("Person Post Test")
void test() {
/* given */
ClientRequest clientRequest = new ClientRequest("hi");
/* when */
final Person responsePerson = webTestClient.post()
.uri("/v1/person")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(clientRequest))
.exchange()
.expectStatus().isOk()
.expectBody(Person.class)
.consumeWith(
document("web-test-person-post", // 디렉토리명(generated-snippets/web-test-person-post)
requestHeaders( // RequestHeader 작성
headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.APPLICATION_JSON_VALUE),
headerWithName(HttpHeaders.ACCEPT).description(MediaType.APPLICATION_JSON_VALUE)
),
requestFields( // RequestField 작성
fieldWithPath("name").optional().description("Name of Person")
),
responseHeaders( // RequestHeader 작성
headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.APPLICATION_JSON_VALUE)
),
responseFields( // ResponseField 작성
fieldWithPath("name").type(JsonFieldType.STRING).description("Name of Person"),
fieldWithPath("age").type(JsonFieldType.NUMBER).description("Age of Person")
))
)
.returnResult()
.getResponseBody();
/* then */
assertThat(responsePerson)
.extracting("name")
.isEqualTo(clientRequest.getName());
}
}
이런방식으로 작성하게 되면~?
위 처럼 문서 파일들이 나타나게 됩니다~!
실제 파일을 열어보면 다음처럼 HTTP Request Format에 대한 형식이 나오는 것을 볼 수 있습니다~!
그럼 위에서 만든 파일들을 어떻게 종합할까요?
Spring REST Docs문서에도 나와있듯이
해당 디렉토리에 알맞는 sourcefile을 생성해서 .adoc 파일을 만들어줘야 합니다!
작성하는 포맷은 adoc 포맷에 맞춰서 작성하면 되는데, adoc 파일을 include 해줘야 하므로
문법을 공부하는 것을 추천드립니다~ ^-^
자, 그러면 우리는 빌드할 준비가 되었죠?
- mvn package
- ./gradlew build
명령어를 실행하고 Application을 실행하면..!!
짠.! 우리가 원하는대로 나왔습니다 ㅎㅎ
문서 필드 커스터마이징
Name, Description, Field 말고도 더 커스터마이징 하고 싶다구요.?
REST Docs에는 이미 다 준비되어 있습니다 ㅎㅎ
바로 Mustache를 기반으로 해서 템플릿을 새로 작성할 수 있는데요!
디렉토리는 src/test/resources/org/springframework/restdocs/templates/asciidoctor 에 넣어주시면 됩니다.!
파일명은... request-fields.snippet 혹은 response-fields.snippet 이런식으로요.!
저는 간단하게 한번 이렇게 만들어보았는데요.!
response-fields.snippet 파일
=== Response Fields
|===
|필드|타입|필수값|설명|제한
{{#fields}}
|{{path}}
|{{type}}
|{{^optional}}true{{/optional}}
|{{description}}
|{{#constraint}}{{constraint}}{{/constraint}}
{{/fields}}
|===
Fields 필드를 돌면서 각각의 Attribute를 파싱하는 것이에요~!
코드에서 보았던
- fieldWithPath() -> #path
- type() -> #type
- optional() -> #optional (위에서는 optional 값이 False일 때 true라고 출력되게 설정)
- description() -> #description
- attributes(key("constraint").value()) -> # constraint ( 위에서는 constraint가 존재할 때만 값을 설정 )
이런식으로 커스텀 필드는 attributes로 설정할 수 있습니다~!
요런식으로 설정해주시면
요런식으로 랜더링되는 것을 볼 수 있습니다 ㅎㅎ
마치며
오늘은 Spring REST Docs에 대해 알아보았는데요.
이렇게나 완벽해보이는 REST Docs도 단점이 존재한답니다
- Test 돌릴때마다 adoc파일이 새로 생성됨
- asciidoc과 mustache 문법이 어려움
보통 어려운 것들은 대체로 배우기만 하면 유용할 때가 더 많더라구요 ㅎㅎ
그러니까 한번 다같이 문서를 자동화해보는 것은 어떨까요~?
참고
Spring REST Docs
'Developer > Spring' 카테고리의 다른 글
Spring Boot 에서 Log4j2 를 설정하는 방법 (0) | 2020.09.28 |
---|---|
Spring AOP의 원리 - CGlib vs Dynamic Proxy (11) | 2020.08.18 |
Spring Boot 에서 log를 남기는 방법 - Spring log 남기기 (4) | 2020.06.13 |
Spring Valid - 스프링에서 Reqeust Body를 Validation 하는 방법 (0) | 2020.05.08 |
Spring Mapstruct - Java Entity DTO 매핑을 편하게 하자! (6) | 2020.03.25 |