API 문서를 사용하다 보면 실수할 일이 많다. 나의 경우에는 토이 프로젝트를 하면서, API를 실시간으로 업데이트하지 않고 수작업으로 입출력 관련 json 포맷을 직접 만들어야 하기 때문에 실수할 여지가 많았다. 그리고 팀원과 함께 토이 프로젝트를 하면서 생긴 문제점 중에 하나는 실시간으로 구현한 API를 문서를 통해 업데이트하지 않으면 어떤 API가 현재 구현되어 있고, 사용가능한지 사용하려는 입장에서 알기 쉽지 않았다.
이렇게 제공하려는 필드가 많거나 이런 API들이 많아질 때 한눈에 파악하기 또한 쉽지 않다. 이를 해결하기 위해서는 목차를 만들어 한눈에 보는 방법을 필요하기도 했다.
java 진영에서 API 문서 자동화로 유명한 라이브러리는 rest docs와 swagger이다. 둘의 특징을 요약하면 다음과 같다.
Rest Docs
Swagger
swagger가 처음에는 굉장히 매력적으로 다가왔지만, 문서 자동화를 위해 배포 코드가 복잡해지는 것을 원치 않았다. 그래서 테스트 코드 기반으로 동작하며, 원하면 제공할 정보를 커스터마이징할 수 있는 rest docs를 선택하게 되었다.
rest docs를 maven과 gradle 모두 셋팅 가능하지만 gradle로만 구성해서 사용해봤기 때문에 gradle 기준으로 설명하겠다.
Rest Docs를 사용하려면 우선 dependency와 plugin을 등록해야 한다.
plugins {
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
dependencies {
// *.adoc 파일을 HTML로 만들어 export 해줌
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.6.RELEASE'
// restdocs-mockmvc의 testCompile 구성 -> mockMvc를 사용해서 snippets 조각들을 뽑아냄
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:2.0.6.RELEASE'
}
그리고 configuration과 변수를 설정해준다.
configurations {
asciidoctorExtensions
}
// 변수 선언
ext {
snippetsDir = file('build/generated-snippets') // rest-docs 스니펫 위치 설정
}
우리가 configuration과 변수들을 설정한 이유는 gradle의 task를 활용해 test build와 같은 gradle 명령 호출시에 자동으로 task를 실행하기 위해서다.
우리가 필요한 task는 test시에 스니펫을 자동으로 생성하고 build시에 adoc를 html로 convert하고, 이를 static/docs에 복사해야한다.
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExtensions'
dependsOn test
sources {
include("**/index.adoc", "**/common/*.adoc")
}
baseDirFollowsSourceDir()
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// 커스텀 task 선언: asciidoctor 동작에 의존하고 해당 동작 이후 생성된 파일을 복사하는 task
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
tasks.named('test') {
// 위에서 작성한 snippetsDir 디렉토리를 test의 output으로 구성하는 설정 -> 스니펫 조각들이 build/generated-snippets로 출력함
outputs.dir snippetsDir
}
나의 프로젝트의 경우에는 asciidoc.gradle로 분리했는데 전체 설정은 다음과 같다.
// asciidoc.gradle
configurations {
asciidoctorExtensions
}
// 변수 선언
ext {
snippetsDir = file('build/generated-snippets') // rest-docs 스니펫 위치 설정
}
dependencies {
// asciidoc
// 1.build/generated-snippets 에 생긴 .adoc 조각들을 프로젝트 내의 .adoc 파일에서 읽어들일 수 있도록 연동
// 2. .adoc 파일을 HTML로 만들어 export 해줌
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.6.RELEASE'
// restdocs-mockmvc의 testCompile 구성 -> mockMvc를 사용해서 snippets 조각들을 뽑아냄
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:2.0.6.RELEASE'
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExtensions'
dependsOn test
sources {
include("**/index.adoc", "**/common/*.adoc")
}
baseDirFollowsSourceDir()
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// 커스텀 task 선언: asciidoctor 동작에 의존하고 해당 동작 이후 생성된 파일을 복사하는 task
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
tasks.named('test') {
// 위에서 작성한 snippetsDir 디렉토리를 test의 output으로 구성하는 설정 -> 스니펫 조각들이 build/generated-snippets로 출력함
outputs.dir snippetsDir
}
//build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.9'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
group = 'com.almondia'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
apply from: 'asciidoc.gradle'
rest docs에는 테스트코드를 작성하면 기본적으로 생성해서 제공하는 스니펫이 있다.
이 중 하나 클릭해서 살펴보면 다음과 같다.
이것은 request-parameters 스니펫인데
pathParameters(parameterWithName("categoryId").description("카테고리 아이디"))
위와 같이 pathParameters name과 description 속성을 반영해서 필요한 adoc 파일을 생성한다. 그러나 name, decription 외에 추가적으로 특성들을 더 넣거나 컬럼명을 수정하고 싶을 수도 있다.
기존에 제공하는 형태의 스니펫과 달리 몇가지 추가 속성을 추가하고 기존의 snippet에 덮어 씌우는 방법을 통해 이를 해결할 수 있다.
우선 src/test/resources 에 다음과 같은 경로를 추가해서 덮어씌우고 싶은 스니펫 이름과 똑같이 생성한다.
그 이후 다음과 같이 추가해주면 된다.
// request-parameters.snippet
|===
|파라미터|필수여부|설명
{{#parameters}}
|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/parameters}}
|===
optional을 받아서 필수여부를 추가한 스니펫
// request-fields.snippet
|===
|필드명|타입|필수여부|제약조건|설명
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{#constraints}}{{.}}{{/constraints}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/fields}}
|===
optional과 contraints 속성을 입력받아 표를 작성하는 스니펫
위와 같이 설정하면 테스트 코드 작성시 다음과 같은 형태의 표를 얻을 수 있다.
실제로 설정한 스니펫과 테스트 코드를 활용해 adoc 파일을 생성해야 한다.
Junit5 기준으로 작성했기 떄문에 이를 기준으로 설명하겠다.
우선
@WebMvcTest(CategoryController.class)
@ExtendWith({RestDocumentationExtension.class})
@Import({WebMvcConfiguration.class, JacksonConfiguration.class})
class CategoryControllerTest {
@WebMvcTest(CategoryController.class)
@ExtendWith({RestDocumentationExtension.class})
이 2개를 등록한다. @WebMvcTest는 Controller 단위 테스트를 위해 등록하는 어노테이션이고 거기에 RestDocumentationExtension을 추가로 설정한다.
그 이후
@Autowired
WebApplicationContext context;
MockMvc mockMvc;
WebApplicationContext를 등록하고 mockMvc 타입을 test class의 인스턴스 변수로 둔다.
테스트마다 테스트 이전에 mockMvc 객체를 생성해서 정의하는데 코드는 다음과 같다.
@BeforeEach
public void setUp(RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.alwaysDo(print())
.apply(documentationConfiguration(restDocumentation))
.build();
}
모든 사전 작업의 준비는 끝났다. 이제 실제로 테스트 코드를 기반으로 adoc 파일을 생성하기 위해서는 mockMvc 결과 작업에 추가적으로 코드를 작성해주면 된다.
@Nested
@DisplayName("카테고리 등록")
class saveCategoryTest {
@Test
@DisplayName("카테고리 등록시 성공하면 201 코드 및 응답 검증")
@WithMockMember
void shouldReturn201WhenEnrollSuccessTest() throws Exception {
// given
Mockito.doReturn(CategoryTestHelper.generateCategoryResponseDto())
.when(categoryservice)
.saveCategory(any(), any());
String saveRequest = objectMapper.writeValueAsString(CategoryTestHelper.generateSaveCategoryRequestDto());
// when
ResultActions result = mockMvc.perform(post("/api/v1/categories").header("Authorization", jwtToken)
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.content(saveRequest));
//then
result.andExpect(status().isCreated())
.andExpect(jsonPath("categoryId").exists())
.andExpect(jsonPath("memberId").exists())
.andExpect(jsonPath("title").exists())
.andExpect(jsonPath("thumbnail").exists())
.andExpect(jsonPath("deleted").exists())
.andExpect(jsonPath("shared").exists())
.andExpect(jsonPath("createdAt").exists())
.andExpect(jsonPath("modifiedAt").exists())
.andDo(document("{class-name}/{method-name}",
getDocumentRequest(),
getDocumentResponse(),
requestHeaders(
headerWithName("Authorization").description("JWT Bearer 토큰")
),
requestFields(
fieldWithPath("title").description("카테고리 제목").attributes(key("constraints").value("최대 40자")),
fieldWithPath("thumbnail").description("카테고리 썸네일 이미지 링크(s3)")
.optional()
.attributes(key("constraints").value("최대 255자"))
),
responseFields(
fieldWithPath("categoryId").description("카테고리 아이디"),
fieldWithPath("memberId").description("회원 아이디"), fieldWithPath("title").description("카테고리 제목"),
fieldWithPath("thumbnail").description("카테고리 썸네일 이미지"),
fieldWithPath("deleted").description("카테고리 삭제 여부"),
fieldWithPath("shared").description("카테고리 공유 여부"),
fieldWithPath("createdAt").description("카테고리 생성일"),
fieldWithPath("modifiedAt").description("카테고리 수정일")
)
));
}
}
document는 Identifier, RequestPreProcessor, ResponsePreProcessor, Snippet 등의 타입을 입력으로 받는다. 아래의 requestHeaders, requestFields, responseFields는 실제로 snippet생성의 중요한 역할을 담당한다.
Identifier는 해당 document를 식별하기위해 사용하는데 snippet 생성 경로라고 생각하면 된다.
마지막으로 PreProccesor는 snippet 생성 이전에 몇가지 설정들을 정할 수 있다. 꽤나 반복되는 코드가 많기 때문에 인터페이스로 정의해두었다.
public interface ApiDocumentUtils {
static OperationRequestPreprocessor getDocumentRequest() {
return preprocessRequest(
modifyUris()
.scheme("https")
.host("mecastudy.com")
.removePort(),
prettyPrint());
}
static OperationResponsePreprocessor getDocumentResponse() {
return preprocessResponse(prettyPrint());
}
}
https://backtony.github.io/spring/2021-10-15-spring-test-3/
https://techblog.woowahan.com/2597/