[Spring] Spring REST Docs, Swagger 조합. restdocs-api-spec
- -
Spring REST Docs + Swagger
Swagger, Spring REST Docs 간단한 설명
자바로 API를 개발하게 되면 일반적으로 Spring REST Docs와 Swagger 중 하나를 사용하여 API 문서화를 진행하게 된다.
Spring Rest Docs와 Swagger의 특징은 아래와 같다.
[Swagger]
- API 테스트가 가능하다.
- API 문서 생성이 자동으로 이루어진다.
- 프로덕션 코드에 Swagger 문서화를 위한 어노테이션이 추가된다.
[Spring REST Docs]
- 테스트 코드 작성을 강제하여 API 문서가 신뢰성이 있다.
- 테스트 성공 이후 생성된 스니펫으로 직접 문서를 작성해야 한다.
- API 테스트 불가.
프로덕션 코드에 API 문서 관련 코드가 추가되면 컨트롤러 단이 상당히 지저분해질 것 같아서 Spring REST Docs를 자주 사용했다.
하지만 테스트 코드 변경시마다 직접 문서를 수정하는 과정이 반복되다 보니 불편함을 느껴서 방법을 찾아보게 되었고 restdocs-api-spec
라이브러리를 알게 되었다.
restdocs-api-spec
restdocs-api-spec
라이브러리를 사용하면 Spring REST Docs와 같이 테스트 코드가 통과하면 OpenAPI 스펙을 얻게되고, Swagger-UI를 통해 OpenAPI 스펙을 문서로 띄우고 테스트할 수 있는 환경을 제공할 수 있다.
이 포스팅은 Spring REST Docs와 Swagger의 장점을 동시에 누릴 수 있도록 restdocs-api-spec
라이브러리를 사용하여 문서를 작성하는 방법을 기록한다.
적용 방법
- 작성한 모든 코드는 필자의 깃허브에서 확인할 수 있다.
build.gradle 설정
restdocs-api-spec 라이브러리를 사용하기 위해 build.gradle
에 의존성을 추가한다.
// restdocs-api-spec: version 변수 설정
buildscript {
ext {
restdocsApiSpecVersion = '0.16.2'
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.7'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
// restdocs-api-spec: plugin 추가
id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
// restdocs-api-spec: OpenAPI 스펙 생성 설정 명시
openapi3 {
server = "http://localhost:8081"
title = "restdocs-swagger Test API Documentation"
description = "Spring REST Docs with SwaggerUI"
version = "0.0.1-SNAPSHOT"
format = "json"
outputDirectory = "src/main/resources/static"
outputFileNamePrefix = "swagger"
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// restdocs-api-spec: restdocs-api-spec-mockmvc 의존성 추가
testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}"
}
tasks.named('test') {
useJUnitPlatform()
}
openapi3 테스크에서 생성될 OpenAPI 스펙 관련 설정을 추가하는 것을 확인할 수 있다.
관련 설정은 여기에서 확인할 수 있다.
프로젝트 생성시 Spring REST Docs 의존성을 추가하여 생성했다면 asciidoctor 관련 설정이 적용되어 있을 텐데, restdocs-api-spec 라이브러리를 사용하게 되면 해당 설정이 필요없다. asciidoctor 관련 설정을 지운다.
’org.springframework.restdocs:spring-restdocs-mockmvc’ 의존성도 필요없다. restdocs-api-spec-mockmvc 라이브러리가 restdocs의 기능을 지원한다.
API 작성
다음으로 API를 작성한다.
예제를 위해 간단하게 다음과 같이 작성했다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@PostMapping
public ApiResponse<UserAddResponse> userAdd(@Validated @RequestBody UserAddRequest request, BindingResult bindingResult) {
// 요청 데이터 검증 실패시 ControllerAdvice가 Catch -> Exception Controll 함.
if (bindingResult.hasErrors()) {
throw new ValidationException(bindingResult);
}
Long userId = userService.saveUser(
new User(
request.getName(),
request.getAge(),
request.getGender()
)
);
return ApiResponse.createSuccess(new UserAddResponse(userId));
}
}
// 공통 응답 Spec
@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ApiResponse<T> {
private LocalDateTime responseTime;
private String errorMessage;
private T data;
public static <T> ApiResponse<T> createSuccess(T data) {
return ApiResponse.<T>builder()
.responseTime(LocalDateTime.now())
.data(data)
.build();
}
public static ApiResponse<?> createError(String errorMessage) {
return ApiResponse.builder()
.responseTime(LocalDateTime.now())
.errorMessage(errorMessage)
.build();
}
// 요청 데이터 검증 실패시 data 필드에 MultiValueMap으로 필드 오류 메시지 넣음
public static <T> ApiResponse<T> createValidationError(T data) {
return ApiResponse.<T>builder()
.responseTime(LocalDateTime.now())
.errorMessage("요청 데이터 검증 오류 발생")
.data(data)
.build();
}
}
// 요청 성공시 ApiResponse.data에 들어갈 응답 데이터
@Data
@AllArgsConstructor
public class UserAddResponse {
private Long userId;
}
// 요청 Body 데이터
@Data
@AllArgsConstructor
public class UserAddRequest {
@NotBlank
private String name;
@Range(min = 19, max = 30)
private int age;
private Gender gender;
}
이제 테스트 코드를 작성하자.
테스트 코드 작성
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs //
@ExtendWith(RestDocumentationExtension.class) //
public class UserControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
// 요청 성공시 공통 응답 Spec 부분 추출
FieldDescriptor[] successResponseFields = {PayloadDocumentation.fieldWithPath("responseTime").type(JsonFieldType.STRING).description("응답 시간"),
PayloadDocumentation.subsectionWithPath("errorMessage").type(JsonFieldType.STRING).description("오류 메시지").optional(),
PayloadDocumentation.subsectionWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터").optional()};
// 요청 데이터 검증 실패시 공통 응답 Spec 부분 추출
FieldDescriptor[] validFailResponseFields = {PayloadDocumentation.fieldWithPath("responseTime").type(JsonFieldType.STRING).description("응답 시간"),
PayloadDocumentation.subsectionWithPath("errorMessage").type(JsonFieldType.STRING).description("오류 메시지"),
PayloadDocumentation.subsectionWithPath("data").type(JsonFieldType.OBJECT).description("오류 필드")};
@Nested
@DisplayName("유저 생성")
class userAdd {
@Test
@DisplayName("유저 생성 성공")
public void success() throws Exception {
// given
UserAddRequest userAddRequest = new UserAddRequest("user1", 25, Gender.MEN);
// when
ResultActions perform = mockMvc.perform(
RestDocumentationRequestBuilders.post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.content(objectMapper.writeValueAsString(userAddRequest))
);
// then
perform.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.data.userId").isNotEmpty());
// docs
perform.andDo(
MockMvcRestDocumentationWrapper.document(
"{class-name}/{method-name}",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
ResourceDocumentation.resource(
ResourceSnippetParameters.builder()
.description("유저 생성")
.requestFields(
PayloadDocumentation.fieldWithPath("name").description("이름"),
PayloadDocumentation.fieldWithPath("age").description("나이"),
PayloadDocumentation.fieldWithPath("gender").description("성별")
)
.responseFields(
new FieldDescriptors(successResponseFields).and(
PayloadDocumentation.fieldWithPath("data.userId").description("생성된 유저 식별값")
)
)
.build()
)
)
);
}
@Test
@DisplayName("요청 데이터 검증 실패")
public void validFail() throws Exception {
// given
UserAddRequest userAddRequest = new UserAddRequest(null, 10, null);
// when
ResultActions perform = mockMvc.perform(
RestDocumentationRequestBuilders.post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.content(objectMapper.writeValueAsString(userAddRequest))
);
// then
perform.andExpect(MockMvcResultMatchers.status().isBadRequest());
// docs
perform.andDo(
MockMvcRestDocumentationWrapper.document(
"{class-name}/{method-name}",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
ResourceDocumentation.resource(
ResourceSnippetParameters.builder()
.responseFields(
new FieldDescriptors(validFailResponseFields).and(
PayloadDocumentation.subsectionWithPath("data.name").description("name 필드 검증 오류 메시지 리스트").optional(),
PayloadDocumentation.subsectionWithPath("data.age").description("age 필드 검증 오류 메시지 리스트").optional()
)
)
.build()
)
)
);
}
}
}
기본적으로 Spring REST Docs 사용법과 같다.
주의할 점은 docs 작성 시에 MockMvcRestDocumentationWrapper.document()
를 사용해야 한다는 점이다.
또한 @AutoConfigureRestDocs
어노테이션이 필요하다.
기존에 Spring REST Docs를 사용할 때에는 해당 어노테이션이 없어도 MockMvc에 커스터마이징한 설정을 넣어서 사용했다.
내가 방법을 못찾은 것일 수도 있지만.. 이번 시도에서는 커스터마이징이 어려웠다.
한 가지 주의할 점이 또 있다.
이 역시 Spring REST Docs를 사용할 때에는 PayloadDocumentation.beneathPath()
를 통해 응답의 특정 필드부터만 문서 작성을 명시해도 테스트가 통과되었다.
이 때에는 @AutoConfigureRestDocs
어노테이션을 사용하지 않았고 직접 설정을 추가했었는데, restdocs-api-spec
라이브러리를 사용할 때에는 @AutoConfigureRestDocs
어노테이션 때문인지 몰라도, beneathPath()
를 사용하면 테스트가 실패했다(스니펫은 정상적으로 생성된다).
이 때문에 공통 API 스펙같은 경우 따로 추출하여 FieldDescriptors(...)
로 묶어주고, 추가로 FieldDescriptor
가 필요한 경우, FieldDescriptors.and(...)
API를 통해 붙여주었다.
Open API Spec 생성
테스트 코드 작성이 완료되고 테스트를 수행하여 통과되면 /build/generated-snippets/
아래에 .adoc
파일과 resource.json
파일이 생성되었을 것이다.
이제 gradle의 openapi3
태스크를 수행하면 .adoc
스니펫 파일들과 resource.json
파일을 이용해서 Open API 스펙을 생성한다../gradlew openapi3
명령을 입력하거나 인텔리제이 기준 Gradle 탭을 열어 Tasks/documentation/openapi3
태스크를 수행하자.
태스크 수행이 성공적으로 완료되면, build.gradle
에서 openapi3
태스크 설정시 지정한 outputDirectory
경로에 {outputFileNamePrefix}.{format}
으로 Open API 스펙이 생성된다.
따로 지정하지 않았다면 디폴트 값인 build/api-spec/openapi3.json
으로 생성된다.
Swagger-UI로 Open API Spec 띄우기
프로젝트에서 Swagger-UI 의존성을 추가하여 Swagger 페이지를 생성할 수도 있지만, 별도 Swagger-UI 서버를 띄워 여기에 Open API 스펙들을 모아서 관리하도록 하였다.
요즘 MSA 프로젝트를 많이 구성하는데 여러 서버의 문서를 한 곳에서 모아서 보고 싶었기 때문이다.
Swagger-UI 서버를 띄우기 위해 Docker Compose를 이용했다.
# swaggerui-docker-compose.yml
version: "3"
services:
swagger:
container_name: local-swagger
image: swaggerapi/swagger-ui
ports:
- "8080:8080"
env_file:
- .env
컨테이너에 필요한 환경변수를 별도 파일로 작성했다.
// .env
URLS=[{url:'http://localhost:8081/swagger.json',name:'Test'},]
URLS에 {url:'url1',name:'name1'}
형식으로 []
내부에 값을 집어넣으면 Swagger-UI에서 name
값을 선택하면 해당 name
에 매핑된 url
을 불러온다. 따라서 name
에 특정 서버의 문서 이름을 기입하고 url
에 해당 서버의 Open API Spec을 불러올 경로를 입력하면, Swagger-UI를 통해 여러 서버의 Open API Spec들을 편리하게 확인할 수 있다.
이제 우리가 작성한 애플리케이션 서버를 띄우고, Swagger-UI 서버 컨테이너를 실행해서 확인해보자.
구동에 성공했다.
Swagger-UI에 접속해서 애플리케이션 서버의 Open API Spec을 불러오는데 Fetch error가 발생한다면?
Failed to fetch http://localhost:8081/swagger.json**Fetch error**
Possible cross-origin (CORS) issue? The URL origin (http://localhost:8081) does not match the page (http://localhost:8080). Check the server returns the correct ‘Access-Control-Allow-*’ headers.Open API Spec을 불러올 서버에 CORS 설정이 필요하다. CORS 설정을 추가해주자.
고찰
restdocs-api-spec
라이브러리가 아직 완벽하게 Spring REST Docs를 지원하지는 못하는 것 같다.MultiValueMap
같은 경우 <String, String>
타입인데, Map
의 value
타입을 String
으로 명시하고 테스트를 수행하니 테스트가 실패한다.
타입 명시를 지우고 테스트를 성공시킨 후, resource.json
을 확인해보니 Array
타입으로 명시되어있다. MultiValueMap<String, String>
의 특정 키의 value[0]
은 String
타입인데 말이다..
또한 Swagger-UI에서 이를 확인하면 아래와 같이 출력된다.
API를 테스트 할 수 있다는 점은 협업하는 입장에서 매우 좋은 것 같다.
하지만 API 필드 타입과 설명을 명시하는 부분에서는 Spring REST Docs만을 사용해서 직접 문서로 만드는 것이 더 확실하게 명시할 수 있는 것 같다(내 개인적인 생각이다).
어쨋든 Spring으로 API 문서를 작성할 때 Swagger, Spring REST Docs 2개의 선택지에서 restdocs-api-spec라는 선택지가 하나 더 생기게 되었다.
협업하는 팀원들간에 상의를 해보고 더 적절한 것을 선택하면 좋을 것 같다.
'framework > spring' 카테고리의 다른 글
[Spring] Logback 설정 (0) | 2023.02.05 |
---|---|
[Spring] Web-Socket, SockJS, STOMP 이론 (0) | 2023.01.06 |
[Spring] 마이바티스(MyBatis) (0) | 2022.12.11 |
[Spring] JDBC Template (0) | 2022.12.10 |
[Spring] 애플리케이션 실행시 DB 테이블 및 데이터 입력 (0) | 2022.12.10 |
소중한 공감 감사합니다