diff --git a/frameworks/ktor/Dockerfile b/frameworks/ktor/Dockerfile index 2313c3a7b..cdceee311 100644 --- a/frameworks/ktor/Dockerfile +++ b/frameworks/ktor/Dockerfile @@ -16,6 +16,7 @@ ENTRYPOINT ["java", \ "-XX:+UseNUMA", \ "-XX:+AlwaysPreTouch", \ "-XX:-OmitStackTraceInFastThrow", \ + "-Dio.netty.transport.noNative=false", \ "-Dio.netty.buffer.checkBounds=false", \ "-Dio.netty.buffer.checkAccessible=false", \ "-Dio.netty.allocator.maxOrder=10", \ diff --git a/frameworks/ktor/build.gradle.kts b/frameworks/ktor/build.gradle.kts index b798d1b56..7ce14313e 100644 --- a/frameworks/ktor/build.gradle.kts +++ b/frameworks/ktor/build.gradle.kts @@ -22,11 +22,15 @@ dependencies { implementation(ktorLibs.server.htmlBuilder) implementation(libs.exposed.core) - implementation(libs.exposed.r2dbc) + implementation(libs.exposed.jdbc) implementation(libs.exposed.json) implementation(libs.logback.classic) implementation(libs.postgresql) - implementation(libs.r2dbc.pool) + implementation(libs.hikaricp) + runtimeOnly(libs.netty.native.epoll) + + testImplementation(kotlin("test")) + testImplementation(ktorLibs.server.testHost) } ktor { diff --git a/frameworks/ktor/gradle/libs.versions.toml b/frameworks/ktor/gradle/libs.versions.toml index 1e5b12f24..7d1c776b0 100644 --- a/frameworks/ktor/gradle/libs.versions.toml +++ b/frameworks/ktor/gradle/libs.versions.toml @@ -4,13 +4,14 @@ kotlin = "2.3.21" [libraries] logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.15" } +netty-native-epoll = { module = "io.netty:netty-transport-native-epoll", version = "4.2.14.Final" } # Database exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } -exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-json = { module = "org.jetbrains.exposed:exposed-json", version.ref = "exposed" } -postgresql = { module = "org.postgresql:r2dbc-postgresql", version = "1.1.1.RELEASE" } -r2dbc-pool = { module = "io.r2dbc:r2dbc-pool", version = "1.0.2.RELEASE" } +postgresql = { module = "org.postgresql:postgresql", version = "42.7.4" } +hikaricp = { module = "com.zaxxer:HikariCP", version = "6.2.1" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index 2510a117a..ad01df537 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -2,6 +2,7 @@ package com.httparena import com.httparena.DbResponse.Companion.toResponse import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* @@ -10,40 +11,33 @@ import io.ktor.server.http.content.* import io.ktor.server.netty.* import io.ktor.server.plugins.compression.* import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.plugins.defaultheaders.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.utils.io.* -import kotlinx.coroutines.flow.* +import io.netty.channel.ChannelOption +import io.netty.channel.WriteBufferWaterMark +import io.netty.handler.flush.FlushConsolidationHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.html.* -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction -import org.jetbrains.exposed.v1.r2dbc.* +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.between +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.upsert import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File fun main() { - val appData = AppData() println("Ktor HttpArena server starting on :8080 (HTTP/1.1) and :8443 (HTTPS/HTTP+2)") - + val deps = ArenaApplicationDepsFactory.load() val environment = applicationEnvironment {} - val module: Application.() -> Unit = { - install(DefaultHeaders) { - header("Server", "ktor") - } - install(Compression) { - gzip() - } - install(ContentNegotiation) { - json(appData.json) - } - install(WebSockets) - configureRouting(appData) - } val server = embeddedServer(Netty, environment, { enableHttp2 = true @@ -51,7 +45,7 @@ fun main() { port = 8080 host = "0.0.0.0" } - appData.keystore?.let { keyStore -> + deps.keyStore?.let { keyStore -> sslConnector( keyStore = keyStore, keyAlias = KEY_ALIAS, @@ -71,7 +65,9 @@ fun main() { host = "0.0.0.0" } } - }, module) + }) { + mainModule(deps) + } // Spin up a second server for H2C embeddedServer(Netty, environment, { @@ -94,19 +90,41 @@ fun main() { } } // Import the same endpoints for this server - module() + mainModule(deps) }.start(wait = false) server.start(wait = true) } -private fun Application.configureRouting(appData: AppData) { +internal fun Application.mainModule(appData: ArenaApplicationDeps) { + install(WebSockets) + + configureRouting(appData) +} + +private fun Application.configureRouting(appData: ArenaApplicationDeps) { + val pipelineResponse = ByteArrayContent("ok".toByteArray(), ContentType.Text.Plain) - fun ApplicationCall.sumQueryParams(): Long = - request.queryParameters.entries().sumOf { (_, v) -> - v.sumOf { it.toLongOrNull() ?: 0L } + fun ApplicationCall.sumQueryParams(): Long { + var start = 0 + var sum = 0L + for (i in request.uri.indices) { + when(request.uri[i]) { + '=' -> { + start = i + 1 + } + '&' -> { + val v = request.uri.substring(start, i) + sum += v.toLongOrNull() ?: 0 + } + } } + return sum + (request.uri.substring(start).toLongOrNull() ?: 0) + } + + suspend fun ApplicationCall.respondNumber(long: Long) = + respond(TextContent(long.toString(), ContentType.Text.Plain)) routing { /** @@ -114,7 +132,7 @@ private fun Application.configureRouting(appData: AppData) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/pipelined/ */ get("/pipeline") { - call.respondText("ok", ContentType.Text.Plain) + call.respond(pipelineResponse) } /** @@ -122,10 +140,7 @@ private fun Application.configureRouting(appData: AppData) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ */ get("/baseline11") { - call.respondText( - call.sumQueryParams().toString(), - ContentType.Text.Plain - ) + call.respondNumber(call.sumQueryParams()) } /** @@ -138,10 +153,7 @@ private fun Application.configureRouting(appData: AppData) { call.respondText(sum.toString(), ContentType.Text.Plain) return@post } - call.respondText( - (sum + body).toString(), - ContentType.Text.Plain - ) + call.respondNumber(sum + body) } /** @@ -149,10 +161,7 @@ private fun Application.configureRouting(appData: AppData) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ */ get("/baseline2") { - call.respondText( - call.sumQueryParams().toString(), - ContentType.Text.Plain - ) + call.respondNumber(call.sumQueryParams()) } /** @@ -161,48 +170,62 @@ private fun Application.configureRouting(appData: AppData) { * https://www.http-arena.com/docs/test-profiles/h1/isolated/json-tls/ * https://www.http-arena.com/docs/test-profiles/h1/isolated/json-compressed/ */ - get("/json/{count}") { - if (appData.dataset.isEmpty()) { - call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError) - return@get + route("/json/{count}") { + install(Compression) { + gzip() } - var count = call.pathParameters["count"]?.toIntOrNull() ?: 0 - if (count < 0) count = 0 - if (count > appData.dataset.size) count = appData.dataset.size - val m = call.request.queryParameters["m"]?.toIntOrNull() ?: 1 - val processed = appData.dataset.take(count).map { d -> - ProcessedItem( - id = d.id, name = d.name, category = d.category, - price = d.price, quantity = d.quantity, active = d.active, - tags = d.tags, rating = d.rating, - total = d.price.toLong() * d.quantity * m - ) + install(ContentNegotiation) { + json(appData.json) + } + get { + if (appData.dataset.isEmpty()) { + call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError) + return@get + } + var count = call.pathParameters["count"]?.toIntOrNull() ?: 0 + if (count < 0) count = 0 + if (count > appData.dataset.size) count = appData.dataset.size + val m = call.request.queryParameters["m"]?.toIntOrNull() ?: 1 + val processed = appData.dataset.take(count).map { d -> + ProcessedItem( + id = d.id, name = d.name, category = d.category, + price = d.price, quantity = d.quantity, active = d.active, + tags = d.tags, rating = d.rating, + total = d.price.toLong() * d.quantity * m + ) + } + call.respond(JsonResponse(items = processed, count = count)) } - call.respond(JsonResponse(items = processed, count = count)) } /** * Async DB * https://www.http-arena.com/docs/test-profiles/h1/isolated/async-database/ */ - get("/async-db") { - val min = call.request.queryParameters["min"]?.toIntOrNull() ?: 10 - val max = call.request.queryParameters["max"]?.toIntOrNull() ?: 50 - val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 50).coerceIn(1, 50) - try { - val items = suspendTransaction(appData.postgres, readOnly = true) { - with(ItemTable) { - selectAll() - .where { price.between(min, max) } - .limit(limit) - .map(::toDbItem) - .toList() + route("/async-db") { + install(ContentNegotiation) { + json(appData.json) + } + get { + val min = call.request.queryParameters["min"]?.toIntOrNull() ?: 10 + val max = call.request.queryParameters["max"]?.toIntOrNull() ?: 50 + val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 50).coerceIn(1, 50) + try { + val items = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + with(ItemTable) { + selectAll() + .where { price.between(min, max) } + .limit(limit) + .map(::toDbItem) + } + } } + call.respond(items.toResponse()) + } catch (e: Exception) { + log.error("Failed to load items from DB", e) + call.respondText("{\"items\":[],\"count\":0}", ContentType.Application.Json) } - call.respond(items.toResponse()) - } catch (e: Exception) { - log.error("Failed to load items from DB", e) - call.respondBytes("{\"items\":[],\"count\":0}".toByteArray(), ContentType.Application.Json) } } @@ -249,10 +272,12 @@ private fun Application.configureRouting(appData: AppData) { get("/fortunes") { val fortunes = mutableListOf() try { - suspendTransaction(appData.postgres, readOnly = true) { - FortuneTable.selectAll() - .map(FortuneTable::toFortune) - .toList(fortunes) + withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + FortuneTable.selectAll() + .map(FortuneTable::toFortune) + .toCollection(fortunes) + } } } catch (e: Exception) { log.error("Failed to load fortunes from DB", e) @@ -284,8 +309,11 @@ private fun Application.configureRouting(appData: AppData) { } } -fun Route.crudEndpoints(appData: AppData, log: Logger = LoggerFactory.getLogger("crudRoutes")): Route = +fun Route.crudEndpoints(appData: ArenaApplicationDeps, log: Logger = LoggerFactory.getLogger("crudRoutes")): Route = route("/crud/items") { + install(ContentNegotiation) { + json(appData.json) + } get { val categoryParam = call.request.queryParameters["category"] ?: "electronics" val page = (call.request.queryParameters["page"]?.toIntOrNull() ?: 1).coerceAtLeast(1) @@ -293,13 +321,14 @@ fun Route.crudEndpoints(appData: AppData, log: Logger = LoggerFactory.getLogger( val offset = (page - 1).toLong() * limit try { - val items = suspendTransaction(appData.postgres, readOnly = true) { - ItemTable.selectAll() - .where { ItemTable.category eq categoryParam } - .orderBy(ItemTable.id, SortOrder.ASC) - .limit(limit).offset(offset) - .map(ItemTable::toDbItem) - .toList() + val items = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + ItemTable.selectAll() + .where { ItemTable.category eq categoryParam } + .orderBy(ItemTable.id, SortOrder.ASC) + .limit(limit).offset(offset) + .map(ItemTable::toDbItem) + } } call.respond(CrudListResponse(items = items, total = items.size, page = page, limit = limit)) } catch (e: Exception) { @@ -322,12 +351,14 @@ fun Route.crudEndpoints(appData: AppData, log: Logger = LoggerFactory.getLogger( } try { - val row = suspendTransaction(appData.postgres, readOnly = true) { - ItemTable.selectAll() - .where { ItemTable.id eq id } - .limit(1) - .map(ItemTable::toDbItem) - .firstOrNull() + val row = withContext(Dispatchers.IO) { + transaction(appData.postgres, readOnly = true) { + ItemTable.selectAll() + .where { ItemTable.id eq id } + .limit(1) + .map(ItemTable::toDbItem) + .firstOrNull() + } } if (row == null) { call.respondText("not found", status = HttpStatusCode.NotFound) @@ -351,20 +382,22 @@ fun Route.crudEndpoints(appData: AppData, log: Logger = LoggerFactory.getLogger( return@post } try { - suspendTransaction(appData.postgres) { - ItemTable.upsert( - keys = arrayOf(ItemTable.id), - onUpdateExclude = listOf(ItemTable.ratingScore, ItemTable.ratingCount), - ) { - it[id] = req.id - it[name] = req.name - it[category] = req.category - it[price] = req.price - it[quantity] = req.quantity - it[active] = req.active - it[tags] = req.tags - it[ratingScore] = 0 - it[ratingCount] = 0 + withContext(Dispatchers.IO) { + transaction(appData.postgres) { + ItemTable.upsert( + keys = arrayOf(ItemTable.id), + onUpdateExclude = listOf(ItemTable.ratingScore, ItemTable.ratingCount), + ) { + it[id] = req.id + it[name] = req.name + it[category] = req.category + it[price] = req.price + it[quantity] = req.quantity + it[active] = req.active + it[tags] = req.tags + it[ratingScore] = 0 + it[ratingCount] = 0 + } } } appData.crudCache.invalidate(req.id) @@ -392,20 +425,22 @@ fun Route.crudEndpoints(appData: AppData, log: Logger = LoggerFactory.getLogger( return@put } try { - val updated = suspendTransaction(appData.postgres, readOnly = false) { - val rows = ItemTable.update({ ItemTable.id eq id }) { stmt -> - req.name?.let { v -> stmt[ItemTable.name] = v } - req.price?.let { v -> stmt[ItemTable.price] = v } - req.quantity?.let { v -> stmt[ItemTable.quantity] = v } - } - if (rows == 0) { - null - } else { - ItemTable.selectAll() - .where { ItemTable.id eq id } - .limit(1) - .map(ItemTable::toDbItem) - .firstOrNull() + val updated = withContext(Dispatchers.IO) { + transaction(appData.postgres) { + val rows = ItemTable.update({ ItemTable.id eq id }) { stmt -> + req.name?.let { v -> stmt[ItemTable.name] = v } + req.price?.let { v -> stmt[ItemTable.price] = v } + req.quantity?.let { v -> stmt[ItemTable.quantity] = v } + } + if (rows == 0) { + null + } else { + ItemTable.selectAll() + .where { ItemTable.id eq id } + .limit(1) + .map(ItemTable::toDbItem) + .firstOrNull() + } } } appData.crudCache.invalidate(id) diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt index ecc87e41a..e7e400779 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt @@ -1,19 +1,13 @@ package com.httparena +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource import io.ktor.utils.io.core.discard -import io.r2dbc.pool.ConnectionPool -import io.r2dbc.pool.ConnectionPoolConfiguration -import io.r2dbc.postgresql.PostgresqlConnectionConfiguration -import io.r2dbc.postgresql.PostgresqlConnectionFactory -import io.r2dbc.spi.ConnectionFactoryOptions -import io.r2dbc.spi.IsolationLevel -import io.r2dbc.spi.ValidationDepth import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.serialization.json.Json -import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect -import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase -import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database import java.io.File import java.net.URI import java.security.KeyFactory @@ -21,6 +15,7 @@ import java.security.KeyStore import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.security.spec.PKCS8EncodedKeySpec +import java.sql.Connection import java.util.Base64 import java.util.concurrent.ConcurrentHashMap @@ -74,88 +69,92 @@ const val KEY_PATH = "/certs/server.key" const val KEY_ALIAS = "server" val KEYSTORE_PASSWORD = CharArray(0) -class AppData { - private val cpuCores = Runtime.getRuntime().availableProcessors() - private val certFile = File(CERT_PATH) - private val keyFile = File(KEY_PATH) - private val datasetFile = File(System.getenv("DATASET_PATH") ?: "/data/dataset.json") - - val json = Json { ignoreUnknownKeys = true } - - /** - * Cache-aside store used by the CRUD single-item read endpoint - * (200 ms absolute TTL, in-process). - */ - val crudCache = CrudCache(ttlMillis = 200) - - /** - * Dataset from file. Used in JSON endpoints. - */ - var dataset: List = datasetFile.takeIf { it.exists() }?.let { - json.decodeFromString(it.readText()) - } ?: emptyList() - - /** - * PostgreSQL connection. Used in async database endpoints. - */ - val postgres: R2dbcDatabase? = System.getenv("DATABASE_URL")?.let { dbUrl -> - runCatching { - val uri = URI(dbUrl.replace("postgres://", "postgresql://")) - val host = uri.host - val port = if (uri.port > 0) uri.port else 5432 - val database = uri.path.removePrefix("/") - val userInfo = uri.userInfo.split(":") - - val factory = PostgresqlConnectionFactory( - PostgresqlConnectionConfiguration.builder() - .host(host) - .port(port) - .database(database) - .username(userInfo[0]) - .password(if (userInfo.size > 1) userInfo[1] else "") - .build() - ) - val maxConn = System.getenv("DATABASE_MAX_CONN")?.toIntOrNull() ?: (cpuCores * 2) - val pool = ConnectionPool( - ConnectionPoolConfiguration.builder(factory) - .initialSize(maxConn) - .maxSize(maxConn) - .validationQuery("") - .validationDepth(ValidationDepth.LOCAL) - .acquireRetry(0) - .build() - ) - R2dbcDatabase.connect( - connectionFactory = pool, - databaseConfig = R2dbcDatabaseConfig.Builder().apply { - explicitDialect = PostgreSQLDialect() - defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED +object ArenaApplicationDepsFactory { + fun load(): ArenaApplicationDeps { + val cpuCores = Runtime.getRuntime().availableProcessors() + val certFile = File(CERT_PATH) + val keyFile = File(KEY_PATH) + val datasetFile = File(System.getenv("DATASET_PATH") ?: "/data/dataset.json") + val json = Json { ignoreUnknownKeys = true } + val crudCache = CrudCache(ttlMillis = 200) + val dataset: List = datasetFile.takeIf { it.exists() }?.let { + json.decodeFromString(it.readText()) + } ?: emptyList() + + val postgres: Database? = System.getenv("DATABASE_URL")?.let { dbUrl -> + runCatching { + val uri = URI(dbUrl.replace("postgres://", "postgresql://")) + val host = uri.host + val port = if (uri.port > 0) uri.port else 5432 + val database = uri.path.removePrefix("/") + val userInfo = uri.userInfo.split(":") + val user = userInfo[0] + val password = if (userInfo.size > 1) userInfo[1] else "" + val maxConn = System.getenv("DATABASE_MAX_CONN")?.toIntOrNull() ?: (cpuCores * 2) + + val hikariConfig = HikariConfig().apply { + jdbcUrl = "jdbc:postgresql://$host:$port/$database" + driverClassName = "org.postgresql.Driver" + username = user + this.password = password + maximumPoolSize = maxConn + minimumIdle = maxConn + isAutoCommit = false + transactionIsolation = "TRANSACTION_READ_COMMITTED" + validate() } + val dataSource = HikariDataSource(hikariConfig) + Database.connect( + datasource = dataSource, + databaseConfig = DatabaseConfig { + defaultIsolationLevel = Connection.TRANSACTION_READ_COMMITTED + } + ) + } + }?.getOrNull() + + val keystore: KeyStore? = certFile.takeIf { it.exists() }?.let { certFile -> + val certs = CertificateFactory.getInstance("X.509") + .generateCertificates(certFile.inputStream()) + .map { it as X509Certificate } + .toTypedArray() + + val keyBytes = Base64.getMimeDecoder().decode( + keyFile.readText() + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") ) - } - }?.getOrNull() - - /** - * Keystore for TLS. Used in JSON TLS and JSON compressed endpoints. - */ - val keystore: KeyStore? = certFile.takeIf { it.exists() }?.let { certFile -> - val certs = CertificateFactory.getInstance("X.509") - .generateCertificates(certFile.inputStream()) - .map { it as X509Certificate } - .toTypedArray() - - val keyBytes = Base64.getMimeDecoder().decode( - keyFile.readText() - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\\s".toRegex(), "") - ) - val privateKey = KeyFactory.getInstance("RSA") - .generatePrivate(PKCS8EncodedKeySpec(keyBytes)) + val privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(keyBytes)) - KeyStore.getInstance("PKCS12").apply { - load(null, null) - setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD, certs) + KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD, certs) + } } + + return ArenaApplicationDeps( + json, + crudCache, + dataset, + postgres, + keystore + ) } } + +/** + * Dependencies required for the HttpArena test array. + * @property json JSON serializer. + * @property crudCache Cache-aside store for the CRUD single-item read endpoint. + * @property dataset Dataset from file. Used in JSON endpoints. + * @property keyStore Keystore for TLS. Used in JSON TLS and JSON compressed endpoints. + */ +class ArenaApplicationDeps( + val json: Json, + val crudCache: CrudCache, + val dataset: List, + val postgres: Database?, + val keyStore: KeyStore?, +) diff --git a/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt b/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt new file mode 100644 index 000000000..5c21b80e2 --- /dev/null +++ b/frameworks/ktor/src/test/kotlin/com/httparena/ApplicationTest.kt @@ -0,0 +1,261 @@ +package com.httparena + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApplicationTest { + + private val testJson = Json { prettyPrint = true; ignoreUnknownKeys = true } + + private val sampleDataset = listOf( + DatasetItem( + id = 1, name = "Item-1", category = "electronics", + price = 10, quantity = 2, active = true, + tags = listOf("a", "b"), rating = RatingInfo(score = 5, count = 1) + ), + DatasetItem( + id = 2, name = "Item-2", category = "books", + price = 20, quantity = 3, active = false, + tags = listOf("c"), rating = RatingInfo(score = 3, count = 2) + ), + DatasetItem( + id = 3, name = "Item-3", category = "toys", + price = 30, quantity = 1, active = true, + tags = emptyList(), rating = RatingInfo(score = 4, count = 4) + ) + ) + + private fun buildTestDeps(dataset: List = emptyList()) = ArenaApplicationDeps( + json = testJson, + crudCache = CrudCache(), + dataset = dataset, + postgres = null, + keyStore = null + ) + + private fun ApplicationTestBuilder.setup(deps: ArenaApplicationDeps = buildTestDeps()) { + application { mainModule(deps) } + } + + @Test + fun baseline11Test() = testApplication { + setup() + val sum = client.get("/baseline11?a=2&b=2").let { + assertEquals(200, it.status.value) + it.bodyAsText().toLong() + } + assertEquals(4L, sum) + } + + @Test + fun pipelineTest() = testApplication { + setup() + val response = client.get("/pipeline") + assertEquals(200, response.status.value) + assertEquals("ok", response.bodyAsText()) + } + + @Test + fun baseline11PostWithBodyTest() = testApplication { + setup() + val response = client.post("/baseline11?a=2&b=3") { + setBody("10") + } + assertEquals(200, response.status.value) + assertEquals(15L, response.bodyAsText().toLong()) + } + + @Test + fun baseline11PostWithEmptyBodyTest() = testApplication { + setup() + val response = client.post("/baseline11?a=4&b=5") + assertEquals(200, response.status.value) + assertEquals(9L, response.bodyAsText().toLong()) + } + + @Test + fun baseline2Test() = testApplication { + setup() + val response = client.get("/baseline2?x=7&y=8&z=10") + assertEquals(200, response.status.value) + assertEquals(25L, response.bodyAsText().toLong()) + } + + @Test + fun jsonProcessingTest() = testApplication { + setup(buildTestDeps(dataset = sampleDataset)) + val response = client.get("/json/2?m=3") + assertEquals(200, response.status.value) + val parsed = testJson.decodeFromString(response.bodyAsText()) + assertEquals(2, parsed.count) + assertEquals(2, parsed.items.size) + // total = price * quantity * m = 10 * 2 * 3 = 60 for the first item + assertEquals(60L, parsed.items[0].total) + assertEquals(1, parsed.items[0].id) + } + + @Test + fun jsonProcessingClampsCountTest() = testApplication { + setup(buildTestDeps(dataset = sampleDataset)) + // Requesting more than the dataset size should be clamped to dataset size + val response = client.get("/json/100") + assertEquals(200, response.status.value) + val parsed = testJson.decodeFromString(response.bodyAsText()) + assertEquals(sampleDataset.size, parsed.count) + assertEquals(sampleDataset.size, parsed.items.size) + } + + @Test + fun jsonProcessingEmptyDatasetReturnsErrorTest() = testApplication { + setup() // empty dataset + val response = client.get("/json/1") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("Dataset not loaded", response.bodyAsText()) + } + + @Test + fun uploadTest() = testApplication { + setup() + val payload = ByteArray(1024) { it.toByte() } + val response = client.post("/upload") { + setBody(payload) + } + assertEquals(200, response.status.value) + assertEquals(payload.size.toLong(), response.bodyAsText().toLong()) + } + + @Test + fun asyncDbWithoutDatabaseReturnsEmptyTest() = testApplication { + setup() + // With postgres = null, the handler catches the exception and returns an empty payload + val response = client.get("/async-db?min=0&max=100&limit=10") + assertEquals(200, response.status.value) + assertEquals("{\"items\":[],\"count\":0}", response.bodyAsText()) + } + + @Test + fun fortunesWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val response = client.get("/fortunes") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + } + + @Test + fun crudListWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val response = client.get("/crud/items?category=electronics&page=1&limit=10") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("list failed", response.bodyAsText()) + } + + @Test + fun crudGetByIdBadIdTest() = testApplication { + setup() + val response = client.get("/crud/items/not-a-number") + assertEquals(HttpStatusCode.BadRequest.value, response.status.value) + assertEquals("bad id", response.bodyAsText()) + } + + @Test + fun crudGetByIdCacheHitTest() = testApplication { + val deps = buildTestDeps() + val cachedItem = DbItem( + id = 42u, name = "cached", category = "electronics", + price = 5, quantity = 1, active = true, + tags = listOf("cached"), rating = RatingInfo(score = 0, count = 0) + ) + val cachedBody = testJson.encodeToString(cachedItem).toByteArray() + deps.crudCache.put(42u, cachedBody) + + setup(deps) + val response = client.get("/crud/items/42") + assertEquals(200, response.status.value) + assertEquals("HIT", response.headers["X-Cache"]) + val parsed = testJson.decodeFromString(response.bodyAsText()) + assertEquals(42u, parsed.id) + assertEquals("cached", parsed.name) + } + + @Test + fun crudGetByIdWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val response = client.get("/crud/items/123") + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("read failed", response.bodyAsText()) + } + + @Test + fun crudCreateInvalidBodyTest() = testApplication { + setup() + val response = client.post("/crud/items") { + contentType(ContentType.Application.Json) + setBody("{not-json}") + } + assertEquals(HttpStatusCode.UnprocessableEntity.value, response.status.value) + assertEquals("invalid body", response.bodyAsText()) + } + + @Test + fun crudCreateWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val body = testJson.encodeToString( + CrudCreateRequest( + id = 1u, name = "x", category = "electronics", + price = 1, quantity = 1, active = true, tags = listOf("t") + ) + ) + val response = client.post("/crud/items") { + contentType(ContentType.Application.Json) + setBody(body) + } + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("create failed", response.bodyAsText()) + } + + @Test + fun crudUpdateBadIdTest() = testApplication { + setup() + val response = client.put("/crud/items/not-a-number") { + contentType(ContentType.Application.Json) + setBody("{}") + } + assertEquals(HttpStatusCode.BadRequest.value, response.status.value) + assertEquals("bad id", response.bodyAsText()) + } + + @Test + fun crudUpdateInvalidBodyTest() = testApplication { + setup() + val response = client.put("/crud/items/1") { + contentType(ContentType.Application.Json) + setBody("{not-json}") + } + assertEquals(HttpStatusCode.UnprocessableEntity.value, response.status.value) + assertEquals("invalid body", response.bodyAsText()) + } + + @Test + fun crudUpdateWithoutDatabaseReturnsErrorTest() = testApplication { + setup() + val body = testJson.encodeToString(CrudUpdateRequest(name = "new-name", price = 99, quantity = 5)) + val response = client.put("/crud/items/1") { + contentType(ContentType.Application.Json) + setBody(body) + } + assertEquals(HttpStatusCode.InternalServerError.value, response.status.value) + assertEquals("update failed", response.bodyAsText()) + } + + @Test + fun unknownRouteReturns404Test() = testApplication { + setup() + val response = client.get("/does-not-exist") + assertEquals(HttpStatusCode.NotFound.value, response.status.value) + } + +} diff --git a/frameworks/ktor/src/test/resources/baseline11.http b/frameworks/ktor/src/test/resources/baseline11.http new file mode 100644 index 000000000..cb46b88c3 --- /dev/null +++ b/frameworks/ktor/src/test/resources/baseline11.http @@ -0,0 +1 @@ +GET http://127.0.0.1:8080/baseline11?a=124389&b=398&c=123328 diff --git a/frameworks/ktor/src/test/resources/baseline11post.http b/frameworks/ktor/src/test/resources/baseline11post.http new file mode 100644 index 000000000..e4bbaa977 --- /dev/null +++ b/frameworks/ktor/src/test/resources/baseline11post.http @@ -0,0 +1,3 @@ +POST http://127.0.0.1:8080/baseline11?a=124389&b=398&c=123328 + +874211