호딩클라우드

[SpringBoot] 스프링 문서화 자동화 하는법 가이드. 문서 배포 (with. Restdocs, restAssured) 본문

문제해결

[SpringBoot] 스프링 문서화 자동화 하는법 가이드. 문서 배포 (with. Restdocs, restAssured)

hoding-cloud 2023. 12. 29. 15:27

 

서론

스프링 부트에서 선택할 수 있는 대표적인 문서화 도구는 아래와 같습니다.
Swagger
RestDocs
그 외 Notion, postman, 등

Swagger, RestDocs의 장단점과 문서 자동화를 시작하기 전 배경지식으로 아래 블로그 글을 추천합니다.
https://hudi.blog/spring-rest-docs/

Spring REST Docs를 사용한 API 문서 자동화

API 문서 자동화 백엔드와 프론트엔드 개발자 사이의 원활한 협업을 위해서는 REST API 명세에 대한 문서화가 잘 되어있어야 한다. 구글 독스, 스프레드 시트, 위키, 노션 등을 사용해서 직접 API 명

hudi.blog

 

swagger, restdocs 비교

swagger와 restdocs의 차이만 요약하자면 아래 표와 같습니다.

출처 [Kurly Tech Blog] 내가 만든 API를 널리 알리기 - Spring REST Docs 가이드편 : 김지헌(jiheon.kim)

 
필자는 스웨거보다 restdocs를 선호하는 편인데 이유는 다음과 같습니다.

'문서의 신뢰도'
문서에 대한 신뢰가 떨어진다면, 문서를 보고 작업하는 클라이언트는 혼란을 겪기 쉬우며 문서에 참여하거나 사용하는 인원이 많아질수록 문서에 오류로 인해 낭비되는 시간은 많아진다고 생각합니다.  이러한 측면에서 restDocs는 강력한 신뢰를 제공하기 때문에 선호합니다.

 
 
그래서 이글에서 제시하는 문서화 방법은 아래와 같은 이점을 가집니다.
1. restDocs 기반으로 제작되어 높은 신뢰도를 가진다.
  1-1 코드 기반으로 문서를 기술할 수 있다.
2. 다양한 문서 UI를 적용할 수 있다.
  2-1. swagger의 장점인 문서에서의 테스트 기능을 제공할 수도 있다.(stopLight 등 특정 UI만 가능)
  2-2. 배포자동화와 연계하여 문서배포도 자동화시킬 수 있다.(redoc처럼 cli 제공 UI 가능)
3. restDocs 기본 UI 보다 보기 좋은 UI를 제공할 수 있다.
 
 

결과 1. stopLight 렌더링

장점 : 문서 내부에서 테스트를 진행할 수 있다.

stopLight 자체적으로 언어별 예시코드도 보여준다.
 

결과 2. redoc 렌더링

장점 : 배포 자동화와 연계하여 문서 배포까지 자동화할 수 있다.

 

본론

소스코드

해당 실습에 대한 전체코드는 아래링크에서 확인하실 수 있습니다.
전체코드와 함께 게시글을 보는 것을 추천드립니다.
https://github.com/seokjun7410/SpringBoot-Document-Automation/tree/main

GitHub - seokjun7410/SpringBoot-Document-Automation: This repository contains example projects for building springboot document

This repository contains example projects for building springboot document automation. - GitHub - seokjun7410/SpringBoot-Document-Automation: This repository contains example projects for building...

github.com

 
먼저 문서를 만들기 위해서는 RestDocs와 restdocs-api-sepc plugin이 필요합니다.
https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/#getting-started

Spring REST Docs

Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test or WebTestClient.

docs.spring.io

https://github.com/ePages-de/restdocs-api-spec

GitHub - ePages-de/restdocs-api-spec: Adds API specification support to Spring REST Docs

Adds API specification support to Spring REST Docs - GitHub - ePages-de/restdocs-api-spec: Adds API specification support to Spring REST Docs

github.com

 

build.gradle 구성

아래와 같이 build.gradle을 구성해줍니다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1'
    id 'io.spring.dependency-management' version '1.1.4'
    id 'org.asciidoctor.jvm.convert' version '3.3.2'
    id 'com.epages.restdocs-api-spec' version '0.18.2' //api-spec plugin for documentation
}

group = 'com.document'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

ext {
    set('snippetsDir', file("build/generated-snippets"))
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'


    //document
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2'
    testImplementation 'com.epages:restdocs-api-spec-restassured:0.18.2'
    testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'

    implementation 'com.h2database:h2'
}

tasks.named('test') {
    outputs.dir snippetsDir
    useJUnitPlatform()
}

tasks.named('asciidoctor') {
    inputs.dir snippetsDir
    dependsOn test
}

openapi3 {
    servers = [
            { url = "http://localhost:8080" },
            { url = "https://서버url" }
    ]
    title = "플젝 제목"
    description = "플젝 설명"
    version = "버전"
    format = "yaml" // (json / yaml)
//    outputDirectory = "src/main/resources/static"
    outputFileNamePrefix = "openapi"
}

 
필요한 의존관계와 api-spec 플러그인을 구성해 줍니다.
또한 api-spec의 openapi3 task 속성들을 지정해 줍니다.
각 속성에 대한 자세한 설명은 상단에 api-spec 저장소를 참고 바랍니다.
 

예제 API 구성

간단한 회원가입 API를 생성해 줍니다. 
해당 예제 프로젝트는 필요한 부분만 약식으로 구성되었음을 알립니다.

프로젝트 패키지 구조

DataResponseFormat.class

@Data
@Builder
public class DataResponseFormat<T> {
    private int status;
    private T data;

    public DataResponseFormat(final int status, T data) {
        this.status = status;
        this.data = data;
    }

    public static<T> DataResponseFormat<T> response(final int status, T data) {
        return DataResponseFormat.<T>builder()
                .status(status)
                .data(data)
                .build();
    }


}

api 응답 포맷을 설정합니다. 비즈니스에 특화된 상태코드를 제공할 수 있도록 구성합니다.
 

ID.class

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class ID {
    @JsonProperty(value = "id")
    private Long value;
    public ID(Long value) {
        this.value = value;
    }

    public Long getValue() {
        return value;
    }


}

진행하던 프로젝트에서 ID 정책이 정해지지 않았기 때문에 이후에 변환작업을 수행하기 위해 Vo로 구성합니다.
 

Member.class

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Access(value = AccessType.FIELD)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String pw;
    private String name;


    public Member(String email, String pw, String name) {
        this.email = email;
        this.pw = pw;
        this.name = name;
    }

    public ID getId() {
        return new ID(id);
    }

    public static Member create(String email, String pw, String name) {
        return new Member(email, pw, name);
    }

}

약식으로 Member entity를 구성합니다.
 

MemberController.class

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

    @PostMapping("/sign-up")
    public ResponseEntity<?> signUp(
            @RequestBody @Valid SignUpRequest signUpRequest
    ) {
        ID id = new ID(1L);
        return new ResponseEntity<>(DataResponseFormat.response(2000, id), CREATED);
    }
}

회원가입 endpoint를 구성합니다.
url에서 첫 번째 요소가 문서의 카테고리를 결정함으로 "/api/member/..." 처럼 구성할 경우 api분류가 어려울 수 있습니다.

url의 첫번째 요소 기준으로 분류됩니다.

 
MemberResponse.class

public record MemberResponse(
        Long id,
        String email,
        String pw,
        String name
){
    public static MemberResponse of(Member member) {
        return new MemberResponse(
                member.getId().getValue(),
                member.getEmail(),
                member.getPw(),
                member.getName());
    }
}

 

MemberRequest.class

public record SignUpRequest(
        @Email
        String email,

        @NotNull
        @NotEmpty
        @Length(min = 6, max = 12) String pw,
        @Length(min = 3, max = 12) String name
) {
}

필요한 DTO를 구성해 줍니다.
jakarta.validation.constraints를 통해 request validation을 구성하고 이를 기반으로 문서에 제약사항이 노출됩니다.
 

API(Controller) TEST 작성

그다음 문서화를 위한 API 테스트를 작성해 줍니다.
 

 
약식으로 패키지를 구성했습니다.
docs패키지 : 문서화 관련된 클래스들
step : api 테스트 코드 재사용을 위한 클래스들 지칭
ApiTest.class : api테스트에 사용되는 공통 코드
JsonBinding : api 반환값을 사용하는 포맷에 맞추어 바인딩
 
 

ApiTest.class

필자는 RestAssuerd를 선호하기 때문에 RestAssuerd로 테스트를 위한 ApiTest 클래스를 만들어 줍니다.

import io.restassured.RestAssured;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.jdbc.EmbeddedDatabaseConnection;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;

import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration;

@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(RestDocumentationExtension.class)
public class ApiTest {
    protected RequestSpecification spec;
    @BeforeEach
    void setUp(RestDocumentationContextProvider provider) {
        this.spec = new RequestSpecBuilder().addFilter(documentationConfiguration(provider))
                .build();
        RestAssured.port = port;
    }

    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }
}

 
restAusserd를 사용한 RestDocs를 구성할 것이기 때문에 관련된 설정을 담아줍니다.
테스트 환경에서 H2 database를 사용하기 위해 AutoConfigureTestDataBase를 지정해 줍니다.
 

MemberApiTest.class

public class MemberApiTest extends ApiTest {


    //회원가입으로 계정을 생성 할 수 있다.
    @Test
    public void 관리자_회원가입_API() {
        final var response = MemberStep.signUp_API(spec);
        assertThat(response.getStatus()).isEqualTo(2000);
        assertThat(response.getData().getValue()).isNotNull();
    }
}

 
회원가입 usecase에 대해 테스트 코드를 작성하고 docmentaion을 위해 ApiTest.class의  RequestSpecification spec을 전달해야 합니다.
 

MemberStep.class

/**
 * step 에서는 HTTP status 까지 검증
 *
 * @cause : step을 재사용 하는 시점에서는 response.Type,HttpStatus 의존 최소화
 */
public class MemberStep {
    public static DataResponseFormat<ID> signUp_API(RequestSpecification spec) {
        
        final SignUpRequest signUpRequest = RequestFactory.signUp.create();

        final var response = RestAssured.given(spec).log().all()
                .filter(SignUpDocs.success200Filter())
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(signUpRequest)
                .when()
                .post("/member/sign-up")
                .then()
                .statusCode(HttpStatus.CREATED.value())
                .log().all().extract();

        ID bind = JsonBinding.getData(response, ID.class);
        int customStatus = JsonBinding.getCustomStatus(response);

        return new DataResponseFormat<>(customStatus, bind);
    }
}

 
api호출 코드를 재사용하기 위해 step클래스를 구성합니다.
테스트 코드와 함께 document를 생성하려면 RestAssuerd.filter()에 필터를 구성하여 넘겨줘야 합니다. 
 

SignUpDocs.class

이때 filter 를 구성하는 코드를 별도의 클래스(SignUpDocs)로 분리하여 재사용성과 유지보수성을 높입니다.

public class SignUpDocs extends BaseDocs {
    private static final String identifier = ADMIN_SIGN_UP_200_OK;
    private static final String description = "관리자 계정을 생성할 수 있습니다.";
    private static final String summary = "관리자 생성";

    public static RestDocumentationFilter success200Filter() {
        RestDocumentationFilter restDocumentationFilter = RestAssuredRestDocumentationWrapper.document(
                // identifier, 이를 이용해 adoc파일을 저장할 디렉토리를 생성한다
                SignUpDocs.identifier, SignUpDocs.description, SignUpDocs.summary,
                SignUpDocs.globalDefaultHeader(),
                SignUpDocs.request(),
                SignUpDocs.response200()
        );
        return restDocumentationFilter;
    }
    
    public static RestDocumentationFilter validationFilter(String identifier) {
        return RestAssuredRestDocumentationWrapper
                .document(identifier, SignUpDocs.description, SignUpDocs.summary,
                        SignUpDocs.globalDefaultHeader(),
                        SignUpDocs.request(),
                        SignUpDocs.defaultExceptionResponse()
                );
    }

    private static RequestFieldsSnippet request() {
        ConstraintDescriptions userConstraints = new ConstraintDescriptions(SignUpRequest.class);
        List<String> emailConstrains = userConstraints.descriptionsForProperty("email");
        List<String> nameConstrains = userConstraints.descriptionsForProperty("name");
        List<String> pwConstrains = userConstraints.descriptionsForProperty("pw");

        return requestFields(
                fieldWithPath("name").description(convertForDescription(nameConstrains)),
                fieldWithPath("email").description(convertForDescription(emailConstrains)),
                fieldWithPath("pw").description(convertForDescription(pwConstrains))
        );
    }

    private static ResponseFieldsSnippet response200() {
        return responseFields(
                fieldWithPath("status").description("커스텀 상태코드"),
                fieldWithPath("data").description("응답 데이터 래퍼 클래스"),
                fieldWithPath("data.id").description("생성된 관리자 Id")
        );
    }


}

 

 ConstraintDescriptions userConstraints = new ConstraintDescriptions(SignUpRequest.class);
 List<String> emailConstrains = userConstraints.descriptionsForProperty("email");
 List<String> nameConstrains = userConstraints.descriptionsForProperty("name");
 List<String> pwConstrains = userConstraints.descriptionsForProperty("pw");

 
request에 명시된 제약조건에 대한 설명을 가져오는 코드입니다. 인자로 api호출에 사용되는 SignUpRequest가 들어간 것을 확인할 수 있습니다.

return requestFields(
                fieldWithPath("name").description(convertForDescription(nameConstrains)),
                fieldWithPath("email").description(convertForDescription(emailConstrains)),
                fieldWithPath("pw").description(convertForDescription(pwConstrains))
        );

 
필드의 제약조건이 하나 이상일 수 있으므로 convertForDescription메서드를 구성하여 재구성된 String을 담아줍니다.
convertForDescription()은 BaseDocs.Class의 메서드임으로 아래에서 자세히 살펴봅니다.
 
validationFilter() : 유효성 검사 테스트 코드에 사용되는 필터를 반환하는 메서드를 작성합니다. 예외 응답의 경우 대부분 공통된 필터(문서형식)가 사용되므로 검사마다 identifier만 전달받아 사용할 수 있도록 구성합니다.
 

JsonBinding.class

public class JsonBinding {

    public static <T> T getData(ExtractableResponse<Response> response, Class<T> bindClass) {
        return response.body().jsonPath().getObject("data",bindClass
        );
    }

    public static int getCustomStatus (ExtractableResponse<Response> response) {
        return response.body().jsonPath().getInt("status");
    }

}

 
해당 프로젝트에서는 DataResponseFormat.class를 만들어 공통으로 사용하는 응답 포맷을 사용함으로 이에 맞게 사용할 수 있는 JsonBind.class를 구성합니다.

Identifier.class

Identifier는 테스트 코드를 구별하는 단위이며 해당 값이 문서 request example의 이름을 결정함으로 역시 한 곳에서 관리할 수 있도록 구성합니다.

/**
 * system variable name - document identifier
 */
public class Identifier {

    /** 회원가입(Sign-up) API DTO**/
    public final static String ADMIN_SIGN_UP_200_OK = "member-create-201";
    public final static String ADMIN_SIGN_UP_PW_VALIDATION = "pw-validation-exception";
    public final static String ADMIN_SIGN_UP_EMAIL_VALIDATION = "email-validation-exception";
}
identifier은 문서의 request example 이름을 결정합니다. [좌: stoplight/우: redoc]

 
 

RequestFactory.class

또한 api 호출 성공케이스, 유효성 실패 케이스에 사용되는 request를 재사용하기 위해 RequestFactory로 관리합니다.

final SignUpRequest signUpRequest = RequestFactory.signUp.create();
public class RequestFactory {

    public class signUp{
        public static String email = "rkskek@naver.com";
        public static String pw = "password";
        public static String name = "아무개";

        public static SignUpRequest create() {
            return new SignUpRequest(email, pw, name);
        }

        public static void clear(){
            email = "rkskek@naver.com";
            pw = "password";
            name = "아무개";
        }
    }

}

 
 

BaseDocs.class

SignUpDocs가 상속하고 있는 BaseDocs는 아래와 같이 구성했습니다.

public class BaseDocs {
    public static RequestHeadersSnippet globalDefaultHeader() {
        return requestHeaders(
                headerWithName(HttpHeaders.CONTENT_TYPE).description("Content Type Header")
        );
    }

    public static ResponseFieldsSnippet defaultExceptionResponse() {
        return responseFields(
                fieldWithPath("timestamp").description("현재 시간"),
                fieldWithPath("status").description("HttpStatus Value"),
                fieldWithPath("error").description("HttpStatus"),
                fieldWithPath("path").description("요청된 경로(path)")
        );
    }

    protected static String convertForDescription(List<String> constraints) {
        if(constraints.size() == 1)
            return "["+constraints.get(0)+"]";
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < constraints.size()-1; i++) {
            stringBuilder
                    .append("[")
                    .append(constraints.get(i))
                    .append("], ");
        }

        stringBuilder
                .append("[")
                .append(constraints.get(constraints.size()-1))
                .append("]");

        return stringBuilder.toString();
    }
}

globalDefaultHeader() : 헤더관련된 문서속성은 대부분 공통되므로 BaseDocs에 구성합니다.
defaultExceptionResponse() : 예외가 발생된 응답 역시 대부분 공통되므로 BaseDocs에 구성합니다.
convertForDescription() : 제약조건을 문서에서 아래와 같은 형식으로 노출시키기 위한 코드를 구성합니다.

 

MemberControllerTest.class

마지막으로 유효성 검사를 위한 테스트코드도 작성해 줍니다.

class MemberControllerTest extends ApiTest {


    @Test
    public void 관리자_회원가입_API_PW_실패() {
        RequestFactory.signUp.pw = "1234";
        final SignUpRequest signUpRequest = RequestFactory.signUp.create();

        final var response = RestAssured.given(spec).log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .filter(SignUpDocs.validationFilter(ADMIN_SIGN_UP_PW_VALIDATION))
                .body(signUpRequest)
                .when()
                .post("/member/sign-up")
                .then()
                .statusCode(HttpStatus.BAD_REQUEST.value())
                .log().all().extract();

        assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value());

        RequestFactory.signUp.clear();
    }



    @Test
    public void 관리자_회원가입_API_EMAIL_실패() {
        RequestFactory.signUp.email = "1234";
        final SignUpRequest signUpRequest = RequestFactory.signUp.create();

        final var response = RestAssured.given(spec).log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .filter(SignUpDocs.validationFilter(ADMIN_SIGN_UP_EMAIL_VALIDATION))
                .body(signUpRequest)
                .when()
                .post("/member/sign-up")
                .then()
                .statusCode(HttpStatus.BAD_REQUEST.value())
                .log().all().extract();

        assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value());
        RequestFactory.signUp.clear();

    }
}

 
앞서 구성된 RequestFactory와 SigupDocs를 이용해 request만 다른 apiTest를 쉽게 구성할 수 있습니다.
해당 코드는 성능을 위해 mockMvc를 이용해 슬라이스 테스트로 구성하는 게 좋은 방법이지만. 예제 단순화를 위해 RestAusserd로 진행하겠습니다.
 
이제 코드 구성은 끝났습니다.
테스트를 구동해 봅니다.

전체 테스트 구동시 *.adoc 파일이 생성됩니다.

 
테스트를 구동하게 되면 asciidoctor가 우리의 테스트 코드를 기반으로 *. adoc 파일들을 만들어 줍니다.
 

asciidoctor란?
Asciidoctor는 텍스트 기반의 마크업 언어로 작성된 문서를 변환하고 포맷하는 데 사용되는 도구입니다. 이 언어는 특정한 형식의 문서를 작성하는 데 특화되어 있으며, 주로 기술 문서, 기술 블로그, 책, 설명서 등을 작성하는 데 활용됩니다.

Asciidoctor는 AsciiDoc라는 마크업 언어의 변환을 처리하는 도구 중 하나로, 텍스트 기반의 문서를 보다 풍부한 형식의 문서로 변환할 수 있습니다. 이 도구를 사용하면 텍스트 기반의 문서를 HTML, PDF, ePub 등의 다양한 형식으로 변환할 수 있으며, 다양한 플러그인과 확장 기능을 활용하여 문서 작성을 보다 효율적으로 할 수 있습니다.

기술 문서 작성, 블로그 게시물 작성, 사용 설명서 작성 등의 다양한 목적으로 Asciidoctor를 사용할 수 있으며, 특히 마크다운보다 더 많은 서식과 기능을 제공하는 등의 장점으로 인해 많은 사용자들에게 선호되고 있습니다.

 
asciidoctor와 restDocs의 자세한 설명은 아래 링크로 대체합니다.
https://techblog.woowahan.com/2597/

Spring Rest Docs 적용 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다. 지금부터 정산시스템 API 문서를 wiki 에서 Spring Rest Docs 로 전환한 이야기를 해보려고 합니다. 1. 전환하는

techblog.woowahan.com

 

OAS 파일 생성

이렇게 *.adoc파일을 생성한 후 gradle > documentaion > openapi3 혹은 gradle openapi3로 해당 task를 실행해 줍니다.

 
해당 Task가 완료되면 build > api-sepc > openapi.yaml 파일이 생성됩니다.

 
해당 파일은 OpenAPI Specification(OAS) 파일로 api-spec plugin을 통해 생성된 파일입니다.
 

OpenAPI Specification(OAS)
OpenAPI Specification (OAS)는 API(Application Programming Interface)를 설명하고 문서화하기 위한 오픈 스탠더드 스펙입니다. 이는 API의 구조, 엔드포인트, 매개변수, 응답 형식 등을 명세화하는 데 사용됩니다.

 
OAS의 관한 자세한 설명한 아래 블로그로 대체합니다.
https://gruuuuu.github.io/programming/openapi/

OpenAPI 란? (feat. Swagger)

Overview 이 문서에서는 API의 기본적인 정의는 알고 있다는 전제하에 OpenAPI와 Swagger의 개념, 차이점, 비교적 최근(2017-07-26) 업데이트한 OpenAPI 3.0에 대해서 알아보도록 하겠습니다. 1. OpenAPI? Open API?

gruuuuu.github.io

 
다시 말해 api-spec 플러그인으로 생성한 파일(OAS)로 원하는 문서 UI를 선택하여 적용시킬 수 있습니다.
해당 게시글에서는 redoc과 stoplight에 대해서만 다룹니다.
 

Redoc-static.html 생성

이제 api-spec에서 제시하는 방법대로 redoc을 생성해 봅시다.

# Install redoc-cli
npm install -g redoc-cli

# Bundle the documentation into a zero-dependency HTML-file
redoc-cli bundle build/api-spec/openapi.yaml

 
필자는 문서를 별도의 공간에 게시하기 위해 html파일로 추출하도록 하겠습니다.
redoc-cli를 통해 redoc-static 파일이 생성된 것을 확인할 수 있습니다.

 
생성된 문서를 확인할 수 있습니다.

 

문제발생 - 제대로 변환되지 않음

다만 문제가 발생합니다. 우측 resposne sample을 보면 형식이 이상하게 들어갔습니다.
 
build > api-sepc > openapi.yaml파일을 다시 확인해 보면

...
           $ref: '#/components/schemas/member-sign-up-1764135359'
              examples:
                email-validation-exception:
                  value: "{\"timestamp\":\"2024-01-01T13:31:11.233+00:00\",\"status\"\
                    :400,\"error\":\"Bad Request\",\"path\":\"/member/sign-up\"}"
                pw-validation-exception:
                  value: "{\"timestamp\":\"2024-01-01T13:31:11.184+00:00\",\"status\"\
                    :400,\"error\":\"Bad Request\",\"path\":\"/member/sign-up\"}"
        "201":
          description: "201"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/member-sign-up-1353246949'
              examples:
                member-create-201:
                  value: "{\"status\":2000,\"data\":{\"id\":1}}"
 ...

 
example 필드에 value들이 마치 java string처럼 들어가 있는 것을 확인할 수 있습니다.
 

해결방안

해당 문제로 검색해 보니 같은 문제를 겪은 이슈를 발견했습니다.
https://github.com/ePages-de/restdocs-api-spec/issues/109#issuecomment-736012775

Remove pretty print in example · Issue #109 · ePages-de/restdocs-api-spec

Hello! I would like to have a human-readable documentation with asciidoctor and a machine-readable documentation with this project. Is there a possibility to disable or un-pretty-print the JSON onl...

github.com

 
위 이슈에서 제시하는 해결방안은 다음과 같습니다.

#!/usr/bin/env python3
import sys
import yaml
import json

def fix_examples(res: dict):
    for key, value in res.items():
        if isinstance(value, dict):
            fix_examples(value)
        if key == "application/json":
            for example_name, content in value["examples"].items():
                try:
                    content["value"] = json.loads(content["value"])
                except:
                    pass

with open(sys.argv[1], "r") as api_file:
    res = yaml.safe_load(api_file)
    fix_examples(res)
    print(yaml.dump(res))

 

"{\"status\":2000,\"data\":{\"id\":1}}"

이런 문자열 들을 python 코드를 통해 

data:
  id: 1
status: 2000

이렇게 yaml형식으로 재가공해주어야 한다고 합니다.
프로젝트에 위 python 파일을 생성하여 줍시다.
 
정리하면 아래와 같은 플로우를 통해 정상적인 redoc-static 파일을 얻을 수 있습니다.

OAS 파일 생성 > python 코드로 문법오류 재가공 > redoc-cli

 
여기서 redoc이 아닌 stoplight로 구성하고 싶으신 분들은 위 과정을 거친 후 redoc-cli가 아닌  생성된 파일을 https://stoplight.io/ 링크를 통해 import 하시면 됩니다.
 

하나의 gradle task로 그룹화

이제 문서 자동화를 위해 아래 과정을 하나의 gradle task로 묶어줍시다.

OAS 파일 생성 > python 코드로 문법오류 재가공 > redoc-cli

 
 

documentaion.gradle 생성

documentaion.gradle을 생성합니다.
 

dependencies {
    //document
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2'
    testImplementation 'com.epages:restdocs-api-spec-restassured:0.18.2'
    testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'
}

openapi3 {
    servers = [
            { url = "http://localhost:8080" },
            { url = "https://서버url" }
    ]
    title = "플젝 제목"
    description = "플젝 설명"
    version = "버전"
    format = "yaml" // (json / yaml)
//    outputDirectory = "src/main/resources/static"
    outputFileNamePrefix = "openapi"
}

 
build.gradle에 있는 문서 자동화와 관련된 dependency와 openapi3 설정을 documentaion.gradle로 옮겨줍니다.
 

MakeOAS task 구성

task makeOAS(type: Exec) {
    commandLine 'gradle', 'openapi3' // 실행할 명령어 및 옵션을 지정합니다.
}

 
다음으로 gradle openapi3을 실행하는 커맨드 라인을 task로 정의합니다.
해당 태스크에서 테스트를 의존하고 있으므로 해당 작업만으로 새로운 *. adoc이 생성되고 OAS파일까지 구성됩니다.
만약 기존파일에서 수정된 파일로 갱신이 안되시는 분들은 아래 gradle 공식문서를 참고하여 기존 파일들을 삭제하는 task도 정의하시길 바랍니다.
https://docs.gradle.org/current/dsl/org.gradle.api.Task.html#org.gradle.api.Task

Task - Gradle DSL Version 8.5

A Task represents a single atomic piece of work for a build, such as compiling classes or generating javadoc. Each task belongs to a Project. You can use the various methods on TaskContainer to create and lookup task instances. For example, TaskContainer.c

docs.gradle.org

 

openapi3fix task 구성

task openapi3fix(type: Exec) { // Exec 타입의 Gradle 작업인 openapi3fix를 정의합니다.
    commandLine 'python3', './fix-openapi-yaml.py', 'build/api-spec/openapi.yaml' // YAML 파일을 인자로 하여 Python 스크립트를 실행하는 명령 줄을 지정합니다.
    standardOutput = new ByteArrayOutputStream() // 표준 출력을 캡처하기 위한 ByteArrayOutputStream을 생성합니다.

    doLast { // 작업 실행 블록의 시작
        def outputStream = new FileOutputStream('openapi-fixed.yaml') // 수정된 YAML 내용을 openapi-fixed.yaml 파일에 작성하기 위한 새로운 출력 스트림을 생성합니다.
        outputStream.write(standardOutput.toString().getBytes()) // 캡처한 표준 출력을 출력 스트림에 작성합니다.
        outputStream.close() // 출력 스트림을 닫습니다.

        def processResult = standardOutput.toString() // 캡처한 표준 출력을 processResult 변수에 문자열로 저장합니다.
        if (processResult.contains('Traceback') || processResult.contains('Error')) { // 표준 출력에 'Traceback' 또는 'Error'가 포함되어 있는지 확인합니다.
            println "오류가 발생했습니다." // 출력에 'Traceback' 또는 'Error'가 있는 경우 오류 메시지를 출력합니다.
        } else {
            println "YAML 파일이 성공적으로 수정되었습니다." // 오류가 발생하지 않은 경우, YAML 파일이 성공적으로 수정되었음을 나타내는 성공 메시지를 출력합니다.
            // YAML 파일 수정이 성공적으로 완료된 경우 여기에 추가 작업이나 동작을 수행할 수 있습니다.
        }
    }
}

이제 아까 이슈에서 제시한 python코드를 실행시켜 수정된 OAS를 생성하는 Task를 작성합니다.
python 파일 경로를 잘 확인하시고 로컬에 python이 설치되어 있지 않다면 설치 바랍니다.
 

redocGenerationIntegration task 구성

task redocGenerationIntegration(type: Exec){
    commandLine 'redoc-cli', 'bundle', 'openapi-fixed.yaml'
}

마지막으로 redoc-static 파일을 생성하는 cli 커맨드 라인도 task로 구성합니다.
 

task chaining 및 그룹화

openapi3fix.dependsOn makeOAS
redocGenerationIntegration.dependsOn openapi3fix

def groupName = "automatic_documentaion"
makeOAS.group = groupName
openapi3fix.group = groupName
redocGenerationIntegration.group = groupName

각 테스크 실행순서를 정하기 위해 dependsOn으로 테스크 체이닝을 구성해 줍니다.
이후 그룹을 설정하여 아래 사진처럼 다른 task와 분리해서 관리해 줍니다.

 

build.gradle apply from documentaion.gradle

이제 다시 build.gradle로 돌아와서

apply from: 'documentaion.gradle'

 
방금 생성한 documentaion.gradle를 적용시켜 줍니다.
 

결론

 
이제 redocGenerationIntegration 태스크 하나로 테스트 전체 실행부터 redoc-static 파일을 생성할 수 있습니다.
이렇게 문서 자동화 과정을 마칩니다.
 
이후 필요하신 분들은 배포 자동화와 연계하여 코드가 배포됨과 동시에 문서도 업데이트할 수 있도록 구성하실 수 있을 것 같습니다.
 
이번 게시글의 예제 코드는 github에서 확인하실 수 있음을 다시 알려드립니다.
https://github.com/seokjun7410/SpringBoot-Document-Automation/tree/main

GitHub - seokjun7410/SpringBoot-Document-Automation: This repository contains example projects for building springboot document

This repository contains example projects for building springboot document automation. - GitHub - seokjun7410/SpringBoot-Document-Automation: This repository contains example projects for building...

github.com

 
게시글에 관련된 오류나 질문 있으시면 댓글 달아주시면 감사하겠습니다.