Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,63 @@ 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<FieldDescriptor>, 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<FieldDescriptor>): String {
val distinctStarters = fieldDescriptors.map { fieldDescriptor -> fieldDescriptor.path.split('/').first() }.distinct()
if (distinctStarters.size == 1) {
return distinctStarters.first()
}
return ""
}

private fun replaceRootElementAndSlashes(fieldDescriptors: List<FieldDescriptor>, rootElementName: String): List<FieldDescriptor> {
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.
*
* The implementation will
*/
private fun reduceFieldDescriptors(fieldDescriptors: List<FieldDescriptor>): List<FieldDescriptorWithSchemaType> {
return fieldDescriptors
.map {
FieldDescriptorWithSchemaType.fromFieldDescriptor(
.map {
FieldDescriptorWithSchemaType.fromFieldDescriptor(
it
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions restdocs-api-spec-mockmvc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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("""
<?xml version="1.0" encoding="UTF-8"?>
<testDataHolder>
<comment>some</comment>
<flag>$flagValue</flag>
<count>1</count>
</testDataHolder>
""".trimIndent()
)
.characterEncoding("UTF-8")
).andExpect(status().isOk)
}

private fun thenSnippetFileExists() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,7 +58,30 @@ open class ResourceSnippetIntegrationTest {
@RequestBody testDataHolder: TestDataHolder
): ResponseEntity<Resource<TestDataHolder>> {
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<TestDataHolder>>(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<Resource<TestDataHolder>> {
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"))
Expand Down Expand Up @@ -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()
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ object OpenApi3Generator {
oauth2SecuritySchemeDefinition: Oauth2Configuration? = null
): OpenAPI {
return OpenAPI().apply {

this.servers = servers
info = Info().apply {
this.title = title
Expand Down Expand Up @@ -128,10 +127,10 @@ object OpenApi3Generator {
.forEach {
it.schema(
extractOrFindSchema(
schemasToKeys,
it.schema,
generateSchemaName(path)
)
schemasToKeys,
it.schema,
generateSchemaName(path)
)
)
}
}
Expand All @@ -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
}
Expand Down
Loading