Skip to content

Commit

Permalink
Feature/78 control schema name (#126)
Browse files Browse the repository at this point in the history
* Add ability to set request / response schema name

(cherry picked from commit 9880dd1)

* Validate schemaName in ResourceSnippetTest.

* Introduce dedicated data structure for schema properties.

* Add support for custom schema names in OpenApi3Generator

* Add test which ensures that different schema names result in different schemas in OpenApi3Generator.

* Simplified expression for schema generation in OpenApi3Generator.

* Add support for custom schema names in OpenApi20Generator.

* Moved schema to ResourceSnippetParameters.

* Add @JvmStatic to Schema companion object.

* Removed unnecessary semicolons

* Fixed wildcard imports

* Fixed further lint errors.

Co-authored-by: Oleksandr Abasov <[email protected]>
  • Loading branch information
Thomas Wimmer and oleksandr-abasov authored Feb 6, 2020
1 parent d27eed2 commit 145ab87
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ fun List<ResourceModel>.groupByPath(): Map<String, List<ResourceModel>> {
.groupBy { it.request.path }
}

data class Schema(
val name: String
)

data class RequestModel(
val path: String,
val method: HTTPMethod,
Expand All @@ -34,7 +38,7 @@ data class RequestModel(
val requestParameters: List<ParameterDescriptor>,
val requestFields: List<FieldDescriptor>,
val example: String? = null,
val schema: String? = null
val schema: Schema? = null
)

data class ResponseModel(
Expand All @@ -43,7 +47,7 @@ data class ResponseModel(
val headers: List<HeaderDescriptor>,
val responseFields: List<FieldDescriptor>,
val example: String? = null,
val schema: String? = null
val schema: Schema? = null
)

enum class SimpleType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.epages.restdocs.apispec.model.ResourceModel
import com.epages.restdocs.apispec.model.ResponseModel
import com.epages.restdocs.apispec.model.SecurityRequirements
import com.epages.restdocs.apispec.model.SecurityType
import com.epages.restdocs.apispec.model.Schema
import com.fasterxml.jackson.module.kotlin.readValue
import io.swagger.models.Info
import io.swagger.models.Model
Expand Down Expand Up @@ -153,7 +154,7 @@ object OpenApi20Generator {
val schemaKey = if (schemasToKeys.containsKey(schema)) {
schemasToKeys[schema]!!
} else {
val name = schemaNameGenerator(schema)
val name = schema.reference ?: schemaNameGenerator(schema)
schemasToKeys[schema] = name
name
}
Expand Down Expand Up @@ -293,7 +294,8 @@ object OpenApi20Generator {
modelsWithSamePathAndMethod
.filter { it.request.contentType != null && it.request.example != null }
.map { it.request.contentType!! to it.request.example!! }
.toMap())
.toMap(),
firstModelForPathAndMethod.request.schema)
)
).nullIfEmpty()
responses = responsesByStatusCode(
Expand Down Expand Up @@ -428,11 +430,12 @@ object OpenApi20Generator {
}
}

private fun requestFieldDescriptor2Parameter(fieldDescriptors: List<FieldDescriptor>, examples: Map<String, String>): BodyParameter? {
private fun requestFieldDescriptor2Parameter(fieldDescriptors: List<FieldDescriptor>, examples: Map<String, String>, requestSchema: Schema?): BodyParameter? {
val firstExample = examples.entries.sortedBy { it.key.length }.map { it.value }.firstOrNull()
return if (!fieldDescriptors.isEmpty()) {
val parsedSchema: Model = Json.mapper().readValue(JsonSchemaFromFieldDescriptorsGenerator().generateSchema(fieldDescriptors = fieldDescriptors))
parsedSchema.example = firstExample // a schema can only have one example
parsedSchema.reference = requestSchema?.name
BodyParameter().apply {
name = ""
schema = parsedSchema
Expand All @@ -458,8 +461,9 @@ object OpenApi20Generator {
.nullIfEmpty()
examples = mapOf(responseModel.contentType to responseModel.example).nullIfEmpty()
responseSchema = if (!responseModel.responseFields.isEmpty()) {
Json.mapper().readValue<Model>(
JsonSchemaFromFieldDescriptorsGenerator().generateSchema(fieldDescriptors = responseModel.responseFields))
val parsedSchema: Model = Json.mapper().readValue(JsonSchemaFromFieldDescriptorsGenerator().generateSchema(fieldDescriptors = responseModel.responseFields))
parsedSchema.reference = responseModel.schema?.name
parsedSchema
} else {
null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.epages.restdocs.apispec.model.RequestModel
import com.epages.restdocs.apispec.model.ResourceModel
import com.epages.restdocs.apispec.model.ResponseModel
import com.epages.restdocs.apispec.model.SecurityRequirements
import com.epages.restdocs.apispec.model.Schema
import com.epages.restdocs.apispec.model.SecurityType.BASIC
import com.epages.restdocs.apispec.model.SecurityType.OAUTH2
import com.fasterxml.jackson.module.kotlin.readValue
Expand Down Expand Up @@ -188,6 +189,26 @@ class OpenApi20GeneratorTest {
then(schemaNameAndSchemaMap.size).isEqualTo(2)
}

@Test
fun `should use custom schema name from resource model`() {
val api = givenPostProductResourceModelWithCustomSchemaNames()

val openapi = whenOpenApiObjectGenerated(api)

thenCustomSchemaNameOfSingleOperationAreSet(openapi)
thenValidateOpenApi(openapi)
}

@Test
fun `should not combine same schemas with custom schema name from multiple resource models`() {
val api = givenMultiplePostProductResourceModelsWithCustomSchemaNames()

val openapi = whenOpenApiObjectGenerated(api)

thenCustomSchemaNameOfMultipleOperationsAreSet(openapi)
thenValidateOpenApi(openapi)
}

private fun whenExtractOrFindSchema(schemaNameAndSchemaMap: MutableMap<Model, String>, ordersSchema: Model, shopsSchema: Model) {
OpenApi20Generator.extractOrFindSchema(schemaNameAndSchemaMap, ordersSchema, OpenApi20Generator.generateSchemaName("/orders"))
OpenApi20Generator.extractOrFindSchema(schemaNameAndSchemaMap, shopsSchema, OpenApi20Generator.generateSchemaName("/shops"))
Expand Down Expand Up @@ -357,6 +378,20 @@ class OpenApi20GeneratorTest {
).isEqualTo(successfulDeleteProductModel.response.example)
}

private fun thenCustomSchemaNameOfSingleOperationAreSet(openapi: Swagger) {
then(openapi.definitions.keys).size().isEqualTo(2)
then(openapi.definitions.keys).contains("ProductRequest")
then(openapi.definitions.keys).contains("ProductResponse")
}

private fun thenCustomSchemaNameOfMultipleOperationsAreSet(openapi: Swagger) {
then(openapi.definitions.keys).size().isEqualTo(4)
then(openapi.definitions.keys).contains("ProductRequest1")
then(openapi.definitions.keys).contains("ProductResponse1")
then(openapi.definitions.keys).contains("ProductRequest2")
then(openapi.definitions.keys).contains("ProductResponse2")
}

private fun givenGetProductResourceModel(): List<ResourceModel> {
return listOf(
ResourceModel(
Expand Down Expand Up @@ -452,6 +487,37 @@ class OpenApi20GeneratorTest {
)
}

private fun givenPostProductResourceModelWithCustomSchemaNames(): List<ResourceModel> {
return listOf(
ResourceModel(
operationId = "test",
privateResource = false,
deprecated = false,
request = postProductRequest(schema = Schema("ProductRequest")),
response = postProduct200Response(getProductPayloadExample(), schema = Schema("ProductResponse"))
)
)
}

private fun givenMultiplePostProductResourceModelsWithCustomSchemaNames(): List<ResourceModel> {
return listOf(
ResourceModel(
operationId = "test1",
privateResource = false,
deprecated = false,
request = postProductRequest(schema = Schema("ProductRequest1"), path = "/products1"),
response = postProduct200Response(getProductPayloadExample(), schema = Schema("ProductResponse1"))
),
ResourceModel(
operationId = "test2",
privateResource = false,
deprecated = false,
request = postProductRequest(schema = Schema("ProductRequest2"), path = "/products2"),
response = postProduct200Response(getProductPayloadExample(), schema = Schema("ProductResponse2"))
)
)
}

private fun givenHeadResourceModel(): List<ResourceModel> {
return listOf(
ResourceModel(
Expand Down Expand Up @@ -521,11 +587,12 @@ class OpenApi20GeneratorTest {
)
}

private fun postProduct200Response(example: String): ResponseModel {
private fun postProduct200Response(example: String, schema: Schema? = null): ResponseModel {
return ResponseModel(
status = 200,
contentType = "application/json",
headers = listOf(),
schema = schema,
responseFields = listOf(
FieldDescriptor(
path = "_id",
Expand Down Expand Up @@ -556,6 +623,7 @@ class OpenApi20GeneratorTest {
return ResponseModel(
status = 200,
contentType = "application/json",
schema = Schema("ProductResponse"),
headers = listOf(
HeaderDescriptor(
name = "SIGNATURE",
Expand Down Expand Up @@ -695,11 +763,12 @@ class OpenApi20GeneratorTest {
)
}

private fun postProductRequest(): RequestModel {
private fun postProductRequest(schema: Schema? = null, path: String = "/products"): RequestModel {
return RequestModel(
path = "/products",
path = path,
method = HTTPMethod.POST,
contentType = "application/json",
schema = schema,
securityRequirements = SecurityRequirements(
type = OAUTH2,
requiredScopes = listOf("prod:c")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ object OpenApi3Generator {
val schemaKey = if (schemasToKeys.containsKey(schema)) {
schemasToKeys[schema]!!
} else {
val name = schemaNameGenerator(schema)
val name = schema.name ?: schemaNameGenerator(schema)
schemasToKeys[schema] = name
name
}
Expand Down Expand Up @@ -263,7 +263,8 @@ object OpenApi3Generator {
toMediaType(
requestFields = requests.flatMap { it.request.requestFields },
examplesWithOperationId = requests.filter { it.request.example != null }.map { it.operationId to it.request.example!! }.toMap(),
contentType = contentType
contentType = contentType,
schemaName = requests.first().request.schema?.name
)
}.toMap()
.let { contentTypeToMediaType ->
Expand Down Expand Up @@ -312,7 +313,8 @@ object OpenApi3Generator {
toMediaType(
requestFields = requests.flatMap { it.response.responseFields },
examplesWithOperationId = requests.map { it.operationId to it.response.example!! }.toMap(),
contentType = contentType
contentType = contentType,
schemaName = responseModelsSameStatus.first().response.schema?.name
)
}.toMap()
.let { contentTypeToMediaType ->
Expand All @@ -328,10 +330,14 @@ object OpenApi3Generator {
private fun toMediaType(
requestFields: List<FieldDescriptor>,
examplesWithOperationId: Map<String, String>,
contentType: String
contentType: String,
schemaName: String? = null
): Pair<String, MediaType> {
val schema = JsonSchemaFromFieldDescriptorsGenerator().generateSchema(requestFields)
val schema = JsonSchemaFromFieldDescriptorsGenerator().generateSchema(requestFields, schemaName)
.let { Json.mapper().readValue<Schema<Any>>(it) }

if (schemaName != null) schema.name = schemaName

return contentType to MediaType()
.schema(schema)
.examples(examplesWithOperationId.map { it.key to Example().apply { value(it.value) } }.toMap().nullIfEmpty())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.epages.restdocs.apispec.model.ResourceModel
import com.epages.restdocs.apispec.model.ResponseModel
import com.epages.restdocs.apispec.model.SecurityRequirements
import com.epages.restdocs.apispec.model.SecurityType
import com.epages.restdocs.apispec.model.Schema
import com.jayway.jsonpath.Configuration
import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
Expand Down Expand Up @@ -145,6 +146,26 @@ class OpenApi3GeneratorTest {
thenOpenApiSpecIsValid()
}

@Test
fun `should use custom schema name from resource model`() {
givenPatchProductResourceModelWithCustomSchemaNames()

whenOpenApiObjectGenerated()

thenCustomSchemaNameOfSingleOperationAreSet()
thenOpenApiSpecIsValid()
}

@Test
fun `should not combine same schemas with custom schema name from multiple resource models`() {
givenMultiplePatchProductResourceModelsWithCustomSchemaNames()

whenOpenApiObjectGenerated()

thenCustomSchemaNameOfMultipleOperationsAreSet()
thenOpenApiSpecIsValid()
}

fun thenGetProductByIdOperationIsValid() {
val productGetByIdPath = "paths./products/{id}.get"
then(openApiJsonPathContext.read<List<String>>("$productGetByIdPath.tags")).isNotNull()
Expand Down Expand Up @@ -207,6 +228,22 @@ class OpenApi3GeneratorTest {
then(openApiJsonPathContext.read<String>("components.securitySchemes.bearerAuthJWT.bearerFormat")).isEqualTo("JWT")
}

private fun thenCustomSchemaNameOfSingleOperationAreSet() {
val schemas = openApiJsonPathContext.read<Map<String, Any>>("components.schemas")
then(schemas.keys).size().isEqualTo(2)
then(schemas.keys).contains("ProductRequest")
then(schemas.keys).contains("ProductResponse")
}

private fun thenCustomSchemaNameOfMultipleOperationsAreSet() {
val schemas = openApiJsonPathContext.read<Map<String, Any>>("components.schemas")
then(schemas.keys).size().isEqualTo(4)
then(schemas.keys).contains("ProductRequest1")
then(schemas.keys).contains("ProductResponse1")
then(schemas.keys).contains("ProductRequest2")
then(schemas.keys).contains("ProductResponse2")
}

private fun whenOpenApiObjectGenerated() {
openApiSpecJsonString = OpenApi3Generator.generateAndSerialize(
resources = resources,
Expand Down Expand Up @@ -442,6 +479,46 @@ class OpenApi3GeneratorTest {
)
}

private fun givenPatchProductResourceModelWithCustomSchemaNames() {
resources = listOf(
ResourceModel(
operationId = "test",
summary = "summary",
description = "description",
privateResource = false,
deprecated = false,
tags = setOf("tag1", "tag2"),
request = getProductPatchRequest(schema = Schema("ProductRequest")),
response = getProductResponse(schema = Schema("ProductResponse"))
)
)
}

private fun givenMultiplePatchProductResourceModelsWithCustomSchemaNames() {
resources = listOf(
ResourceModel(
operationId = "test1",
summary = "summary1",
description = "description1",
privateResource = false,
deprecated = false,
tags = setOf("tag1", "tag2"),
request = getProductPatchRequest(schema = Schema("ProductRequest1"), path = "/products1/{id}"),
response = getProductResponse(schema = Schema("ProductResponse1"))
),
ResourceModel(
operationId = "test2",
summary = "summary2",
description = "description2",
privateResource = false,
deprecated = false,
tags = setOf("tag1", "tag2"),
request = getProductPatchRequest(schema = Schema("ProductRequest2"), path = "/products2/{id}"),
response = getProductResponse(schema = Schema("ProductResponse2"))
)
)
}

private fun getProductErrorResponse(): ResponseModel {
return ResponseModel(
status = 400,
Expand All @@ -460,10 +537,11 @@ class OpenApi3GeneratorTest {
)
}

private fun getProductResponse(): ResponseModel {
private fun getProductResponse(schema: Schema? = null): ResponseModel {
return ResponseModel(
status = 200,
contentType = "application/json",
schema = schema,
headers = listOf(
HeaderDescriptor(
name = "SIGNATURE",
Expand Down Expand Up @@ -518,13 +596,14 @@ class OpenApi3GeneratorTest {
)
}

private fun getProductPatchRequest(): RequestModel {
private fun getProductPatchRequest(schema: Schema? = null, path: String = "/products/{id}"): RequestModel {
return RequestModel(
path = "/products/{id}",
path = path,
method = HTTPMethod.PATCH,
headers = listOf(),
pathParameters = listOf(),
requestParameters = listOf(),
schema = schema,
securityRequirements = null,
requestFields = listOf(
FieldDescriptor(
Expand Down
Loading

0 comments on commit 145ab87

Please sign in to comment.