본문 바로가기
스프링/Spring-Docs

Spring-Docs

by 공부 안하고 싶은 사람 2021. 2. 9.
반응형


Spring-Docs 이란?

  • Rest API 문서 제작을 JUNIT 안에서 만드는 framework
  • 편리/ 가독성/ TDD
  • JUNIT 테스트 -> snippets(.adoc) 생성 → api.docs.adoc로 html5(.html) 생성

 

Spring-Docs 사용 이유

  • API를 서비스 한다면, 사용자를 위한 명세서(문서) 작업이 필요하다.
    -> JUNIT TEST 코드를 기반으로 자동으로 가독성 좋은 문서화 작업이 가능하다.
  • (비교) Spring-Docs vs Swagger
  Spring Rest Docs Swagger
장점 제품코드에 영향이 없다 API를 테스트 해 볼수 있는 화면을 제공한다.
  테스트가 성공해야 문서작성된다. 적용하기 쉽다
단점 적용하기 어렵다 제품코드에 어노테이션 추가해야한다.
    제품코드와 동기화가 안될 수 있다.
    많은 주석이 필요하며, 코드검사로 알아낼 수 없는
몇 가지 사항이 있다.

JUNIT의 테스트코드 기반으로 간결하게 작성할 수 있는 Spring-Docs가 적절하다고 판단.

 

설정 방법

 1. 프로젝트 적용 기술

spring boot 2.3.xx

java 8 이상

gradle 5.xx

junit5

mockmvc / spring docs / asciidoctor

 2. build.gradle 파일 설정

plugins {
    id 'org.springframework.boot' version '2.3.3.RELEASE'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
    //Asciidoctor
    id 'org.asciidoctor.convert' version '1.5.9.2'
    id 'java'
}

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

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

ext {
    //snippets dir
    snippetsDir = file('build/generated-snippets')
}

dependencies {

    implementation 'org.springframework.boot:spring-boot-starter-hateoas'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'


    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    /*testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
    //junit 3,4
    testCompileOnly 'junit:junit:4.13'
    testRuntimeOnly 'org.junit.vintage:junit-vintage-engine'*/

    compile group: 'com.google.code.gson', name: 'gson', version: '2.8.0'
    compileOnly 'com.google.code.gson:gson'
    //snippets
    asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    //mockmvc
    testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

test {
    outputs.dir snippetsDir
    useJUnitPlatform()
}

asciidoctor {
    //snippets dir input
    inputs.dir snippetsDir
    //test depend on
    dependsOn test
}

bootJar {
    //create html
    dependsOn asciidoctor
    //copy to .jar
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/docs'
    }
}
  1. 기능
@SpringBootTest
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
public class ProductControllerTest {
    /*생성자에 String 형식으로 output directory를 지정할 수 있습니다. (기본값은 target/generated-snippets)
    @RegisterExtension
    final RestDocumentationExtension restDocumentation = new RestDocumentationExtension ("build/generated-snippets");
    */

    @Autowired
    private WebApplicationContext context;

    private MockMvc mockMvc;
    private RestDocumentationResultHandler document;

    @BeforeEach
    public void setUp(RestDocumentationContextProvider restDocumentation) {
        //스니펫 경로를 {class-name}/{method-name}
        this.document = document(
                "{class-name}/{method-name}" //{className} no-formate  {step} 현재 테스트에서 서비스에 대한 호출 수
        );

        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
                .apply(documentationConfiguration(restDocumentation)
                        .uris().withScheme("http").withHost("localhost").withPort(8080)//스니펫 파일에서 나오는 호스트를 변조해줍니다.
                        .and().snippets().withEncoding("UTF-8")//snippet encoding
                        .and().operationPreprocessors().withResponseDefaults(prettyPrint())//json 정렬
                        .and().operationPreprocessors().withRequestDefaults(prettyPrint()))//json 정렬
                //.and().snippets().withTemplateFormat(TemplateFormats.markdown()) 마크다운 지원
                .alwaysDo(document)
                .build();
    }

    @Test
    public void regist() throws Exception {

        ProductDto productDto = new ProductDto();
        productDto.setName("갤럭시 폴드");
        productDto.setDesc("삼성의 폴더블 스마트폰");
        productDto.setQuantity(10);

        String jsonString = new GsonBuilder().setPrettyPrinting().create().toJson(productDto);

        mockMvc.perform(
                post("/api/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonString)
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andDo(document.document(//기본(6개) + @ 생성
                        requestFields(
                                fieldWithPath("name").description("상품 이름"),
                                fieldWithPath("desc").description("상품 설명"),
                                fieldWithPath("quantity").type(JsonFieldType.NUMBER).description("상품 수량")
                        )
                ));
    }

    @Test
    public void search() throws Exception {
        FieldDescriptor[] testField = new FieldDescriptor[] {
                fieldWithPath("id").description("상품 아이디"),
                fieldWithPath("name").description("상품 이름"),
                fieldWithPath("desc").description("상품 설명"),
                fieldWithPath("quantity").type(Integer.class).description("상품 수량")
        };

        mockMvc.perform(
                get("/api/products/{id}", 1)
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andDo(document.document(
                        pathParameters(
                                parameterWithName("id").description("상품 id")
                        ),
                        responseFields(
                                //subsectionWithPath("contact").description("The user's contact details") 하위 섹션 문서화
                               /* fieldWithPath("id").description("상품 아이디"),
                                fieldWithPath("name").description("상품 이름"),
                                fieldWithPath("desc").description("상품 설명"),
                                fieldWithPath("quantity").type(Integer.class).description("상품 수량")*/
                                //beneathPath("0020.XX60",fieldWithPath("1").description("a"),fieldWithPath("2").description("b"))   부분표현
                                testField
                        ))
                )
                .andExpect(jsonPath("id", is(notNullValue())))
                .andExpect(jsonPath("name", is(notNullValue())))
                .andExpect(jsonPath("desc", is(notNullValue())))
                .andExpect(jsonPath("quantity", is(notNullValue())));
    }
    
    @Test
    public void searchAll() throws Exception {
        mockMvc.perform(
                get("/api/products")//(get("/api/products?page=1&size=10") 이것도 가능
                        .param("page", "1")
                        .param("size", "10")
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andDo(document.document(
                        requestParameters(
                                parameterWithName("page").description("페이지 번호"),
                                parameterWithName("size").description("페이지 사이즈")
                        ),
                        responseFields(
                                fieldWithPath("[].id").description("상품 아이디"),
                                fieldWithPath("[].name").type(JsonFieldType.STRING).description("상품 이름"),
                                fieldWithPath("[].desc").type(JsonFieldType.STRING).description("상품 설명"),
                                fieldWithPath("[].quantity").type(Integer.class).description("상품 수량")
                        )
                ))
                .andExpect(jsonPath("[0].id", is(notNullValue())))
                .andExpect(jsonPath("[0].name", is(notNullValue())))
                .andExpect(jsonPath("[0].desc", is(notNullValue())))
                .andExpect(jsonPath("[0].quantity", is(notNullValue())));

    }

    //@Test
    public void linksTest(LinkDescriptor description, LinkDescriptor linkDescriptor) throws Exception {
        LinksSnippet pagingLinks = links(
                linkWithRel("first").optional().description("The first page of results"),
                linkWithRel("last").optional().description("The last page of results"),
                linkWithRel("next").optional().description("The next page of results"),
                linkWithRel("prev").optional().description("The previous page of results"));

        this.mockMvc.perform(get("/").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document("example", pagingLinks.and(
                        linkWithRel("alpha").description("Link to the alpha resource"),
                        linkWithRel("bravo").description("Link to the bravo resource"))));
    }

    //@Test
    public void upload1() throws Exception {
        this.mockMvc.perform(multipart("/upload").file("file", "example".getBytes()))
                .andExpect(status().isOk())
                .andDo(document.document(
                        requestParts(
                                partWithName("file").description("The file to upload")
                        )
                ));
    }

    //@Test
    public void upload2() throws Exception {
        MockMultipartFile image = new MockMultipartFile("image", "image.png", "image/png",
                "<<png data>>".getBytes());
        MockMultipartFile metadata = new MockMultipartFile("metadata", "",
                "application/json", "{ \"version\": \"1.0\"}".getBytes());

        this.mockMvc.perform(fileUpload("/images").file(image).file(metadata)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document.document(
                        requestPartFields("metadata",
                                fieldWithPath("version").description("The version of the image"))));
    }

    //@Test
    public void header() throws Exception {
        this.mockMvc
                .perform(get("/people").header("Authorization", "Basic dXNlcjpzZWNyZXQ="))
                .andExpect(status().isOk())
                .andDo(document("headers",
                        requestHeaders(
                                headerWithName("Authorization").description(
                                        "Basic auth credentials")),
                        responseHeaders(
                                headerWithName("X-RateLimit-Limit").description(
                                        "The total number of requests permitted per period"),
                                headerWithName("X-RateLimit-Remaining").description(
                                        "Remaining requests permitted in current period"),
                                headerWithName("X-RateLimit-Reset").description(
                                        "Time at which the rate limit period will reset"))));
    }

    private static class ConstrainedFields {

        private final ConstraintDescriptions constraintDescriptions;

        ConstrainedFields(Class<?> input) {
            this.constraintDescriptions = new ConstraintDescriptions(input);
        }

        private FieldDescriptor withPath(String path) {
            return fieldWithPath(path).attributes(key("constraints").value(StringUtils
                    .collectionToDelimitedString(this.constraintDescriptions
                            .descriptionsForProperty(path), ". ")));
        }
    }
}
  • @BeforeEach 에서 문서의 형식(경로, 도메인, 인코딩, 프린트 등) 을 전처리
  • .snippets은 기본 6개 생성 +
    requestFields, responseFields, pathParameters, requestParameters, link, requestParts
    ,requestHeaders, responseHeaders, ConstrainedFields 를 통해 추가 생성
  • 공통 설정 변수화하여 재사용가능

참고자료 : woowabros.github.io/experience/2018/12/28/spring-rest-docs.html

728x90
반응형

'스프링 > Spring-Docs' 카테고리의 다른 글

AsciiDoctor 문법  (0) 2021.02.09

댓글