반응형
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'
}
}
- 기능
@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 |
---|
댓글