Spring Rest Docs + Swagger 문서화

Spring으로 개발한 API 서비스를 문서화하기 위한 도구로 Swagger와 Spring REST Docs를 주로 사용되며, 마이크로서비스 아키텍처에서 아래 그림과 같이 문서화를 진행하였다.

image-20240129141929791

위 그림은 각각의 마이크로서비스가 빌드될 때마다 스니펫(ADOC)과 JSON파일들을 하나의 문서 서버로 전송한 다음 HTML로 변환하는 것이며,

그 과정은 다음과 같다.

  • Github에서 커밋을 push하면 webhook으로 jenkins가 빌드된다.
  • jenkins에서 Gradle 빌드가 끝나면 생성되는 스니펫(ADOC), JSON 파일들을 문서 서버로 전송한다.
  • jenkins에서 파일 전송이 끝난 후, 문서 서버에 Gradle과 Asciidoctor를 이용해 ADOC 파일들을 빌드하여 HTML 파일을 생성한다.
  • 변환된 HTML 파일은 Nginx를 통해 브라우저로 확인할 수 있다.

index.html 화면

image-20240129143226493

Spring REST Docs와 Swagger 장단점

Swagger

테스트와 별개로 소스 코드에 작성한 애노테이션 기반으로 문서를 자동으로 구성하며, 서비스에 접속해야 문서를 확인할 수 있다.

  • 장점

    • 자동 생성되는 UI가 예쁘다
    • UI 화면에서 API를 실행해볼 수 있다
    • 애노테이션을 통해 문서가 생성되므로 코드와 문서의 동기화가 쉬움
  • 단점

    • 소스 코드에 Swagger 코드가 상당히 많이 포함되어 가독성이 떨어짐.

      @Operation(summary = "매장 목록 조회", description = "조건에 맞는 매장 목록을 조회합니다.", responses = {
                  @ApiResponse(responseCode = CommonApiCode.ResponseCode.SUCCESS_200,
                          description = "조회 성공",
                          content = @Content(array = @ArraySchema(schema = @Schema(implementation = ShopModel.class))))
      })
      @GetMapping("/query")
      public ResponseEntity<PagedModel<ShopModel>> findAllByParams(
              @Valid SearchParams params,
              @Parameter(required = false, hidden = true) Pageable pageable,
              Errors errors) {
          ...
      }
      
      @Schema(description = "매장")
      public class ShopModel extends AbstractRepresentationModel<ShopModel> implements Serializable {
          @Schema(description = "매장 일련번호")
          private Long shopSeq;
          @Schema(description = "사업자등록번호")
          private String bizNo;
          @Schema(description = "매장명")
          private String shopName;
          ...
      }      
      

Spring Rest Docs

테스트가 Success되면 Asciidoc 스니펫으로 생성되고 이를 조합하여 HTML 파일을 생성한다.

  • 장점
    • 테스트 코드가 성공해야 API 문서에 포함되므로 신뢰도가 높다.
    • 소스코드에 문서화를 위한 코드가 필요 없고, 테스트 코드에 문서화를 위한 코드가 포함된다.
    • 커스터 마이징이 용이하다.
  • 단점

    • 장점인 테스트 코드 작성이 어떤 개발 조직에서는 단점으로 작용될 수 있다.

    • 스니펫을 HTML로 변환하기 위한 asciidoc를 직접 작성해야되는 불편함이 있다.

      === [blue]#회원 목록 조회#
      operation::findAllMember[snippets='http-request,http-response,response-fields']
          
      === [blue]#회원 조회#
      operation::findMember[snippets='http-request,path-parameters,http-response,response-fields']
          
      === [blue]#회원 등록#
      operation::createMember[snippets='http-request,http-response,request-fields']
      
    • 생성된 문서에서 swagger 처럼 실행해볼 수 없기 때문에 Postman 등을 사용해야 한다.

Intellij 및 프로젝트 설정

IntelliJ에서 AsciiDoc Plugin을 설치

image-20231227160132641

프로젝트 경로에 디렉토리 추가 및 파일 생성

  • src/docs/asciidoc

    • snippet 파일을 HTML로 만들기 위한 adoc 파일이 생성될 위치
  • src/main/resources/static/docs

    • asciidoctor에 의해 생성된 HTML 파일이 생성될 위치
  • test/resources/org/springframework/restdocs/templates

    • request 또는 response의 커스텀 필드 작성을 위한 snippet 파일의 위치

    • 기본적으로 api 명세에 필드명, 타입, 설명 3가지만 기본으로 제공되기 때문에 maxlength, 필수값 등 기타 항목을 설정하려면 아래와 같이 커스텀 snippet파일을 작성해야한다.

      image-20240104144056537

      *참고: intellij에서 ctrl+shift+N에서 default-원하는이름으로 파일을 검색하면 snippet 파일이 나오는데 해당 파일을 참고해서 작성하면 된다.

      image-20240104144315887

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.7'
    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.4'
}

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

java {
    sourceCompatibility = '17'
}

configurations {
    asciidoctorExt
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

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

openapi3 {
    server = "http://localhost:9090"
    title = "Test API Docs"
    description = "Spring REST Docs with OpenApi3"
    version = "0.0.1-SNAPSHOT"
    format = "json"
    outputDirectory = "src/main/resources/static/docs"
    outputFileNamePrefix = "test-api"
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    testImplementation "com.epages:restdocs-api-spec-mockmvc:0.18.4"
}

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

asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'
    dependsOn test
}

asciidoctor.doFirst {
    delete file('src/main/resources/static/docs')
}

task copyDocument(type: Copy) {
    dependsOn asciidoctor
    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

build {
    dependsOn copyDocument
    dependsOn 'openapi3'
}

bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/docs'
    }
}

테스트 공통 Controller

@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@AutoConfigureRestDocs
@WebMvcTest
public class RestDocsTestController {
    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected MockMvc mockMvc;

    @BeforeEach
    public void setMockMvc(WebApplicationContext context, RestDocumentationContextProvider provider) {
        MockMvcRestDocumentationConfigurer configurer = MockMvcRestDocumentation.documentationConfiguration(provider);

        // Spring REST Docs 문서에 host 정보 설정
        configurer.uris()
                .withHost("doc.api.com")
                .withPort(-1)
                .withScheme("https");

        // Spring REST Docs 문서에 json 데이터를 json 형식에 맞게 출력
        configurer.operationPreprocessors()
                .withRequestDefaults(prettyPrint())
                .withResponseDefaults(prettyPrint());

        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(configurer)
                .alwaysDo(MockMvcResultHandlers.print())
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .build();
    }
}

테스트 전용 Security 설정

@TestConfiguration
@EnableWebSecurity
public class TestSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .csrf().disable()
                    .cors(withDefaults()) // withDefaults() by default uses a Bean by the name of corsConfigurationSource
                    .formLogin().disable()
                    .httpBasic().disable()
                    .exceptionHandling()
                .and()
                    .authorizeRequests()
                        .anyRequest()
                            .permitAll();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfig = new CorsConfiguration();
        corsConfig.setAllowedHeaders(Arrays.asList("*"));
        corsConfig.setAllowedMethods(Arrays.asList("*"));
        corsConfig.setAllowedOriginPatterns(List.of("*"));
        corsConfig.setExposedHeaders(Arrays.asList("Authorization","Content-Disposition"));
        corsConfig.setAllowCredentials(true);
        corsConfig.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();
        corsConfigSource.registerCorsConfiguration("/**", corsConfig);
        return corsConfigSource;
    }
}

테스트 전용 인증 객체

public class TestAuthentication implements Authentication {
    private Collection<? extends GrantedAuthority> authorities;
    private Object principal;

    public TestAuthentication(Object principal) {
        this.principal = principal;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getDetails() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public boolean isAuthenticated() {
        return false;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {

    }

    @Override
    public String getName() {
        return null;
    }
}

테스트 전용 WebConfig

@TestConfiguration
public class TestWebConfig implements WebMvcConfigurer {
}

Spring Could 설정 제외 커스텀 애노테이션

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@TestPropertySource(properties = {
        "eureka.client.enabled=false",
        "spring.cloud.discovery.enabled=false",
        "spring.cloud.config.discovery.enabled=false",
        "spring.cloud.config.enabled=false"
})
public @interface IgnoreSpringCloudTest {
}

테스트 코드 작성

Asciidoctor(ADOC) 스니펫과 Swagger(JSON) 파일 모두 생성하려면 아래와 같이 중복 코드가 발생한다.

@WebMvcTest(controllers = MemberController.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE,
                        classes = {SecurityConfig.class, WebConfig.class})
        })
@Import(TestSecurityConfig.class)
@IgnoreSpringCloudTest
@WithMockUser
class MemberControllerTest extends RestDocsTestController {
    @MockBean MemberService memberService;
    
    ...
    
    @Test
    @DisplayName("회원 목록을 조회한다")
    void findAllMember() throws Exception {
        // given
        MemberSearchDto searchDto = MemberSearchDto.builder()
                .build();
        
        List<MemberDto> results = List.of(
            MemberDto.builder()
                .id(id)
                .name("홍길동")
                .mobileNo("01022223333")
                .build()
         );
        
        given(this.memberService.findAllMember(isA(MemberSearchDto.class)))
                .willReturn(results);
        
        
        // when
        ResultActions resultActions = this.mockMvc.perform(RestDocumentationRequestBuilders.get("/members/query")
                    .params(convertDtoToMultiValueMap(objectMapper, searchDto))
                    .contentType(MediaType.APPLICATION_JSON)
                );

        // then
        resultActions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].id").value(1L))
                .andExpect(jsonPath("$[0].name").value("홍길동"))
                .andExpect(jsonPath("$[0].mobileNo").value("01022223333"))
                .andDo(document("findAllMember",
                        queryParameters(
                                parameterWithName("name")
                                        .optional()
                                        .attributes(key("type").value("String"))
                                        .description("이름"),
                                parameterWithName("mobileNo")
                                        .optional()
                                        .attributes(key("type").value("String"))
                                        .description("전화번호"),
                                parameterWithName("pageNo")
                                        .optional()
                                        .attributes(key("type").value("Number"))
                                        .attributes(key("default").value("0"))
                                        .description("페이지번호"),
                                parameterWithName("rowNum")
                                        .optional()
                                        .attributes(key("type").value("Number"))
                                        .attributes(key("default").value("10"))
                                        .description("페이지 row 갯수")
                        ),
                        responseFields( // response 필드 정보 입력
                                fieldWithPath("[].id").description("아이디"),
                                fieldWithPath("[].name").description("이름"),
                                fieldWithPath("[].mobileNo").description("전화번호")
                        ),
                        ResourceDocumentation.resource(
                                ResourceSnippetParameters.builder()
                                        .tag("회원 목록 조회")
                                        .summary("회원 목록을 조회한다")
                                        .queryParameters(
                                                parameterWithName("name")
                                                        .optional()
                                                        .attributes(key("type").value("String"))
                                                        .description("이름"),
                                                parameterWithName("mobileNo")
                                                        .optional()
                                                        .attributes(key("type").value("String"))
                                                        .description("전화번호"),
                                                parameterWithName("pageNo")
                                                        .optional()
                                                        .attributes(key("type").value("Number"))
                                                        .attributes(key("default").value("0"))
                                                        .description("페이지번호"),
                                                parameterWithName("rowNum")
                                                        .optional()
                                                        .attributes(key("type").value("Number"))
                                                        .attributes(key("default").value("10"))
                                                        .description("페이지 row 갯수")
                                        )
                                        .responseFields( // response 필드 정보 입력
                                                fieldWithPath("[].id").description("아이디"),
                                                fieldWithPath("[].name").description("이름"),
                                                fieldWithPath("[].mobileNo").description("전화번호")
                                        )
                                        .responseSchema(Schema.schema("MemberDto"))
                                        .build()

                )));
    }
}

리팩토링

위 코드에서 중복 코드를 제거하기 위해 아래와 같이 리팩토링한다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomDocument {
    private String name;
    private String tag;
    private String summary;
    
    @Builder.Default
    private List<HeaderDescriptor> requestHeaders  = new ArrayList<>();
    @Builder.Default
    private List<HeaderDescriptor> responseHeaders  = new ArrayList<>();

    @Builder.Default
    private List<ParameterDescriptor> pathParameters = new ArrayList<>();
    @Builder.Default
    private List<ParameterDescriptor> queryParameters = new ArrayList<>();
    @Builder.Default
    private List<FieldDescriptor> requestFields = new ArrayList<>();
    @Builder.Default
    private List<RequestPartDescriptor> requestParts = new ArrayList<>();
    @Builder.Default
    private List<FieldDescriptor> requestPartsFields = new ArrayList<>();

    @Builder.Default
    private List<CustomResponseFieldsSnippet> responseFields = new ArrayList<>();

    @Builder.Default
    private List<LinkDescriptor> links = new ArrayList<>();
}
public class CustomDocumentFactory {

    public static RestDocumentationResultHandler create(CustomDocument customDocument) {
        if(customDocument == null) {
            throw new NullPointerException("customDocument is null!");
        }

        if(!StringUtils.hasText(customDocument.getName())) {
            throw new IllegalArgumentException("document name is empty!");
        }

        List<Snippet> snippets = getSnippetsByAsciidoc(customDocument);

        snippets.add(getSnippetsBySwaggerJson(customDocument));

        return MockMvcRestDocumentation.document(customDocument.getName(),
                snippets.toArray(Snippet[]::new));
    }

    private static List<Snippet> getSnippetsByAsciidoc(CustomDocument customDocument) {
        List<Snippet> snippets = new ArrayList<>();

        Optional.ofNullable(customDocument.getRequestHeaders())
                .filter(fields -> !fields.isEmpty())
                .map(HeaderDocumentation::requestHeaders)
                .ifPresent(snippets::add);

        Optional.ofNullable(customDocument.getPathParameters())
                .filter(fields -> !fields.isEmpty())
                .map(RequestDocumentation::pathParameters)
                .ifPresent(snippets::add);

        Optional.ofNullable(customDocument.getQueryParameters())
                .filter(fields -> !fields.isEmpty())
                .map(RequestDocumentation::requestParameters)
                .ifPresent(snippets::add);

        Optional.ofNullable(customDocument.getRequestFields())
                .filter(fields -> !fields.isEmpty())
                .map(PayloadDocumentation::requestFields)
                .ifPresent(snippets::add);

        Optional.ofNullable(customDocument.getRequestParts())
                .filter(fields -> !fields.isEmpty())
                .map(RequestDocumentation::requestParts)
                .ifPresent(snippets::add);

        Optional.ofNullable(customDocument.getRequestPartsFields())
                .filter(fields -> !fields.isEmpty())
                .map(CustomRequestPartsFieldsSnippet::customFields)
                .ifPresent(snippets::add);

        Optional.ofNullable(customDocument.getResponseHeaders())
                .filter(fields -> !fields.isEmpty())
                .map(HeaderDocumentation::responseHeaders)
                .ifPresent(snippets::add);

        if(customDocument.getResponseFields() != null && !customDocument.getResponseFields().isEmpty()) {
            customDocument.getResponseFields().forEach(fieldsSnippet -> {
                Optional.ofNullable(fieldsSnippet)
                        .filter(fields -> !fields.getDescriptors().isEmpty())
                        .ifPresent(snippets::add);
            });
        }
        
        return snippets;
    }

    private static Snippet getSnippetsBySwaggerJson(CustomDocument customDocument) {
        List<HeaderDescriptorWithType> requestHeadersWithType = customDocument.getRequestHeaders().stream()
                .map(descriptor -> HeaderDescriptorWithType.Companion.fromHeaderDescriptor(descriptor))
                .collect(Collectors.toList());

        List<ParameterDescriptorWithType> pathParametersWithType = customDocument.getPathParameters().stream()
                .map(descriptor -> ParameterDescriptorWithType.Companion.fromParameterDescriptor(descriptor))
                .collect(Collectors.toList());

        List<ParameterDescriptorWithType> queryParametersWithType = customDocument.getQueryParameters().stream()
                .map(descriptor -> ParameterDescriptorWithType.Companion.fromParameterDescriptor(descriptor))
                .collect(Collectors.toList());


        List<FieldDescriptor> descriptors = new ArrayList<>();
        if (customDocument.getResponseFields() != null && !customDocument.getResponseFields().isEmpty()) {
            for(CustomResponseFieldsSnippet responseFieldsSnippet : customDocument.getResponseFields()) {
                for(FieldDescriptor fieldDescriptor : responseFieldsSnippet.getDescriptors()) {
                    String path = StringUtils.hasText(responseFieldsSnippet.getPath())
                            ? String.format("%s.%s", responseFieldsSnippet.getPath(), fieldDescriptor.getPath())
                            : fieldDescriptor.getPath();

                    FieldDescriptor f = fieldWithPath(path).description(fieldDescriptor.getDescription());
                    if(fieldDescriptor.isIgnored()) {
                        f.ignored();
                    }

                    if(fieldDescriptor.isOptional()) {
                        f.optional();
                    }

                    descriptors.add(f);
                }
            }
        }

        return ResourceDocumentation.resource(
                ResourceSnippetParameters.builder()
                        .tag(customDocument.getTag())
                        .summary(customDocument.getSummary())
                        .requestHeaders(requestHeadersWithType)
                        .pathParameters(
                                pathParametersWithType
                        )
                        .requestParameters(
                                queryParametersWithType
                        )
                        .requestFields(
                                customDocument.getRequestFields()
                        )
                        .responseFields(
                                descriptors
                        )
                        .links(customDocument.getLinks())
                        .build());
    }
}

리팩토링된 테스트 코드 부분


    // then
    resultActions
            .andExpect(status().isOk())
            .andExpect(jsonPath("id").value(id))
            .andExpect(jsonPath("name").value("홍길동"))
            .andExpect(jsonPath("mobileNo").value("01022223333"))
            .andDo(CustomDocumentFactory.create(CustomDocument.builder()
                            .name("findMember")
                            .tag("회원 조회")
                            .summary("회원을 조회한다")
                            .pathParameters(List.of(parameterWithName("id").description("회원 일련번호")))
                            .responseFields(List.of(
                                    fieldWithPath("id").description("아이디"),
                                    fieldWithPath("name").description("이름"),
                                    fieldWithPath("mobileNo").description("전화번호")))
                            .build()));


    // then
    resultActions
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].id").value(1L))
            .andExpect(jsonPath("$[0].name").value("홍길동"))
            .andExpect(jsonPath("$[0].mobileNo").value("01022223333"))
            .andDo(CustomDocumentFactory.create(CustomDocument.builder()
                    .name("findAllMember")
                    .tag("회원 목록 조회")
                    .summary("회원 목록을 조회한다")
                    .queryParameters(List.of(
                                    parameterWithName("name")
                                            .optional()
                                            .attributes(key("type").value("String"))
                                            .description("이름"),
                                    parameterWithName("mobileNo")
                                            .optional()
                                            .attributes(key("type").value("String"))
                                            .description("전화번호"),
                                    parameterWithName("pageNo")
                                            .optional()
                                            .attributes(key("type").value("Number"))
                                            .attributes(key("default").value("0"))
                                            .description("페이지번호"),
                                    parameterWithName("rowNum")
                                            .optional()
                                            .attributes(key("type").value("Number"))
                                            .attributes(key("default").value("10"))
                                            .description("페이지 row 갯수")
                    ))
                    .responseFields(List.of(
                            fieldWithPath("[].id").description("아이디"),
                            fieldWithPath("[].name").description("이름"),
                            fieldWithPath("[].mobileNo").description("전화번호")
                    ))
                    .build()));

    // then
    resultActions
            .andExpect(status().isNoContent())
            .andDo(CustomDocumentFactory.create(CustomDocument.builder()
                    .name("createMember")
                    .tag("회원 등록")
                    .summary("회원을 등록한다")
                    .requestFields(List.of(
                            fieldWithPath("id")
                                    .type(JsonFieldType.NUMBER)
                                    .description("아이디")
                                    .attributes(key("maxLength").value("10")),
                            fieldWithPath("name")
                                    .type(JsonFieldType.STRING)
                                    .attributes(key("maxLength").value("20"))
                                    .description("이름"),
                            fieldWithPath("mobileNo")
                                    .type(JsonFieldType.STRING)
                                    .optional()
                                    .attributes(key("default").value("None"))
                                    .description("전화번호")
                    )).build()));

ADOC 파일 생성

원하는 스니펫 파일들을 하나로 묶는 ADOC파일을 생성한다.

src/docs/asciidoc/test-api.adoc

[[Test-API]]
== 회원 API

=== [blue]#회원 목록 조회#
operation::findAllMember[snippets='http-request,query-parameters,http-response,response-fields']

=== [blue]#회원 조회#
operation::findMember[snippets='http-request,path-parameters,http-response,response-fields']

=== [blue]#회원 등록#
operation::createMember[snippets='http-request,http-response,request-fields']

operation::adoc_name [snippets=’snippets_name, snippets_name’]

  • adoc_name: CustomDocumentFactory.name으로 설정한 값
  • snippets_name: adoc_name에 해당하는 문서를 구성할 때 보여줄 스니펫들을 정의한다. snippets_name은 build/generated-snippets/adoc_name/ 아래 생성되는 스니펫 이름과 동일해야 한다.

< 중요한 부분>

여기에서는 test-api.adoc이라는 이름으로 파일을 생성하였지만 실제 프로덕트에 반영할 때는 이름을 의미있게 만들어야 한다.

그 이유는 ADOC 파일들을 하나의 index.html로 만들때 index.adoc을 직접 작성/변경하지 않고 스크립트로 자동 생성되도록 하였기 때문이다.

아래쪽에 문서 빌드 shell 스크립트를 보면 index.adoc에 나열할 순서를 ADOC 파일 이름으로 정렬하고 있고, 이전에 API에서 업로드한 ADOC 파일들을 API명으로 삭제하고 있다.

예시)

1. 하나의 API에 하나의 도메인만 있는 경우

목차순번-서비스명-API명.adoc

sort001-pos-shop.adoc

sort002-pos-order.adoc

sort003-pos-review.adoc

sort101-app-shop.adoc

sort102-app-order.adoc

sort103-app-review.adoc

2. 하나의 API에 여러개의 도메인이 존재하는 경우

목차순번-서비스명-API명-도메인명.adoc

sort001-pos-shop-shop.adoc

sort002-pos-shop-item.adoc

sort003-pos-shop-review.adoc

sort101-app-member-auth.adoc

sort102-app-member-member.adoc

sort103-app-member-review.adoc

Gradle build를 수행하여 테스트가 성공되면 아래와 같은 파일들이 만들어진다.

image-20240104144958008

test-api.html 브라우저로 확인

image-20240104175717068

참고사항

Spring REST Docs의 QueryParameters는 3.0.0 버전부터 지원하는데, Spring REST Docs 3.0.0은 아래와 같은 조건이 필요하다.

(https://spring.io/blog/2022/11/21/spring-rest-docs-3-0-0/)

  • Java 17
  • Spring Framework 6.0 (Spring Boot 3.0.0)

만약 Spring REST Docs 2.x 버전을 사용해야 된다면 QueryParameters 대신 RequestParameters를 사용해야 한다.

문서 통합 서버 설정

java 설치

Java 설치
cat <<EOF > /etc/yum.repos.d/adoptium.repo
[Adoptium]
name=Adoptium
baseurl=https://packages.adoptium.net/artifactory/rpm/centos/8/$(uname -m)
enabled=1
gpgcheck=1
gpgkey=https://packages.adoptium.net/artifactory/api/gpg/key/public
EOF

yum install temurin-17-jdk

Gradle 설치

gradle 버전 별 download 정보: https://services.gradle.org/distributions/

# cd /opt
# wget https://services.gradle.org/distributions/gradle-8.1-bin.zip -P /opt
# unzip /opt/gradle-8.1-bin.zip
# rm -f /opt/gradle-8.1-bin.zip
# vim /etc/profile.d/gradle.sh
export GRADLE_HOME=/opt/gradle-8.1
export PATH=${GRADLE_HOME}/bin:${PATH}

# chmod +x /etc/profile.d/gradle.sh
# source /etc/profile.d/gradle.sh
Complete!

# gradle -v
Welcome to Gradle 8.1!
...

API 문서 관련 디렉토리 생성

# mkdir -p /var/docs
# mkdir -p /var/docs/api
# mkdir -p /var/docs/html
# mkdir -p /var/docs/adoc
# mkdir -p /var/docs/config

API 문서 디렉토리 Gradle 초기 설정

# cd /var/docs
# /opt/gradle-8.1/bin/gradle wrapper
# ls 
  api  gradle  gradlew  gradlew.bat  html

build.gradle 설정

vim /var/docs/build.gradle

plugins {                                                                                              
    id 'java'                                                                                          
    id 'org.springframework.boot' version '3.1.7'                                                      
    id 'io.spring.dependency-management' version '1.1.4'                                               
    id 'org.asciidoctor.jvm.convert' version '3.3.2'                                                   
}                                                                                                      
                                                                                                       
configurations {                                                                                       
    asciidoctorExt                                                                                     
    compileOnly {                                                                                      
        extendsFrom annotationProcessor                                                                
    }                                                                                                  
}                                                                                                      
                                                                                                       
repositories {                                                                                         
    mavenCentral()                                                                                     
}                                                                                                      
                                                                                                       
dependencies {                                                                                        
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'                          
}                                                                                                      
                                                                                                       
asciidoctor {                                                                                          
    configurations 'asciidoctorExt'                                                                    
    sourceDir file('api/src/docs/asciidoc')                                                            
    sources {                                                                                          
        include 'index.adoc'                                                                           
    }                                                                                                  
    outputDir file('html')                                                                             
    attributes(                                                                                        
    //    'baseDir': project.file('path'), // base directory                
    //    'backend': 'html5',                                                                          
        'snippets': project.file('api/build/generated-snippets') // snippets path            
    )                                                                                                  
}                                                                                                      

관련 옵션 참고(https://asciidoctor.github.io/asciidoctor-gradle-plugin/master/user-guide/)

문서 template 파일 생성

index.doc을 구성하는 TOC(목차) 부분과 docinfo(스타일 및 스크립트) 파일 설정을 한다

vim /var/docs/config/index.template

= API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toc-title: API 목록
:toclevels: 2
:sectlinks:
:docinfodir: /var/docs/config
:docinfo: shared

docinfo 파일 생성

index.doc로 생성하는 index.html을 커스터마이징 하기 위한 docinfo.html 파일을 생성한다.

vim /var/docs/config/docinfo.html

<style>
   .sectlevel2 {                              
      display: none;                           
    }                                          

    .sectlevel1 li.collapsible .sectlevel2 {   
      display: block;                          
    }                                          

    li > a {                                   
      color: #141517;                          
      text-decoration: none;                   
    }                                          

    li > a:hover {                             
      color: #f82f62;                          
      text-decoration: underline;              
    }                                          

    li > a:hover span {                        
      color: #f82f62;                          
      text-decoration: underline;              
    }                                          

    a.active {                                 
      color: #f82f62;                          
      text-decoration: underline;              
    }                                          

    a.active span {                            
      color: #f82f62;                          
      text-decoration: underline;              
    }                                          

    body.toc2 {                                
      padding-left: 26em;                      
    }                                          

    #toc.toc2 {                                
      /* initially 20em */                     
      width: 25em;                             
    }       
    
    /* right body page full width*/
      #header,
      #content,
      #footnotes,
      #footer {
        max-width: 100%;
      }
</style>

<script>
   document.addEventListener('DOMContentLoaded', function () {
        var sectlevel1Items = document.querySelectorAll('.sectlevel1 li');
        var sectlevel2Items = document.querySelectorAll('.sectlevel2 li a');
        var currentFragment = decodeURIComponent(window.location.hash);
        var matchingAnchor = document.querySelector('.sectlevel2 li a[href="' + currentFragment + '"]');
       
       // request, response 등 필드 테이블에서 설명 부분의 col width를 높게 가져간다
        var resultTables = document.querySelectorAll('table.tableblock');
        resultTables.forEach(function (table) {
          var colgroups = table.querySelectorAll('colgroup col');

          if (colgroups.length > 2) {
            var distributionWidth = (100 / colgroups.length).toFixed(0);
            var weightWidth = Math.floor(distributionWidth / colgroups.length);
            var regularColWidth = distributionWidth - weightWidth;
            var regularColLastIndex = colgroups.length - 1;
            var descriptionColWidth = parseInt(weightWidth * regularColLastIndex) + parseInt(distributionWidth);

            colgroups.forEach(function (col, index) {
              col.style.width = index < regularColLastIndex ? `${regularColWidth}%` : `${descriptionColWidth}%`;
            });
          }
        });
       
       // 좌측 메뉴에서 레벨 1단계 메뉴를 클릭하면 숨겨진 2단계 메뉴를 펼치기한다. 
       // 펼쳐진 상태에서 1단계 메뉴를 다시 클릭하면 2단계 메뉴를 숨긴다.
        sectlevel1Items.forEach(function (item) {
          item.addEventListener('click', function (e) {
            if (item.classList.contains('collapsible')) {
              item.classList.remove('collapsible');
            } else {
              item.classList.add('collapsible');
            }

            e.stopPropagation();
          });
        });
       
       // 좌측 메뉴에서 클릭한 메뉴를 빨간색으로 표시하고, 해당 페이지를 새로고침한 경우 메뉴 펼치기를 한다
        if (matchingAnchor) {
          matchingAnchor.classList.add('active');
          var parentLi = matchingAnchor.closest('li');
            
          if (parentLi) {
            parentLi.classList.add('collapsible');
            var topLi = parentLi.parentElement.closest('li');
            if (topLi) {
              topLi.classList.add('collapsible');
            }
          }
        }

       // 좌측 메뉴 클릭 시 해당 메뉴를 빨간색으로 표시하고 다른 메뉴들은 기본색으로 변경.
        sectlevel2Items.forEach(function (item) {
          item.addEventListener('click', function (e) {
            sectlevel2Items.forEach(function (otherItem) {
              otherItem.classList.remove('active');
            });

            this.classList.add('active');
              
             e.stopPropagation();
          });
        });
      });
</script>

문서 빌드 Shell 스크립트 작성

이 스크립트는 다음과 같은 역할을 한다.

  • 이전에 생성된 index.html과 현재 프로젝트가 업로드한 adoc 파일들을 삭제한다.
  • jenkins에서 업로드한 adoc 파일들을 빌드 경로로 옮긴다.

  • ADOC 파일들을 하나의 index.html을 만들기 위한 index.adoc을 생성한다.

  • index.adoc에 index.template 내용을 추가한다.

  • index.adoc adoc 파일들을 이름순으로 include 코드로 넣는다.

  • gradle로 asciidoctor를 빌드한다.

vim /var/docs/convert-adoc-to-html.sh

#!/bin/bash

# 문서 root로 이동
cd /var/docs

# 현재 배포중인 서비스명
SERVICE_NAME=$1

ROOT_PATH="/var/docs"
ADOC_PATH="$ROOT_PATH/api/src/docs/asciidoc"
ADOC_TEMP_PATH="$ROOT_PATH/adoc"
LOG_PATH="$ROOT_PATH/convert-adoc-to-html.log"

# index.template 파일 경로
TEMPLATE_FILE="$ROOT_PATH/config/index.template"
# index.adoc 파일 경로
OUTPUT_FILE="$ADOC_PATH/index.adoc"


if [ -z "$SERVICE_NAME" ]; then
    echo "Error: SERVICE_NAME is empty. Please provide a valid value."
    exit 1
fi

# index.html 삭제
rm -f $ROOT_PATH/html/index.html

# 현재 배포한 서비스의 기존 adoc 파일들 삭제
rm -f $OUTPUT_FILE
rm -f $ADOC_PATH/*${SERVICE_NAME}*.adoc

# index.adoc 파일 생성
touch $OUTPUT_FILE

# index.template 파일의 내용을 index.adoc 파일에 추가
cat "$TEMPLATE_FILE" >> "$OUTPUT_FILE"

# 임시 경로의 *.adoc 파일들을 adoc path로 옮겨준다
mv $ADOC_TEMP_PATH/*${SERVICE_NAME}*.adoc $ADOC_PATH/

# index.adoc 파일에 *.adoc들을 이름순으로 정렬하여 포함시킨다
for file in $(ls -1 "$ADOC_PATH"/*.adoc | sort); do
    if [ "$file" != "$OUTPUT_FILE" ]; then
        echo "include::$file[]" >> "$OUTPUT_FILE"
    fi
done

./gradlew clean asciidoctor

Nginx 설정

nginx에서 접근하게 될 문서 디렉토리 권한 관련 설정

chcon -R -t httpd_sys_content_t /var/docs

vim /etc/nginx.conf

worker_processes auto;
pid /run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    
	server {                              
        listen       443 ssl http2;                       
        listen       [::]:443 ssl http2;     
        server_name  doc.api.com;                                                                                                                                                                    
        charset utf-8;                    
        root /var/docs/html;            
        index index.html;                                                                                                                                               
        access_log /var/log/nginx/doc.access.log main;
        error_log /var/log/nginx/doc.error.log warn;                         
                                                                
        location @rewrites {                                      
            rewrite ^(.+)$ /index.html last;                      
        }                                                         
                                                                
        location ~* \.html?$ {                                    
            add_header Cache-Control "no-cache, no-store";        
            try_files $uri $uri/ /index.html;                     
        }                                                         
        location / {                                              
            try_files $uri $uri/ /index.html;                     
        }                                                         
    } 
}

# systemctl restart nginx

Jenkins 빌드 설정

문서통합서버로 관련 파일 전송하고 문서 서버의 convert-adoc-to-html.sh를 실행한다.

convert-adoc-to-html.sh가 정상 실행되면 /var/docs/html/index.html이 생성된다.

image-20240119164741396

image-20240129164242224

두번째 Transfer Set의 Exec command 부분에 스크립트 인자로 api name을 넣어준다.

여기서 설정한 api name은 기존에 api에서 업로드한 adoc 파일들을 삭제하는데 사용된다.

확인 작업

이제 모든 설정이 끝났다.

github에 커밋을 push하는 것만으로도 자동으로 문서가 구성될 것이다.

(단, 지금까지 설명 부분에는 github와 jenkins 연동, 서비스 배포 등의 설정 부분은 제외되어 있다.)

https://doc.api.com

image-20240129143226493

Swagger 구성은 다음편에 작성한다.

댓글남기기