Skip to content

Commit 93c5c96

Browse files
authored
KTOR-8936 Improvements for the routing annotation API (#5206)
* KTOR-8936 Improvements for the routing annotation API * KTOR-8936 Make response functions in annotation DSL additive
1 parent eaddf43 commit 93c5c96

File tree

18 files changed

+1978
-843
lines changed

18 files changed

+1978
-843
lines changed
Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
1+
public final class io/ktor/annotate/CollectSchemaReferences : io/ktor/annotate/OperationMapping {
2+
public fun <init> (Lkotlin/jvm/functions/Function1;)V
3+
public fun map (Lio/ktor/openapi/Operation;)Lio/ktor/openapi/Operation;
4+
public fun plus (Lio/ktor/annotate/OperationMapping;)Lio/ktor/annotate/OperationMapping;
5+
}
6+
7+
public abstract interface class io/ktor/annotate/OperationMapping {
8+
public abstract fun map (Lio/ktor/openapi/Operation;)Lio/ktor/openapi/Operation;
9+
public fun plus (Lio/ktor/annotate/OperationMapping;)Lio/ktor/annotate/OperationMapping;
10+
}
11+
12+
public final class io/ktor/annotate/OperationMapping$DefaultImpls {
13+
public static fun plus (Lio/ktor/annotate/OperationMapping;Lio/ktor/annotate/OperationMapping;)Lio/ktor/annotate/OperationMapping;
14+
}
15+
16+
public final class io/ktor/annotate/OperationMappingKt {
17+
public static final fun getPopulateMediaTypeDefaults ()Lio/ktor/annotate/OperationMapping;
18+
}
19+
120
public final class io/ktor/annotate/RouteAnnotationApiKt {
221
public static final fun annotate (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/routing/Route;
3-
public static final fun findPathItems (Lio/ktor/server/routing/RoutingNode;)Ljava/util/Map;
22+
public static final fun findPathItems (Lio/ktor/server/routing/RoutingNode;Lio/ktor/annotate/OperationMapping;)Ljava/util/Map;
23+
public static synthetic fun findPathItems$default (Lio/ktor/server/routing/RoutingNode;Lio/ktor/annotate/OperationMapping;ILjava/lang/Object;)Ljava/util/Map;
24+
public static final fun generateOpenApiSpec (Lio/ktor/openapi/OpenApiInfo;Lio/ktor/server/routing/RoutingNode;)Lio/ktor/openapi/OpenApiSpecification;
425
public static final fun getEndpointAnnotationAttributeKey ()Lio/ktor/util/AttributeKey;
526
}
627

ktor-server/ktor-server-plugins/ktor-server-routing-annotate/api/ktor-server-routing-annotate.klib.api

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,22 @@
66
// - Show declarations: true
77

88
// Library unique name: <io.ktor:ktor-server-routing-annotate>
9+
abstract fun interface io.ktor.annotate/OperationMapping { // io.ktor.annotate/OperationMapping|null[0]
10+
abstract fun map(io.ktor.openapi/Operation): io.ktor.openapi/Operation // io.ktor.annotate/OperationMapping.map|map(io.ktor.openapi.Operation){}[0]
11+
open fun plus(io.ktor.annotate/OperationMapping): io.ktor.annotate/OperationMapping // io.ktor.annotate/OperationMapping.plus|plus(io.ktor.annotate.OperationMapping){}[0]
12+
}
13+
14+
final class io.ktor.annotate/CollectSchemaReferences : io.ktor.annotate/OperationMapping { // io.ktor.annotate/CollectSchemaReferences|null[0]
15+
constructor <init>(kotlin/Function1<io.ktor.openapi/JsonSchema, kotlin/String?>) // io.ktor.annotate/CollectSchemaReferences.<init>|<init>(kotlin.Function1<io.ktor.openapi.JsonSchema,kotlin.String?>){}[0]
16+
17+
final fun map(io.ktor.openapi/Operation): io.ktor.openapi/Operation // io.ktor.annotate/CollectSchemaReferences.map|map(io.ktor.openapi.Operation){}[0]
18+
}
19+
920
final val io.ktor.annotate/EndpointAnnotationAttributeKey // io.ktor.annotate/EndpointAnnotationAttributeKey|{}EndpointAnnotationAttributeKey[0]
10-
final fun <get-EndpointAnnotationAttributeKey>(): io.ktor.util/AttributeKey<io.ktor.openapi/Operation> // io.ktor.annotate/EndpointAnnotationAttributeKey.<get-EndpointAnnotationAttributeKey>|<get-EndpointAnnotationAttributeKey>(){}[0]
21+
final fun <get-EndpointAnnotationAttributeKey>(): io.ktor.util/AttributeKey<kotlin.collections/List<kotlin/Function1<io.ktor.openapi/Operation.Builder, kotlin/Unit>>> // io.ktor.annotate/EndpointAnnotationAttributeKey.<get-EndpointAnnotationAttributeKey>|<get-EndpointAnnotationAttributeKey>(){}[0]
22+
final val io.ktor.annotate/PopulateMediaTypeDefaults // io.ktor.annotate/PopulateMediaTypeDefaults|{}PopulateMediaTypeDefaults[0]
23+
final fun <get-PopulateMediaTypeDefaults>(): io.ktor.annotate/OperationMapping // io.ktor.annotate/PopulateMediaTypeDefaults.<get-PopulateMediaTypeDefaults>|<get-PopulateMediaTypeDefaults>(){}[0]
1124

1225
final fun (io.ktor.server.routing/Route).io.ktor.annotate/annotate(kotlin/Function1<io.ktor.openapi/Operation.Builder, kotlin/Unit>): io.ktor.server.routing/Route // io.ktor.annotate/annotate|[email protected](kotlin.Function1<io.ktor.openapi.Operation.Builder,kotlin.Unit>){}[0]
13-
final fun (io.ktor.server.routing/RoutingNode).io.ktor.annotate/findPathItems(): kotlin.collections/Map<kotlin/String, io.ktor.openapi/PathItem> // io.ktor.annotate/findPathItems|[email protected](){}[0]
26+
final fun (io.ktor.server.routing/RoutingNode).io.ktor.annotate/findPathItems(io.ktor.annotate/OperationMapping = ...): kotlin.collections/Map<kotlin/String, io.ktor.openapi/PathItem> // io.ktor.annotate/findPathItems|[email protected](io.ktor.annotate.OperationMapping){}[0]
27+
final fun io.ktor.annotate/generateOpenApiSpec(io.ktor.openapi/OpenApiInfo, io.ktor.server.routing/RoutingNode): io.ktor.openapi/OpenApiSpecification // io.ktor.annotate/generateOpenApiSpec|generateOpenApiSpec(io.ktor.openapi.OpenApiInfo;io.ktor.server.routing.RoutingNode){}[0]
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.annotate
6+
7+
import io.ktor.http.*
8+
import io.ktor.openapi.*
9+
10+
/**
11+
* Mapping function for [Operation].
12+
*
13+
* Used in post-processing of the OpenAPI model.
14+
*/
15+
public fun interface OperationMapping {
16+
public fun map(operation: Operation): Operation
17+
18+
public operator fun plus(other: OperationMapping): OperationMapping =
19+
JoinedOperationMapping(listOf(this, other))
20+
}
21+
22+
internal class JoinedOperationMapping(private val operations: List<OperationMapping>) : OperationMapping {
23+
override fun map(operation: Operation): Operation {
24+
var current = operation
25+
for (processor in operations) {
26+
current = processor.map(current)
27+
}
28+
return current
29+
}
30+
31+
override fun plus(other: OperationMapping): OperationMapping =
32+
JoinedOperationMapping(operations + other)
33+
}
34+
35+
/**
36+
* Populate [Parameter.content] and response [Header.content] fields with default values.
37+
*
38+
* Defaults applied:
39+
* - Parameters: if `in` is missing, default to `query`.
40+
* - Parameters: if both `schema` and `content` are missing, set `content` to `text/plain`.
41+
* - Response headers: if both `schema` and `content` are missing, set `content` to `text/plain`.
42+
*/
43+
public val PopulateMediaTypeDefaults: OperationMapping = OperationMapping { operation ->
44+
// Fast path: detect whether any defaults are needed
45+
val hasMissingParamMediaInfo = operation.parameters.orEmpty()
46+
.filterIsInstance<ReferenceOr.Value<Parameter>>()
47+
.any { paramRef ->
48+
(paramRef.value.schema == null && paramRef.value.content == null) ||
49+
paramRef.value.`in` == null
50+
}
51+
52+
val hasMissingHeaderMediaInfo = run {
53+
val responses = operation.responses ?: return@run false
54+
fun ReferenceOr<Response>.hasMissingInHeaders(): Boolean {
55+
val headers = this.valueOrNull()?.headers ?: return false
56+
return headers.values.filterIsInstance<ReferenceOr.Value<Header>>()
57+
.any { it.value.schema == null && it.value.content == null }
58+
}
59+
60+
(responses.default?.hasMissingInHeaders() == true) ||
61+
(responses.responses?.values?.any { it.hasMissingInHeaders() } == true)
62+
}
63+
64+
if (!hasMissingParamMediaInfo && !hasMissingHeaderMediaInfo) {
65+
return@OperationMapping operation
66+
}
67+
68+
operation.copy(
69+
// Parameter defaults
70+
parameters = operation.parameters?.map { ref ->
71+
val param = ref.valueOrNull() ?: return@map ref
72+
ReferenceOr.Value(
73+
param.copy(
74+
`in` = param.`in` ?: ParameterType.query,
75+
content = param.content ?: MediaType.Text.takeIf { param.schema == null },
76+
)
77+
)
78+
},
79+
// Response header defaults
80+
responses = operation.responses?.let { responses ->
81+
responses.copy(
82+
default = responses.default?.mapValue { resp ->
83+
resp.copy(
84+
headers = resp.headers?.mapValues { (_, headerRef) ->
85+
headerRef.mapValue { header ->
86+
header.copy(
87+
content = header.content ?: MediaType.Text.takeIf { header.schema == null },
88+
)
89+
}
90+
}
91+
)
92+
},
93+
responses = responses.responses?.mapValues { (_, responseRef) ->
94+
responseRef.mapValue { resp ->
95+
resp.copy(
96+
headers = resp.headers?.mapValues { (_, headerRef) ->
97+
headerRef.mapValue { header ->
98+
header.copy(
99+
content = header.content ?: MediaType.Text.takeIf { header.schema == null },
100+
)
101+
}
102+
}
103+
)
104+
}
105+
}
106+
)
107+
}
108+
)
109+
}
110+
111+
/**
112+
* Replace all JSON class schema values with component references.
113+
*/
114+
public class CollectSchemaReferences(private val schemaToComponent: (JsonSchema) -> String?) : OperationMapping {
115+
override fun map(operation: Operation): Operation =
116+
operation.copy(
117+
requestBody = operation.requestBody?.mapValue {
118+
it.copy(content = it.content?.let(::collectSchemaReferences))
119+
},
120+
responses = operation.responses?.let { responses ->
121+
responses.copy(
122+
responses = responses.responses?.mapValues { (_, response) ->
123+
response.mapValue {
124+
it.copy(content = it.content?.let(::collectSchemaReferences))
125+
}
126+
}
127+
)
128+
},
129+
parameters = operation.parameters?.map { parameter ->
130+
parameter.mapValue {
131+
it.copy(
132+
schema = it.schema?.mapToReference(::collectSchema),
133+
content = it.content?.let(::collectSchemaReferences)
134+
)
135+
}
136+
},
137+
)
138+
139+
private fun collectSchemaReferences(content: Map<ContentType, MediaType>): Map<ContentType, MediaType> =
140+
content.mapValues { (_, mediaType) ->
141+
mediaType.copy(
142+
schema = mediaType.schema?.mapToReference(::collectSchema),
143+
)
144+
}
145+
146+
/**
147+
* We use the "title" field for referencing types to schema definitions.
148+
*/
149+
private fun collectSchema(schema: JsonSchema): ReferenceOr<JsonSchema> {
150+
return schemaToComponent(schema)?.let { ref ->
151+
ReferenceOr.schema(ref)
152+
} ?: ReferenceOr.value(
153+
schema.copy(
154+
allOf = schema.allOf?.map { it.mapToReference(::collectSchema) },
155+
anyOf = schema.anyOf?.map { it.mapToReference(::collectSchema) },
156+
oneOf = schema.oneOf?.map { it.mapToReference(::collectSchema) },
157+
not = schema.not?.mapToReference(::collectSchema),
158+
properties = schema.properties?.mapValues { (_, value) -> value.mapToReference(::collectSchema) },
159+
items = schema.items?.mapToReference(::collectSchema),
160+
)
161+
)
162+
}
163+
}

ktor-server/ktor-server-plugins/ktor-server-routing-annotate/common/src/io/ktor/annotate/RouteAnnotationApi.kt

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,71 @@ import io.ktor.utils.io.*
1515
import kotlin.collections.plus
1616

1717
/**
18-
* Attribute key for storing [Operation] in a [Route].
18+
* Attribute key for including OpenAPI metadata on a [Route].
1919
*/
20-
public val EndpointAnnotationAttributeKey: AttributeKey<Operation> =
21-
AttributeKey<Operation>("operation-docs")
20+
public val EndpointAnnotationAttributeKey: AttributeKey<List<RouteAnnotationFunction>> =
21+
AttributeKey("operation-docs")
22+
23+
/**
24+
* Function that configures an OpenAPI [Operation].
25+
*/
26+
public typealias RouteAnnotationFunction = Operation.Builder.() -> Unit
2227

2328
/**
2429
* Annotate a [Route] with an OpenAPI [Operation].
2530
*/
26-
public fun Route.annotate(configure: Operation.Builder.() -> Unit): Route {
31+
public fun Route.annotate(configure: RouteAnnotationFunction): Route {
2732
attributes[EndpointAnnotationAttributeKey] =
2833
when (val previous = attributes.getOrNull(EndpointAnnotationAttributeKey)) {
29-
null -> Operation.build(configure)
30-
else -> previous + Operation.build(configure)
34+
null -> listOf(configure)
35+
else -> previous + configure
3136
}
3237
return this
3338
}
3439

40+
/**
41+
* Generates an OpenAPI specification for the given [route].
42+
*
43+
* @param info The OpenAPI info object.
44+
* @param route The route to generate the specification for.
45+
*/
46+
public fun generateOpenApiSpec(
47+
info: OpenApiInfo,
48+
route: RoutingNode,
49+
): OpenApiSpecification {
50+
val jsonSchema = mutableMapOf<String, JsonSchema>()
51+
val pathItems = route.findPathItems(
52+
PopulateMediaTypeDefaults + CollectSchemaReferences { schema ->
53+
val title = schema.title ?: return@CollectSchemaReferences null
54+
val unqualifiedTitle = title.substringAfterLast('.')
55+
val existingTitle = jsonSchema[unqualifiedTitle]?.title ?: title
56+
// if the shortened title is already in use, use the full title instead
57+
if (existingTitle != title) {
58+
jsonSchema[title] = schema
59+
title
60+
} else {
61+
jsonSchema[unqualifiedTitle] = schema
62+
unqualifiedTitle
63+
}
64+
}
65+
)
66+
67+
return OpenApiSpecification(
68+
info = info,
69+
paths = pathItems,
70+
components = Components(schemas = jsonSchema)
71+
.takeIf(Components::isNotEmpty)
72+
)
73+
}
74+
3575
/**
3676
* Finds all [PathItem]s under the given [RoutingNode].
3777
*/
38-
public fun RoutingNode.findPathItems(): Map<String, PathItem> =
39-
descendants()
40-
.mapNotNull(RoutingNode::asPathItem)
78+
public fun RoutingNode.findPathItems(
79+
onOperation: OperationMapping = PopulateMediaTypeDefaults
80+
): Map<String, PathItem> {
81+
return descendants()
82+
.mapNotNull { it.asPathItem(onOperation) }
4183
.fold(mutableMapOf()) { map, (route, pathItem) ->
4284
map.also {
4385
if (route in map) {
@@ -47,12 +89,15 @@ public fun RoutingNode.findPathItems(): Map<String, PathItem> =
4789
}
4890
}
4991
}
92+
}
5093

51-
private fun RoutingNode.asPathItem(): Pair<String, PathItem>? {
94+
private fun RoutingNode.asPathItem(
95+
onOperation: OperationMapping
96+
): Pair<String, PathItem>? {
5297
if (!hasHandler()) return null
5398
val path = path(format = OpenApiRoutePathFormat)
5499
val method = method() ?: return null
55-
val operation = operation()?.normalize() ?: Operation()
100+
val operation = operation()?.let(onOperation::map) ?: Operation()
56101
val pathItem = newPathItem(method, operation) ?: return null
57102

58103
return path to pathItem
@@ -65,18 +110,23 @@ private fun RoutingNode.method(): HttpMethod? =
65110
.firstOrNull()
66111
?.method
67112

68-
private fun RoutingNode.operation(): Operation? =
69-
lineage().fold(null) { acc, node ->
113+
private fun RoutingNode.operation(): Operation? {
114+
// TODO KTOR-9086 get schema inference from ContentNegotiation plugin
115+
val schemaInference = KotlinxJsonSchemaInference
116+
return lineage().fold(null) { acc, node ->
70117
val current = mergeNullable(
71-
node.operationAttribute(),
118+
node.operationFromAnnotateCalls(schemaInference),
72119
node.operationFromSelector(),
73120
Operation::plus
74121
)
75122
mergeNullable(acc, current, Operation::plus)
76123
}
124+
}
77125

78-
private fun RoutingNode.operationAttribute(): Operation? =
126+
private fun RoutingNode.operationFromAnnotateCalls(schemaInference: JsonSchemaInference): Operation? =
79127
attributes.getOrNull(EndpointAnnotationAttributeKey)
128+
?.map { function -> Operation.build(schemaInference, function) }
129+
?.reduce(Operation::plus)
80130

81131
private fun RoutingNode.operationFromSelector(): Operation? {
82132
return when (val paramSelector = selector) {
@@ -88,6 +138,7 @@ private fun RoutingNode.operationFromSelector(): Operation? {
88138
}
89139
}
90140
}
141+
91142
is PathSegmentParameterRouteSelector,
92143
is PathSegmentOptionalParameterRouteSelector -> Operation.build {
93144
parameters {
@@ -96,6 +147,7 @@ private fun RoutingNode.operationFromSelector(): Operation? {
96147
}
97148
}
98149
}
150+
99151
is HttpHeaderRouteSelector -> Operation.build {
100152
parameters {
101153
header(paramSelector.name) {}
@@ -192,6 +244,7 @@ private operator fun Parameter.plus(other: Parameter): Parameter =
192244
required = required || other.required,
193245
deprecated = deprecated || other.deprecated,
194246
schema = schema ?: other.schema,
247+
content = mergeNullable(content, other.content) { a, b -> b + a },
195248
style = style ?: other.style,
196249
explode = explode ?: other.explode,
197250
allowReserved = allowReserved ?: other.allowReserved,
@@ -229,23 +282,3 @@ private fun <E, K> Iterable<E>.mergeElementsBy(
229282

230283
private fun <K, V> Iterable<Map.Entry<K, V>>.toMap() =
231284
associate { it.key to it.value }
232-
233-
private fun Operation.normalize(): Operation {
234-
val hasMissingMediaInfo = parameters.orEmpty()
235-
.filterIsInstance<ReferenceOr.Value<Parameter>>()
236-
.any { it.value.schema == null && it.value.content == null || it.value.`in` == null }
237-
if (!hasMissingMediaInfo) {
238-
return this
239-
}
240-
return copy(
241-
parameters = parameters?.map { ref ->
242-
val param = ref.valueOrNull() ?: return@map ref
243-
ReferenceOr.Value(
244-
param.copy(
245-
`in` = param.`in` ?: ParameterType.query,
246-
content = param.content ?: MediaType.Text.takeIf { param.schema == null },
247-
)
248-
)
249-
}
250-
)
251-
}

0 commit comments

Comments
 (0)