rest docs

Lee·2023년 10월 16일
0

서론

개인 프로젝트 중 진행한 API 문서 자동화 작업에 대해 기록한다.

API 문서 자동화를 고려하게 된 배경

API 문서 관리를 노션으로 진행했었는데
프로젝트에 변경사항이 발생하고 기능을 추가하는 과정에서
노션에 최신화를 하지 않는다거나 코드에 적용하지 않는 문제점이 발생했다.
이런 문제를 방지하기 위해 API 문서 자동화를 고려하게 되었다.

Swagger vs Rest docs

API 문서화 툴 중 가장 유명한 것으로 Swagger와 Rest docs가 있다.

Rest docsSwagger
장점제품 코드에 영향이 없다API 테스트를 제공한다
테스트가 적용되야 문서가 작성된다적용하기 쉽다
단점적용하기 어렵다제품 코드에 어노테이션을 추가해야 한다
제품 코드와 동기화가 안될 수 있다.

코드에 영향을 주지 않고 테스트가 우선 적용되야하는 특징으로 프로젝트에는 Rest docs를 선택했다.

적용과정

build.gradle

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")
					)
				)
			);
	}

생성한 adoc 파일

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']
profile
발전하고 싶은 백엔드 개발자

0개의 댓글