HoneyBee

Spring REST Docs + Swagger UI 본문

Language/Java

Spring REST Docs + Swagger UI

아포갸토 2024. 2. 29. 17:48

요약

  • AS-IS
    • 현재 API 문서 작성을 위해 Controller 위에 API 문서 작성을 위한 어노테이션 사용 중
      • 정의에 대한 내용이 들어가다보니 코드가 간결하지 않고 복잡성이 더해짐.
  • TO-MAYBE
    • controller에 어노테이션 삭제 → 코드 간결화
    • Spring REST Docs 활용하여 API 문서 작성
      • TEST code 실행 후 성공 시에만 API 문서 생성 가능
    • 생성된 REST Docs 파일을 Swagger-UI를 통해 출력

Swagger vs Spring REST Docs

Swagger

  • 장점

    • 어노테이션 추가로 API 문서 자동 생성
    • UI 를 통해 바로 테스트 가능
  • 단점

    • controller 로직이 지저분함
      • 운영코드에 스웨거 애노테이션이 침투하기 시작하며 생각보다 많은 코드를 작성
    • Swagger 사용이 익숙하지 않을 경우 테스트에 어려움
      • ex. 필요한 변수들 넣기에 불편해서 Postman 사용하는 경우 존재

Spring REST Docs

  • 장점

    • 테스트 코드 강제
      • API 문서가 작성되려면 테스트가 완료 되어야 가능함.
      • Postman 사용에 익숙한 경우 Import해서 사용 가능
  • 단점

    • UI를 통해 API 실행을 할수 없음

환경

  • Java 17
  • WebFlux
  • gradle kotlin DSL → ex. build.gradle.kts

Process

  1. Spring REST Docs 적용
  2. restdocs-api-spec 라이브러리 사용해서 Open-api-3.0.1 문서 생성
  3. Swagger-ui 를 통해 문서 import

Spring REST Docs 적용

  • build.gradle.kts
plugins {
    java
    id("org.springframework.boot") version "3.0.1"
    id("io.spring.dependency-management") version "1.1.0"
    id("org.springdoc.openapi-gradle-plugin") version "1.6.0"
    id("org.asciidoctor.jvm.convert") version "3.3.2" 
    id ("com.epages.restdocs-api-spec") version "0.18.2"
}

/*

생략

*/

java.sourceCompatibility = JavaVersion.VERSION_17

val asciidoctorExt by configurations.creating

repositories {
    mavenCentral()
}

dependencies {
    //
    *
    * 필요한 요소 추가
    *
    //

    //Test
    testImplementation("io.projectreactor:reactor-test:$ioProjectreactor")

    //Spring REST Docs
    asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor")
    testImplementation("org.springframework.restdocs:spring-restdocs-webtestclient")
    testImplementation("com.epages:restdocs-api-spec:0.18.2")
    testImplementation("com.epages:restdocs-api-spec-webtestclient:0.18.2")

}

val snippetsDir by extra { file("build/generated-snippets") }
val staticsDir by extra { file("src/main/resources/static") }

tasks.test {
    outputs.dir(snippetsDir)
}

tasks.asciidoctor {
    inputs.dir(snippetsDir)
//    configurations(asciidoctorExt)
//    dependsOn(tasks.test)
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.bootJar {
    dependsOn(":openapi3") // OpenAPI 작성 자동화를 위해 패키징 전에 openapi3 태스크 선실행을 유발
}

//build.gradle.kts 사용하기 위함
openapi3 {
    setServer("http://localhost:10078")
    title = "Your title"
    description = "Your description"
    version = "0.1.0"
    format = "json"
    outputFileNamePrefix = "open-api-3.0.1"
    outputDirectory = "$staticsDir/docs"
}
  • ReadControllerTest.java

package /* 패키지 경로 */;

import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseBody;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.documentationConfiguration;

import com.epages.restdocs.apispec.WebTestClientRestDocumentationWrapper;
import com.maestro.user.workspace.read.adapter.in.ReadWorkspaceController;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.headers.HeaderDocumentation;
import org.springframework.restdocs.payload.PayloadDocumentation;
import org.springframework.test.web.reactive.server.WebTestClient;

@WebFluxTest(controllers = ReadController.class)
@ExtendWith(RestDocumentationExtension.class)
@AutoConfigureWebTestClient
public class ReadControllerTest {

  private WebTestClient webTestClient;
  private String userId = "필요한 필드";

  @BeforeEach
  void setUp(ApplicationContext applicationContext, RestDocumentationContextProvider restDocumentation) {
    this.webTestClient = WebTestClient.bindToApplicationContext(applicationContext).configureClient()
        .filter(documentationConfiguration(restDocumentation))
        .build();
  }

  @Test
  void findAccessibleWorkspacesForSwagger() {
    webTestClient
        .get()
        .uri("{테스트 할 API 경로}")
        .accept(MediaType.APPLICATION_JSON)
        .header("userId", userId)
        .exchange()
        .expectStatus().isOk()
        .expectBody()
        .consumeWith(WebTestClientRestDocumentationWrapper
            .document("accessible",
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                HeaderDocumentation.responseHeaders(
                    headerWithName(HttpHeaders.CONTENT_TYPE)
                        .description(MediaType.APPLICATION_JSON_UTF8_VALUE)
                ),
                requestHeaders(
                    headerWithName("userId").description("The ID of the user making the request")
                ),
                responseFields(
                    PayloadDocumentation.fieldWithPath("returnCode").description("The return code of the response"),
                    PayloadDocumentation.fieldWithPath("returnMessage").description("The return message of the response"),
                    PayloadDocumentation.fieldWithPath("timestamp").description("The timestamp of the response"),
                    PayloadDocumentation.subsectionWithPath("data").description("An array containing the data of the response"),
                    PayloadDocumentation.fieldWithPath("totalPage").description("The total number of pages"),
                    PayloadDocumentation.fieldWithPath("currentPage").description("The current page number"),
                    PayloadDocumentation.fieldWithPath("totalCount").description("The total number of items"),
                    PayloadDocumentation.fieldWithPath("pageSize").description("The number of items per page")
                ))
        );
  }
}
- 공식 문서의 ./gradlew openapi3 를 잊지 말 것

Swagger-ui 를 통해 문서 import

  • 위에 코드를 참고해서 테스트 실행할 경우 지정한 경로에 파일 생성 확인 : open-api-3.0.1.json

  • /src/main/resources/static/docs 위치에 다운로드 받은 swagger 파일 압축해제 후 dist 폴더 아래 있는 파일들 옮기기

  • index.html 파일 아래 script에 있는 uri 수정

    <script>
    window.onload = function() {
      // Begin Swagger UI call region
      const ui = SwaggerUIBundle({
        url: "./open-api-3.0.1.json",
        dom_id: '#swagger-ui',
        deepLinking: true,
        presets: [
          SwaggerUIBundle.presets.apis,
          SwaggerUIStandalonePreset
        ],
        plugins: [
          SwaggerUIBundle.plugins.DownloadUrl
        ],
        layout: "StandaloneLayout"
      });
      // End Swagger UI call region
    
      window.ui = ui;
    };
    </script>
  • index.html 크롬에서 실행 시 확인 가능

    • 꼭 ./gradlew openapi3 실행 후 확인하기

참고 자료