개인 프로젝트 중 진행한 API 문서 자동화 작업에 대해 기록한다.
API 문서 관리를 노션으로 진행했었는데
프로젝트에 변경사항이 발생하고 기능을 추가하는 과정에서
노션에 최신화를 하지 않는다거나 코드에 적용하지 않는 문제점이 발생했다.
이런 문제를 방지하기 위해 API 문서 자동화를 고려하게 되었다.
API 문서화 툴 중 가장 유명한 것으로 Swagger와 Rest docs가 있다.
Rest docs | Swagger | |
---|---|---|
장점 | 제품 코드에 영향이 없다 | API 테스트를 제공한다 |
테스트가 적용되야 문서가 작성된다 | 적용하기 쉽다 | |
단점 | 적용하기 어렵다 | 제품 코드에 어노테이션을 추가해야 한다 |
제품 코드와 동기화가 안될 수 있다. |
코드에 영향을 주지 않고 테스트가 우선 적용되야하는 특징으로 프로젝트에는 Rest docs를 선택했다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.14'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'org.asciidoctor.jvm.convert' version '3.3.2' // 플러그인 추가
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '1.8'
}
repositories {
mavenCentral()
}
configurations {
asciidoctorExt //asciidoctor 사용을 위한 선언
}
dependencies {
//asciidoctor 의존성 추가
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
//테스트코드 작성을 위한 의존성 추가
testImplementation 'io.rest-assured:rest-assured:4.4.0'
testImplementation 'io.rest-assured:spring-mock-mvc:4.4.0'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'
}
//스니펫이 만들어질 경로 변수 선언
ext {
set('snippetsDir', file("build/generated-snippets"))
}
tasks.named('test') {
outputs.dir snippetsDir //스니펫 생성 위치
useJUnitPlatform()
}
//asciidoctor 설정
tasks.named('asciidoctor') {
configurations 'asciidoctorExt'
sources {
include("**/index.adoc") // 특정 adoc만 생성
}
baseDirFollowsSourceFile()
inputs.dir snippetsDir
dependsOn test //테스트가 먼저 실행되고 동작하도록 설정
}
// 최신 상태로 유지해야 하므로 경로상 기존 파일 제거
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// from -> into 로 파일 복사
task createDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
//빌드 시 문서가 생성되고 빌드되도록 설정
bootJar {
dependsOn createDocument
}
restdocs configuration 클래스로
snippet의 이름 규칙과 요청, 반환의 출력을 prettyPrint로 설정해 주었다.
@Configuration
public class RestDocsConfiguration {
@Bean
public RestDocumentationResultHandler write() {
return MockMvcRestDocumentation.document(
"{class-name}/{method-name}",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
);
}
}
문서화할 테스트 코드가 사용할 부모 클래스로
위에서 설정한 RestDocsConfiguration을 적용하고
테스트에 필요한 의존성 및 beforeEach를 통한 초기화 과정을 설정한 클래스
@Import(RestDocsConfiguration.class)
@ExtendWith(RestDocumentationExtension.class)
public abstract class AbstractRestDocsTest {
@Autowired
protected RestDocumentationResultHandler restDocs;
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@BeforeEach
void setUp(
final WebApplicationContext context,
final RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(documentationConfiguration(restDocumentation))
.alwaysDo(MockMvcResultHandlers.print())
.alwaysDo(restDocs)
.addFilters(new CharacterEncodingFilter("UTF-8", true))
.build();
}
}
작성한 테스트 코드 중 하나로
RestaurantController가 RestaurantService를 의존하기 때문에
@MockBean을 사용해 RestaurantService를 사용
given().willReturn을 통해 식당 등록시 RestaurantService의 반환값을 미리 정해두고 테스트 하는 방식을 사용했다.
restDocs.document를 통해
API 문서에 어떤 field, parameter 등을 표시할지 설정했다.
@WebMvcTest(RestaurantController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
class RestaurantControllerTest extends AbstractRestDocsTest {
private static final LoginResDto TOKEN_DTO = new LoginResDto("accessToken");
private static final List<MenuResDto> MENU_LIST = new ArrayList<>(
Collections.singletonList(
new MenuResDto(1L, "양념치킨", 10000, MenuStatus.SALE))
);
@MockBean
private RestaurantService restaurantService;
@Test
@DisplayName("식당 등록 성공")
void successCreateRestaurant() throws Exception {
//given
CreateRestaurantReqDto reqDto = new CreateRestaurantReqDto("치킨집", 10000, 3000);
given(restaurantService.createRestaurant(anyString(), any(CreateRestaurantReqDto.class)))
.willReturn(1L);
//when
ResultActions resultActions = createRestaurant(reqDto);
//then
resultActions
.andExpect(status().isCreated())
.andExpect(header().string(LOCATION, "/api/v1/restaurants/1"))
.andDo(
restDocs.document(
requestFields(
fieldWithPath("name")
.type(JsonFieldType.STRING)
.description("식당 이름"),
fieldWithPath("minPrice")
.type(JsonFieldType.NUMBER)
.description("주문 최소 금액"),
fieldWithPath("deliveryFee")
.type(JsonFieldType.NUMBER)
.description("배달비")
),
responseHeaders(
headerWithName(LOCATION).description("생성된 식당 URL")
)
)
);
}
src/docs/asciidoc 경로에 restaurants.adoc 파일을 생성 후 아래와 같이 작성
식당 등록 API의 요청하는 목록을 표시
식당 등록 API의 요청 필드 값 표시
식당 등록 API의 반환 목록 표시
[[restaurant]]
== 식당 API
=== 식당 등록 성공
==== 요청
operation::restaurant-controller-test/success-create-restaurant[snippets='http-request']
operation::restaurant-controller-test/success-create-restaurant[snippets='request-fields']
==== 응답
operation::restaurant-controller-test/success-create-restaurant[snippets='http-response']