Skip to content

Commit a2205e6

Browse files
committed
openapi: adding openApi() to context will now add /openapi.json, /openapi.html and /openapi routes, which will also serve Swagger-UI automatically
1 parent 83fa252 commit a2205e6

File tree

5 files changed

+197
-158
lines changed

5 files changed

+197
-158
lines changed

.idea/gradle.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* jdbc: improve AlreadyExistsException message (always starts with errors.alreadyExists)
44
* jdbc: better handling of comments inside quoted strings in migration scripts
55
* csv: support for providing a different encoding than UTF-8 when parsing or generating
6+
* openapi: adding openApi() to context will now add /openapi.json, /openapi.html and /openapi routes, which will also serve Swagger-UI automatically
67

78
# 1.6.14
89
* jdbc: add helpful details to Postgres exception "no hstore extension installed"

openapi/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# klite-openapi
22

3-
An experimental module to generate OpenAPI spec for klite routes.
3+
A module to generate OpenAPI spec for Klite routes.
44

55
Input/output parameters, paths, names will be generated from annotated route classes automatically.
66
Use Swagger/OpenAPI annotations to specify descriptions or more details.
@@ -14,19 +14,19 @@ Usage:
1414
useOnly<JsonBody>()
1515
annotated<MyRoutes>()
1616
// ... more routes
17-
openApi(annotations = MyRoutes::class.annotations) // adds /openapi endpoint to the /api context
17+
openApi(annotations = MyRoutes::class.annotations) // adds /openapi endpoint to the context
1818
}
1919
```
2020

2121
See [sample Launcher](../sample/src/Launcher.kt).
2222

2323
## Swagger UI
2424

25-
For nice visual representation of OpenAPI json output:
26-
* add `before(CorsHandler())` before `openApi()`, as Swagger UI requires CORS to request openapi json from another host/domain
27-
* `docker run -d -p 8080:8088 swaggerapi/swagger-ui`
28-
* Open http://localhost:8088/?url=http://YOUR-IP:PORT/api/openapi
29-
* Alternatively, use https://petstore.swagger.io/?url= if your `/openapi` route is available over https.
25+
You can request the following endpoints in your context (`/api` in this case):
26+
27+
* `/openapi.json` - will serve Open API json spec
28+
* `/openapi.html` - will serve Swagger UI
29+
* `/openapi` - will detect if you are asking for json or html
3030

3131
## Docs
3232

openapi/src/Generate.kt

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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

Comments
 (0)