Skip to content

Commit

Permalink
KTOR-8183 Add a simplified route string (#4691)
Browse files Browse the repository at this point in the history
* KTOR-8183 Add RoutingNode.path extension property

* KTOR-8183 Update report a problem link

* KTOR-8183 Make metrics route label configurable

Uses RoutingNode.path as default label instead of RoutingNode.toString().

* KTOR-8183 Pass RoutingNode as parameter

* KTOR-8183 Remove redundant call to let

* KTOR-8183 Add test cases with RoutingNodes not related to path

* KTOR-8183 Update API dump

* KTOR-8183 Fix linting errors
  • Loading branch information
adriandieter authored Feb 28, 2025
1 parent 3e70916 commit 1242eb2
Showing 8 changed files with 174 additions and 2 deletions.
1 change: 1 addition & 0 deletions ktor-server/ktor-server-core/api/ktor-server-core.api
Original file line number Diff line number Diff line change
@@ -1687,6 +1687,7 @@ public class io/ktor/server/routing/RoutingNode : io/ktor/server/application/App

public final class io/ktor/server/routing/RoutingNodeKt {
public static final fun getAllRoutes (Lio/ktor/server/routing/RoutingNode;)Ljava/util/List;
public static final fun getPath (Lio/ktor/server/routing/RoutingNode;)Ljava/lang/String;
public static final fun insertPhaseAfter (Lio/ktor/server/routing/Route;Lio/ktor/util/pipeline/PipelinePhase;Lio/ktor/util/pipeline/PipelinePhase;)V
public static final fun insertPhaseBefore (Lio/ktor/server/routing/Route;Lio/ktor/util/pipeline/PipelinePhase;Lio/ktor/util/pipeline/PipelinePhase;)V
public static final fun intercept (Lio/ktor/server/routing/Route;Lio/ktor/util/pipeline/PipelinePhase;Lkotlin/jvm/functions/Function3;)V
2 changes: 2 additions & 0 deletions ktor-server/ktor-server-core/api/ktor-server-core.klib.api
Original file line number Diff line number Diff line change
@@ -1660,6 +1660,8 @@ final val io.ktor.server.routing/RoutingFailureStatusCode // io.ktor.server.rout
final fun <get-RoutingFailureStatusCode>(): io.ktor.util/AttributeKey<io.ktor.http/HttpStatusCode> // io.ktor.server.routing/RoutingFailureStatusCode.<get-RoutingFailureStatusCode>|<get-RoutingFailureStatusCode>(){}[0]
final val io.ktor.server.routing/application // io.ktor.server.routing/application|@io.ktor.server.routing.Route{}application[0]
final fun (io.ktor.server.routing/Route).<get-application>(): io.ktor.server.application/Application // io.ktor.server.routing/application.<get-application>|<get-application>@io.ktor.server.routing.Route(){}[0]
final val io.ktor.server.routing/path // io.ktor.server.routing/path|@io.ktor.server.routing.RoutingNode{}path[0]
final fun (io.ktor.server.routing/RoutingNode).<get-path>(): kotlin/String // io.ktor.server.routing/path.<get-path>|<get-path>@io.ktor.server.routing.RoutingNode(){}[0]

final var io.ktor.server.application/receiveType // io.ktor.server.application/receiveType|@io.ktor.server.application.ApplicationCall{}receiveType[0]
final fun (io.ktor.server.application/ApplicationCall).<get-receiveType>(): io.ktor.util.reflect/TypeInfo // io.ktor.server.application/receiveType.<get-receiveType>|<get-receiveType>@io.ktor.server.application.ApplicationCall(){}[0]
Original file line number Diff line number Diff line change
@@ -12,7 +12,6 @@ import io.ktor.util.*
import io.ktor.util.pipeline.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import kotlinx.coroutines.*
import kotlin.coroutines.*

/**
@@ -350,6 +349,38 @@ private fun RoutingNode.getAllRoutes(endpoints: MutableList<RoutingNode>) {
children.forEach { it.getAllRoutes(endpoints) }
}

/**
* String representation of the path matched by this route.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.routing.RoutingNode.path)
*/
public val RoutingNode.path: String
get() = path()

private fun RoutingNode.path(): String {
val parentPath = parent?.path()
val selectorElement = selector.toPathElement()
return when {
parentPath == null -> selectorElement
selectorElement.isEmpty() -> parentPath
parentPath.endsWith('/') || selectorElement.startsWith('/') -> "$parentPath$selectorElement"
else -> "$parentPath/$selectorElement"
}
}

private fun RouteSelector.toPathElement(): String = when (this) {
is PathSegmentConstantRouteSelector,
is PathSegmentParameterRouteSelector,
is PathSegmentOptionalParameterRouteSelector,
is PathSegmentTailcardRouteSelector,
is PathSegmentWildcardRouteSelector,
is PathSegmentRegexRouteSelector -> toString()

is TrailingSlashRouteSelector -> "/"

else -> ""
}

@Deprecated("Please use route scoped plugins instead")
public fun Route.intercept(
phase: PipelinePhase,
Original file line number Diff line number Diff line change
@@ -138,4 +138,54 @@ class RouteTest {

assertEquals(HttpStatusCode.OK, client.get("/").status)
}

@Test
fun testPathProperty() = testApplication {
application {
val root = routing {
get {}
get("/") {}
get("/trailing/slash/") {}
get("/parameter/{mandatory}/{optional?}") {}
get("/wildcard/*") {}
get("/tailcard/{...}") {}
get("/parameter/tailcard/{path...}") {}
get(Regex("/.+regex")) {}

// Routing nodes not related to path
route("omitted") {
contentType(ContentType.Text.CSV) {
post("contentType") {}
}
param("order", "asc") {
post("param") {}
}
header("Accept-Language", "en-US,en;q=0.5") {
get("header") {}
}
}
}

val paths = root.getAllRoutes()
.map { it.path }
.toSet()

val expected = setOf(
"",
"/",
"/trailing/slash/",
"/parameter/{mandatory}/{optional?}",
"/wildcard/*",
"/tailcard/{...}",
"/parameter/tailcard/{...}",
"/Regex(/.+regex)",

// contentType, param and header RouteSelectors should be omitted
"/omitted/contentType",
"/omitted/param",
"/omitted/header",
)
assertEquals(expected, paths)
}
}
}
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ public final class io/ktor/server/metrics/micrometer/MicrometerMetricsConfig {
public final fun setMetricName (Ljava/lang/String;)V
public final fun setRegistry (Lio/micrometer/core/instrument/MeterRegistry;)V
public final fun timers (Lkotlin/jvm/functions/Function3;)V
public final fun transformRoute (Lkotlin/jvm/functions/Function1;)V
}

public final class io/ktor/server/metrics/micrometer/MicrometerMetricsKt {
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ kotlin {
jvmTest {
dependencies{
implementation(project(":ktor-server:ktor-server-plugins:ktor-server-metrics"))
api(project(":ktor-server:ktor-server-plugins:ktor-server-auth"))
}
}
}
Original file line number Diff line number Diff line change
@@ -119,6 +119,34 @@ public class MicrometerMetricsConfig {
public fun timers(block: Timer.Builder.(ApplicationCall, Throwable?) -> Unit) {
timerBuilder = block
}

internal var transformRoute: (RoutingNode) -> String = { it.path }

/**
* Configures mapping function for the route label string of the [CallMeasure].
* Defaults to [RoutingNode.path].
*
* **Examples:**
*
* Use the toString() function of the RoutingNode:
* ```kotlin
* transformRoute {
* it.toString()
* }
* ```
*
* Remove a prefix from the path:
* ```kotlin
* transformRoute {
* it.path.removePrefix("/path/prefix")
* }
* ```
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.metrics.micrometer.MicrometerMetricsConfig.transformRoute)
*/
public fun transformRoute(block: (RoutingNode) -> String) {
transformRoute = block
}
}

/**
@@ -194,7 +222,7 @@ public val MicrometerMetrics: ApplicationPlugin<MicrometerMetricsConfig> =

application.monitor.subscribe(RoutingRoot.RoutingCallStarted) { call ->
call.attributes.getOrNull(measureKey)?.let { measure ->
measure.route = call.route.parent.toString()
measure.route = pluginConfig.transformRoute(call.route)
}
}
}
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.metrics.dropwizard.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@@ -401,6 +402,63 @@ class MicrometerMetricsTests {
}
}

@Test
fun `route transformation can be configured`() = testApplication {
val testRegistry = SimpleMeterRegistry()

install(MicrometerMetrics) {
registry = testRegistry
transformRoute {
"/prefix${it.path}"
}
}

routing {
get("/uri") {
call.respond("some response")
}
}

client.request("/uri")

with(testRegistry.find(requestTimeTimerName).timers()) {
assertEquals(1, size)
this.first().run {
assertTag("route", "/prefix/uri")
}
}
}

@Test
fun `route with auth plugin should not include authentication provider`() = testApplication {
val testRegistry = SimpleMeterRegistry()

install(MicrometerMetrics) {
registry = testRegistry
}

install(Authentication) {
bearer { }
}

routing {
authenticate {
get("/uri") {
call.respond("some response")
}
}
}

client.request("/uri")

with(testRegistry.find(requestTimeTimerName).timers()) {
assertEquals(1, size)
this.first().run {
assertTag("route", "/uri")
}
}
}

@Test
fun `with DropwizardMetrics plugin`() = testApplication {
application {

0 comments on commit 1242eb2

Please sign in to comment.