Skip to content

Commit

Permalink
Feat : Schema reuse through subschema (#246)
Browse files Browse the repository at this point in the history
* feat : Input a name for the subschema

* feat : Input a name for the subschema

* feat : Make sub schema

* fix: lint

* fix: requested & Suggested
  • Loading branch information
xeromank authored Sep 25, 2023
1 parent 2900374 commit 437d7da
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ class JsonSchemaFromFieldDescriptorsGenerator {
.build()
)
} else {
val schemaName = propertyField?.fieldDescriptor?.attributes?.schemaName
builder.addPropertySchema(
propertyName,
traverse(
traversedSegments, fields,
ObjectSchema.builder()
.title(schemaName)
.description(propertyField?.fieldDescriptor?.description) as ObjectSchema.Builder
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest {

private var schemaString: String? = null

@Test
@Throws(IOException::class)
fun should_generate_reuse_schema() {
givenFieldDescriptorsWithSchemaName()

whenSchemaGenerated()

then(schema).isInstanceOf(ObjectSchema::class.java)
val objectSchema = schema as ObjectSchema?
val postSchema = objectSchema?.propertySchemas?.get("post") as ObjectSchema
val shippingAddressSchema = postSchema.propertySchemas["shippingAddress"] as ObjectSchema
then(shippingAddressSchema.title).isEqualTo("Address")
val billingAddressSchema = postSchema.propertySchemas["billingAddress"] as ObjectSchema
then(billingAddressSchema.title).isEqualTo("Address")
}

@Test
@Throws(IOException::class)
fun should_generate_complex_schema() {
Expand Down Expand Up @@ -789,6 +805,23 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest {
)
}

private fun givenFieldDescriptorsWithSchemaName() {

fieldDescriptors = listOf(
FieldDescriptor(
"post",
"some",
"OBJECT",
),
FieldDescriptor("post.shippingAddress", "some", "OBJECT", attributes = Attributes(schemaName = "Address")),
FieldDescriptor("post.shippingAddress.firstName", "some", "STRING"),
FieldDescriptor("post.shippingAddress.valid", "some", "BOOLEAN"),
FieldDescriptor("post.billingAddress", "some", "OBJECT", attributes = Attributes(schemaName = "Address")),
FieldDescriptor("post.billingAddress.firstName", "some", "STRING"),
FieldDescriptor("post.billingAddress.valid", "some", "BOOLEAN"),
)
}

private fun thenSchemaValidatesJson(json: String) {
schema!!.validate(if (json.startsWith("[")) JSONArray(json) else JSONObject(json))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ open class FieldDescriptor(
data class Attributes(
val validationConstraints: List<Constraint> = emptyList(),
val enumValues: List<Any> = emptyList(),
val itemsType: String? = null
val itemsType: String? = null,
val schemaName: String? = null,
)

data class Constraint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.epages.restdocs.apispec.model.SimpleType
import com.epages.restdocs.apispec.model.groupByPath
import com.epages.restdocs.apispec.openapi3.SecuritySchemeGenerator.addSecurityDefinitions
import com.epages.restdocs.apispec.openapi3.SecuritySchemeGenerator.addSecurityItemFromSecurityRequirements
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.swagger.v3.core.util.Json
import io.swagger.v3.oas.models.Components
Expand Down Expand Up @@ -76,11 +77,41 @@ object OpenApi3Generator {
resources,
oauth2SecuritySchemeDefinition
)

extractDefinitions()
makeSubSchema()
addSecurityDefinitions(oauth2SecuritySchemeDefinition)
}
}

private fun OpenAPI.makeSubSchema() {
val schemas = this.components.schemas
val subSchemas = mutableMapOf<String, Schema<Any>>()
schemas.forEach {
val schema = it.value
if (schema.properties != null) {
makeSubSchema(subSchemas, schema.properties)
}
}

if (subSchemas.isNotEmpty()) {
this.components.schemas.putAll(subSchemas)
}
}

private fun makeSubSchema(schemas: MutableMap<String, Schema<Any>>, properties: Map<String, Schema<Any>>) {
properties.asSequence().filter { it.value.title != null }.forEach {
val objectMapper = jacksonObjectMapper()
val subSchema = it.value
val strSubSchema = objectMapper.writeValueAsString(subSchema)
val copySchema = objectMapper.readValue(strSubSchema, subSchema.javaClass)
val schemaTitle = copySchema.title
subSchema.`$ref`("#/components/schemas/$schemaTitle")
schemas[schemaTitle] = copySchema
makeSubSchema(schemas, copySchema.properties)
}
}

fun generateAndSerialize(
resources: List<ResourceModel>,
servers: List<Server>,
Expand Down Expand Up @@ -132,6 +163,8 @@ object OpenApi3Generator {
schemasToKeys.getValue(it) to it
}.toMap()
}

this.components
}

private fun List<MediaType>.extractSchemas(
Expand Down Expand Up @@ -453,24 +486,28 @@ object OpenApi3Generator {
.map { it as Boolean }
.forEach { this.addEnumItem(it) }
}

SimpleType.STRING.name.toLowerCase() -> StringSchema().apply {
this._default(parameterDescriptor.defaultValue?.let { it as String })
parameterDescriptor.attributes.enumValues
.map { it as String }
.forEach { this.addEnumItem(it) }
}

SimpleType.NUMBER.name.toLowerCase() -> NumberSchema().apply {
this._default(parameterDescriptor.defaultValue?.asBigDecimal())
parameterDescriptor.attributes.enumValues
.map { it.asBigDecimal() }
.forEach { this.addEnumItem(it) }
}

SimpleType.INTEGER.name.toLowerCase() -> IntegerSchema().apply {
this._default(parameterDescriptor.defaultValue?.asInt())
parameterDescriptor.attributes.enumValues
.map { it.asInt() }
.forEach { this.addEnumItem(it) }
}

else -> throw IllegalArgumentException("Unknown type '${parameterDescriptor.type}'")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ class OpenApi3GeneratorTest {
lateinit var openApiSpecJsonString: String
lateinit var openApiJsonPathContext: DocumentContext

@Test
fun `should convert multi level schema model to openapi`() {
givenPutProductResourceModel()

whenOpenApiObjectGenerated()

val optionDTOPath = "components.schemas.OptionDTO"
then(openApiJsonPathContext.read<LinkedHashMap<String, Any>>("$optionDTOPath.properties.name")).isNotNull()
then(openApiJsonPathContext.read<LinkedHashMap<String, Any>>("$optionDTOPath.properties.id")).isNotNull()
}

@Test
fun `should convert single resource model to openapi`() {
givenGetProductResourceModel()
Expand Down Expand Up @@ -929,6 +940,21 @@ class OpenApi3GeneratorTest {
)
}

private fun givenPutProductResourceModel() {
resources = listOf(
ResourceModel(
operationId = "test",
summary = "summary",
description = "description",
privateResource = false,
deprecated = false,
tags = setOf("tag1", "tag2"),
request = getProductPutRequest(),
response = getProductPutResponse(Schema("ProductPutResponse"))
)
)
}

private fun givenGetProductResourceModel() {
resources = listOf(
ResourceModel(
Expand Down Expand Up @@ -1055,6 +1081,54 @@ class OpenApi3GeneratorTest {
)
}

private fun getProductPutResponse(schema: Schema? = null): ResponseModel {
return ResponseModel(
status = 200,
contentType = "application/json",
schema = schema,
headers = listOf(
HeaderDescriptor(
name = "SIGNATURE",
description = "This is some signature",
type = "STRING",
optional = false
)
),
responseFields = listOf(
FieldDescriptor(
path = "id",
description = "product id",
type = "STRING"
),
FieldDescriptor(
path = "option",
description = "option",
type = "OBJECT",
attributes = Attributes(schemaName = "OptionDTO")
),
FieldDescriptor(
path = "option.id",
description = "option id",
type = "STRING"
),
FieldDescriptor(
path = "option.name",
description = "option name",
type = "STRING"
),
),
example = """
{
"id": "pid12312",
"option": {
"id": "otid00001",
"name": "Option name"
}
}
""".trimIndent(),
)
}

private fun getProductHalResponse(schema: Schema? = null): ResponseModel {
return ResponseModel(
status = 200,
Expand Down Expand Up @@ -1152,6 +1226,52 @@ class OpenApi3GeneratorTest {
)
}

private fun getProductPutRequest(): RequestModel {
return RequestModel(
path = "/products/{id}",
method = HTTPMethod.PUT,
headers = listOf(),
pathParameters = listOf(),
queryParameters = listOf(),
formParameters = listOf(),
securityRequirements = null,
requestFields = listOf(
FieldDescriptor(
path = "id",
description = "product id",
type = "STRING"
),
FieldDescriptor(
path = "option",
description = "option",
type = "OBJECT",
attributes = Attributes(schemaName = "OptionDTO")
),
FieldDescriptor(
path = "option.id",
description = "option id",
type = "STRING"
),
FieldDescriptor(
path = "option.name",
description = "option name",
type = "STRING"
),
),
contentType = "application/json",
example = """
{
"id": "pid12312",
"option": {
"id": "otid00001",
"name": "Option name"
}
}
""".trimIndent(),
schema = Schema("ProductPutRequest")
)
}

private fun getProductRequestWithMultiplePathParameters(getSecurityRequirement: () -> SecurityRequirements = ::getOAuth2SecurityRequirement): RequestModel {
return RequestModel(
path = "/products/{id}-{subId}",
Expand Down

0 comments on commit 437d7da

Please sign in to comment.