RestDocs에서 파일명은 일반적으로 request(response)-fields, request(response)-body 등의 규칙이 있다.
Snippet 파일은 보통 src/test/resources/org/springframework/restdocs/templates/asciidoctor
아래에 커스텀하여 두는데, 이 때도 파일명을 규칙에 맞게 사용해야 requestFields() 메서드, responseFeilds() 메서드 등이 이해하여 커스텀 snippet을 읽어서 문서를 만들어준다.
이 때 규칙을 찾아보면,
AbstractFieldsSninnpet이나 AbstractBodySnippet 이나, 생성자에서 첫번째 파라미터로 String type 또는 String name이라는 파라미터를 받는데, 이 파라미터로 super를 호출하면서 각자의 파일 이름 규칙을 만든다.
즉 body나 parameter나 fields나 snippet 파일 이름 규칙만 맞춰 커스텀하면
src/test/resources/org/springframework/restdocs/templates/asciidoctor
디렉토리 밑의 파일을 찾아 매핑해서 문서를 만들어준다.
- request(response) field 문서를 생성을 도와주는 클래스 - AbstractFieldsSnippet
- RequestFieldSnippet 클래스 - (규칙 : 스니펫 파일 이름 : {xxx} -fields )
- ResponseFieldSnippet 클래스 - (규칙 : 스니펫 파일 이름 : {xxx}-fields )
- request(response) body 문서를 생성을 도와주는 클래스 - AbstractBodySnippet
- RequestBodySnippet 클래스 (규칙 - {xxx} -body)
- ResponseBodySnippet 클래스 (규칙 - {xxx} -body)
- requestParam, pathParemeters 문서 생성을 도와주는 클래스 - AbstractParametersSnippet
- RequestParametersSnippet 클래스(규칙 : 스니펫 파일이름 - {xxx} request-parameters.snippet),
- PathParametersSnippet 클래스 (규칙 : 스니펫 파일 이름 - {xxx} path-parameters.snippet ).
예제를 통해 response fields를 내가 원하는 이름대로 커스텀 해보도록 한다.
1. custom snippet 파일 생성
파일 이름은 ys-custom-fields.snippet 이다.
=== YS Custom Response Data Fields 규칙과 다른 파일명을 커스텀 해보고 싶었습니다. |=== |필드명|타입|필수값|설명 {{#fields}} |{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} |{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} |{{#tableCellContent}}{{description}}{{/tableCellContent}} {{/fields}} |===
파일 위치는 src/test/resources/org/springframework/restdocs/templates/asciidoctor
에 두도록 한다.
- 필드명, 타입, 필수값여부, 설명 정도만 추가하였다.
2. 공통 에러를 보여주기 위한 테스트에 RestController 생성
공통 에러를 보여주기 위해 Test 패키지에 Controller를 생성하였고, Controller 클래스와 공통 에러 클래스는 다음과 같다.
// test 패키지이다. package com.ys.board.domain.restdocs.error; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @RestController public class ErrorController { @GetMapping("/error") public ResponseEntity<ErrorResponse> error() { return ResponseEntity.ok(ErrorResponse.badRequest("요청이 잘못되었습니다", ServletUriComponentsBuilder.fromCurrentContextPath().toUriString())); } } // @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @JsonInclude(JsonInclude.Include.NON_NULL) public class ErrorResponse { private int status; private String message; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") private LocalDateTime timestamp; private String path; }
3. MockMvc 테스트 코드 작성
@WebMvcTest @AutoConfigureRestDocs @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) @RequiredArgsConstructor class CustomSnippet { protected final MockMvc mockMvc; protected final ObjectMapper objectMapper; @Test void customSnippet() throws Exception { //when ResultActions result = this.mockMvc.perform(get("/error") .characterEncoding(StandardCharsets.UTF_8.name()) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ); MvcResult mvcResult = result.andReturn(); //then result.andExpect(status().isOk()) .andDo(document("ys-custom-response", customResponseFields("ys-custom", fieldWithPath("status").type(JsonFieldType.NUMBER).description("결과코드"), fieldWithPath("message").type(JsonFieldType.STRING).description("결과메시지"), fieldWithPath("timestamp").type(JsonFieldType.STRING).description("응답시간"), fieldWithPath("path").type(JsonFieldType.STRING).description("요청 path") ) )) .andDo(print()); } // 파일명마다 규칙이 있음. fields 인 경우에는 AbstractFieldsSnippet에서 'name'-fields 로 구성함 public static CustomResponseFieldsSnippet customResponseFields( String snippetFilePrefix, FieldDescriptor... descriptors) { return new CustomResponseFieldsSnippet(snippetFilePrefix, Arrays.asList(descriptors), true); } public static class CustomResponseFieldsSnippet extends AbstractFieldsSnippet { public CustomResponseFieldsSnippet( String type, List<FieldDescriptor> descriptors, boolean ignoreUndocumentedFields) { super(type, descriptors, null, ignoreUndocumentedFields); } @Override protected MediaType getContentType(Operation operation) { return operation.getResponse().getHeaders().getContentType(); } @Override protected byte[] getContent(Operation operation) throws IOException { return operation.getResponse().getContent(); } } }
- AbstractFieldsSnippet를 상속받아 구현한 Custom Class를 만든다.
- 이 클래스는 우리가 지정한 파일 이름을 사용하기 위해 직접 parameter로 파일 이름을 지정하여 생성한다.
- 위에서 설명했듯이, AbstractFieldsSnippet은 super를 통한 호출에서 type(또는 name) 이라는 파라미터에 뒤에 -fields를 붙인다.
- 그로 인해 ys-custom 을 넘겨주면 ys-custom-fields라는 파일을 찾아 매핑해주는것이다.
- responseFields() 메소드 처럼 static method를 만들어
- 만든 snippet 파일 이름은 ys-custom-fields.snippet이며, 이 때, 인자로 snippetFile 이름의 -fields를 제외한 값을 입력한다.
- 위에서 설명했듯이, AbstractFieldsSnippet은 super를 통한 호출에서 name이라는 파라미터에 뒤에 -fields를 붙인다.
4. 생성된 파일 확인

이렇게 상속과 Custom을 이용하여 여러개의 snippet을 만들어 필요한 필드 등을 정의할 수 있다.
이걸 잘 이용하면 enum과 link도 가능하다.
- request(response) field 문서를 생성을 도와주는 클래스 - AbstractFieldsSnippet
- RequestFieldSnippet 클래스 - (규칙 : 스니펫 파일 이름 : {xxx} -fields )
- ResponseFieldSnippet 클래스 - (규칙 : 스니펫 파일 이름 : {xxx}-fields )
- request(response) body 문서를 생성을 도와주는 클래스 - AbstractBodySnippet
- RequestBodySnippet 클래스 (규칙 - {xxx} -body)
- ResponseBodySnippet 클래스 (규칙 - {xxx} -body)
- requestParam, pathParemeters 문서 생성을 도와주는 클래스 - AbstractParametersSnippet
- RequestParametersSnippet 클래스(규칙 : 스니펫 파일이름 - {xxx} request-parameters.snippet),
- PathParametersSnippet 클래스 (규칙 : 스니펫 파일 이름 - {xxx} path-parameters.snippet ).
RestDocs 문서 링크 걸기
html 에 a tag 를 사용시 외부 링크 뿐만 아니라 hash 를 이용한 내부 링크가 가능하다.
- index.adoc 문서에
[[태그명]]
을 이용하여 다음 태그를 추가한다
== User [[user]] [[resources-post-create]] === create ==== HTTP request [[user-create]] <<<<<<<<<<<<<< 여기! include::{snippets}/users-create/http-request.adoc[]
adoc 으로 생성되는 문서에 id=user-create 이라는 형태로 div가 생성된다.
- *주의할점은, 반드시 이동할 단락(include 바로 위라던가) 에 넣어야 한다. 허공에 넣게 되면 이동하지 못한다. *
- adoc에서는 <<>> 를 이용하면 link를 만들 수 있다.
<<태그명, 화면에 띄울 텍스트!>> 를 이용하여 작성한다.
@Test void test() { ... 생략 MvcResult mvcResult = result.andReturn(); //then result.andExpect(status().isOk()) .andDo(document("ys-custom-response", customResponseFields("ys-custom", fieldWithPath("status").type(JsonFieldType.NUMBER).description("결과코드"), fieldWithPath("message").type(JsonFieldType.STRING).description("결과메시지"), fieldWithPath("timestamp").type(JsonFieldType.STRING).description("응답시간"), fieldWithPath("path").type(JsonFieldType.STRING) .description("요청 path, <<user-create,유저로이동!>>") << 추가 ) )) .andDo(print()); }
<<user-create,유저로이동!>> 하면 href="url#user-create"가 생성된다.
해당 테이블의 링크를 클릭하면 그곳으로 이동된다.
html 커스텀
링크를 누르면 공통 코드 쪽으로 화면 이동이 되지만 다시 보던 화면으로 돌아가려면 불편하다.
공통 코드가 차지 하는 영역이 넓기 때문 이다.
만약 클릭했을 때 이동이 아닌, 팝업 창 등 다른 창에 내용이 나오면 좋겠다라면 다음을 이용하자.
다음은 우아한 형제들 블로그에서 제공해주는 내용이다.
ascii 문법을 보면
link:index.html[Docs]
이렇게 Relative 한 링크를 연결할 수 있다.
link:index.html#user-create[유저 생성]
public class UserDocTest { @Test public void test() throws Exception { // ... fieldWithPath("test").type(JsonFieldType.STRING) .description("link:#user-create[유저 생성,window="_blank"]") // (1) // ... } }
(1) 위에 언급한 방법대로 외부 링크를 작성 하고 새창 열기를 위해 window="_blank" 를 선언 한다.
대부분의 문제가 해결이 되었지만, 새창이나 새 탭이 아닌 팝업창을 띄우고 싶다면?
하지만 아쉽게도 asciidoc에서는 팝업을 제공하지 않지만 asciidoctor의 docinfo 라는게 있다.
adoc 파일에 html 파일을 주입 할 수 있게 해주는 속성.
- docinfo 는 private, shared, head, footer 등의 조합을 할 수 있습니다.
- private 시 특정 파일 이름을 선언해서 사용 가능합니다.
- shared 선언 시 docinfo.html 을 기본적으로 가져다 사용합니다.
- head 는 private-head 또는 shared-head 로 선언이 가능하며 선언 시 head 위치에 붙습니다.
- footer 는 head 와 반대입니다.
- docinfo1, docinfo2 등등 도 있는데 이것은 alias 입니다.
a tag 에 class 속성을 넣고 클릭 시 html에 선언한 javascript로 팝업을 띄운다.
참고로 head에는 style도 넣을 수 있기 때문에 자신만의 독특한 스타일의 문서를 만들수 있다.
index.adoc
ifndef::snippets[] :snippets: ../../../build/generated-snippets endif::[] = API Document :doctype: book :icons: font :source-highlighter: highlightjs :toc: left :toclevels: 3 :sectlinks: :docinfo: shared-head // (1)
팝업을 사용할 adoc 에만 선언
- docinfo 선언

common/job.adoc 생성
include/common/custom-response-fields-jobs.adoc[]
hash가 아닌 별도 파일로 변경한다. 이렇게 하는 이유는 팝업에서 해당 내용만 보여주기 위함이다.
개선 후 docinfo.html 생성
<script> function ready(callbackFunc) { if (document.readyState !== 'loading') { // Document is already ready, call the callback directly callbackFunc(); } else if (document.addEventListener) { // All modern browsers to register DOMContentLoaded document.addEventListener('DOMContentLoaded', callbackFunc); } else { // Old IE browsers document.attachEvent('onreadystatechange', function () { if (document.readyState === 'complete') { callbackFunc(); } }); } } function openPopup(event) { const target = event.target; if (target.className !== "popup") { //(1) return; } event.preventDefault(); const screenX = event.screenX; const screenY = event.screenY; window.open(target.href, target.text, `left=$, top=$, width=500, height=600, status=no, menubar=no, toolbar=no, resizable=no`); } ready(function () { const el = document.getElementById("content"); el.addEventListener("click", event => openPopup(event), false); }); </script>
해당 파일은 기본옵션으로 만들었기 때문에 docinfo.html 이라는 이름이 지정되었고 해당 이름과 경로는 옵션으로 변경 가능하다.
각 페이지마다 스타일과 스크립트가 다르다면 옵션으로 만들고, 그게 아니라면 이것처럼 하나만 작성하면 된다.
(1) class 가 popup 인 경우 팝업 생성
public class UserDocTest { @Test public void test() throws Exception { // ... fieldWithPath("job").type(JsonFieldType.STRING) .description("link:common/job.html[직업 코드,role="popup"]") // (1) // ... } }
- role(role="popup") 은 doc 파일을 생성하면 class 가 된다.
하지만 "link:common/job.html[직업 코드,role="popup"]" 이런 부분이 반복적이며 글자 타이핑 하다가 오타가 발생할 수 있으니 코드로 관리할 수 있다.
public interface DocumentLinkGenerator { static String generateLinkCode(DocUrl docUrl) { return String.format("link:common/%s.html[%s %s,role="popup"]", docUrl.pageId, docUrl.text, "코드"); // (1) } static String generateText(DocUrl docUrl) { return String.format("%s %s", docUrl.text, "코드명"); // (2) } @RequiredArgsConstructor enum DocUrl { JOB("job", "직업"), JOBV1("jobV1", "직업"), JOBV2("jobV2", "직업"), JOBV3("jobV3", "직업"), GENDER("gender", "성별"), ; private final String pageId; // (3) private final String text; // (4) } }
해당 파일은 테스트에서만 사용하니 테스트 패키지에 작성.
(1) "link:common/job.html[직업 코드,role="popup"]" 이 부분으로 변경 해주는 코드.
(2) 링크가 없는 단순 코드 명이 노출될 수 도 있으니 링크 없이 텍스트만 노출하는 해당 유틸도 만들어준다 .
(3) DocUrl 이라는 enum 에서 pageId 는 common 폴더에 있는 파일 명.
(4) text 는 문서에 노출 되는 텍스트.
public class UserDocTest { @Test public void test() throws Exception { // ... fieldWithPath("job").type(JsonFieldType.STRING) .description(generateLinkCode(JOB)), // fieldWithPath("jobName").type(JsonFieldType.STRING) .description(generateText(JOB))// // ... } }
참조
'Spring > RestDocs' 카테고리의 다른 글
RestDocs 문서 분리 방법 - adoc, mustache (0) | 2023.01.29 |
---|---|
Restdocs Enum 공통코드 문서화 방법 - Enum 문서화 (0) | 2023.01.29 |
IntelliJ Restdocs Unexpected token - .snippet 파일을 AsciiDoc로 인식하지 않을 때 (0) | 2023.01.28 |
RestDocs By Gradle 설정 (0) | 2022.12.13 |
RestDocs, Swagger 조합해서 사용하기 (RestDocs + Swagger) (0) | 2022.12.12 |