스프링부트

Spring REST docs + asciidoctor + restdoc spec + swagger ui 를 통한 문서 자동화 - 필드 제약사항 추가 (커스텀)

알쓸개잡 2023. 7. 28.

이번 포스팅에서는 문서화 작업시에 REST API 의 요청 파라미터 혹은 요청 body payload 의 각 항목에 대한 제약사항을 문서화에 추가하는 방법에 대해서 기술한다. 이전 포스팅의 샘플 코드를 기반으로 내용을 발전시켜 나가겠다.

 

Validation 디펜던시 추가

이전 포스팅의 pom.xml 에서 validation 디펜던시를 추가한다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

 

DTO 에 validation 추가

이전 포스팅의 MemberDto 에 validation 을 추가한다.

package com.example.spring.docs.example.dto;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Builder;

@Builder
public record MemberDto(
	String id,
	@NotBlank
	String firstName,

	@NotBlank
	String lastName,
	
	@Min(1) @Max(100)
	Integer age,
	String hobby) {

	public MemberDto withId(String id) {
		return MemberDto.builder()
			.id(id)
			.firstName(firstName)
			.lastName(lastName)
			.age(age)
			.hobby(hobby)
			.build();
	}
}

firstName, lastName 필드가 공백이나 null 값을 허용하지 않는 제약사항을 지정하였다. 또한 1~100 범위의 age 필드를 추가하였다.

테스트 코드에서는 ConstraintDescriptions 객체와 attributes 를 통해서 DTO 에 지정된 validation 을 문서에 포함시킬 수 있다.

추가적으로 기본으로 제공되는 snippet 템플릿을 커스텀하게 변경하기 위해서는 test/resources/org/springframework/restdocs/templates 에 적용하기 위한 snippet 템플릿을 지정해야 한다.

 

Custom Snippet 템플릿 생성

요청 본문의 필드와 요청 파라미터에 제약사항 항목을 추가할 것이기 때문에 request-fields.snippet, request-parameters.snippet 이름으로 test/resources/org/springframework/restdocs/templates 경로에 생성할 것이다.

spring 에서 기본으로 제공하는 요청 필드 snippet 는 default-request-fields.snippet 이며 test/resources/org/springframework/restdocs/templates 경로에 request-fields.snippet 파일이 존재하면 해당 파일이 적용된다.

아래의 파일들은 mustache 문법을 따른다.

mustache 문법에 대해서는 http://mustache.github.io/mustache.5.html 링크를 참고한다.

request-fields.snippet

|===
|필드명|타입|필수여부|제약조건|설명
{{#fields}}
    |{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
    |{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
    |{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}}
    |{{#tableCellContent}}{{#constraints}}{{.}}{{/constraints}}{{/tableCellContent}}
    |{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/fields}}
|===

위 항목은 아래의 테스트 코드와 맵핑된다.

{{path}} : AbstractDescriptor 를 상속한 FieldDescriptor 의 path 멤버

{{type}} : AbstractDescriptor 를 상속한 FieldDescriptor 의 type 멤버

{{optional}} : AbstractDescriptor 를 상속한 FieldDescriptor 의 optional 멤버

{{constraints}} : AbstractDescriptor 의 atrributes 멤버 (이후 코드에서 보겠지만 attributes 에 'constraints' 를 key 로 지정한다)

{{description}} : AbstractDescriptor 의 description 멤버

query-parameters.snippet

|===
|파라미터|필수여부|제약사항|설명
{{#parameters}}
|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{^optional}}true{{/optional}}{{#optional}}false{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{#constraints}}{{.}}{{/constraints}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/parameters}}
|===

위 항목은 아래의 테스트 코드와 맵핑된다.

{{name}} : AbstractDescriptor 를 상속한 ParameterDescriptor 의 name 멤버

{{optional}} : AbstractDescriptor 를 상속한 ParameterDescriptor 의 optional 멤버

{{constraints}} : AbstractDescriptor 의 atrributes 멤버 (이후 코드에서 보겠지만 attributes 에 'constraints' 를 key 로 지정한다)

{{description}} : AbstractDescriptor 의 description 멤버

 

MemberController 테스트 코드

package com.example.spring.docs.example.controller;

import com.example.spring.docs.example.BaseDocumentTest;
import com.example.spring.docs.example.dto.MemberDto;
import com.example.spring.docs.example.service.MemberService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.constraints.ConstraintDescriptions;
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import org.springframework.restdocs.payload.JsonFieldType;

import java.util.UUID;

import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.*;
import static org.springframework.restdocs.snippet.Attributes.key;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class MemberControllerTest extends BaseDocumentTest {
	@MockBean
	MemberService memberService;

	@DisplayName("멤버 정보를 가져오는 테스트")
	@Test
	void getMember() throws Exception {
		MemberDto memberDto = MemberDto.builder()
			.id(UUID.randomUUID().toString())
			.firstName("firstname")
			.lastName("lastname")
			.age(20)
			.hobby("study")
			.build();

		given(memberService.getMember(anyString())).willReturn(memberDto);

		this.mockMvc.perform(
				RestDocumentationRequestBuilders.get("/members/{id}", UUID.randomUUID())
				.accept(MediaType.APPLICATION_JSON)
			)
			.andExpect(status().isOk())
			.andDo(
				document(snippetPath,
					"아이디 기반 멤버 정보를 조회하는 API",
					//아래 부터는 spring REST docs snippets 를 정의한다
					//path parameter 에 대한 문서 정의
					pathParameters(
						parameterWithName("id").description("멤버 아이디")
					),
					//응답에 대한 문서 정의
					responseFields(
						fieldWithPath("id").type(JsonFieldType.STRING).description("멤버 아이디"),
						fieldWithPath("firstName").type(JsonFieldType.STRING).description("성"),
						fieldWithPath("lastName").type(JsonFieldType.STRING).description("이름"),
						fieldWithPath("age").type(JsonFieldType.NUMBER).description("나이"),
						fieldWithPath("hobby").type(JsonFieldType.STRING).description("취미").optional()
					)
				)
			);
	}

	@DisplayName("멤버를 생성하는 테스트")
	@Test
	void createMember() throws Exception {
		MemberDto memberDto = MemberDto.builder()
			.firstName("firstname")
			.lastName("lastname")
			.age(20)
			.build();

		MemberDto responseDto = memberDto.withId(UUID.randomUUID().toString());

		given(memberService.createMember(any())).willReturn(responseDto);

		//MemberDto 의 validation 이 지정된 제약사항을 구문 분석한다.
		ConstraintDescriptions constraintDescriptions = new ConstraintDescriptions(MemberDto.class);

		this.mockMvc.perform(
				RestDocumentationRequestBuilders.post("/members")
					.content(createJson(memberDto))
					.contentType(MediaType.APPLICATION_JSON)
					.accept(MediaType.APPLICATION_JSON)
			)
			.andExpect(status().isOk())
			.andDo(
				document(snippetPath,
					"멤버 정보를 생성하는 API",
					//요청에 대한 문서 정의
					requestFields(
						//요청 body payload 에서 member dto 정의의 id 필드 정보는 없으므로 ignored 를 호출한다.
						fieldWithPath("id").ignored(),
						fieldWithPath("firstName")
							.type(JsonFieldType.STRING)
							.description("멤버 성")
							//MemberDto 의 firstName 필드의 제약사항을 문서화
							.attributes(key("constraints").value(constraintDescriptions.descriptionsForProperty("firstName"))),
						fieldWithPath("lastName")
							.type(JsonFieldType.STRING)
							.description("멤버 이름")
							//MemberDto 의 lastName 필드의 제약사항을 문서화
							.attributes(key("constraints").value(constraintDescriptions.descriptionsForProperty("lastName"))),
						fieldWithPath("age")
							.type(JsonFieldType.NUMBER)
							.description("멤버 나이")
							//MemberDto 의 age 필드의 제약사항을 문서화
							.attributes(key("constraints").value(constraintDescriptions.descriptionsForProperty("age"))),
						fieldWithPath("hobby").type(JsonFieldType.STRING).description("멤버 취미").optional()
					),
					//응답에 대한 문서 정의
					responseFields(
						fieldWithPath("id").type(JsonFieldType.STRING).description("멤버 아이디"),
						fieldWithPath("firstName").type(JsonFieldType.STRING).description("성"),
						fieldWithPath("lastName").type(JsonFieldType.STRING).description("이름"),
						fieldWithPath("age").type(JsonFieldType.NUMBER).description("나이"),
						fieldWithPath("hobby").type(JsonFieldType.STRING).description("취미").optional()
					)
				)
			);
	}

	@DisplayName("파라미터 기반 멤버를 생성하는 테스트")
	@Test
	void createMemberParam() throws Exception {
		String firstName = "firstname";
		String lastName = "lastname";
		Integer age = 20;
		MemberDto memberDto = MemberDto.builder()
			.id(UUID.randomUUID().toString())
			.firstName(firstName)
			.lastName(lastName)
			.age(age)
			.build();

		//request 가 파라미터 방식이라도 파라미터 필드가 동일하다면 MemberDto 의 validation 을 그대로 활용할 수 있다.
		//MemberDto 의 validation 정보를 구문분석하여 문서화에 사용될 뿐임을 알 수 있다.
		ConstraintDescriptions constraintDescriptions = new ConstraintDescriptions(MemberDto.class);

		//3번째 필드인 hobby 는 optional 이기 때문에 anyString 이 아닌 any() 로 선언해야 한다
		given(memberService.createMember(
				anyString(),
				anyString(),
				anyInt(),
				any()
			)
		).willReturn(memberDto);

		this.mockMvc.perform(
				RestDocumentationRequestBuilders.get("/members-param")
					.queryParam("firstName", firstName)
					.queryParam("lastName", lastName)
					.queryParam("age", age.toString())
					.header("x-custom-header", "custom header")
					.accept(MediaType.APPLICATION_JSON)
			)
			.andExpect(status().isOk())
			.andDo(
				document(snippetPath,
					"파라미터 기반 입력으로 멤버를 생성하는 API",
					//요청 헤더 에 대한 문서 정의
					requestHeaders(
						headerWithName("x-custom-header").description("커스텀 헤더 정보")
					),
					//요청 파라미터에 대한 문서 정의
					queryParameters(
						parameterWithName("firstName")
							.description("멤버 이름 성")
							.attributes(key("constraints").value(constraintDescriptions.descriptionsForProperty("firstName"))),
						parameterWithName("lastName")
							.description("멤버 이름")
							.attributes(key("constraints").value(constraintDescriptions.descriptionsForProperty("lastName"))),
						parameterWithName("age")
							.description("멤버 나이")
							.attributes(key("constraints").value(constraintDescriptions.descriptionsForProperty("age"))),
						parameterWithName("hobby")
							.description("멤버 취미")
							.optional()
					),
					//응답에 대한 문서 정의
					responseFields(
						fieldWithPath("id").type(JsonFieldType.STRING).description("멤버 아이디"),
						fieldWithPath("firstName").type(JsonFieldType.STRING).description("성"),
						fieldWithPath("lastName").type(JsonFieldType.STRING).description("이름"),
						fieldWithPath("age").type(JsonFieldType.NUMBER).description("나이"),
						fieldWithPath("hobby").type(JsonFieldType.STRING).description("취미").optional()
					)
				)
			);
	}
}

 

문서화 실행

./mvnw clean prepare-package

 

Member 생성 API 문서

필수여부 및 제약조건 항목이 추가되었다

파라미터 기반 Member 생성 API 문서

필수여부 및 제약사항 항목이 추가되었다

 

 

샘플 소스코드

https://gitlab.com/blog4031530/spring-docs-example

 

blog / spring-docs-example · GitLab

GitLab.com

gitlab.com

 

Conclusion

간단하게나마 Spring REST Docs 와 asciidoctor 플러그인, spring restdocs 를 통해서 문서 자동화하는 방법을 알아보았다. 사실 문서화를 작성하는데 있어서 다양한 고려 사항들이 있을 것으로 보여진다. 예를 들면 여러가지 enum 타입에 대한 문서화나 지정된 예외가 발생하였을 경우에 대한 문서화와 같은 것이 있겠다. 조금 더 많은 학습이 필요하겠다.

asciidoctor 나 mustache 문법과 Spring REST Docs 에 대한 spring 공식 문서를 학습하는데 필요한 러닝커브가 어느정도 있어 보인다. 처음 도입이 쉽지는 않겠지만 테스트 코드 기반으로 문서화를 할 수 있다는 점과 API spec 이 변경되더라도 테스트 코드를 통해서 문서에 바로 반영할 수 있다는 점에서 시도해 볼 만한 가치가 있다고 생각된다.

 

댓글

💲 추천 글