All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.epages.restdocs.apispec.openapi3.OpenApi3Generator.kt Maven / Gradle / Ivy

package com.epages.restdocs.apispec.openapi3

import com.epages.restdocs.apispec.jsonschema.JsonSchemaFromFieldDescriptorsGenerator
import com.epages.restdocs.apispec.model.AbstractParameterDescriptor
import com.epages.restdocs.apispec.model.FieldDescriptor
import com.epages.restdocs.apispec.model.HTTPMethod
import com.epages.restdocs.apispec.model.HeaderDescriptor
import com.epages.restdocs.apispec.model.Oauth2Configuration
import com.epages.restdocs.apispec.model.ParameterDescriptor
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.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.readValue
import io.swagger.v3.core.util.Json
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.Operation
import io.swagger.v3.oas.models.PathItem
import io.swagger.v3.oas.models.Paths
import io.swagger.v3.oas.models.examples.Example
import io.swagger.v3.oas.models.headers.Header
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.media.BooleanSchema
import io.swagger.v3.oas.models.media.Content
import io.swagger.v3.oas.models.media.IntegerSchema
import io.swagger.v3.oas.models.media.MediaType
import io.swagger.v3.oas.models.media.NumberSchema
import io.swagger.v3.oas.models.media.Schema
import io.swagger.v3.oas.models.media.StringSchema
import io.swagger.v3.oas.models.parameters.HeaderParameter
import io.swagger.v3.oas.models.parameters.PathParameter
import io.swagger.v3.oas.models.parameters.QueryParameter
import io.swagger.v3.oas.models.parameters.RequestBody
import io.swagger.v3.oas.models.responses.ApiResponse
import io.swagger.v3.oas.models.responses.ApiResponses
import io.swagger.v3.oas.models.servers.Server
import io.swagger.v3.oas.models.tags.Tag
import java.math.BigDecimal

object OpenApi3Generator {

    private val PATH_PARAMETER_PATTERN = """\{([^/}]+)}""".toRegex()
    internal fun generate(
        resources: List,
        servers: List,
        title: String = "API",
        description: String? = null,
        tagDescriptions: Map = emptyMap(),
        version: String = "1.0.0",
        oauth2SecuritySchemeDefinition: Oauth2Configuration? = null
    ): OpenAPI {
        return OpenAPI().apply {

            this.servers = servers
            info = Info().apply {
                this.title = title
                this.description = description
                this.version = version
            }
            this.tags(
                tagDescriptions.map {
                    Tag().apply {
                        this.name = it.key
                        this.description = it.value
                    }
                }
            )
            paths = generatePaths(
                resources,
                oauth2SecuritySchemeDefinition
            )
            extractDefinitions()
            addSecurityDefinitions(oauth2SecuritySchemeDefinition)
        }
    }

    fun generateAndSerialize(
        resources: List,
        servers: List,
        title: String = "API",
        description: String? = null,
        tagDescriptions: Map = emptyMap(),
        version: String = "1.0.0",
        oauth2SecuritySchemeDefinition: Oauth2Configuration? = null,
        format: String
    ) =
        ApiSpecificationWriter.serialize(
            format,
            generate(
                resources = resources,
                servers = servers,
                title = title,
                description = description,
                tagDescriptions = tagDescriptions,
                version = version,
                oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition
            )
        )

    private fun OpenAPI.extractDefinitions() {
        val schemasToKeys = HashMap, String>()
        val operationToPathKey = HashMap()

        paths.map { it.key to it.value.readOperations() }
            .forEach { (path, operations) ->
                operations.forEach { operation ->
                    operationToPathKey[operation] = path
                }
            }

        operationToPathKey.keys.forEach { operation ->
            val path = operationToPathKey[operation]!!

            operation.requestBody?.content?.mapNotNull { it.value }
                ?.extractSchemas(schemasToKeys, path)

            operation.responses.values.mapNotNull { it.content }.flatMap { it.values }
                .extractSchemas(schemasToKeys, path)
        }

        this.components = Components().apply {
            schemas = schemasToKeys.keys.map {
                schemasToKeys.getValue(it) to it
            }.toMap()
        }
    }

    private fun List.extractSchemas(
        schemasToKeys: MutableMap, String>,
        path: String
    ) {
        this.filter { it.schema != null }
            .forEach {
                it.schema(
                    extractOrFindSchema(
                        schemasToKeys,
                        it.schema,
                        generateSchemaName(path)
                    )
                )
            }
    }

    private fun extractOrFindSchema(schemasToKeys: MutableMap, String>, schema: Schema, schemaNameGenerator: (Schema) -> String): Schema {
        val schemaKey = if (schemasToKeys.containsKey(schema)) {
            schemasToKeys[schema]!!
        } else {
            val name = schema.name ?: schemaNameGenerator(schema)
            schemasToKeys[schema] = name
            name
        }
        return Schema().apply { `$ref`("#/components/schemas/$schemaKey") }
    }

    private fun generateSchemaName(path: String): (Schema) -> String {
        return { schema ->
            path
                .removePrefix("/")
                .replace("/", "-")
                .replace(Regex.fromLiteral("{"), "")
                .replace(Regex.fromLiteral("}"), "")
                .plus(schema.hashCode())
        }
    }

    private fun generatePaths(
        resources: List,
        oauth2SecuritySchemeDefinition: Oauth2Configuration?
    ): Paths {
        return resources.groupByPath().entries
            .map {
                it.key to resourceModels2PathItem(
                    it.value,
                    oauth2SecuritySchemeDefinition
                )
            }
            .let { pathAndPathItem ->
                Paths().apply { pathAndPathItem.forEach { addPathItem(it.first, it.second) } }
            }
    }

    private fun groupByHttpMethod(resources: List): Map> {
        return resources.groupBy { it.request.method }
    }

    private fun resourceModels2PathItem(
        modelsWithSamePath: List,
        oauth2SecuritySchemeDefinition: Oauth2Configuration?
    ): PathItem {
        val path = PathItem()
        groupByHttpMethod(modelsWithSamePath)
            .entries
            .forEach {
                addOperation(
                    method = it.key,
                    pathItem = path,
                    operation = resourceModels2Operation(
                        it.value,
                        oauth2SecuritySchemeDefinition
                    )
                )
            }

        return path
    }

    private fun addOperation(method: HTTPMethod, pathItem: PathItem, operation: Operation) =
        when (method) {
            HTTPMethod.GET -> pathItem.get(operation)
            HTTPMethod.POST -> pathItem.post(operation)
            HTTPMethod.PUT -> pathItem.put(operation)
            HTTPMethod.DELETE -> pathItem.delete(operation)
            HTTPMethod.PATCH -> pathItem.patch(operation)
            HTTPMethod.HEAD -> pathItem.head(operation)
            HTTPMethod.OPTIONS -> pathItem.options(operation)
        }

    private fun resourceModels2Operation(
        modelsWithSamePathAndMethod: List,
        oauth2SecuritySchemeDefinition: Oauth2Configuration?
    ): Operation {
        val firstModelForPathAndMethod = modelsWithSamePathAndMethod.first()
        val operationIds = modelsWithSamePathAndMethod.map { model -> model.operationId }
        return Operation().apply {
            operationId = operationId(operationIds)
            summary = modelsWithSamePathAndMethod.map { it.summary }.find { !it.isNullOrBlank() }
            description = modelsWithSamePathAndMethod.map { it.description }.find { !it.isNullOrBlank() }
            tags = modelsWithSamePathAndMethod.flatMap { it.tags }.distinct().nullIfEmpty()
            deprecated = if (modelsWithSamePathAndMethod.all { it.deprecated }) true else null
            parameters =
                extractPathParameters(
                    firstModelForPathAndMethod
                ).plus(
                    modelsWithSamePathAndMethod
                        .filter { it.request.contentType != "application/x-www-form-urlencoded" }
                        .flatMap { it.request.requestParameters }
                        .distinctBy { it.name }
                        .map { requestParameterDescriptor2Parameter(it) }
                ).plus(
                    modelsWithSamePathAndMethod
                        .flatMap { it.request.headers }
                        .distinctBy { it.name }
                        .map { header2Parameter(it) }
                ).nullIfEmpty()
            requestBody = resourceModelsToRequestBody(
                modelsWithSamePathAndMethod.map {
                    RequestModelWithOperationId(
                        it.operationId,
                        it.request
                    )
                }
            )
            responses = resourceModelsToApiResponses(
                modelsWithSamePathAndMethod.map {
                    ResponseModelWithOperationId(
                        it.operationId,
                        it.response
                    )
                }
            )
        }.apply { addSecurityItemFromSecurityRequirements(firstModelForPathAndMethod.request.securityRequirements) }
    }

    private fun operationId(operationIds: List): String {
        var prefix = operationIds.first()
        for (operationId in operationIds) {
            prefix = prefix.commonPrefixWith(operationId)
        }

        if (prefix.isEmpty()) {
            prefix = operationIds.sorted().joinToString(separator = "")
        }

        return prefix
    }

    private fun resourceModelsToRequestBody(requestModelsWithOperationId: List): RequestBody? {
        val requestByContentType = requestModelsWithOperationId
            .filter { it.request.contentType != null }
            .groupBy { it.request.contentType!! }

        if (requestByContentType.isEmpty())
            return null

        return requestByContentType
            .map { (contentType, requests) ->
                toMediaType(
                    requestFields = requests.flatMap { it ->
                        if (it.request.contentType == "application/x-www-form-urlencoded") {
                            it.request.requestParameters.map { parameterDescriptor2FieldDescriptor(it) }
                        } else {
                            it.request.requestFields
                        }
                    },
                    examplesWithOperationId = requests.filter { it.request.example != null }.map { it.operationId to it.request.example!! }.toMap(),
                    contentType = contentType,
                    schemaName = requests.first().request.schema?.name
                )
            }.toMap()
            .let { contentTypeToMediaType ->
                if (contentTypeToMediaType.isEmpty()) null
                else RequestBody()
                    .apply {
                        content = Content().apply { contentTypeToMediaType.forEach { addMediaType(it.key, it.value) } }
                    }
            }
    }

    private fun resourceModelsToApiResponses(responseModelsWithOperationId: List): ApiResponses? {
        val responsesByStatus = responseModelsWithOperationId
            .groupBy { it.response.status }

        if (responsesByStatus.isEmpty())
            return null

        return responsesByStatus
            .mapValues { (_, responses) ->
                responsesWithSameStatusToApiResponse(
                    responses
                )
            }
            .let {
                ApiResponses().apply {
                    it.forEach { (status, apiResponse) -> addApiResponse(status.toString(), apiResponse) }
                }
            }
    }

    private fun responsesWithSameStatusToApiResponse(responseModelsSameStatus: List): ApiResponse {
        val responsesByContentType = responseModelsSameStatus
            .filter { it.response.contentType != null }
            .groupBy { it.response.contentType!! }

        val apiResponse = ApiResponse().apply {
            description = responseModelsSameStatus.first().response.status.toString()
            headers = responseModelsSameStatus.flatMap { it.response.headers }
                .map {
                    it.name to Header().apply {
                        description(it.description)
                        schema = simpleTypeToSchema(it)
                    }
                }.toMap().nullIfEmpty()
        }
        return responsesByContentType
            .map { (contentType, requests) ->
                toMediaType(
                    requestFields = requests.flatMap { it.response.responseFields },
                    examplesWithOperationId = requests.map { it.operationId to it.response.example!! }.toMap(),
                    contentType = contentType,
                    schemaName = requests.first().response.schema?.name
                )
            }.toMap()
            .let { contentTypeToMediaType ->
                apiResponse
                    .apply {
                        content =
                            if (contentTypeToMediaType.isEmpty()) null
                            else Content().apply { contentTypeToMediaType.forEach { addMediaType(it.key, it.value) } }
                    }
            }
    }

    private fun toMediaType(
        requestFields: List,
        examplesWithOperationId: Map,
        contentType: String,
        schemaName: String? = null
    ): Pair {
        val schema = JsonSchemaFromFieldDescriptorsGenerator().generateSchema(requestFields, schemaName)
            .let { Json.mapper().readValue>(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())
    }

    private fun extractPathParameters(resourceModel: ResourceModel): List {
        val pathParameterNames = PATH_PARAMETER_PATTERN.findAll(resourceModel.request.path)
            .map { matchResult -> matchResult.groupValues[1] }
            .toList()

        return pathParameterNames.map { parameterName ->
            resourceModel.request.pathParameters
                .firstOrNull { it.name == parameterName }
                ?.let { pathParameterDescriptor2Parameter(it) }
                ?: parameterName2PathParameter(parameterName)
        }
    }

    private fun parameterDescriptor2FieldDescriptor(parameterDescriptor: ParameterDescriptor): FieldDescriptor {
        return FieldDescriptor(
            // It's safe to map name to path, as in application/x-www-form-urlencoded
            // we should have a flat structure.
            path = parameterDescriptor.name,
            description = parameterDescriptor.description,
            type = parameterDescriptor.type,
            optional = parameterDescriptor.optional,
            ignored = parameterDescriptor.ignored,
            attributes = parameterDescriptor.attributes
        )
    }

    private fun pathParameterDescriptor2Parameter(parameterDescriptor: ParameterDescriptor): PathParameter {
        return PathParameter().apply {
            name = parameterDescriptor.name
            description = parameterDescriptor.description
            schema = simpleTypeToSchema(parameterDescriptor)
        }
    }

    private fun parameterName2PathParameter(parameterName: String): PathParameter {
        return PathParameter().apply {
            name = parameterName
            description = ""
            schema = StringSchema()
        }
    }

    private fun requestParameterDescriptor2Parameter(parameterDescriptor: ParameterDescriptor): QueryParameter {
        return QueryParameter().apply {
            name = parameterDescriptor.name
            description = parameterDescriptor.description
            required = parameterDescriptor.optional.not()
            schema = simpleTypeToSchema(parameterDescriptor)
        }
    }

    private fun header2Parameter(headerDescriptor: HeaderDescriptor): HeaderParameter {
        return HeaderParameter().apply {
            name = headerDescriptor.name
            description = headerDescriptor.description
            required = headerDescriptor.optional.not()
            schema = simpleTypeToSchema(headerDescriptor)
            example = headerDescriptor.example
        }
    }

    private fun simpleTypeToSchema(parameterDescriptor: AbstractParameterDescriptor): Schema<*>? {
        return when (parameterDescriptor.type.toLowerCase()) {
            SimpleType.BOOLEAN.name.toLowerCase() -> BooleanSchema().apply {
                this._default(parameterDescriptor.defaultValue?.let { it as Boolean })
                parameterDescriptor.attributes.enumValues
                    .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}'")
        }
    }

    private fun  Map.nullIfEmpty(): Map? {
        return if (this.isEmpty()) null else this
    }

    private fun  List.nullIfEmpty(): List? {
        return if (this.isEmpty()) null else this
    }

    private fun Any.asInt(): Int {
        return when (this) {
            is Int -> this
            is Long -> toInt()
            else -> this as Int
        }
    }

    private fun Any.asBigDecimal(): BigDecimal {
        return when (this) {
            is Int -> toBigDecimal()
            is Long -> toBigDecimal()
            is Double -> toBigDecimal()
            is Float -> toBigDecimal()
            else -> this as BigDecimal
        }
    }

    private data class RequestModelWithOperationId(
        val operationId: String,
        val request: RequestModel
    )

    private data class ResponseModelWithOperationId(
        val operationId: String,
        val response: ResponseModel
    )
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy