diff --git a/restdocs-api-spec-mockmvc/build.gradle.kts b/restdocs-api-spec-mockmvc/build.gradle.kts index 273e49b1..2fa87eb0 100644 --- a/restdocs-api-spec-mockmvc/build.gradle.kts +++ b/restdocs-api-spec-mockmvc/build.gradle.kts @@ -8,6 +8,7 @@ repositories { } val springBootVersion: String by extra +val jacksonVersion: String by extra val springRestDocsVersion: String by extra val junitVersion: String by extra @@ -16,6 +17,9 @@ dependencies { api(project(":restdocs-api-spec")) implementation("org.springframework.restdocs:spring-restdocs-mockmvc:$springRestDocsVersion") + implementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { exclude("junit") diff --git a/restdocs-api-spec-mockmvc/src/main/kotlin/com/epages/restdocs/apispec/MockMvcAutoRestDocumentationWrapper.kt b/restdocs-api-spec-mockmvc/src/main/kotlin/com/epages/restdocs/apispec/MockMvcAutoRestDocumentationWrapper.kt new file mode 100644 index 00000000..0ef02ada --- /dev/null +++ b/restdocs-api-spec-mockmvc/src/main/kotlin/com/epages/restdocs/apispec/MockMvcAutoRestDocumentationWrapper.kt @@ -0,0 +1,106 @@ +package com.epages.restdocs.apispec + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler +import org.springframework.restdocs.operation.preprocess.Preprocessors +import org.springframework.restdocs.payload.FieldDescriptor +import org.springframework.restdocs.payload.PayloadDocumentation +import org.springframework.test.web.servlet.ResultActions +import org.springframework.web.servlet.HandlerMapping + +object MockMvcAutoRestDocumentationWrapper { + @JvmStatic + fun createDocs( + tag: String, + identifier: String, + description: String, + resultActions: ResultActions + ): RestDocumentationResultHandler { + val resourceSnippetParametersBuilder = ResourceSnippetParametersBuilder().tags(tag).description(description) + + val request = resultActions.andReturn().request + val requestNode: JsonNode? = + request.contentAsString?.let { jacksonObjectMapper().readTree(request.contentAsString) } + val requestFieldDescriptors = createFieldDescriptors(requestNode) + + val response = resultActions.andReturn().response + val responseNode: JsonNode? = + response.contentAsString.let { jacksonObjectMapper().readTree(response.contentAsString) } + val responseFieldDescriptors = createFieldDescriptors(responseNode) + + val requestParameter = createParameters(request, ParameterType.QUERY) + val requestPathParameter = createParameters(request, ParameterType.PATH) + + resourceSnippetParametersBuilder.requestFields(requestFieldDescriptors) + resourceSnippetParametersBuilder.queryParameters(requestParameter) + resourceSnippetParametersBuilder.pathParameters(requestPathParameter) + resourceSnippetParametersBuilder.responseFields(responseFieldDescriptors) + + return MockMvcRestDocumentationWrapper.document( + identifier, + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint()), + ResourceDocumentation.resource(resourceSnippetParametersBuilder.build()) + ) + } + + private fun createFieldDescriptors(jsonNode: JsonNode?, parentPath: String = ""): List { + if (jsonNode == null) return emptyList() + + val fieldDescriptors = mutableListOf() + + val iterator = jsonNode.fields() + while (iterator.hasNext()) { + val entry = iterator.next() + val key = entry.key + val value = entry.value + val path = if (parentPath.isEmpty()) key else "$parentPath.$key" + + when { + value.isObject -> { + fieldDescriptors.addAll(createFieldDescriptors(value, path)) + } + + value.isArray -> { + value.forEach { item -> + if (item.isObject) { + fieldDescriptors.addAll(createFieldDescriptors(item, "$path.[].")) + } + } + } + + else -> { + fieldDescriptors.add( + PayloadDocumentation.fieldWithPath(path).description(value.asText()) + ) + } + } + } + + return fieldDescriptors + } + + private fun createParameters( + request: MockHttpServletRequest, + type: ParameterType + ): List { + return if (type == ParameterType.QUERY) { + request.parameterMap.map { (key, value) -> + ResourceDocumentation.parameterWithName(key).description(value.joinToString()) + } + } else { + val uriVars = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE) as? Map<*, *> + + return uriVars?.entries?.map { (key, value) -> + ResourceDocumentation.parameterWithName(key.toString()).description("$value") + }?.toList() ?: listOf() + } + } + + private enum class ParameterType { + PATH, + QUERY + } +} \ No newline at end of file diff --git a/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcAutoRestDocumentationWrapperTest.kt b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcAutoRestDocumentationWrapperTest.kt new file mode 100644 index 00000000..1cd8cf5d --- /dev/null +++ b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcAutoRestDocumentationWrapperTest.kt @@ -0,0 +1,49 @@ +package com.epages.restdocs.apispec + +import com.epages.restdocs.apispec.ResourceSnippetIntegrationTest.TestJoinHolder +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@ExtendWith(SpringExtension::class) +@WebMvcTest +class MockMvcAutoRestDocumentationWrapperTest(@Autowired private val mockMvc: MockMvc) : ResourceSnippetIntegrationTest() { + @Test + fun TestController() { + val testJoinHolder = TestJoinHolder("testLoginId", "123") + + val resultActions = mockMvc.perform( + post("/join") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonObjectMapper().writeValueAsBytes(testJoinHolder)) + ) + + // validate my api response + resultActions.andExpectAll( + status().isOk(), + jsonPath("$.id").value(1), + jsonPath("$.loginId").value("testLoginId"), + jsonPath("$.password").value("123"), + jsonPath("$.createdAt").isNotEmpty + ) + + // create automated docs + resultActions.andDo( + MockMvcAutoRestDocumentationWrapper.createDocs( + "join", + "join/test", + "join api test", + resultActions + )) + } +} \ No newline at end of file diff --git a/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt index 16c4d1d8..42a3cfea 100644 --- a/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt +++ b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RestController +import java.time.OffsetDateTime import java.util.UUID @ExtendWith(SpringExtension::class) @@ -66,6 +67,22 @@ open class ResourceSnippetIntegrationTest { .header("X-Custom-Header", customHeader) .body>(resource) } + + @PostMapping(path = ["/join"]) + fun join( + @RequestBody testDataHolder: TestJoinHolder + ): ResponseEntity { + val user = User( + 1L, + testDataHolder.loginId, + testDataHolder.password, + OffsetDateTime.now() + ) + + return ResponseEntity + .ok() + .body(user) + } } } @@ -77,6 +94,18 @@ open class ResourceSnippetIntegrationTest { @field:NotEmpty val id: String? = null ) + + internal data class TestJoinHolder( + val loginId: String, + val password: String + ) + + internal class User( + val id: Long, + val loginId: String, + val password: String, + val createdAt: OffsetDateTime + ) } fun fieldDescriptors(): FieldDescriptors {