Spring Rest Docs + Swagger 문서화
Spring으로 개발한 API 서비스를 문서화하기 위한 도구로 Swagger와 Spring REST Docs를 주로 사용되며, 마이크로서비스 아키텍처에서 아래 그림과 같이 문서화를 진행하였다.
위 그림은 각각의 마이크로서비스가 빌드될 때마다 스니펫(ADOC)과 JSON파일들을 하나의 문서 서버로 전송한 다음 HTML로 변환하는 것이며,
그 과정은 다음과 같다.
- Github에서 커밋을 push하면 webhook으로 jenkins가 빌드된다.
- jenkins에서 Gradle 빌드가 끝나면 생성되는 스니펫(ADOC), JSON 파일들을 문서 서버로 전송한다.
- jenkins에서 파일 전송이 끝난 후, 문서 서버에 Gradle과 Asciidoctor를 이용해 ADOC 파일들을 빌드하여 HTML 파일을 생성한다.
- 변환된 HTML 파일은 Nginx를 통해 브라우저로 확인할 수 있다.
index.html 화면
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을 설치
프로젝트 경로에 디렉토리 추가 및 파일 생성
-
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파일을 작성해야한다.
*참고: intellij에서 ctrl+shift+N에서 default-원하는이름으로 파일을 검색하면 snippet 파일이 나오는데 해당 파일을 참고해서 작성하면 된다.
-
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를 수행하여 테스트가 성공되면 아래와 같은 파일들이 만들어진다.
test-api.html 브라우저로 확인
참고사항
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이 생성된다.
두번째 Transfer Set의 Exec command 부분에 스크립트 인자로 api name을 넣어준다.
여기서 설정한 api name은 기존에 api에서 업로드한 adoc 파일들을 삭제하는데 사용된다.
확인 작업
이제 모든 설정이 끝났다.
github에 커밋을 push하는 것만으로도 자동으로 문서가 구성될 것이다.
(단, 지금까지 설명 부분에는 github와 jenkins 연동, 서비스 배포 등의 설정 부분은 제외되어 있다.)
https://doc.api.com
댓글남기기