From 437d7da1bceb231eaad3f32f0ec8a7d54bf4e92b Mon Sep 17 00:00:00 2001 From: "Xeroman.K" <81915068+xeromank@users.noreply.github.com> Date: Mon, 25 Sep 2023 20:51:54 +0900 Subject: [PATCH] Feat : Schema reuse through subschema (#246) * feat : Input a name for the subschema * feat : Input a name for the subschema * feat : Make sub schema * fix: lint * fix: requested & Suggested --- ...JsonSchemaFromFieldDescriptorsGenerator.kt | 2 + ...SchemaFromFieldDescriptorsGeneratorTest.kt | 33 +++++ .../restdocs/apispec/model/ResourceModel.kt | 3 +- .../apispec/openapi3/OpenApi3Generator.kt | 37 ++++++ .../apispec/openapi3/OpenApi3GeneratorTest.kt | 120 ++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) 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 d9b11154..56f7adb6 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 @@ -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 ) ) 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 dddf15e7..dee7210f 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 @@ -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() { @@ -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)) } diff --git a/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt index 50580667..0cbb6aba 100644 --- a/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt +++ b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt @@ -91,7 +91,8 @@ open class FieldDescriptor( data class Attributes( val validationConstraints: List = emptyList(), val enumValues: List = emptyList(), - val itemsType: String? = null + val itemsType: String? = null, + val schemaName: String? = null, ) data class Constraint( 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 3481d151..00b46cda 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 @@ -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 @@ -76,11 +77,41 @@ object OpenApi3Generator { resources, oauth2SecuritySchemeDefinition ) + extractDefinitions() + makeSubSchema() addSecurityDefinitions(oauth2SecuritySchemeDefinition) } } + private fun OpenAPI.makeSubSchema() { + val schemas = this.components.schemas + val subSchemas = mutableMapOf>() + 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>, properties: Map>) { + 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, servers: List, @@ -132,6 +163,8 @@ object OpenApi3Generator { schemasToKeys.getValue(it) to it }.toMap() } + + this.components } private fun List.extractSchemas( @@ -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}'") } } 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 dd3d83fe..a87b115d 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 @@ -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>("$optionDTOPath.properties.name")).isNotNull() + then(openApiJsonPathContext.read>("$optionDTOPath.properties.id")).isNotNull() + } + @Test fun `should convert single resource model to openapi`() { givenGetProductResourceModel() @@ -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( @@ -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, @@ -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}",