Skip to content

Commit d03d25d

Browse files
committed
KTOR-8936 Improvements for the routing annotation API
1 parent c68f4ee commit d03d25d

File tree

19 files changed

+1993
-826
lines changed

19 files changed

+1993
-826
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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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] fields with default values.
37+
*/
38+
public val PopulateMediaTypeDefaults: OperationMapping = OperationMapping { operation ->
39+
val hasMissingMediaInfo = operation.parameters.orEmpty()
40+
.filterIsInstance<ReferenceOr.Value<Parameter>>()
41+
.any { it.value.schema == null && it.value.content == null || it.value.`in` == null }
42+
if (!hasMissingMediaInfo) {
43+
return@OperationMapping operation
44+
}
45+
46+
operation.copy(
47+
parameters = operation.parameters?.map { ref ->
48+
val param = ref.valueOrNull() ?: return@map ref
49+
ReferenceOr.Value(
50+
param.copy(
51+
`in` = param.`in` ?: ParameterType.query,
52+
content = param.content ?: MediaType.Text.takeIf { param.schema == null },
53+
)
54+
)
55+
}
56+
)
57+
}
58+
59+
/**
60+
* Replace all JSON class schema values with component references.
61+
*/
62+
public class CollectSchemaReferences(private val schemaToComponent: (JsonSchema) -> String?) : OperationMapping {
63+
override fun map(operation: Operation): Operation =
64+
operation.copy(
65+
requestBody = operation.requestBody?.mapValue {
66+
it.copy(content = it.content?.let(::collectSchemaReferences))
67+
},
68+
responses = operation.responses?.let { responses ->
69+
responses.copy(
70+
responses = responses.responses?.mapValues { (_, response) ->
71+
response.mapValue {
72+
it.copy(content = it.content?.let(::collectSchemaReferences))
73+
}
74+
}
75+
)
76+
},
77+
parameters = operation.parameters?.map { parameter ->
78+
parameter.mapValue {
79+
it.copy(
80+
schema = it.schema?.mapToReference(::collectSchema),
81+
content = it.content?.let(::collectSchemaReferences)
82+
)
83+
}
84+
},
85+
)
86+
87+
private fun collectSchemaReferences(content: Map<ContentType, MediaType>): Map<ContentType, MediaType> =
88+
content.mapValues { (_, mediaType) ->
89+
mediaType.copy(
90+
schema = mediaType.schema?.mapToReference(::collectSchema),
91+
)
92+
}
93+
94+
/**
95+
* We use the "title" field for referencing types to schema definitions.
96+
*/
97+
private fun collectSchema(schema: JsonSchema): ReferenceOr<JsonSchema> {
98+
return schemaToComponent(schema)?.let { ref ->
99+
ReferenceOr.schema(ref)
100+
} ?: ReferenceOr.value(
101+
schema.copy(
102+
allOf = schema.allOf?.map { it.mapToReference(::collectSchema) },
103+
oneOf = schema.oneOf?.map { it.mapToReference(::collectSchema) },
104+
not = schema.not?.mapToReference(::collectSchema),
105+
properties = schema.properties?.mapValues { (_, value) -> value.mapToReference(::collectSchema) },
106+
items = schema.items?.mapToReference(::collectSchema),
107+
)
108+
)
109+
}
110+
}

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

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,69 @@ import kotlin.collections.plus
1717
/**
1818
* Attribute key for storing [Operation] in 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) {
@@ -192,6 +242,7 @@ private operator fun Parameter.plus(other: Parameter): Parameter =
192242
required = required || other.required,
193243
deprecated = deprecated || other.deprecated,
194244
schema = schema ?: other.schema,
245+
content = mergeNullable(content, other.content) { a, b -> b + a },
195246
style = style ?: other.style,
196247
explode = explode ?: other.explode,
197248
allowReserved = allowReserved ?: other.allowReserved,
@@ -229,23 +280,3 @@ private fun <E, K> Iterable<E>.mergeElementsBy(
229280

230281
private fun <K, V> Iterable<Map.Entry<K, V>>.toMap() =
231282
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)