|
| 1 | +package klite.openapi |
| 2 | + |
| 3 | +import io.swagger.v3.oas.annotations.Hidden |
| 4 | +import io.swagger.v3.oas.annotations.OpenAPIDefinition |
| 5 | +import io.swagger.v3.oas.annotations.Operation |
| 6 | +import io.swagger.v3.oas.annotations.Parameter |
| 7 | +import io.swagger.v3.oas.annotations.enums.ParameterIn |
| 8 | +import io.swagger.v3.oas.annotations.info.Info |
| 9 | +import io.swagger.v3.oas.annotations.media.Schema.* |
| 10 | +import io.swagger.v3.oas.annotations.parameters.RequestBody |
| 11 | +import io.swagger.v3.oas.annotations.responses.ApiResponse |
| 12 | +import io.swagger.v3.oas.annotations.security.SecurityRequirement |
| 13 | +import io.swagger.v3.oas.annotations.security.SecurityScheme |
| 14 | +import io.swagger.v3.oas.annotations.tags.Tag |
| 15 | +import klite.* |
| 16 | +import klite.StatusCode.Companion.NoContent |
| 17 | +import klite.StatusCode.Companion.OK |
| 18 | +import klite.annotations.* |
| 19 | +import java.net.URI |
| 20 | +import java.net.URL |
| 21 | +import java.time.* |
| 22 | +import java.util.* |
| 23 | +import kotlin.reflect.KClass |
| 24 | +import kotlin.reflect.KParameter.Kind.INSTANCE |
| 25 | +import kotlin.reflect.KProperty1 |
| 26 | +import kotlin.reflect.KType |
| 27 | +import kotlin.reflect.KTypeProjection |
| 28 | +import kotlin.reflect.full.* |
| 29 | + |
| 30 | +context(HttpExchange) |
| 31 | +internal fun Router.generateOpenAPI() = mapOf( |
| 32 | + "openapi" to "3.1.0", |
| 33 | + "info" to route.findAnnotation<Info>()?.toNonEmptyValues(), |
| 34 | + "servers" to listOf(mapOf("url" to fullUrl(prefix))), |
| 35 | + "tags" to toTags(routes), |
| 36 | + "components" to mapOfNotNull( |
| 37 | + "securitySchemes" to route.findAnnotations<SecurityScheme>().associate { s -> |
| 38 | + s.name to s.toNonEmptyValues { it.name != "paramName" }.let { it + ("name" to s.paramName) } |
| 39 | + }.takeIf { it.isNotEmpty() } |
| 40 | + ).takeIf { it.isNotEmpty() }, |
| 41 | + "paths" to routes.filter { !it.hasAnnotation<Hidden>() }.groupBy { pathParamRegexer.toOpenApi(it.path) }.mapValues { (_, routes) -> |
| 42 | + routes.associate(::toOperation) |
| 43 | + }, |
| 44 | +) + (route.findAnnotation<OpenAPIDefinition>()?.let { |
| 45 | + it.toNonEmptyValues() + ("security" to it.security.toList().toSecurity()) |
| 46 | +} ?: emptyMap()) |
| 47 | + |
| 48 | +internal fun toTags(routes: List<Route>) = routes.asSequence() |
| 49 | + .map { it.handler } |
| 50 | + .filterIsInstance<FunHandler>() |
| 51 | + .flatMap { it.instance::class.findAnnotations<Tag>().map { it.toNonEmptyValues() }.ifEmpty { listOf(mapOf("name" to it.instance::class.simpleName)) } } |
| 52 | + .toSet() |
| 53 | + |
| 54 | +internal fun toOperation(route: Route): Pair<String, Any> { |
| 55 | + val op = route.findAnnotation<Operation>() |
| 56 | + val funHandler = route.handler as? FunHandler |
| 57 | + return (op?.method?.trimToNull() ?: route.method.name).lowercase() to mapOf( |
| 58 | + "operationId" to route.handler.let { (if (it is FunHandler) it.instance::class.simpleName + "." + it.f.name else it::class.simpleName) }, |
| 59 | + "tags" to listOfNotNull(funHandler?.let { it.instance::class.annotation<Tag>()?.name ?: it.instance::class.simpleName }), |
| 60 | + "parameters" to funHandler?.let { |
| 61 | + it.params.filter { it.source != null }.map { p -> toParameter(p, op) } |
| 62 | + }, |
| 63 | + "requestBody" to toRequestBody(route, route.findAnnotation<RequestBody>() ?: op?.requestBody), |
| 64 | + "responses" to toResponsesByCode(route, op, funHandler?.f?.returnType), |
| 65 | + "security" to (op?.security?.toList() ?: route.findAnnotations<SecurityRequirement>()).toSecurity() |
| 66 | + ) + (op?.let { it.toNonEmptyValues { it.name !in setOf("method", "requestBody", "responses") } } ?: emptyMap()) |
| 67 | +} |
| 68 | + |
| 69 | +fun toParameter(p: Param, op: Operation? = null) = mapOf( |
| 70 | + "name" to p.name, |
| 71 | + "required" to (!p.p.isOptional && !p.p.type.isMarkedNullable), |
| 72 | + "in" to toParameterIn(p.source), |
| 73 | + "schema" to p.p.type.toJsonSchema(), |
| 74 | +) + ((p.p.findAnnotation<Parameter>() ?: op?.parameters?.find { it.name == p.name })?.toNonEmptyValues() ?: emptyMap()) |
| 75 | + |
| 76 | +private fun toParameterIn(paramAnnotation: Annotation?) = when(paramAnnotation) { |
| 77 | + is HeaderParam -> ParameterIn.HEADER |
| 78 | + is QueryParam -> ParameterIn.QUERY |
| 79 | + is PathParam -> ParameterIn.PATH |
| 80 | + is CookieParam -> ParameterIn.COOKIE |
| 81 | + else -> null |
| 82 | +} |
| 83 | + |
| 84 | +private fun KType.toJsonSchema(response: Boolean = false): Map<String, Any?>? { |
| 85 | + val cls = classifier as? KClass<*> ?: return null |
| 86 | + return when { |
| 87 | + cls == Nothing::class -> mapOf("type" to "null") |
| 88 | + cls == Boolean::class -> mapOf("type" to "boolean") |
| 89 | + cls == Int::class -> mapOf("type" to "integer", "format" to "int32") |
| 90 | + cls == Long::class -> mapOf("type" to "integer", "format" to "int64") |
| 91 | + cls == Float::class -> mapOf("type" to "number", "format" to "float") |
| 92 | + cls == Double::class -> mapOf("type" to "number", "format" to "double") |
| 93 | + cls.isSubclassOf(Number::class) -> mapOf("type" to "number") |
| 94 | + cls.isSubclassOf(Enum::class) -> mapOf("type" to "string", "enum" to cls.java.enumConstants.toList()) |
| 95 | + cls.isSubclassOf(Array::class) || cls.isSubclassOf(Iterable::class) -> mapOf("type" to "array", "items" to arguments.firstOrNull()?.type?.toJsonSchema(response)) |
| 96 | + cls.isSubclassOf(CharSequence::class) || Converter.supports(cls) && cls != Any::class -> mapOfNotNull("type" to "string", "format" to when (cls) { |
| 97 | + LocalDate::class, Date::class -> "date" |
| 98 | + LocalTime::class -> "time" |
| 99 | + Instant::class, LocalDateTime::class -> "date-time" |
| 100 | + Period::class, Duration::class -> "duration" |
| 101 | + URI::class, URL::class -> "uri" |
| 102 | + UUID::class -> "uuid" |
| 103 | + else -> null |
| 104 | + }) |
| 105 | + else -> mapOfNotNull("type" to "object", |
| 106 | + "properties" to cls.publicProperties.associate { it.name to it.returnType.toJsonSchema(response) }.takeIf { it.isNotEmpty() }, |
| 107 | + "required" to cls.publicProperties.filter { p -> |
| 108 | + !p.returnType.isMarkedNullable && (response || cls.primaryConstructor?.parameters?.find { it.name == p.name }?.isOptional != true) |
| 109 | + }.map { it.name }.toSet().takeIf { it.isNotEmpty() }) |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +private fun toRequestBody(route: Route, annotation: RequestBody?): Map<String, Any?>? { |
| 114 | + val bodyParam = (route.handler as? FunHandler)?.params?.find { it.p.kind != INSTANCE && it.source == null && it.cls.java.packageName != "klite" }?.p |
| 115 | + val requestBody = annotation?.toNonEmptyValues() ?: HashMap() |
| 116 | + if (annotation != null && annotation.content.isNotEmpty()) |
| 117 | + requestBody["content"] = annotation.content.associate { |
| 118 | + val content = it.toNonEmptyValues { it.name != "mediaType" } |
| 119 | + if (it.schema.implementation != Void::class.java) content["schema"] = it.schema.implementation.createType().toJsonSchema() |
| 120 | + else if (it.array.schema.implementation != Void::class.java) content["schema"] = Array::class.createType(arguments = listOf(KTypeProjection(null, it.array.schema.implementation.createType()))).toJsonSchema() |
| 121 | + it.mediaType to content |
| 122 | + } |
| 123 | + if (bodyParam != null) requestBody.putIfAbsent("content", bodyParam.type.toJsonContent()) |
| 124 | + if (requestBody.isEmpty()) return null |
| 125 | + requestBody.putIfAbsent("required", bodyParam == null || !bodyParam.isOptional) |
| 126 | + return requestBody |
| 127 | +} |
| 128 | + |
| 129 | +private fun toResponsesByCode(route: Route, op: Operation?, returnType: KType?): Map<StatusCode, Any?> { |
| 130 | + val responses = LinkedHashMap<StatusCode, Any?>() |
| 131 | + if (returnType?.classifier == Unit::class) responses[NoContent] = mapOf("description" to "No content") |
| 132 | + else if (op?.responses?.isEmpty() != false) responses[OK] = mapOfNotNull("description" to "OK", "content" to returnType?.toJsonContent(response = true)) |
| 133 | + (route.findAnnotations<ApiResponse>() + (op?.responses ?: emptyArray())).forEach { |
| 134 | + responses[StatusCode(it.responseCode.toInt())] = it.toNonEmptyValues { it.name != "responseCode" } |
| 135 | + } |
| 136 | + return responses |
| 137 | +} |
| 138 | + |
| 139 | +private fun KType.toJsonContent(response: Boolean = false) = mapOf(MimeTypes.json to mapOf("schema" to toJsonSchema(response))) |
| 140 | + |
| 141 | +private fun List<SecurityRequirement>.toSecurity() = map { mapOf(it.name to it.scopes.toList()) }.takeIf { it.isNotEmpty() } |
| 142 | + |
| 143 | +internal fun <T: Annotation> T.toNonEmptyValues(filter: (KProperty1<T, *>) -> Boolean = { true }): MutableMap<String, Any?> = HashMap<String, Any?>().also { map -> |
| 144 | + publicProperties.filter(filter).forEach { p -> |
| 145 | + when(val v = p.valueOf(this)) { |
| 146 | + "", false, 0, Int.MAX_VALUE, Int.MIN_VALUE, 0.0, Void::class.java, AccessMode.AUTO, RequiredMode.AUTO, AdditionalPropertiesValue.USE_ADDITIONAL_PROPERTIES_ANNOTATION -> null |
| 147 | + is Enum<*> -> v.takeIf { v.name != "DEFAULT" && v.name != "AUTO" } |
| 148 | + is Annotation -> v.toNonEmptyValues().takeIf { it.isNotEmpty() } |
| 149 | + is Array<*> -> v.map { (it as? Annotation)?.toNonEmptyValues() ?: it }.takeIf { it.isNotEmpty() } |
| 150 | + else -> v |
| 151 | + }?.let { map[p.name] = it } |
| 152 | + }} |
0 commit comments