API 문서 자동화를 해야 할 필요성을 느끼면서 알게 된 방법은 2가지가 있었다.
1. Swagger 2. RestDocs
처음에는 사용이 간편한 Swagger를 사용했었다.
build.gradle 에
implementation 'org.springdoc:springdoc-openapi-ui:1.6.6'
위의 의존성 하나만 추가해주고 코드에
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI();
}
}
이 Config 클래스만 등록해주면 간편하게 API 문서가 자동화가 되는 것을 확인할 수 있었다.
하지만 API 가 어떤 동작을 하는 API 인지, URI의 쿼리 파라미터나 Path variable 이 어떤 걸 뜻하는지, request와 response의 필드들이 어떤 것을 의미하는지 나타내도록 커스텀하려면 Controller의 코드에 어노테이션을 잔뜩 추가할 수밖에 없었다.
그리고 다른 방법인 RestDocs 는 테스트 코드를 만들고, 그 테스트 코드가 성공을 해야 문서가 작성되고, 소스 코드를 침범하지 않는다는 점에서 장점이 있으나 설정이 복잡하고, 만들어지는 문서가 interactive 하지 않다는 단점이 있었다.
그래서 어떤 것을 사용해야 할까 고민하던 중 RestDocs와 Swagger를 같이 사용해서 두 방법의 장점만 뽑아서 사용할 수가 있다는 이야기를 듣고 해당 방법을 사용해 보기로 결정하였다.
RestDocs와 SwaggerUI를 함께 사용하는 방법은 간단히 말하면 RestDocs로 만들어진. adoc 파일들을
id 'com.epages.restdocs-api-spec' 플러그인이 OpenApi3 스펙을 가진 .json 파일로 변화를 시켜주고, 해당 파일을 SwaggerUI (Swagger 랑 다름! Swagger를 사용하면 OpenApi 코드가 작성되고 SwaggerUI 가 그 코드를 읽어 시각화가 되는 것)가 읽어서 Swagger처럼 API 문서가 시각화가 되는 것이다.
Java 17, Spring Boot 3.1.1 기준으로 설정 방법이다.
// restDocs Api 스펙 버전 추가
buildscript {
ext {
restdocsApiSpecVersion = '0.18.2'
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.1'
id 'io.spring.dependency-management' version '1.1.0'
// openApi 스펙으로 변경해주는 플러그인들 추가
id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
id 'org.hidetake.swagger.generator' version '2.18.2'
}
group = 'com.stampcrush'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// swagger ui openapi 의존성 추가
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:5.3.1'
testImplementation 'org.projectlombok:lombok'
// restdocs-mockmvc 라이브러리 추가
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}"
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
tasks.named('test') {
useJUnitPlatform()
}
// openapi3 문서 설정
openapi3 {
setServer("http://localhost:8080")
title = "스탬프크러쉬 API Docs"
description = "스탬프크러쉬 API 명세서"
version = "0.0.1"
format = "json"
outputDirectory = 'build/resources/main/static/docs'
}
// 처음에 outputDirectory 가 만들어져있지 않으면 문서가 생성되지 않기에 처음에 output directory 설정
task createOutputDirectory {
doFirst {
file(openapi3.outputDirectory).mkdirs()
}
}
// swagger UI 가 시각화할 대상을 openapi3 로 설정
tasks.withType(GenerateSwaggerUI) {
dependsOn 'openapi3'
delete file('src/main/resources/static/docs/')
copy {
from "build/resources/main/static/docs"
into "src/main/resources/static/docs/"
}
}
// bootJar 가 실행되기 전에 실행될 태스크들 설정
bootJar {
dependsOn 'createOutputDirectory', ':openapi3'
}
나는 RestDocs 테스트 코드를 MockMvc 를 사용해서 구현했기 때문에 MockMvc 의존성을 추가해 주었다. RestAssured를 사용해서 구현하려면 RestAssured 의존성을 구현해 주면 된다.
테스트 코드의 소스 코드는 아래와 같다.
package com.stampcrush.backend.api.docs;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.stampcrush.backend.application.manager.cafe.ManagerCafeCommandService;
import com.stampcrush.backend.common.KorNamingConverter;
import com.stampcrush.backend.entity.user.Owner;
import com.stampcrush.backend.entity.user.RegisterCustomer;
import com.stampcrush.backend.repository.user.OwnerRepository;
import com.stampcrush.backend.repository.user.RegisterCustomerRepository;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;
import java.util.Base64;
import static com.stampcrush.backend.fixture.CustomerFixture.REGISTER_CUSTOMER_GITCHAN;
import static com.stampcrush.backend.fixture.OwnerFixture.OWNER3;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
@KorNamingConverter
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@WebMvcTest({
ManagerCustomerCommandApiController.class,
ManagerCafeCommandApiController.class,
ManagerCafeCouponSettingCommandApiController.class,
})
@ExtendWith({RestDocumentationExtension.class})
public abstract class DocsControllerTest {
protected static final Long CAFE_ID = 1L;
protected static final Owner OWNER = OWNER3;
protected static final RegisterCustomer CUSTOMER = REGISTER_CUSTOMER_GITCHAN;
protected static String OWNER_BASIC_HEADER;
protected static String CUSTOMER_BASIC_HEADER;
protected MockMvc mockMvc;
@Autowired
protected WebApplicationContext ctx;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected ManagerCafeFindService managerCafeFindService;
@MockBean
protected OwnerRepository ownerRepository;
@MockBean
protected RegisterCustomerRepository customerRepository;
@MockBean
protected ManagerCafeCommandService managerCafeCommandService;
@MockBean
public AuthTokensGenerator authTokensGenerator;
@BeforeAll
static void setUpAuth() {
OWNER_BASIC_HEADER = "Basic " + Base64.getEncoder().encodeToString((OWNER.getLoginId() + ":" + OWNER.getEncryptedPassword()).getBytes());
CUSTOMER_BASIC_HEADER = "Basic " + Base64.getEncoder().encodeToString((CUSTOMER.getLoginId() + ":" + CUSTOMER.getEncryptedPassword()).getBytes());
}
@BeforeEach
void setUp(final RestDocumentationContextProvider restDocumentation) {
mockMvc = MockMvcBuilders.webAppContextSetup(ctx)
.apply(documentationConfiguration(restDocumentation))
.addFilters(new CharacterEncodingFilter("UTF-8", true))
.alwaysDo(print())
.build();
}
}
이렇게 DocsControllerTest 라는 최상위 클래스를 두고
package com.stampcrush.backend.api.docs.manager.cafe;
import com.epages.restdocs.apispec.ResourceSnippetParameters;
import com.epages.restdocs.apispec.Schema;
import com.stampcrush.backend.api.docs.DocsControllerTest;
import com.stampcrush.backend.api.manager.cafe.request.CafeCreateRequest;
import com.stampcrush.backend.api.manager.cafe.request.CafeUpdateRequest;
import com.stampcrush.backend.application.manager.cafe.dto.CafeCreateDto;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import java.time.LocalTime;
import java.util.Optional;
import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document;
import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
public class ManagerCafeCommandApiDocsControllerTest extends DocsControllerTest {
@Test
void 카페_상세_정보_변경() throws Exception {
// given
when(ownerRepository.findByLoginId(OWNER.getLoginId())).thenReturn(Optional.of(OWNER));
CafeUpdateRequest request = new CafeUpdateRequest("안녕하세요", LocalTime.NOON, LocalTime.MIDNIGHT, "01012345678", "imageUrl");
// when, then
mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/admin/cafes/{cafeId}", CAFE_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header(HttpHeaders.AUTHORIZATION, OWNER_BASIC_HEADER))
.andDo(document("manager/cafe/update-cafe",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
resource(
ResourceSnippetParameters.builder()
.tag("사장 모드")
.description("카페 상세 정보 업데이트")
.requestHeaders(headerWithName("Authorization").description("임시(Basic)"))
.requestFields(
fieldWithPath("openTime").description("오픈 시간"),
fieldWithPath("closeTime").description("마감 시간"),
fieldWithPath("telephoneNumber").description("전화번호"),
fieldWithPath("cafeImageUrl").description("카페 이미지 URL"),
fieldWithPath("introduction").description("카페 소개글")
)
.requestSchema(Schema.schema("CafeUpdateRequest"))
.build()
)
)
)
.andExpect(status().isOk());
}
}
이런 식으로 테스트 하나하나씩 작성하면 된다!
혹시 동작이 되지 않는다면 import 되는 의존성들을 다시 잘 확인해봐야 한다.
테스트 코드가 성공을 하고 build 를 하면 build 디렉토리의 generated-snippets에. adoc 파일이 생성되는 것과 build.gradle 에 설정해 놨듯이 openapi3.json 파일이 build/resources/main/static/docs 디렉토리에 생성되는 것을 볼 수 있다.
그리고 어플리케이션을 실행시키면 src/main/resources/static/docs 위치로 해당 json 파일이 복사가 되고 SwaggerUI 가 해당 위치에 있는 파일을 읽게 해야 할 필요가 있다.
그래서 application.yml 에 아래 설정들을 추가해 주면 된다.
springdoc:
swagger-ui:
url: /docs/openapi3.json
path: /api/swagger-ui/index.html
url 은 swaggerUI 가 기본으로 읽어올 대상의 위치를 지정해 주는 설정이고 path는 swaggerUI의 경로를 설정해 준다. 위의 경우는 localhost:8080/api/swagger-ui/index.html로 접근하면 swaggerUI 가 동작되도록 설정해 둔 것이다.
최종적으로 스탬프크러쉬의 API 를 예쁘게 문서화할 수 있었다 🥳
참고 글
'우아한테크코스' 카테고리의 다른 글
5차 데모데이 회고 (0) | 2023.09.26 |
---|---|
Tomcat 구현하기 (1) - Servlet (0) | 2023.09.04 |
런칭 페스티벌 회고 (0) | 2023.08.21 |
3차 데모데이 회고 (7) | 2023.08.04 |
git fetch, rebase, merge, pull 명령어 확실히 알아보기 (0) | 2023.08.04 |