diff --git a/README.md b/README.md index 5c97620c..c31f64e3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ This is why we came up with this project. - [Build configuration](#build-configuration) - [Gradle](#gradle) - [Maven](#maven) - - [Usage with Spring REST Docs](#usage-with-spring-rest-docs) + - [Usage with Spring REST Docs - MockMvc](#usage-with-spring-rest-docs---mockmvc) + - [Usage with Spring REST Docs - RestAssured](#usage-with-spring-rest-docs---rest-assured) - [Documenting Bean Validation constraints](#documenting-bean-validation-constraints) - [Migrate existing Spring REST Docs tests](#migrate-existing-spring-rest-docs-tests) - [Security Definitions in OpenAPI](#security-definitions-in-openapi) @@ -134,7 +135,7 @@ See the [build.gradle](samples/restdocs-api-spec-sample/build.gradle) for the se The root project does not provide a maven plugin. But you can find a plugin that works with `restdocs-api-spec` at [BerkleyTechnologyServices/restdocs-spec](https://github.com/BerkleyTechnologyServices/restdocs-spec). -### Usage with Spring REST Docs +### Usage with Spring REST Docs - MockMvc The class [ResourceDocumentation](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt) contains the entry point for using the [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippet.kt). @@ -219,6 +220,28 @@ This makes the `urlTemplate` available in the snippet and we can depend on the n mockMvc.perform(get("/carts/{id}", cartId) ``` +### Usage with Spring REST Docs - REST Assured +The usage for REST Assured is similar to MockMVC, except that [com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapper.kt) is used instead of [com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt). + +To use the ``RestAssuredRestDocumentationWrapper``, you have to add a dependency to [restdocs-api-spec-restassured](restdocs-api-spec-restassured) to your build. +```java +RestAssured.given(this.spec) + .filter(RestAssuredRestDocumentationWrapper.document("{method-name}", + "The API description", + requestParameters( + parameterWithName("param").description("the param") + ), + responseFields( + fieldWithPath("doc.timestamp").description("Creation timestamp") + ) + )) + .when() + .queryParam("param", "foo") + .get("/restAssuredExample") + .then() + .statusCode(200); +``` + ### Documenting Bean Validation constraints Similar to the way Spring REST Docs allows to use [bean validation constraints](https://docs.spring.io/spring-restdocs/docs/current/reference/html5/#documenting-your-api-constraints) to enhance your documentation, you can also use the constraints from your model classes to let `restdocs-api-spec` enrich the generated JsonSchemas. @@ -438,8 +461,3 @@ See [openapi2raml.gradle](samples/restdocs-api-spec-sample/openapi2raml.gradle). ./gradlew -b samples/restdocs-api-spec-sample/openapi2raml.gradle openapi2raml ``` -## Limitations - -### Rest Assured - -Spring REST Docs also supports REST Assured to write tests that produce documentation. We currently have not tried REST Assured with our project. diff --git a/restdocs-api-spec-mockmvc/build.gradle.kts b/restdocs-api-spec-mockmvc/build.gradle.kts new file mode 100644 index 00000000..71958034 --- /dev/null +++ b/restdocs-api-spec-mockmvc/build.gradle.kts @@ -0,0 +1,29 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} + +repositories { + mavenCentral() + maven { url = uri("https://jitpack.io") } +} + +val springBootVersion: String by extra +val springRestDocsVersion: String by extra +val junitVersion: String by extra + +dependencies { + compile(kotlin("stdlib-jdk8")) + + compile(project(":restdocs-api-spec")) + compile("org.springframework.restdocs:spring-restdocs-mockmvc:$springRestDocsVersion") + + testCompile("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { + exclude("junit") + } + testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit-pioneer:junit-pioneer:0.2.2") + testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") +} + diff --git a/restdocs-api-spec-mockmvc/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt b/restdocs-api-spec-mockmvc/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt new file mode 100644 index 00000000..71516b82 --- /dev/null +++ b/restdocs-api-spec-mockmvc/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt @@ -0,0 +1,98 @@ +package com.epages.restdocs.apispec + +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor +import org.springframework.restdocs.snippet.Snippet +import java.util.function.Function + +/** + * Convenience class to migrate to restdocs-openapi in a non-invasive way. + * It is a wrapper and replacement for MockMvcRestDocumentation that transparently adds a ResourceSnippet with the descriptors provided in the given snippets. + */ +object MockMvcRestDocumentationWrapper : RestDocumentationWrapper() { + + @JvmOverloads @JvmStatic + fun document( + identifier: String, + resourceDetails: ResourceSnippetDetails, + requestPreprocessor: OperationRequestPreprocessor? = null, + responsePreprocessor: OperationResponsePreprocessor? = null, + snippetFilter: Function, List> = Function.identity(), + vararg snippets: Snippet + ): RestDocumentationResultHandler { + + val enhancedSnippets = + enhanceSnippetsWithResourceSnippet( + resourceDetails = resourceDetails, + snippetFilter = snippetFilter, + snippets = *snippets + ) + + if (requestPreprocessor != null && responsePreprocessor != null) { + return MockMvcRestDocumentation.document( + identifier, + requestPreprocessor, + responsePreprocessor, + *enhancedSnippets + ) + } else if (requestPreprocessor != null) { + return MockMvcRestDocumentation.document(identifier, requestPreprocessor, *enhancedSnippets) + } else if (responsePreprocessor != null) { + return MockMvcRestDocumentation.document(identifier, responsePreprocessor, *enhancedSnippets) + } + + return MockMvcRestDocumentation.document(identifier, *enhancedSnippets) + } + + @JvmOverloads @JvmStatic + fun document( + identifier: String, + description: String? = null, + summary: String? = null, + privateResource: Boolean = false, + deprecated: Boolean = false, + requestPreprocessor: OperationRequestPreprocessor? = null, + responsePreprocessor: OperationResponsePreprocessor? = null, + snippetFilter: Function, List> = Function.identity(), + vararg snippets: Snippet + ): RestDocumentationResultHandler { + return document( + identifier = identifier, + resourceDetails = ResourceSnippetParametersBuilder() + .description(description) + .summary(summary) + .privateResource(privateResource) + .deprecated(deprecated), + requestPreprocessor = requestPreprocessor, + responsePreprocessor = responsePreprocessor, + snippetFilter = snippetFilter, + snippets = *snippets + ) + } + + @JvmStatic + fun document( + identifier: String, + requestPreprocessor: OperationRequestPreprocessor, + vararg snippets: Snippet + ): RestDocumentationResultHandler { + return document(identifier, null, null, false, false, requestPreprocessor, snippets = *snippets) + } + + @JvmStatic + fun document( + identifier: String, + description: String, + privateResource: Boolean, + vararg snippets: Snippet + ): RestDocumentationResultHandler { + return document(identifier, description, null, privateResource, snippets = *snippets) + } + + @JvmStatic + fun resourceDetails(): ResourceSnippetDetails { + return ResourceSnippetParametersBuilder() + } +} diff --git a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt similarity index 74% rename from restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt rename to restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt index fba3811b..9ecbe5cc 100644 --- a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt +++ b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt @@ -1,10 +1,14 @@ package com.epages.restdocs.apispec +import com.epages.restdocs.apispec.ResourceDocumentation.resource import org.assertj.core.api.Assertions.assertThatCode +import org.assertj.core.api.BDDAssertions.then 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.hateoas.MediaTypes.HAL_JSON +import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName import org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders import org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders @@ -20,10 +24,14 @@ import org.springframework.restdocs.request.RequestDocumentation.pathParameters import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.io.File @ExtendWith(SpringExtension::class) @WebMvcTest -class MockMvcRestDocumentationWrapperIntegrationTest(@Autowired mockMvc: MockMvc) : ResourceSnippetIntegrationTest(mockMvc) { +class MockMvcRestDocumentationWrapperIntegrationTest(@Autowired private val mockMvc: MockMvc) : ResourceSnippetIntegrationTest() { @Test fun should_document_both_restdocs_and_resource() { @@ -68,6 +76,81 @@ class MockMvcRestDocumentationWrapperIntegrationTest(@Autowired mockMvc: MockMvc thenSnippetFileExists() } + @Test + fun should_document_request() { + givenEndpointInvoked() + + whenResourceSnippetDocumentedWithoutParameters() + + thenSnippetFileExists() + } + + @Test + fun should_document_request_with_description() { + givenEndpointInvoked() + + whenResourceSnippetDocumentedWithDescription() + + thenSnippetFileExists() + } + + @Test + fun should_document_request_with_fields() { + givenEndpointInvoked() + + whenResourceSnippetDocumentedWithRequestAndResponseFields() + + thenSnippetFileExists() + } + + @Test + fun should_document_request_with_null_field() { + givenEndpointInvoked("null") + + assertThatCode { this.whenResourceSnippetDocumentedWithRequestAndResponseFields() } + .doesNotThrowAnyException() + } + + private fun whenResourceSnippetDocumentedWithoutParameters() { + resultActions + .andDo(document(operationName, resource())) + } + + private fun whenResourceSnippetDocumentedWithDescription() { + resultActions + .andDo(document(operationName, resource("A description"))) + } + + private fun whenResourceSnippetDocumentedWithRequestAndResponseFields() { + resultActions + .andDo(document(operationName, buildFullResourceSnippet())) + } + + private fun givenEndpointInvoked(flagValue: String = "true") { + resultActions = mockMvc.perform( + post("/some/{someId}/other/{otherId}", "id", 1) + .contentType(APPLICATION_JSON) + .header("X-Custom-Header", "test") + .accept(HAL_JSON) + .content("""{ + "comment": "some", + "flag": $flagValue, + "count": 1 + }""".trimIndent() + ) + ).andExpect(status().isOk) + } + + private fun thenSnippetFileExists() { + with(generatedSnippetFile()) { + then(this).exists() + val contents = readText() + then(contents).isNotEmpty() + } + } + + private fun generatedSnippetFile() = File("build/generated-snippets", "$operationName/resource.json") + @Throws(Exception::class) private fun whenDocumentedWithRestdocsAndResource() { resultActions 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 new file mode 100644 index 00000000..2e73b22a --- /dev/null +++ b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt @@ -0,0 +1,117 @@ +package com.epages.restdocs.apispec + +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName +import com.epages.restdocs.apispec.ResourceDocumentation.resource +import org.hibernate.validator.constraints.Length +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.hateoas.Link +import org.springframework.hateoas.Resource +import org.springframework.hateoas.mvc.BasicLinkBuilder +import org.springframework.http.HttpHeaders.ACCEPT +import org.springframework.http.HttpHeaders.CONTENT_TYPE +import org.springframework.http.ResponseEntity +import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName +import org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.ResultActions +import org.springframework.web.bind.annotation.PathVariable +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.util.UUID +import javax.validation.constraints.NotEmpty + +@ExtendWith(SpringExtension::class) +@WebMvcTest +@AutoConfigureRestDocs +open class ResourceSnippetIntegrationTest { + + val operationName = "test-${System.currentTimeMillis()}" + + lateinit var resultActions: ResultActions + + @SpringBootApplication + open class TestApplication { + lateinit var applicationContext: ConfigurableApplicationContext + fun main(args: Array) { + applicationContext = SpringApplication.run(TestApplication::class.java, *args) + } + + @RestController + internal open class TestController { + + @PostMapping(path = ["/some/{someId}/other/{otherId}"]) + fun doSomething( + @PathVariable someId: String, + @PathVariable otherId: Int?, + @RequestHeader("X-Custom-Header") customHeader: String, + @RequestBody testDataHolder: TestDataHolder + ): ResponseEntity> { + val resource = Resource(testDataHolder.copy(id = UUID.randomUUID().toString())) + val link = BasicLinkBuilder.linkToCurrentMapping().slash("some").slash(someId).slash("other").slash(otherId).toUri().toString() + resource.add(Link(link, Link.REL_SELF)) + resource.add(Link(link, "multiple")) + resource.add(Link(link, "multiple")) + + return ResponseEntity + .ok() + .header("X-Custom-Header", customHeader) + .body>(resource) + } + } + } + + internal data class TestDataHolder( + @field:Length(min = 1, max = 255) + val comment: String? = null, + val flag: Boolean = false, + val count: Int = 0, + @field:NotEmpty + val id: String? = null + ) +} + +fun fieldDescriptors(): FieldDescriptors { + val fields = ConstrainedFields(ResourceSnippetIntegrationTest.TestDataHolder::class.java) + return ResourceDocumentation.fields( + fields.withPath("comment").description("the comment").optional(), + fields.withPath("flag").description("the flag"), + fields.withMappedPath("count", "count").description("the count") + ) +} + +fun buildFullResourceSnippet(): ResourceSnippet { + return resource( + ResourceSnippetParameters.builder() + .description("description") + .summary("summary") + .deprecated(true) + .privateResource(true) + .requestFields(fieldDescriptors()) + .responseFields(fieldDescriptors().and(fieldWithPath("id").description("id"))) + .requestHeaders( + headerWithName("X-Custom-Header").description("A custom header"), + headerWithName(ACCEPT).description("Accept") + ) + .responseHeaders( + headerWithName("X-Custom-Header").description("A custom header"), + headerWithName(CONTENT_TYPE).description("ContentType") + ) + .pathParameters( + parameterWithName("someId").description("some id"), + parameterWithName("otherId").description("otherId id").type(SimpleType.INTEGER) + ) + .links( + linkWithRel("self").description("some"), + linkWithRel("multiple").description("multiple") + ) + .build() + ) +} diff --git a/restdocs-api-spec-restassured/build.gradle.kts b/restdocs-api-spec-restassured/build.gradle.kts new file mode 100644 index 00000000..af5f8362 --- /dev/null +++ b/restdocs-api-spec-restassured/build.gradle.kts @@ -0,0 +1,28 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} +repositories { + mavenCentral() + maven { url = uri("https://jitpack.io") } +} + +val springBootVersion: String by extra +val springRestDocsVersion: String by extra +val junitVersion: String by extra + +dependencies { + compile(kotlin("stdlib-jdk8")) + + compile(project(":restdocs-api-spec")) + compile("org.springframework.restdocs:spring-restdocs-restassured:$springRestDocsVersion") + + testCompile("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { + exclude("junit") + } + testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit-pioneer:junit-pioneer:0.2.2") + testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") +} + diff --git a/restdocs-api-spec-restassured/src/main/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapper.kt b/restdocs-api-spec-restassured/src/main/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapper.kt new file mode 100644 index 00000000..3f08284e --- /dev/null +++ b/restdocs-api-spec-restassured/src/main/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapper.kt @@ -0,0 +1,98 @@ +package com.epages.restdocs.apispec + +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor +import org.springframework.restdocs.restassured3.RestAssuredRestDocumentation +import org.springframework.restdocs.restassured3.RestDocumentationFilter +import org.springframework.restdocs.snippet.Snippet +import java.util.function.Function + +/** + * Convenience class to migrate to restdocs-openapi in a non-invasive way. + * It is a wrapper and replacement for RestAssuredRestDocumentation that transparently adds a ResourceSnippet with the descriptors provided in the given snippets. + */ +object RestAssuredRestDocumentationWrapper : RestDocumentationWrapper() { + + @JvmOverloads @JvmStatic + fun document( + identifier: String, + resourceDetails: ResourceSnippetDetails, + requestPreprocessor: OperationRequestPreprocessor? = null, + responsePreprocessor: OperationResponsePreprocessor? = null, + snippetFilter: Function, List> = Function.identity(), + vararg snippets: Snippet + ): RestDocumentationFilter { + + val enhancedSnippets = + enhanceSnippetsWithResourceSnippet( + resourceDetails = resourceDetails, + snippetFilter = snippetFilter, + snippets = *snippets + ) + + if (requestPreprocessor != null && responsePreprocessor != null) { + return RestAssuredRestDocumentation.document( + identifier, + requestPreprocessor, + responsePreprocessor, + *enhancedSnippets + ) + } else if (requestPreprocessor != null) { + return RestAssuredRestDocumentation.document(identifier, requestPreprocessor, *enhancedSnippets) + } else if (responsePreprocessor != null) { + return RestAssuredRestDocumentation.document(identifier, responsePreprocessor, *enhancedSnippets) + } + + return RestAssuredRestDocumentation.document(identifier, *enhancedSnippets) + } + + @JvmOverloads @JvmStatic + fun document( + identifier: String, + description: String? = null, + summary: String? = null, + privateResource: Boolean = false, + deprecated: Boolean = false, + requestPreprocessor: OperationRequestPreprocessor? = null, + responsePreprocessor: OperationResponsePreprocessor? = null, + snippetFilter: Function, List> = Function.identity(), + vararg snippets: Snippet + ): RestDocumentationFilter { + return document( + identifier = identifier, + resourceDetails = ResourceSnippetParametersBuilder() + .description(description) + .summary(summary) + .privateResource(privateResource) + .deprecated(deprecated), + requestPreprocessor = requestPreprocessor, + responsePreprocessor = responsePreprocessor, + snippetFilter = snippetFilter, + snippets = *snippets + ) + } + + @JvmStatic + fun document( + identifier: String, + requestPreprocessor: OperationRequestPreprocessor, + vararg snippets: Snippet + ): RestDocumentationFilter { + return document(identifier, null, null, false, false, requestPreprocessor, snippets = *snippets) + } + + @JvmStatic + fun document( + identifier: String, + description: String, + privateResource: Boolean, + vararg snippets: Snippet + ): RestDocumentationFilter { + return document(identifier, description, null, privateResource, snippets = *snippets) + } + + @JvmStatic + fun resourceDetails(): ResourceSnippetDetails { + return ResourceSnippetParametersBuilder() + } +} diff --git a/restdocs-api-spec-restassured/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt b/restdocs-api-spec-restassured/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt new file mode 100644 index 00000000..d582d5df --- /dev/null +++ b/restdocs-api-spec-restassured/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt @@ -0,0 +1,135 @@ +package com.epages.restdocs.apispec + +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName +import com.epages.restdocs.apispec.ResourceDocumentation.resource +import org.hibernate.validator.constraints.Length +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.hateoas.Link +import org.springframework.hateoas.Resource +import org.springframework.hateoas.mvc.BasicLinkBuilder +import org.springframework.http.HttpHeaders.ACCEPT +import org.springframework.http.HttpHeaders.CONTENT_TYPE +import org.springframework.http.ResponseEntity +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName +import org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.ResultActions +import org.springframework.web.bind.annotation.PathVariable +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.util.UUID +import javax.validation.constraints.NotEmpty + +@ExtendWith(SpringExtension::class) +@WebMvcTest +@AutoConfigureRestDocs +open class ResourceSnippetIntegrationTest { + + val operationName = "test-${System.currentTimeMillis()}" + + lateinit var resultActions: ResultActions + + private lateinit var app: ResourceSnippetIntegrationTest.TestApplication + protected var serverPort: Int? = null + + @BeforeEach + fun setUp(restDocumentation: RestDocumentationContextProvider) { + app = ResourceSnippetIntegrationTest.TestApplication() + app.main(arrayOf("--server.port=0")) + serverPort = app.applicationContext.environment.getProperty("local.server.port")?.toInt() + } + + @AfterEach + fun tearDown() { + app.applicationContext.close() + } + + @SpringBootApplication + open class TestApplication { + lateinit var applicationContext: ConfigurableApplicationContext + fun main(args: Array) { + applicationContext = SpringApplication.run(TestApplication::class.java, *args) + } + + @RestController + internal open class TestController { + + @PostMapping(path = ["/some/{someId}/other/{otherId}"]) + fun doSomething( + @PathVariable someId: String, + @PathVariable otherId: Int?, + @RequestHeader("X-Custom-Header") customHeader: String, + @RequestBody testDataHolder: TestDataHolder + ): ResponseEntity> { + val resource = Resource(testDataHolder.copy(id = UUID.randomUUID().toString())) + val link = BasicLinkBuilder.linkToCurrentMapping().slash("some").slash(someId).slash("other").slash(otherId).toUri().toString() + resource.add(Link(link, Link.REL_SELF)) + resource.add(Link(link, "multiple")) + resource.add(Link(link, "multiple")) + + return ResponseEntity + .ok() + .header("X-Custom-Header", customHeader) + .body>(resource) + } + } + } + + internal data class TestDataHolder( + @field:Length(min = 1, max = 255) + val comment: String? = null, + val flag: Boolean = false, + val count: Int = 0, + @field:NotEmpty + val id: String? = null + ) +} + +fun fieldDescriptors(): FieldDescriptors { + val fields = ConstrainedFields(ResourceSnippetIntegrationTest.TestDataHolder::class.java) + return ResourceDocumentation.fields( + fields.withPath("comment").description("the comment").optional(), + fields.withPath("flag").description("the flag"), + fields.withMappedPath("count", "count").description("the count") + ) +} + +fun buildFullResourceSnippet(): ResourceSnippet { + return resource( + ResourceSnippetParameters.builder() + .description("description") + .summary("summary") + .deprecated(true) + .privateResource(true) + .requestFields(fieldDescriptors()) + .responseFields(fieldDescriptors().and(fieldWithPath("id").description("id"))) + .requestHeaders( + headerWithName("X-Custom-Header").description("A custom header"), + headerWithName(ACCEPT).description("Accept") + ) + .responseHeaders( + headerWithName("X-Custom-Header").description("A custom header"), + headerWithName(CONTENT_TYPE).description("ContentType") + ) + .pathParameters( + parameterWithName("someId").description("some id"), + parameterWithName("otherId").description("otherId id").type(SimpleType.INTEGER) + ) + .links( + linkWithRel("self").description("some"), + linkWithRel("multiple").description("multiple") + ) + .build() + ) +} diff --git a/restdocs-api-spec-restassured/src/test/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapperIntegrationTest.kt b/restdocs-api-spec-restassured/src/test/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapperIntegrationTest.kt new file mode 100644 index 00000000..472ea5d7 --- /dev/null +++ b/restdocs-api-spec-restassured/src/test/kotlin/com/epages/restdocs/apispec/RestAssuredRestDocumentationWrapperIntegrationTest.kt @@ -0,0 +1,255 @@ +package com.epages.restdocs.apispec + +import io.restassured.RestAssured +import io.restassured.builder.RequestSpecBuilder +import io.restassured.filter.Filter +import io.restassured.http.ContentType +import io.restassured.specification.RequestSpecification +import org.assertj.core.api.Assertions.assertThatCode +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.hateoas.MediaTypes +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName +import org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders +import org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders +import org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel +import org.springframework.restdocs.hypermedia.HypermediaDocumentation.links +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.requestFields +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath +import org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import org.springframework.restdocs.request.RequestDocumentation.pathParameters +import org.springframework.restdocs.restassured3.RestAssuredRestDocumentation +import org.springframework.restdocs.restassured3.RestDocumentationFilter +import java.io.File + +@ExtendWith(RestDocumentationExtension::class) +class RestAssuredRestDocumentationWrapperIntegrationTest : ResourceSnippetIntegrationTest() { + + private lateinit var spec: RequestSpecification + + @BeforeEach + fun setUpSpec(restDocumentation: RestDocumentationContextProvider) { + spec = RequestSpecBuilder() + .addFilter(RestAssuredRestDocumentation.documentationConfiguration(restDocumentation)) + .build() + } + + private fun givenEndpointInvoked(documentationFilter: Filter, flagValue: String = "true") { + RestAssured.given(spec) + .filter(documentationFilter) + .baseUri("http://localhost") + .port(requireNotNull(serverPort) { IllegalStateException("Server port is not available!") }) + .pathParam("someId", "id") + .pathParam("otherId", 1) + .contentType(ContentType.JSON) + .header("X-Custom-Header", "test") + .accept(MediaTypes.HAL_JSON_VALUE) + .body("""{ + "comment": "some", + "flag": $flagValue, + "count": 1 + }""".trimIndent()) + .`when`() + .post("/some/{someId}/other/{otherId}") + .then() + .statusCode(200) + } + + @Test + fun should_document_both_restdocs_and_resource() { + givenEndpointInvoked(whenDocumentedAsPrivateResource()) + thenSnippetFileExists() + } + + @Test + fun should_document_both_restdocs_and_resource_as_private_resource() { + givenEndpointInvoked(whenDocumentedAsPrivateResource()) + thenSnippetFileExists() + } + + @Test + fun should_document_using_the_passed_raml_snippet() { + givenEndpointInvoked(whenDocumentedWithRamlSnippet()) + thenSnippetFileExists() + } + + @Test + fun should_value_ignored_fields_and_links() { + assertThatCode { givenEndpointInvoked(this.whenDocumentedWithAllFieldsLinksIgnored()) }.doesNotThrowAnyException() + } + + @Test + fun should_document_restdocs_and_resource_snippet_details() { + givenEndpointInvoked(whenDocumentedWithResourceSnippetDetails()) + thenSnippetFileExists() + } + + @Test + fun should_document_request() { + givenEndpointInvoked(whenResourceSnippetDocumentedWithoutParameters()) + thenSnippetFileExists() + } + + @Test + fun should_document_request_with_description() { + givenEndpointInvoked(whenResourceSnippetDocumentedWithDescription()) + thenSnippetFileExists() + } + + @Test + fun should_document_request_with_fields() { + givenEndpointInvoked(whenResourceSnippetDocumentedWithRequestAndResponseFields()) + thenSnippetFileExists() + } + + @Test + fun should_document_request_with_null_field() { + assertThatCode { + givenEndpointInvoked(this.whenResourceSnippetDocumentedWithRequestAndResponseFields(), "null") + } + .doesNotThrowAnyException() + } + + private fun whenResourceSnippetDocumentedWithoutParameters(): RestDocumentationFilter { + return RestAssuredRestDocumentationWrapper.document(identifier = operationName, snippets = *arrayOf(ResourceDocumentation.resource())) + } + + private fun whenResourceSnippetDocumentedWithDescription(): RestDocumentationFilter { + return RestAssuredRestDocumentationWrapper.document(identifier = operationName, snippets = *arrayOf(ResourceDocumentation.resource("A description"))) + } + + private fun whenResourceSnippetDocumentedWithRequestAndResponseFields(): RestDocumentationFilter { + return RestAssuredRestDocumentationWrapper.document( + identifier = operationName, + snippets = *arrayOf(buildFullResourceSnippet()) + ) + } + + @Throws(Exception::class) + private fun whenDocumentedWithRestdocsAndResource(): RestDocumentationFilter { + return RestAssuredRestDocumentationWrapper.document( + identifier = operationName, + snippets = *arrayOf( + pathParameters( + parameterWithName("someId").description("someId"), + parameterWithName("otherId").description("otherId") + ), + requestFields(fieldDescriptors().fieldDescriptors), + requestHeaders( + headerWithName("X-Custom-Header").description("some custom header") + ), + responseFields( + fieldWithPath("comment").description("the comment"), + fieldWithPath("flag").description("the flag"), + fieldWithPath("count").description("the count"), + fieldWithPath("id").description("id"), + subsectionWithPath("_links").ignored() + ), + responseHeaders( + headerWithName("X-Custom-Header").description("some custom header") + ), + links( + linkWithRel("self").description("some"), + linkWithRel("multiple").description("multiple") + ) + ) + ) + } + + @Throws(Exception::class) + private fun whenDocumentedWithRamlSnippet(): RestDocumentationFilter { + return RestAssuredRestDocumentationWrapper.document( + identifier = operationName, + snippets = *arrayOf(buildFullResourceSnippet()) + ) + } + + @Throws(Exception::class) + private fun whenDocumentedWithAllFieldsLinksIgnored(): RestDocumentationFilter { + return RestAssuredRestDocumentationWrapper.document( + identifier = operationName, + snippets = *arrayOf( + requestFields(fieldDescriptors().fieldDescriptors), + responseFields( + fieldWithPath("comment").ignored(), + fieldWithPath("flag").ignored(), + fieldWithPath("count").ignored(), + fieldWithPath("id").ignored(), + subsectionWithPath("_links").ignored() + ), + links( + linkWithRel("self").optional().ignored(), + linkWithRel("multiple").optional().ignored() + ) + ) + ) + } + + @Throws(Exception::class) + private fun whenDocumentedAsPrivateResource(): RestDocumentationFilter { + val operationRequestPreprocessor = OperationRequestPreprocessor { r -> r } + return RestAssuredRestDocumentationWrapper.document( + identifier = operationName, + privateResource = true, + requestPreprocessor = operationRequestPreprocessor, + snippets = *arrayOf( + requestFields(fieldDescriptors().fieldDescriptors), + responseFields( + fieldWithPath("comment").description("the comment"), + fieldWithPath("flag").description("the flag"), + fieldWithPath("count").description("the count"), + fieldWithPath("id").description("id"), + subsectionWithPath("_links").ignored() + ), + links( + linkWithRel("self").description("some"), + linkWithRel("multiple").description("multiple") + ) + ) + ) + } + + @Throws(Exception::class) + private fun whenDocumentedWithResourceSnippetDetails(): RestDocumentationFilter { + val operationRequestPreprocessor = OperationRequestPreprocessor { r -> r } + return RestAssuredRestDocumentationWrapper.document( + identifier = operationName, + resourceDetails = RestAssuredRestDocumentationWrapper.resourceDetails() + .description("The Resource") + .privateResource(true) + .tag("some-tag"), + requestPreprocessor = operationRequestPreprocessor, + snippets = *arrayOf( + requestFields(fieldDescriptors().fieldDescriptors), + responseFields( + fieldWithPath("comment").description("the comment"), + fieldWithPath("flag").description("the flag"), + fieldWithPath("count").description("the count"), + fieldWithPath("id").description("id"), + subsectionWithPath("_links").ignored() + ), + links( + linkWithRel("self").description("some"), + linkWithRel("multiple").description("multiple") + ) + ) + ) + } + + private fun thenSnippetFileExists() { + with(generatedSnippetFile()) { + then(this).exists() + val contents = readText() + then(contents).isNotEmpty() + } + } + + private fun generatedSnippetFile() = File("build/generated-snippets", "$operationName/resource.json") +} diff --git a/restdocs-api-spec/build.gradle.kts b/restdocs-api-spec/build.gradle.kts index 9513829d..1f317e44 100755 --- a/restdocs-api-spec/build.gradle.kts +++ b/restdocs-api-spec/build.gradle.kts @@ -17,7 +17,7 @@ dependencies { compile(kotlin("stdlib-jdk8")) compile(kotlin("reflect")) - compile("org.springframework.restdocs:spring-restdocs-mockmvc:$springRestDocsVersion") + compile("org.springframework.restdocs:spring-restdocs-core:$springRestDocsVersion") compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") diff --git a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt deleted file mode 100644 index d3eefb4f..00000000 --- a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt +++ /dev/null @@ -1,197 +0,0 @@ -package com.epages.restdocs.apispec - -import org.springframework.restdocs.headers.HeaderDescriptor -import org.springframework.restdocs.headers.RequestHeadersSnippet -import org.springframework.restdocs.headers.ResponseHeadersSnippet -import org.springframework.restdocs.hypermedia.LinkDescriptor -import org.springframework.restdocs.hypermedia.LinksSnippet -import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation -import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler -import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor -import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor -import org.springframework.restdocs.payload.FieldDescriptor -import org.springframework.restdocs.payload.RequestFieldsSnippet -import org.springframework.restdocs.payload.ResponseFieldsSnippet -import org.springframework.restdocs.request.ParameterDescriptor -import org.springframework.restdocs.request.PathParametersSnippet -import org.springframework.restdocs.request.RequestParametersSnippet -import org.springframework.restdocs.snippet.Snippet - -import java.util.function.Function - -/** - * Convenience class to migrate to restdocs-openapi in a non-invasive way. - * It it a wrapper and replacement for MockMvcRestDocumentation that transparently adds a ResourceSnippet with the descriptors provided in the given snippets. - */ -object MockMvcRestDocumentationWrapper { - - @JvmOverloads @JvmStatic - fun document( - identifier: String, - resourceDetails: ResourceSnippetDetails, - requestPreprocessor: OperationRequestPreprocessor? = null, - responsePreprocessor: OperationResponsePreprocessor? = null, - snippetFilter: Function, List> = Function.identity(), - vararg snippets: Snippet - ): RestDocumentationResultHandler { - - val enhancedSnippets = - enhanceSnippetsWithResourceSnippet( - resourceDetails = resourceDetails, - snippetFilter = snippetFilter, - snippets = *snippets - ) - - if (requestPreprocessor != null && responsePreprocessor != null) { - return MockMvcRestDocumentation.document( - identifier, - requestPreprocessor, - responsePreprocessor, - *enhancedSnippets - ) - } else if (requestPreprocessor != null) { - return MockMvcRestDocumentation.document(identifier, requestPreprocessor, *enhancedSnippets) - } else if (responsePreprocessor != null) { - return MockMvcRestDocumentation.document(identifier, responsePreprocessor, *enhancedSnippets) - } - - return MockMvcRestDocumentation.document(identifier, *enhancedSnippets) - } - - @JvmOverloads @JvmStatic - fun document( - identifier: String, - description: String? = null, - summary: String? = null, - privateResource: Boolean = false, - deprecated: Boolean = false, - requestPreprocessor: OperationRequestPreprocessor? = null, - responsePreprocessor: OperationResponsePreprocessor? = null, - snippetFilter: Function, List> = Function.identity(), - vararg snippets: Snippet - ): RestDocumentationResultHandler { - return document( - identifier = identifier, - resourceDetails = ResourceSnippetParametersBuilder() - .description(description) - .summary(summary) - .privateResource(privateResource) - .deprecated(deprecated), - requestPreprocessor = requestPreprocessor, - responsePreprocessor = responsePreprocessor, - snippetFilter = snippetFilter, - snippets = *snippets - ) - } - - @JvmStatic - fun document( - identifier: String, - requestPreprocessor: OperationRequestPreprocessor, - vararg snippets: Snippet - ): RestDocumentationResultHandler { - return document(identifier, null, null, false, false, requestPreprocessor, snippets = *snippets) - } - - @JvmStatic - fun document( - identifier: String, - description: String, - privateResource: Boolean, - vararg snippets: Snippet - ): RestDocumentationResultHandler { - return document(identifier, description, null, privateResource, snippets = *snippets) - } - - @JvmStatic - fun resourceDetails(): ResourceSnippetDetails { - return ResourceSnippetParametersBuilder() - } - - internal fun enhanceSnippetsWithResourceSnippet( - resourceDetails: ResourceSnippetDetails, - snippetFilter: Function, List>, - vararg snippets: Snippet - ): Array { - - val enhancedSnippets = if (snippets.none { it is ResourceSnippet }) { // No ResourceSnippet, so we configure our own based on the info of the other snippets - val resourceParameters = createBuilder(resourceDetails) - .requestFields( - snippets.filter { it is RequestFieldsSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - ) - .responseFields( - snippets.filter { it is ResponseFieldsSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - ) - .links( - snippets.filter { it is LinksSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - ) - .requestParameters( - *snippets.filter { it is RequestParametersSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - .toTypedArray() - ) - .pathParameters( - *snippets.filter { it is PathParametersSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - .toTypedArray() - ) - .requestHeaders( - *snippets.filter { it is RequestHeadersSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - .toTypedArray() - ) - .responseHeaders( - *snippets.filter { it is ResponseHeadersSnippet } - .flatMap { - DescriptorExtractor.extractDescriptorsFor( - it - ) - } - .toTypedArray() - ) - .build() - snippets.toList() + ResourceDocumentation.resource(resourceParameters) - } else snippets.toList() - - return snippetFilter.apply(enhancedSnippets).toTypedArray() - } - - internal fun createBuilder(resourceDetails: ResourceSnippetDetails): ResourceSnippetParametersBuilder { - return when (resourceDetails) { - is ResourceSnippetParametersBuilder -> resourceDetails - else -> ResourceSnippetParametersBuilder() - .description(resourceDetails.description) - .summary(resourceDetails.summary) - .privateResource(resourceDetails.privateResource) - .deprecated(resourceDetails.deprecated) - .tags(*resourceDetails.tags.toTypedArray()) - } - } -} diff --git a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/RestDocumentationWrapper.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/RestDocumentationWrapper.kt new file mode 100644 index 00000000..92fefe58 --- /dev/null +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/RestDocumentationWrapper.kt @@ -0,0 +1,105 @@ +package com.epages.restdocs.apispec + +import org.springframework.restdocs.headers.HeaderDescriptor +import org.springframework.restdocs.headers.RequestHeadersSnippet +import org.springframework.restdocs.headers.ResponseHeadersSnippet +import org.springframework.restdocs.hypermedia.LinkDescriptor +import org.springframework.restdocs.hypermedia.LinksSnippet +import org.springframework.restdocs.payload.FieldDescriptor +import org.springframework.restdocs.payload.RequestFieldsSnippet +import org.springframework.restdocs.payload.ResponseFieldsSnippet +import org.springframework.restdocs.request.ParameterDescriptor +import org.springframework.restdocs.request.PathParametersSnippet +import org.springframework.restdocs.request.RequestParametersSnippet +import org.springframework.restdocs.snippet.Snippet +import java.util.function.Function + +abstract class RestDocumentationWrapper { + + protected fun enhanceSnippetsWithResourceSnippet( + resourceDetails: ResourceSnippetDetails, + snippetFilter: Function, List>, + vararg snippets: Snippet + ): Array { + + val enhancedSnippets = if (snippets.none { it is ResourceSnippet }) { // No ResourceSnippet, so we configure our own based on the info of the other snippets + val resourceParameters = createBuilder(resourceDetails) + .requestFields( + snippets.filter { it is RequestFieldsSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + ) + .responseFields( + snippets.filter { it is ResponseFieldsSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + ) + .links( + snippets.filter { it is LinksSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + ) + .requestParameters( + *snippets.filter { it is RequestParametersSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + .toTypedArray() + ) + .pathParameters( + *snippets.filter { it is PathParametersSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + .toTypedArray() + ) + .requestHeaders( + *snippets.filter { it is RequestHeadersSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + .toTypedArray() + ) + .responseHeaders( + *snippets.filter { it is ResponseHeadersSnippet } + .flatMap { + DescriptorExtractor.extractDescriptorsFor( + it + ) + } + .toTypedArray() + ) + .build() + snippets.toList() + ResourceDocumentation.resource(resourceParameters) + } else snippets.toList() + + return snippetFilter.apply(enhancedSnippets).toTypedArray() + } + + internal fun createBuilder(resourceDetails: ResourceSnippetDetails): ResourceSnippetParametersBuilder { + return when (resourceDetails) { + is ResourceSnippetParametersBuilder -> resourceDetails + else -> ResourceSnippetParametersBuilder() + .description(resourceDetails.description) + .summary(resourceDetails.summary) + .privateResource(resourceDetails.privateResource) + .deprecated(resourceDetails.deprecated) + .tags(*resourceDetails.tags.toTypedArray()) + } + } +} \ No newline at end of file diff --git a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt deleted file mode 100644 index 610b77ad..00000000 --- a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.epages.restdocs.apispec - -import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName -import com.epages.restdocs.apispec.ResourceDocumentation.resource -import org.assertj.core.api.Assertions.assertThatCode -import org.assertj.core.api.BDDAssertions.then -import org.hibernate.validator.constraints.Length -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.SpringApplication -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.hateoas.Link -import org.springframework.hateoas.MediaTypes.HAL_JSON -import org.springframework.hateoas.Resource -import org.springframework.hateoas.mvc.BasicLinkBuilder -import org.springframework.http.HttpHeaders.ACCEPT -import org.springframework.http.HttpHeaders.CONTENT_TYPE -import org.springframework.http.MediaType.APPLICATION_JSON -import org.springframework.http.ResponseEntity -import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName -import org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel -import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post -import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath -import org.springframework.test.context.junit.jupiter.SpringExtension -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.ResultActions -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.annotation.PathVariable -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.io.File -import java.util.UUID -import javax.validation.constraints.NotEmpty - -@ExtendWith(SpringExtension::class) -@WebMvcTest -@AutoConfigureRestDocs -open class ResourceSnippetIntegrationTest(@Autowired private val mockMvc: MockMvc) { - - val operationName = "test-${System.currentTimeMillis()}" - - lateinit var resultActions: ResultActions - - @Test - fun should_document_request() { - givenEndpointInvoked() - - whenResourceSnippetDocumentedWithoutParameters() - - thenSnippetFileExists() - } - - @Test - fun should_document_request_with_description() { - givenEndpointInvoked() - - whenResourceSnippetDocumentedWithDescription() - - thenSnippetFileExists() - } - - @Test - fun should_document_request_with_fields() { - givenEndpointInvoked() - - whenResourceSnippetDocumentedWithRequestAndResponseFields() - - thenSnippetFileExists() - } - - @Test - fun should_document_request_with_null_field() { - givenEndpointInvoked("null") - - assertThatCode { this.whenResourceSnippetDocumentedWithRequestAndResponseFields() } - .doesNotThrowAnyException() - } - - private fun whenResourceSnippetDocumentedWithoutParameters() { - resultActions - .andDo(document(operationName, resource())) - } - - private fun whenResourceSnippetDocumentedWithDescription() { - resultActions - .andDo(document(operationName, resource("A description"))) - } - - private fun whenResourceSnippetDocumentedWithRequestAndResponseFields() { - resultActions - .andDo(document(operationName, buildFullResourceSnippet())) - } - - protected fun buildFullResourceSnippet(): ResourceSnippet { - return resource( - ResourceSnippetParameters.builder() - .description("description") - .summary("summary") - .deprecated(true) - .privateResource(true) - .requestFields(fieldDescriptors()) - .responseFields(fieldDescriptors().and(fieldWithPath("id").description("id"))) - .requestHeaders( - headerWithName("X-Custom-Header").description("A custom header"), - headerWithName(ACCEPT).description("Accept") - ) - .responseHeaders( - headerWithName("X-Custom-Header").description("A custom header"), - headerWithName(CONTENT_TYPE).description("ContentType") - ) - .pathParameters( - parameterWithName("someId").description("some id"), - parameterWithName("otherId").description("otherId id").type(SimpleType.INTEGER) - ) - .links( - linkWithRel("self").description("some"), - linkWithRel("multiple").description("multiple") - ) - .build() - ) - } - - protected fun fieldDescriptors(): FieldDescriptors { - val fields = ConstrainedFields(TestDataHolder::class.java) - return ResourceDocumentation.fields( - fields.withPath("comment").description("the comment").optional(), - fields.withPath("flag").description("the flag"), - fields.withMappedPath("count", "count").description("the count") - ) - } - - protected fun givenEndpointInvoked(flagValue: String = "true") { - resultActions = mockMvc.perform( - post("/some/{someId}/other/{otherId}", "id", 1) - .contentType(APPLICATION_JSON) - .header("X-Custom-Header", "test") - .accept(HAL_JSON) - .content("""{ - "comment": "some", - "flag": $flagValue, - "count": 1 - }""".trimIndent() - ) - ).andExpect(status().isOk) - } - - fun thenSnippetFileExists() { - with(generatedSnippetFile()) { - then(this).exists() - val contents = readText() - then(contents).isNotEmpty() - } - } - - fun generatedSnippetFile() = File("build/generated-snippets", "$operationName/resource.json") - - @SpringBootApplication - internal open class TestApplication { - fun main(args: Array) { - SpringApplication.run(TestApplication::class.java, *args) - } - - @RestController - internal open class TestController { - - @PostMapping(path = ["/some/{someId}/other/{otherId}"]) - fun doSomething( - @PathVariable someId: String, - @PathVariable otherId: Int?, - @RequestHeader("X-Custom-Header") customHeader: String, - @RequestBody testDataHolder: TestDataHolder - ): ResponseEntity> { - val resource = Resource(testDataHolder.copy(id = UUID.randomUUID().toString())) - val link = BasicLinkBuilder.linkToCurrentMapping().slash("some").slash(someId).slash("other").slash(otherId).toUri().toString() - resource.add(Link(link, Link.REL_SELF)) - resource.add(Link(link, "multiple")) - resource.add(Link(link, "multiple")) - - return ResponseEntity - .ok() - .header("X-Custom-Header", customHeader) - .body>(resource) - } - } - } - - internal data class TestDataHolder( - @field:Length(min = 1, max = 255) - val comment: String? = null, - val flag: Boolean = false, - val count: Int = 0, - @field:NotEmpty - val id: String? = null - ) -} diff --git a/samples/restdocs-api-spec-sample/build.gradle b/samples/restdocs-api-spec-sample/build.gradle index d05f2da3..51cfa4e6 100755 --- a/samples/restdocs-api-spec-sample/build.gradle +++ b/samples/restdocs-api-spec-sample/build.gradle @@ -39,8 +39,8 @@ dependencies { testCompile('org.springframework.boot:spring-boot-starter-test') testCompile('org.springframework.restdocs:spring-restdocs-mockmvc') - //testCompile('com.epages:restdocs-api-spec:0.5.0') - testCompile project(':restdocs-api-spec') //enable for depending on the submodule directly +// testCompile('com.epages:restdocs-api-spec-mockmvc:0.5.0') + testCompile project(':restdocs-api-spec-mockmvc') //enable for depending on the submodule directly testCompile('com.google.guava:guava:23.0') } diff --git a/settings.gradle b/settings.gradle index 223be33c..ed0bf275 100755 --- a/settings.gradle +++ b/settings.gradle @@ -7,3 +7,5 @@ include 'restdocs-api-spec-openapi3-generator' include 'restdocs-api-spec-gradle-plugin' include 'restdocs-api-spec-sample' project(':restdocs-api-spec-sample').projectDir = file('samples/restdocs-api-spec-sample') +include 'restdocs-api-spec-mockmvc' +include 'restdocs-api-spec-restassured'