From a259433e2f5744c221f987b2a44851242c2ac1e0 Mon Sep 17 00:00:00 2001 From: Bruce Hamilton <150327496+bjhham@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:33:43 +0100 Subject: [PATCH] KTOR-7194 Deferred session fetching for public endpoints (#4609) * KTOR-7194 Deferred session fetching for public endpoints * KTOR-7194 Use system property for deferred sessions * fixup! KTOR-7194 Use system property for deferred sessions * Reduce test size due to timeouts on native agent --- .../common/test/ByteChannelReplayTest.kt | 2 +- .../io/ktor/tests/auth/SessionAuthTest.kt | 7 +- .../tests/auth/SessionAuthDeferredTest.kt | 78 ++++++++++++++++++ .../io/ktor/server/sessions/SessionData.kt | 23 +++++- .../ktor/server/sessions/SessionDeferral.kt | 20 +++++ .../src/io/ktor/server/sessions/Sessions.kt | 39 +++++---- .../SessionDeferral.jsAndWasmShared.kt | 12 +++ .../server/sessions/SessionDeferral.jvm.kt | 8 ++ .../sessions/SessionSerializerReflection.kt | 17 ++-- .../sessions/BlockingDeferredSessionData.kt | 82 +++++++++++++++++++ .../sessions/SessionDeferral.jvmAndPosix.kt | 20 +++++ .../server/sessions/SessionDeferral.posix.kt | 12 +++ 12 files changed, 288 insertions(+), 32 deletions(-) create mode 100644 ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/SessionAuthDeferredTest.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionDeferral.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-sessions/jsAndWasmShared/src/io/ktor/server/sessions/SessionDeferral.jsAndWasmShared.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionDeferral.jvm.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/BlockingDeferredSessionData.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/SessionDeferral.jvmAndPosix.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-sessions/posix/src/io/ktor/server/sessions/SessionDeferral.posix.kt diff --git a/ktor-client/ktor-client-core/common/test/ByteChannelReplayTest.kt b/ktor-client/ktor-client-core/common/test/ByteChannelReplayTest.kt index 44a2827c880..0f88870491e 100644 --- a/ktor-client/ktor-client-core/common/test/ByteChannelReplayTest.kt +++ b/ktor-client/ktor-client-core/common/test/ByteChannelReplayTest.kt @@ -42,7 +42,7 @@ internal class ByteChannelReplayTest { @Test fun readABunch() = runTest { - val jobs = (0..10).map { + val jobs = (0..5).map { launch { val readChannel = channelReplay.replay() yield() diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth/common/test/io/ktor/tests/auth/SessionAuthTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth/common/test/io/ktor/tests/auth/SessionAuthTest.kt index c565add44a4..2372f3ce927 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth/common/test/io/ktor/tests/auth/SessionAuthTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth/common/test/io/ktor/tests/auth/SessionAuthTest.kt @@ -14,10 +14,11 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.sessions.* import io.ktor.server.testing.* -import kotlinx.serialization.* -import kotlin.test.* +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals -class SessionAuthTest { +open class SessionAuthTest { @Test fun testSessionOnly() = testApplication { install(Sessions) { diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/SessionAuthDeferredTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/SessionAuthDeferredTest.kt new file mode 100644 index 00000000000..a124a814ace --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/SessionAuthDeferredTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.tests.auth + +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.sessions.* +import io.ktor.server.sessions.serialization.* +import io.ktor.server.testing.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.test.* + +class SessionAuthDeferredTest : SessionAuthTest() { + + @BeforeTest + fun setProperty() { + System.setProperty("io.ktor.server.sessions.deferred", "true") + } + + @AfterTest + fun clearProperty() { + System.clearProperty("io.ktor.server.sessions.deferred") + } + + @Test + fun sessionIgnoredForNonPublicEndpoints() = testApplication { + val brokenStorage = object : SessionStorage { + override suspend fun write(id: String, value: String) = Unit + override suspend fun invalidate(id: String) = error("invalidate called") + override suspend fun read(id: String): String = error("read called") + } + application { + install(Sessions) { + cookie("S", storage = brokenStorage) { + serializer = KotlinxSessionSerializer(Json.Default) + } + } + install(Authentication.Companion) { + session { + validate { it } + } + } + routing { + authenticate { + get("/authenticated") { + call.respondText("Secret info") + } + } + post("/session") { + call.sessions.set(MySession(1)) + call.respondText("OK") + } + get("/public") { + call.respondText("Public info") + } + } + } + val withCookie: HttpRequestBuilder.() -> Unit = { + header("Cookie", "S=${defaultSessionSerializer().serialize(MySession(1))}") + } + + assertEquals(HttpStatusCode.Companion.OK, client.post("/session").status) + assertEquals(HttpStatusCode.Companion.OK, client.get("/public", withCookie).status) + assertFailsWith { + client.get("/authenticated", withCookie).status + } + } + + @Serializable + data class MySession(val id: Int) +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionData.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionData.kt index f431b146132..51f819eb81b 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionData.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionData.kt @@ -44,6 +44,19 @@ public interface CurrentSession { public fun findName(type: KClass<*>): String } +/** + * Extends [CurrentSession] with a call to include session data in the server response. + */ +internal interface StatefulSession : CurrentSession { + + /** + * Iterates over session data items and writes them to the application call. + * The session cannot be modified after this is called. + * This is called after the session data is sent to the response. + */ + suspend fun sendSessionData(call: ApplicationCall, onEach: (String) -> Unit = {}) +} + /** * Sets a session instance with the type [T]. * @throws IllegalStateException if no session provider is registered for the type [T] @@ -99,11 +112,15 @@ public inline fun CurrentSession.getOrSet(name: String = findN internal data class SessionData( val providerData: Map> -) : CurrentSession { +) : StatefulSession { private var committed = false - internal fun commit() { + override suspend fun sendSessionData(call: ApplicationCall, onEach: (String) -> Unit) { + providerData.values.forEach { data -> + onEach(data.provider.name) + data.sendSessionData(call) + } committed = true } @@ -175,7 +192,7 @@ internal data class SessionProviderData( val provider: SessionProvider ) -internal val SessionDataKey = AttributeKey("SessionKey") +internal val SessionDataKey = AttributeKey("SessionKey") private fun ApplicationCall.reportMissingSession(): Nothing { application.plugin(Sessions) // ensure the plugin is installed diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionDeferral.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionDeferral.kt new file mode 100644 index 00000000000..772ae834f80 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/SessionDeferral.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.sessions + +import io.ktor.server.application.ApplicationCall + +/** + * System property flag to defer session data retrieval to the point of access rather than at the start of the + * call lifecycle. + */ +internal const val SESSIONS_DEFERRED_FLAG = "io.ktor.server.sessions.deferred" + +internal expect fun isDeferredSessionsEnabled(): Boolean + +/** + * Creates a lazy loading session from the given providers. + */ +internal expect fun createDeferredSession(call: ApplicationCall, providers: List>): StatefulSession diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/Sessions.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/Sessions.kt index d3a4336f12b..4acf0a33833 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/Sessions.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/common/src/io/ktor/server/sessions/Sessions.kt @@ -6,7 +6,6 @@ package io.ktor.server.sessions import io.ktor.server.application.* import io.ktor.server.request.* -import io.ktor.server.response.* import io.ktor.util.* import io.ktor.util.logging.* @@ -27,24 +26,23 @@ internal val LOGGER = KtorSimpleLogger("io.ktor.server.sessions.Sessions") */ public val Sessions: RouteScopedPlugin = createRouteScopedPlugin("Sessions", ::SessionsConfig) { val providers = pluginConfig.providers.toList() + val sessionSupplier: suspend (ApplicationCall, List>) -> StatefulSession = + if (isDeferredSessionsEnabled()) { + ::createDeferredSession + } else { + ::createSession + } application.attributes.put(SessionProvidersKey, providers) onCall { call -> - // For each call, call each provider and retrieve session data if needed. - // Capture data in the attribute's value - val providerData = providers.associateBy({ it.name }) { - it.receiveSessionData(call) - } - - if (providerData.isEmpty()) { - LOGGER.trace("No sessions found for ${call.request.uri}") + if (providers.isEmpty()) { + LOGGER.trace { "No sessions found for ${call.request.uri}" } } else { - val sessions = providerData.keys.joinToString() - LOGGER.trace("Sessions found for ${call.request.uri}: $sessions") + val sessions = providers.joinToString { it.name } + LOGGER.trace { "Sessions found for ${call.request.uri}: $sessions" } } - val sessionData = SessionData(providerData) - call.attributes.put(SessionDataKey, sessionData) + call.attributes.put(SessionDataKey, sessionSupplier(call, providers)) } // When response is being sent, call each provider to update/remove session data @@ -58,11 +56,18 @@ public val Sessions: RouteScopedPlugin = createRouteScopedPlugin */ val sessionData = call.attributes.getOrNull(SessionDataKey) ?: return@on - sessionData.providerData.values.forEach { data -> - LOGGER.trace("Sending session data for ${call.request.uri}: ${data.provider.name}") - data.sendSessionData(call) + sessionData.sendSessionData(call) { provider -> + LOGGER.trace { "Sending session data for ${call.request.uri}: $provider" } } + } +} - sessionData.commit() +private suspend fun createSession(call: ApplicationCall, providers: List>): StatefulSession { + // For each call, call each provider and retrieve session data if needed. + // Capture data in the attribute's value + val providerData = providers.associateBy({ it.name }) { + it.receiveSessionData(call) } + + return SessionData(providerData) } diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/jsAndWasmShared/src/io/ktor/server/sessions/SessionDeferral.jsAndWasmShared.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/jsAndWasmShared/src/io/ktor/server/sessions/SessionDeferral.jsAndWasmShared.kt new file mode 100644 index 00000000000..f1bf1386fa4 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/jsAndWasmShared/src/io/ktor/server/sessions/SessionDeferral.jsAndWasmShared.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.sessions + +import io.ktor.server.application.ApplicationCall + +internal actual fun isDeferredSessionsEnabled(): Boolean = false + +internal actual fun createDeferredSession(call: ApplicationCall, providers: List>): StatefulSession = + TODO("Deferred session retrieval is currently only available for JVM") diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionDeferral.jvm.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionDeferral.jvm.kt new file mode 100644 index 00000000000..c3f6ee91193 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionDeferral.jvm.kt @@ -0,0 +1,8 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.sessions + +internal actual fun isDeferredSessionsEnabled(): Boolean = + System.getProperty(SESSIONS_DEFERRED_FLAG)?.toBoolean() == true diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionSerializerReflection.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionSerializerReflection.kt index 75127ceac4f..b098981dbeb 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionSerializerReflection.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvm/src/io/ktor/server/sessions/SessionSerializerReflection.kt @@ -5,17 +5,18 @@ package io.ktor.server.sessions import io.ktor.http.* -import io.ktor.server.sessions.serialization.* import io.ktor.util.* -import kotlinx.serialization.* -import kotlinx.serialization.json.* -import java.lang.reflect.* -import java.math.* +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.math.BigDecimal +import java.math.BigInteger import java.util.* -import java.util.concurrent.* +import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.* -import kotlin.reflect.full.* -import kotlin.reflect.jvm.* +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.superclasses +import kotlin.reflect.jvm.javaType +import kotlin.reflect.jvm.jvmErasure private const val TYPE_TOKEN_PARAMETER_NAME: String = "\$type" diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/BlockingDeferredSessionData.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/BlockingDeferredSessionData.kt new file mode 100644 index 00000000000..616da68e144 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/BlockingDeferredSessionData.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.sessions + +import io.ktor.server.application.ApplicationCall +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KClass + +/** + * An implementation of [StatefulSession] that lazily references session providers to + * avoid unnecessary calls to session storage. + * All access to the deferred providers is done through blocking calls. + */ +internal class BlockingDeferredSessionData( + val callContext: CoroutineContext, + val providerData: Map>>, +) : StatefulSession { + + private var committed = false + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun sendSessionData(call: ApplicationCall, onEach: (String) -> Unit) { + for (deferredProvider in providerData.values) { + // skip non-completed providers because they were not modified + if (!deferredProvider.isCompleted) continue + val data = deferredProvider.getCompleted() + onEach(data.provider.name) + data.sendSessionData(call) + } + committed = true + } + + override fun findName(type: KClass<*>): String { + val entry = providerData.values.map { + it.awaitBlocking() + }.firstOrNull { + it.provider.type == type + } ?: throw IllegalArgumentException("Session data for type `$type` was not registered") + + return entry.provider.name + } + + override fun set(name: String, value: Any?) { + if (committed) { + throw TooLateSessionSetException() + } + val providerData = + providerData[name] ?: throw IllegalStateException("Session data for `$name` was not registered") + setTyped(providerData.awaitBlocking(), value) + } + + @Suppress("UNCHECKED_CAST") + private fun setTyped(data: SessionProviderData, value: Any?) { + if (value != null) { + data.provider.tracker.validate(value as S) + } + data.newValue = value as S + } + + override fun get(name: String): Any? { + val providerDataDeferred = + providerData[name] ?: throw IllegalStateException("Session data for `$name` was not registered") + val providerData = providerDataDeferred.awaitBlocking() + return providerData.newValue ?: providerData.oldValue + } + + override fun clear(name: String) { + val providerDataDeferred = + providerData[name] ?: throw IllegalStateException("Session data for `$name` was not registered") + val providerData = providerDataDeferred.awaitBlocking() + providerData.oldValue = null + providerData.newValue = null + } + + private fun Deferred>.awaitBlocking() = + runBlocking(callContext) { await() } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/SessionDeferral.jvmAndPosix.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/SessionDeferral.jvmAndPosix.kt new file mode 100644 index 00000000000..8e676ccd3c9 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/jvmAndPosix/src/io/ktor/server/sessions/SessionDeferral.jvmAndPosix.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.sessions + +import io.ktor.server.application.ApplicationCall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async + +internal actual fun createDeferredSession(call: ApplicationCall, providers: List>): StatefulSession = + BlockingDeferredSessionData( + call.coroutineContext, + providers.associateBy({ it.name }) { + CoroutineScope(call.coroutineContext).async(start = CoroutineStart.LAZY) { + it.receiveSessionData(call) + } + } + ) diff --git a/ktor-server/ktor-server-plugins/ktor-server-sessions/posix/src/io/ktor/server/sessions/SessionDeferral.posix.kt b/ktor-server/ktor-server-plugins/ktor-server-sessions/posix/src/io/ktor/server/sessions/SessionDeferral.posix.kt new file mode 100644 index 00000000000..7271529d506 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-sessions/posix/src/io/ktor/server/sessions/SessionDeferral.posix.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.sessions +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +internal actual fun isDeferredSessionsEnabled(): Boolean = + getenv(SESSIONS_DEFERRED_FLAG)?.toKString()?.toBoolean() == true