diff --git a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt index 8d584f31..6c959ac2 100644 --- a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt @@ -18,21 +18,54 @@ import org.everit.json.schema.Schema import org.everit.json.schema.StringSchema import org.everit.json.schema.internal.JSONPrinter import java.io.StringWriter -import java.util.ArrayList import java.util.Collections.emptyList import java.util.function.Predicate class JsonSchemaFromFieldDescriptorsGenerator { fun generateSchema(fieldDescriptors: List, title: String? = null): String { - val jsonFieldPaths = reduceFieldDescriptors(fieldDescriptors) - .map { JsonFieldPath.compile(it) } + var workingFieldDescriptors = fieldDescriptors - val schema = traverse(emptyList(), jsonFieldPaths, ObjectSchema.builder().title(title) as ObjectSchema.Builder) + var rootElementName: String? = null + if (fieldDescriptors.any { fieldDescriptor -> fieldDescriptor.path.contains('/') }) { + rootElementName = findRootElementName(fieldDescriptors) + workingFieldDescriptors = replaceRootElementAndSlashes(fieldDescriptors, rootElementName) + } + + val jsonFieldPaths = reduceFieldDescriptors(workingFieldDescriptors) + .map { JsonFieldPath.compile(it) } + + val schemaTitle = if (!rootElementName.isNullOrEmpty()) rootElementName else title + val schema = traverse(emptyList(), + jsonFieldPaths, + ObjectSchema.builder().title(schemaTitle) as ObjectSchema.Builder) return toFormattedString(unWrapRootArray(jsonFieldPaths, schema)) } + private fun findRootElementName(fieldDescriptors: List): String { + val distinctStarters = fieldDescriptors.map { fieldDescriptor -> fieldDescriptor.path.split('/').first() }.distinct() + if (distinctStarters.size == 1) { + return distinctStarters.first() + } + return "" + } + + private fun replaceRootElementAndSlashes(fieldDescriptors: List, rootElementName: String): List { + return fieldDescriptors + .filter { fieldDescriptor -> fieldDescriptor.path != rootElementName } + .map { fieldDescriptor -> + FieldDescriptor( + fieldDescriptor.path.replace(rootElementName + "/", "").replace('/', '.'), + fieldDescriptor.description, + fieldDescriptor.type, + fieldDescriptor.optional, + fieldDescriptor.ignored, + fieldDescriptor.attributes + ) + } + } + /** * Reduce the list of field descriptors so that the path of each list item is unique. * @@ -40,8 +73,8 @@ class JsonSchemaFromFieldDescriptorsGenerator { */ private fun reduceFieldDescriptors(fieldDescriptors: List): List { return fieldDescriptors - .map { - FieldDescriptorWithSchemaType.fromFieldDescriptor( + .map { + FieldDescriptorWithSchemaType.fromFieldDescriptor( it ) } diff --git a/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt index 439f1d9a..044fa950 100644 --- a/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt +++ b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt @@ -120,6 +120,18 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { thenSchemaValidatesJson("""[{"id": "some"}]""") } + @Test + fun should_generate_schema_for_xpath_descriptors() { + givenFieldDescriptorWithXpathDescriptors() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + thenSchemaIsValid() + then(schema?.title).isEqualTo("root") + thenSchemaValidatesJson("""{"a": {"suba":"some suba"}, "b": "some b"}""") + } + @Test fun should_generate_schema_for_top_level_array_of_any() { givenFieldDescriptorWithTopLevelArrayOfAny() @@ -241,21 +253,30 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { private fun givenDifferentFieldDescriptorsWithSamePathAndDifferentTypes() { fieldDescriptors = listOf( - FieldDescriptor("id", "some", "STRING"), - FieldDescriptor("id", "some", "NULL"), - FieldDescriptor("id", "some", "BOOLEAN") - ) + FieldDescriptor("id", "some", "STRING"), + FieldDescriptor("id", "some", "NULL"), + FieldDescriptor("id", "some", "BOOLEAN") + ) + } + + private fun givenFieldDescriptorWithXpathDescriptors() { + fieldDescriptors = listOf( + FieldDescriptor("root", "some root", "OBJECT"), + FieldDescriptor("root/a", "some a", "OBJECT"), + FieldDescriptor("root/a/suba", "some sub a", "STRING"), + FieldDescriptor("root/b", "some b", "STRING") + ) } private fun givenFieldDescriptorsWithConstraints() { val constraintAttributeWithNotNull = - Attributes( - listOf( - Constraint( - NotNull::class.java.name, - emptyMap() - ) - ) + Attributes( + listOf( + Constraint( + NotNull::class.java.name, + emptyMap() + ) + ) ) val constraintAttributeWithLength = diff --git a/restdocs-api-spec-mockmvc/build.gradle.kts b/restdocs-api-spec-mockmvc/build.gradle.kts index 85575a87..5e365bd8 100644 --- a/restdocs-api-spec-mockmvc/build.gradle.kts +++ b/restdocs-api-spec-mockmvc/build.gradle.kts @@ -22,5 +22,6 @@ dependencies { testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") testImplementation("org.junit-pioneer:junit-pioneer:0.3.0") testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") + testCompile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.10.0") } diff --git a/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt index 9ecbe5cc..be9680c9 100644 --- a/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt +++ b/restdocs-api-spec-mockmvc/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt @@ -9,11 +9,15 @@ 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.http.MediaType.APPLICATION_XML 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.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath import org.springframework.restdocs.payload.PayloadDocumentation.requestFields @@ -24,8 +28,6 @@ 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 @@ -103,6 +105,15 @@ class MockMvcRestDocumentationWrapperIntegrationTest(@Autowired private val mock thenSnippetFileExists() } + @Test + fun should_document_request_with_fields_when_fields_are_xml() { + givenEndpointInvokedWithXml() + + whenResourceSnippetDocumentedWithRequestAndResponseFieldsWithXml() + + thenSnippetFileExists() + } + @Test fun should_document_request_with_null_field() { givenEndpointInvoked("null") @@ -126,6 +137,11 @@ class MockMvcRestDocumentationWrapperIntegrationTest(@Autowired private val mock .andDo(document(operationName, buildFullResourceSnippet())) } + private fun whenResourceSnippetDocumentedWithRequestAndResponseFieldsWithXml() { + resultActions + .andDo(document(operationName, buildFullResourceSnippetWithXml())) // XML + } + private fun givenEndpointInvoked(flagValue: String = "true") { resultActions = mockMvc.perform( post("/some/{someId}/other/{otherId}", "id", 1) @@ -137,8 +153,27 @@ class MockMvcRestDocumentationWrapperIntegrationTest(@Autowired private val mock "flag": $flagValue, "count": 1 }""".trimIndent() - ) - ).andExpect(status().isOk) + ) + ).andExpect(status().isOk) + } + + private fun givenEndpointInvokedWithXml(flagValue: String = "true") { + resultActions = mockMvc.perform( + put("/some/{someId}/other/{otherId}", "id", 1) + .contentType(APPLICATION_XML) + .header("X-Custom-Header", "test") + .accept(APPLICATION_XML) + .content(""" + + + some + $flagValue + 1 + + """.trimIndent() + ) + .characterEncoding("UTF-8") + ).andExpect(status().isOk) } private fun thenSnippetFileExists() { 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 2e73b22a..83bdd839 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 @@ -14,14 +14,17 @@ 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 import org.springframework.http.ResponseEntity import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName import org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel +import org.springframework.restdocs.payload.JsonFieldType 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.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RestController @@ -55,7 +58,30 @@ open class ResourceSnippetIntegrationTest { @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() + 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) + } + + @PutMapping(path = ["/some/{someId}/other/{otherId}"], + consumes = [MediaType.APPLICATION_XML_VALUE], + produces = [MediaType.APPLICATION_XML_VALUE]) + fun doSomethingWithXml( + @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")) @@ -99,19 +125,81 @@ fun buildFullResourceSnippet(): ResourceSnippet { .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() - ) + ) +} + +fun fieldDescriptorsForXmlRequest(): FieldDescriptors { + val fields = ConstrainedFields(ResourceSnippetIntegrationTest.TestDataHolder::class.java) + return ResourceDocumentation.fields( + fields.withPath("testDataHolder").type(JsonFieldType.OBJECT).description("the data holder").optional(), + fields.withPath("testDataHolder/comment").type(JsonFieldType.STRING).description("the comment").optional(), + fields.withPath("testDataHolder/flag").type(JsonFieldType.BOOLEAN).description("the flag"), + fields.withPath("testDataHolder/count").type(JsonFieldType.NUMBER).description("the count") + ) +} + +fun fieldDescriptorsForXmlResponse(): FieldDescriptors { + val fields = ConstrainedFields(ResourceSnippetIntegrationTest.TestDataHolder::class.java) + return ResourceDocumentation.fields( + fields.withPath("Resource").type(JsonFieldType.OBJECT).description("the data holder").optional(), + fields.withPath("Resource/comment").type(JsonFieldType.STRING).description("the comment").optional(), + fields.withPath("Resource/flag").type(JsonFieldType.BOOLEAN).description("the flag"), + fields.withPath("Resource/count").type(JsonFieldType.NUMBER).description("the count"), + fields.withPath("Resource/id").type(JsonFieldType.STRING).description("id"), + // incorporate links here, see buildFullResourceSnippetWithXml() + fields.withPath("Resource/links").type(JsonFieldType.ARRAY).description("array of links"), + fields.withPath("Resource/links/links").type(JsonFieldType.OBJECT).description("link object"), + fields.withPath("Resource/links/links/rel").type(JsonFieldType.STRING).description("rel of link"), + fields.withPath("Resource/links/links/href").type(JsonFieldType.STRING).description("href of link"), + fields.withPath("Resource/links/links/hreflang").type(JsonFieldType.STRING).description("hreflang of link"), + fields.withPath("Resource/links/links/media").type(JsonFieldType.STRING).description("media of link"), + fields.withPath("Resource/links/links/title").type(JsonFieldType.STRING).description("title of link"), + fields.withPath("Resource/links/links/type").type(JsonFieldType.STRING).description("type of link"), + fields.withPath("Resource/links/links/deprecation").type(JsonFieldType.STRING).description("deprecation of link") + ) +} + +fun buildFullResourceSnippetWithXml(): ResourceSnippet { + return resource( + ResourceSnippetParameters.builder() + .description("description") + .summary("summary") + .deprecated(true) + .privateResource(true) + .requestFields(fieldDescriptorsForXmlRequest()) + .responseFields(fieldDescriptorsForXmlResponse()) + .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) + ) + /* + Can not be used, since spring framework expects links to be json, if we try this with xml it will fail + .links( + linkWithRel("self").description("some"), + linkWithRel("multiple").description("multiple") + )*/ + .build() + ) } diff --git a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt index cc9a0714..d9e71407 100644 --- a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt +++ b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt @@ -51,7 +51,6 @@ object OpenApi3Generator { oauth2SecuritySchemeDefinition: Oauth2Configuration? = null ): OpenAPI { return OpenAPI().apply { - this.servers = servers info = Info().apply { this.title = title @@ -128,10 +127,10 @@ object OpenApi3Generator { .forEach { it.schema( extractOrFindSchema( - schemasToKeys, - it.schema, - generateSchemaName(path) - ) + schemasToKeys, + it.schema, + generateSchemaName(path) + ) ) } } @@ -140,7 +139,7 @@ object OpenApi3Generator { val schemaKey = if (schemasToKeys.containsKey(schema)) { schemasToKeys[schema]!! } else { - val name = schemaNameGenerator(schema) + val name = if (!schema.title.isNullOrEmpty()) schema.title else schemaNameGenerator(schema) schemasToKeys[schema] = name name } diff --git a/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt b/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt index d80c6918..608a604e 100644 --- a/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt +++ b/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt @@ -32,11 +32,27 @@ class OpenApi3GeneratorTest { whenOpenApiObjectGenerated() - thenGetProductByIdOperationIsValid() + thenGetProductByIdOperationIsValid("application/json") thenOAuth2SecuritySchemesPresent() thenInfoFieldsPresent() thenTagFieldsPresent() thenServersPresent() + thenSchemaPresentNamedLike("product") + thenOpenApiSpecIsValid() + } + + @Test + fun `should convert single resource model to openapi with xml in response body`() { + givenGetProductResourceModelWithXmlResponse() + + whenOpenApiObjectGenerated() + + thenGetProductByIdOperationIsValid("application/xml") + thenOAuth2SecuritySchemesPresent() + thenInfoFieldsPresent() + thenTagFieldsPresent() + thenServersPresent() + thenSchemaPresentNamedLike("TestDataHolder") thenOpenApiSpecIsValid() } @@ -145,7 +161,7 @@ class OpenApi3GeneratorTest { thenOpenApiSpecIsValid() } - fun thenGetProductByIdOperationIsValid() { + fun thenGetProductByIdOperationIsValid(contentType: String) { val productGetByIdPath = "paths./products/{id}.get" then(openApiJsonPathContext.read>("$productGetByIdPath.tags")).isNotNull() then(openApiJsonPathContext.read("$productGetByIdPath.operationId")).isNotNull() @@ -160,18 +176,22 @@ class OpenApi3GeneratorTest { then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'locale')].schema.type")).containsOnly("string") then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].in")).containsOnly("header") then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].required")).containsOnly(true) - then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].example")).containsOnly("some example") - then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].schema.type")).containsOnly("string") + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].example")).containsOnly( + "some example") + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].schema.type")).containsOnly( + "string") then(openApiJsonPathContext.read("$productGetByIdPath.requestBody")).isNull() then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.description")).isNotNull() then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.headers.SIGNATURE.schema.type")).isNotNull() - then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.schema.\$ref")).isNotNull() - then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.examples.test.value")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.$contentType.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.$contentType.examples.test.value")).isNotNull() - then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2_clientCredentials").flatMap { it }).containsOnly("prod:r") - then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2_authorizationCode").flatMap { it }).containsOnly("prod:r") + then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2_clientCredentials").flatMap { it }).containsOnly( + "prod:r") + then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2_authorizationCode").flatMap { it }).containsOnly( + "prod:r") } private fun thenServersPresent() { @@ -191,14 +211,23 @@ class OpenApi3GeneratorTest { then(openApiJsonPathContext.read("tags[1].description")).isEqualTo("tag2 description") } + private fun thenSchemaPresentNamedLike(desiredNamepart: String) { + val schemas = openApiJsonPathContext.read>("components.schemas") + then(schemas).isNotEmpty() + val schemakey = schemas.filterKeys { key -> key.contains(desiredNamepart) }.keys + then(schemakey).hasSize(1) + then(openApiJsonPathContext.read>("components.schemas." + schemakey.first() + ".properties")).hasSize( + 2) + } + private fun thenOAuth2SecuritySchemesPresent() { then(openApiJsonPathContext.read("components.securitySchemes.oauth2.type")).isEqualTo("oauth2") then(openApiJsonPathContext.read>("components.securitySchemes.oauth2.flows")) - .containsKeys("clientCredentials", "authorizationCode") + .containsKeys("clientCredentials", "authorizationCode") then(openApiJsonPathContext.read>("components.securitySchemes.oauth2.flows.clientCredentials.scopes")) - .containsKeys("prod:r") + .containsKeys("prod:r") then(openApiJsonPathContext.read>("components.securitySchemes.oauth2.flows.authorizationCode.scopes")) - .containsKeys("prod:r") + .containsKeys("prod:r") } private fun thenJWTSecuritySchemesPresent() { @@ -414,17 +443,32 @@ class OpenApi3GeneratorTest { private fun givenGetProductResourceModel() { resources = listOf( - ResourceModel( - operationId = "test", - summary = "summary", - description = "description", - privateResource = false, - deprecated = false, - tags = setOf("tag1", "tag2"), - request = getProductRequest(), - response = getProductResponse() + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductResponse() + ) + ) + } + + private fun givenGetProductResourceModelWithXmlResponse() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductXmlResponse() + ) ) - ) } private fun givenGetProductResourceModelWithJWTSecurityRequirement() { @@ -462,44 +506,72 @@ class OpenApi3GeneratorTest { private fun getProductResponse(): ResponseModel { return ResponseModel( - status = 200, - contentType = "application/json", - headers = listOf( - HeaderDescriptor( - name = "SIGNATURE", - description = "This is some signature", - type = "STRING", - optional = false - ) - ), - responseFields = listOf( - FieldDescriptor( - path = "_id", - description = "ID of the product", - type = "STRING" - ), - FieldDescriptor( - path = "description", - description = "Product description, localized.", - type = "STRING" - ) - ), - example = """{ + status = 200, + contentType = "application/json", + headers = listOf( + HeaderDescriptor( + name = "SIGNATURE", + description = "This is some signature", + type = "STRING", + optional = false + ) + ), + responseFields = listOf( + FieldDescriptor( + path = "_id", + description = "ID of the product", + type = "STRING" + ), + FieldDescriptor( + path = "description", + description = "Product description, localized.", + type = "STRING" + ) + ), + example = """{ "_id": "123", "description": "Good stuff!" }""" - ) + ) + } + + private fun getProductXmlResponse(): ResponseModel { + return ResponseModel( + status = 200, + contentType = "application/xml", + headers = listOf( + HeaderDescriptor( + name = "SIGNATURE", + description = "This is some signature", + type = "STRING", + optional = false + ) + ), + responseFields = listOf( + FieldDescriptor( + path = "TestDataHolder/_id", + description = "ID of the product", + type = "STRING" + ), + FieldDescriptor( + path = "TestDataHolder/description", + description = "Product description, localized.", + type = "STRING" + ) + ), + example = """<_id>123Good stuff!""".trimMargin() + ) } private fun getProductHalResponse(): ResponseModel { return ResponseModel( - status = 200, - contentType = "application/hal+json", - responseFields = listOf( - FieldDescriptor( - path = "_id", - description = "ID of the product", - type = "STRING" + status = 200, + contentType = "application/hal+json", + responseFields = listOf( + FieldDescriptor( + path = "_id", + description = "ID of the product", + type = "STRING" ), FieldDescriptor( path = "description1", diff --git a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt index 224932c4..3bd27d33 100644 --- a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt @@ -15,33 +15,37 @@ class ConstrainedFields(private val classHoldingConstraints: Class<*>) { /** * Create a field description with constraints for bean property with the same name - * @param path json path of the field + * @param path json path or xpath of the field */ fun withPath(path: String): FieldDescriptor = - withMappedPath(path, beanPropertyNameFromPath(path)) + withMappedPath(path, beanPropertyNameFromPath(path)) /** * * Create a field description with constraints for bean property with a name differing from the path - * @param jsonPath json path of the field + * @param path json path or xpath of the field * @param beanPropertyName name of the property of the bean that is used to get the field constraints */ - fun withMappedPath(jsonPath: String, beanPropertyName: String): FieldDescriptor = - addConstraints(fieldWithPath(jsonPath), beanPropertyName) + fun withMappedPath(path: String, beanPropertyName: String): FieldDescriptor = + addConstraints(fieldWithPath(path), beanPropertyName) /** * Add bean validation constraints for the field beanPropertyName to the descriptor */ fun addConstraints(fieldDescriptor: FieldDescriptor, beanPropertyName: String): FieldDescriptor = - fieldDescriptor.attributes( - key(CONSTRAINTS_KEY) - .value(this.validatorConstraintResolver.resolveForProperty(beanPropertyName, classHoldingConstraints)) - ) + fieldDescriptor + .attributes(key(CONSTRAINTS_KEY).value(this.validatorConstraintResolver.resolveForProperty( + beanPropertyName, classHoldingConstraints))) - private fun beanPropertyNameFromPath(jsonPath: String) = jsonPath.substringAfterLast(DOT_NOTATION_DELIMITER) + private fun beanPropertyNameFromPath(path: String) = + if (path.contains(SLASH_NOTATION_DELIMITER)) + path.substringAfterLast(SLASH_NOTATION_DELIMITER) + else + path.substringAfterLast(DOT_NOTATION_DELIMITER) companion object { private const val CONSTRAINTS_KEY = "validationConstraints" private const val DOT_NOTATION_DELIMITER = "." + private const val SLASH_NOTATION_DELIMITER = "/" } } diff --git a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt index 00f3ca09..0f306477 100644 --- a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt @@ -9,29 +9,29 @@ internal class ConstrainedFieldsTest { @Test @Suppress("UNCHECKED_CAST") - fun `should resolve constraints`() { + fun `should resolve json constraints`() { val fields = ConstrainedFields(SomeWithConstraints::class.java) val descriptor = fields.withPath("nonEmpty") then(descriptor.attributes).containsKey("validationConstraints") then((descriptor.attributes["validationConstraints"] as List).map { it.name }) - .containsExactly(NotEmpty::class.java.name) + .containsExactly(NotEmpty::class.java.name) } @Test @Suppress("UNCHECKED_CAST") - fun `should resolve one level nested constraints`() { + fun `should resolve one level nested json constraints`() { val fields = ConstrainedFields(SomeWithConstraints::class.java) val descriptor = fields.withPath("nested.nonEmpty") then(descriptor.attributes).containsKey("validationConstraints") then((descriptor.attributes["validationConstraints"] as List).map { it.name }) - .containsExactly(NotEmpty::class.java.name) + .containsExactly(NotEmpty::class.java.name) } @Test @Suppress("UNCHECKED_CAST") - fun `should resolve two level nested constraints`() { + fun `should resolve two level nested json constraints`() { val fields = ConstrainedFields(SomeWithConstraints::class.java) val descriptor = fields.withPath("nested.nested.nonEmpty") @@ -40,10 +40,42 @@ internal class ConstrainedFieldsTest { .containsExactly(NotEmpty::class.java.name) } + @Test + @Suppress("UNCHECKED_CAST") + fun `should resolve xml constraints`() { + val fields = ConstrainedFields(SomeWithConstraints::class.java) + val descriptor = fields.withPath("nonEmpty") + + then(descriptor.attributes).containsKey("validationConstraints") + then((descriptor.attributes["validationConstraints"] as List).map { it.name }) + .containsExactly(NotEmpty::class.java.name) + } + + @Test + @Suppress("UNCHECKED_CAST") + fun `should resolve one level nested xml constraints`() { + val fields = ConstrainedFields(SomeWithConstraints::class.java) + val descriptor = fields.withPath("nested/nonEmpty") + + then(descriptor.attributes).containsKey("validationConstraints") + then((descriptor.attributes["validationConstraints"] as List).map { it.name }) + .containsExactly(NotEmpty::class.java.name) + } + + @Test + @Suppress("UNCHECKED_CAST") + fun `should resolve two level nested xml constraints`() { + val fields = ConstrainedFields(SomeWithConstraints::class.java) + val descriptor = fields.withPath("nested/nested/nonEmpty") + + then(descriptor.attributes).containsKey("validationConstraints") + then((descriptor.attributes["validationConstraints"] as List).map { it.name }) + .containsExactly(NotEmpty::class.java.name) + } + private data class SomeWithConstraints( @field:NotEmpty val nonEmpty: String, - val nested: SomeWithConstraints? ) }