Spring REST Docs 사용기

FreshBreeze·2024년 10월 28일

Spring REST Docs

공식 문서

Spring REST Docs는 RESTful 서비스를 문서화하는 데 도움을 줍니다.
Asciidoctor로 작성된 수동 문서와 Spring MVC Test를 사용해 자동으로 생성된 스니펫을 결합합니다. 이러한 접근 방식은 Swagger 같은 도구로 생성된 문서의 한계를 벗어나게 해줍니다.
Spring REST Docs는 정확하고 간결하며 잘 구조화된 문서를 작성할 수 있도록 돕습니다. 이를 통해 사용자는 최소한의 노력으로 필요한 정보를 얻을 수 있습니다.

https://spring.io/projects/spring-restdocs

Spring REST Docs란??

  • 테스트 코드를 통한 API 문서 자동화 도구
  • API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원활하게 한다.
  • 기본적으로 AsciiDoc을 사용하여 문서를 작성한다.

장점

  • 테스트가 성공해야 문서가 생성되므로, 문서의 정확성을 보장한다.
  • 프로덕션 코드에 어노테이션을 추가할 필요가 없어, 비즈니스 로직과 문서를 분리할 수 있다.

단점

  • 코드 양이 많고, 초기 설정이 복잡하다.

사용 방법

먼저, 인텔리제이 plugin에서 AsciiDoc을 설치하자. Ascii 문서에 대해 미리보기를 제공한다.

build.gradle 설정

해당 코드를 build.gradle 파일에 추가한다.


plugins {
	...
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    asciidoctorExt
}

dependencies {

    // RestDocs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}


ext { // 전역 변수 코드조각 (snippets) 경로 지정
    snippetsDir = file('build/generated-snippets')
}

test { // 테스트가 끝난 결과물 (문서파일) -> snippetsDirectory
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'

    dependsOn test // 의존성이 있다 = 작업 순서를 test 후 asciidoctor 실행한다.
}

bootJar{
    dependsOn asciidoctor
    from("${asciidoctor.outputDir}") { // static/docs 하위에 복사
        into 'static/docs'
    }
}

RestDocsSupport

test 하위에 spring/docs 패키지를 생성하고, RestDocsSupport 클래스를 생성한다.
해당 클래스는 후에 작성할 RESTful API를 테스트하고, 문서화하는 추상클래스이다.
이때, 해당 필드 및 메서드는 protected로 설정한다.


@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsSupport {

    protected MockMvc mockMvc;
    protected ObjectMapper objectMapper;

    @BeforeEach
    void setUp(RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
            .apply(documentationConfiguration(provider))
            .build();
    }
    
    protected abstract Object initController();
}

ProductController

문서화 하고자 하는 API

@RequiredArgsConstructor
@RestController
public class ProductController {

    private final ProductService productService;

    @PostMapping("/api/v1/products/new")
    public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
        return ApiResponse.ok(productService.createProduct(request.toServiceRequest()));
    }

    @GetMapping("/api/v1/products/selling")
    public ApiResponse<List<ProductResponse>> getSellingProducts() {
        return ApiResponse.ok(productService.getSellingProducts());
    }

}

ProductControllerDocsTest

RestDocsSupport를 상속받아 REST API의 문서화를 테스트한다.
mockMvc.perform() 부터 주의깊게 보면 되는데,
"/api/v1/products/new" 엔드포인트에 POST로 요청을 보내고, 응답상태가 200OK 라면
andDo(document()) 를 통해 요청과 응답을 문서화한다.
"product-create"는 문서 스니펫의 이름이고, 해당 이름으로 저장된다.
prettyPrint() 는 문서화된 JSON 을 들여쓰기를 통해 보기좋게 만들어준다.
requestFields() , responseFields() 에는 요청과 응답에 사용한 DTO의 필드.
ApiResponse<ProductResponse> 처럼 ApiResponse 의 data는 제네릭 형식으로 ProductResponse의 값을 받아오기 때문에,
해당 ProductResponse의 필드 "data.id" 와 같이 "." 을 사용하여 나타낸다.

public class ProductControllerDocsTest extends RestDocsSupport {

    private final ProductService productService = mock(ProductService.class);

    @Override
    protected Object initController() {
        return new ProductController(productService);
    }

    @DisplayName("신규 상품을 등록하는 API.")
    @Test
    void createProduct() throws Exception {

        ProductCreateRequest request = ProductCreateRequest.builder()
            .type(ProductType.HANDMADE)
            .sellingStatus(ProductSellingStatus.SELLING)
            .name("아메리카노")
            .price(4000)
            .build();

        BDDMockito.given(productService.createProduct(any(ProductCreateServiceRequest.class)))
            .willReturn(ProductResponse.builder()
                .id(1L)
                .productNumber("001")
                .type(ProductType.HANDMADE)
                .sellingStatus(ProductSellingStatus.SELLING)
                .name("아메리카노")
                .price(4000)
                .build());

        mockMvc.perform(
                post("/api/v1/products/new")
                    .content(objectMapper.writeValueAsString(request))
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andDo(print())
            .andExpect(status().isOk())
            .andDo(document("product-create",
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                requestFields(
                    // ProductCreateRequest
                    fieldWithPath("type").type(JsonFieldType.STRING)
                        .description("상품 타입"),
                    fieldWithPath("sellingStatus").type(JsonFieldType.STRING)
                        .description("상품 판매상태"),
                    fieldWithPath("name").type(JsonFieldType.STRING)
                        .description("상품 이름"),
                    fieldWithPath("price").type(JsonFieldType.NUMBER)
                        .description("상품 가격")
                ),
                responseFields(
                    // ApiResponse
                    fieldWithPath("code").type(JsonFieldType.NUMBER)
                        .description("코드"),
                    fieldWithPath("status").type(JsonFieldType.STRING)
                        .description("상태"),
                    fieldWithPath("message").type(JsonFieldType.STRING)
                        .description("메시지"),
                    fieldWithPath("data").type(JsonFieldType.OBJECT)
                        .description("응답 데이터"),
					// ProductResponse
                    fieldWithPath("data.id").type(JsonFieldType.NUMBER)
                        .description("상품 ID"),
                    fieldWithPath("data.productNumber").type(JsonFieldType.STRING)
                        .description("상품 번호"),
                    fieldWithPath("data.type").type(JsonFieldType.STRING)
                        .description("상품 번호"),
                    fieldWithPath("data.sellingStatus").type(JsonFieldType.STRING)
                        .description("상품 판매상태"),
                    fieldWithPath("data.name").type(JsonFieldType.STRING)
                        .description("상품 이름"),
                    fieldWithPath("data.price").type(JsonFieldType.NUMBER)
                        .description("상품 가격")
                )
            ));
    }
}

asciidoctor

해당 테스트 작성을 마치고, 우측 gradle의 asciidoctor를 실행해주면
build/generated-snippets/product-create 경로에 adoc파일들이 만들어진다.
해당 경로의 이름들은 build.gradle 에서 설정하였다.

prettyPrint()를 설정하지 않으면 하단 사진처럼 JSON 파일이 일렬로 생성된다.

index.adoc

이후 src/docs/asciidoc 에 index.adoc 파일을 만들어준다.
Asciidoctor 문법을 사용해 많이 생소한데, REST API 문서를 초기 정의하는 index 파일이다.

ifndef::snippets[]
:snippets: ../../build/generated-snippets
endif::[]
= CafeKiosk REST API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

[[Product-API]]
== Product API

[[product-create]]
=== 신규 상품 등록

==== HTTP Request
include::{snippets}/product-create/http-request.adoc[]
include::{snippets}/product-create/request-fields.adoc[]

==== HTTP Response
include::{snippets}/product-create/http-response.adoc[]
include::{snippets}/product-create/response-fields.adoc[]

이후 다시 우측 gradle 의 build를 눌러주면, 해당 html 파일이 생긴다.

html 파일의 크롬브라우저 아이콘을 눌러보면, 해당 페이지가 나타난다.

커스터마이징

만약 필수값을 구분하고 싶다면 어떻게 할까?
sellingStatus 같은 경우는 안보내면 기본값으로 넣어준다고 가정한 뒤,
해당 값은 Optional 이라고 알려주고 싶다면?
test/resources/org/springframework/restdocs/templates 경로에
request-fields.snippet 파일을 만들어준다.
(response-fields.snippet 파일도 상단 이름만 바꿔주고, 동일하다)

==== Request Fields // Response Fields

|===
|Path|Type|Optional|Description

{{#fields}}

|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}`+{{description}}+`{{/tableCellContent}}

{{/fields}}

|===

그리고, 해당 필드에 optional() 메서드를 추가해준다.

requestFields(
              // ProductCreateRequest
              fieldWithPath("type").type(JsonFieldType.STRING)
                  .description("상품 타입"),
              fieldWithPath("sellingStatus").type(JsonFieldType.STRING)
                  .optional()
                  .description("상품 판매상태"),
              fieldWithPath("name").type(JsonFieldType.STRING)
                  .description("상품 이름"),
              fieldWithPath("price").type(JsonFieldType.NUMBER)
                  .description("상품 가격")
          ),

다시 빌드하고, 새로 생성된 html 파일을 실행하면 Optional 이 새로 생성된 걸 볼 수 있다.

include

Spring REST Docs 는 파일을 분리한 후 include를 통해 빌드할 때 합치는 것이 가능하다.

여기까지만 하면 에러가 나는데, 경로를 못찾아서 그렇다.
해당 경로 설정은, build.gradle 파일에

asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'
    
    sources { // 특정 파일만 html로 만든다. 
        include("**/index.adoc")
    }
    baseDirFollowsSourceFile() // 다른 adoc 파일을 include 할 때 경로를 baseDir로 맞춘다.
    dependsOn test // 의존성이 있다 = 작업 순서 : test 후 asciidoctor 실행
}

해당 코드를 기존 코드에 추가하면 된다.

배포하는법

빌드를 하면, build/libs/Xxxx.jar 파일이 생긴다.
터미널을 열고, java -jar Xxxx.jar 파일을 실행하면 Spring 서버가 뜨고,
localhost:8080/docs/index.html 경로로 접속하면 문서를 확인할 수 있다.

출처

Readable Code: 읽기 좋은 코드를 작성하는 사고법 (박우빈)

0개의 댓글