diff --git a/.gitignore b/.gitignore index e37d6b31a..aefab3537 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,10 @@ site/resources/ *.o *.exe .idea/ +.gradle/ obj/ bin/ +frameworks/*/build/ !frameworks/fletch/bin/ target/ frameworks/blitz/zig-linux-* @@ -23,4 +25,4 @@ frameworks/fletch/.dart_tool/ node_modules/ # macOS Finder metadata -.DS_Store \ No newline at end of file +.DS_Store diff --git a/frameworks/ktor/Dockerfile b/frameworks/ktor/Dockerfile index 9148587ef..2313c3a7b 100644 --- a/frameworks/ktor/Dockerfile +++ b/frameworks/ktor/Dockerfile @@ -1,6 +1,7 @@ -FROM gradle:8.12-jdk21 AS build +FROM gradle:9.5.1-jdk21-corretto AS build WORKDIR /app COPY build.gradle.kts settings.gradle.kts gradle.properties ./ +COPY gradle/libs.versions.toml ./gradle/ RUN gradle dependencies --no-daemon -q 2>/dev/null || true COPY src ./src RUN gradle buildFatJar --no-daemon -q @@ -8,13 +9,13 @@ RUN gradle buildFatJar --no-daemon -q FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/build/libs/ktor-httparena.jar . -EXPOSE 8080 +EXPOSE 8080 8443 ENTRYPOINT ["java", \ "-server", \ "-XX:+UseG1GC", \ "-XX:+UseNUMA", \ "-XX:+AlwaysPreTouch", \ - "-XX:-StackTraceInThrowable", \ + "-XX:-OmitStackTraceInFastThrow", \ "-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 e0518d1dc..f398906fe 100644 --- a/frameworks/ktor/build.gradle.kts +++ b/frameworks/ktor/build.gradle.kts @@ -17,15 +17,20 @@ repositories { } dependencies { - implementation("io.ktor:ktor-server-core:3.4.1") - implementation("io.ktor:ktor-server-netty:3.4.1") - implementation("io.ktor:ktor-server-compression:3.4.1") - implementation("io.ktor:ktor-server-default-headers:3.4.1") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") - implementation("org.xerial:sqlite-jdbc:3.47.2.0") - implementation("org.postgresql:postgresql:42.7.4") - implementation("com.zaxxer:HikariCP:6.2.1") - implementation("ch.qos.logback:logback-classic:1.5.15") + implementation(ktorLibs.server.core) + implementation(ktorLibs.server.netty) + implementation(ktorLibs.server.compression) + implementation(ktorLibs.server.defaultHeaders) + implementation(ktorLibs.server.contentNegotiation) + implementation(ktorLibs.serialization.kotlinx.json) + implementation(ktorLibs.server.websockets) + + implementation(libs.exposed.core) + implementation(libs.exposed.r2dbc) + implementation(libs.exposed.json) + implementation(libs.postgresql) + implementation(libs.r2dbc.pool) + implementation(libs.logback.classic) } ktor { diff --git a/frameworks/ktor/gradle/libs.versions.toml b/frameworks/ktor/gradle/libs.versions.toml new file mode 100644 index 000000000..6a0ae4921 --- /dev/null +++ b/frameworks/ktor/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[versions] +exposed = "1.2.0" + +[libraries] +logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.15" } + +# Database +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", 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" } diff --git a/frameworks/ktor/gradle/wrapper/gradle-wrapper.jar b/frameworks/ktor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..b1b8ef56b Binary files /dev/null and b/frameworks/ktor/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frameworks/ktor/gradle/wrapper/gradle-wrapper.properties b/frameworks/ktor/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..b52fb7e71 --- /dev/null +++ b/frameworks/ktor/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frameworks/ktor/gradlew b/frameworks/ktor/gradlew new file mode 100755 index 000000000..b9bb139f7 --- /dev/null +++ b/frameworks/ktor/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frameworks/ktor/gradlew.bat b/frameworks/ktor/gradlew.bat new file mode 100644 index 000000000..24c62d56f --- /dev/null +++ b/frameworks/ktor/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/frameworks/ktor/meta.json b/frameworks/ktor/meta.json index a91622bb7..4733a72eb 100644 --- a/frameworks/ktor/meta.json +++ b/frameworks/ktor/meta.json @@ -1,22 +1,29 @@ { "display_name": "ktor", "language": "Kotlin", - "type": "tuned", + "type": "production", "engine": "netty", "description": "JetBrains Ktor 3.x on Netty with Kotlin coroutines, kotlinx.serialization, JDK 21.", "repo": "https://github.com/ktorio/ktor", "enabled": true, "tests": [ "baseline", - "pipelined", "limited-conn", + "pipelined", "json", "json-comp", + "json-tls", "upload", + "static", + "async-db", "api-4", "api-16", - "async-db", - "static" + "baseline-h2", + "static-h2", + "echo-ws", + "echo-ws-pipeline" ], - "maintainers": [] -} \ No newline at end of file + "maintainers": [ + "bjhham" + ] +} diff --git a/frameworks/ktor/settings.gradle.kts b/frameworks/ktor/settings.gradle.kts index 0070044c8..0bce0555b 100644 --- a/frameworks/ktor/settings.gradle.kts +++ b/frameworks/ktor/settings.gradle.kts @@ -1 +1,10 @@ rootProject.name = "ktor-httparena" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } + versionCatalogs { + create("ktorLibs").from("io.ktor:ktor-version-catalog:3.5.0") + } +} diff --git a/frameworks/ktor/src/main/java/io/netty/util/LeakPresenceDetector.java b/frameworks/ktor/src/main/java/io/netty/util/LeakPresenceDetector.java deleted file mode 100644 index 4c7185ab5..000000000 --- a/frameworks/ktor/src/main/java/io/netty/util/LeakPresenceDetector.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.netty.util; - -import java.util.function.Supplier; - -/** - * Shadow of Netty's LeakPresenceDetector that skips the stack-trace check, - * allowing -XX:-StackTraceInThrowable to be used safely. - */ -public final class LeakPresenceDetector { - private LeakPresenceDetector() {} - - public static T staticInitializer(Supplier supplier) { - return supplier.get(); - } -} diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt index df6c76883..c9e3d2033 100644 --- a/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Application.kt @@ -1,338 +1,217 @@ package com.httparena import io.ktor.http.* +import io.ktor.network.util.DefaultByteBufferPool +import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* +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 com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json +import io.ktor.utils.io.pool.useInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.io.Buffer +import org.jetbrains.exposed.v1.core.between +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction import java.io.File -import java.net.URI -import java.sql.Connection -import java.sql.DriverManager -@Serializable -data class DatasetItem( - val id: Int, - val name: String, - val category: String, - val price: Int, - val quantity: Int, - val active: Boolean, - val tags: List, - val rating: RatingInfo -) - -@Serializable -data class RatingInfo( - val score: Int, - val count: Int -) - -@Serializable -data class ProcessedItem( - val id: Int, - val name: String, - val category: String, - val price: Int, - val quantity: Int, - val active: Boolean, - val tags: List, - val rating: RatingInfo, - val total: Long -) - -@Serializable -data class JsonResponse( - val items: List, - val count: Int -) - -@Serializable -data class DbItem( - val id: Int, - val name: String, - val category: String, - val price: Int, - val quantity: Int, - val active: Boolean, - val tags: List, - val rating: RatingInfo -) - -@Serializable -data class DbResponse( - val items: List, - val count: Int -) - -object AppData { - val json = Json { ignoreUnknownKeys = true } - var dataset: List = emptyList() - data class StaticEntry(val data: ByteArray, val br: ByteArray?, val gz: ByteArray?, val contentType: String) - val staticFiles: MutableMap = mutableMapOf() - var db: Connection? = null - var pgPool: HikariDataSource? = null +fun main() { + val appData = AppData() + println("Ktor HttpArena server starting on :8080 (HTTP/1.1) and :8443 (HTTPS/HTTP+2)") - private val mimeTypes = mapOf( - ".css" to "text/css", - ".js" to "application/javascript", - ".html" to "text/html", - ".woff2" to "font/woff2", - ".svg" to "image/svg+xml", - ".webp" to "image/webp", - ".json" to "application/json" - ) + val environment = applicationEnvironment {} + val server = embeddedServer(Netty, environment, { + enableHttp2 = true - fun load() { - // Dataset - val path = System.getenv("DATASET_PATH") ?: "/data/dataset.json" - val dataFile = File(path) - if (dataFile.exists()) { - dataset = json.decodeFromString>(dataFile.readText()) + connector { + port = 8080 + host = "0.0.0.0" } - - // Static files with pre-compressed variants - val staticDir = File("/data/static") - if (staticDir.isDirectory) { - staticDir.listFiles()?.forEach { file -> - if (file.isFile && !file.name.endsWith(".br") && !file.name.endsWith(".gz")) { - val ext = file.extension.let { if (it.isNotEmpty()) ".$it" else "" } - val ct = mimeTypes[ext] ?: "application/octet-stream" - val brFile = File(file.path + ".br") - val gzFile = File(file.path + ".gz") - staticFiles[file.name] = StaticEntry( - data = file.readBytes(), - br = if (brFile.exists()) brFile.readBytes() else null, - gz = if (gzFile.exists()) gzFile.readBytes() else null, - contentType = ct - ) - } + appData.keystore?.let { keyStore -> + sslConnector( + keyStore = keyStore, + keyAlias = KEY_ALIAS, + keyStorePassword = { KEYSTORE_PASSWORD }, + privateKeyPassword = { KEYSTORE_PASSWORD } + ) { + port = 8081 + host = "0.0.0.0" + } + sslConnector( + keyStore = keyStore, + keyAlias = KEY_ALIAS, + keyStorePassword = { KEYSTORE_PASSWORD }, + privateKeyPassword = { KEYSTORE_PASSWORD } + ) { + port = 8443 + host = "0.0.0.0" } } - - // Database - val dbFile = File("/data/benchmark.db") - if (dbFile.exists()) { - db = DriverManager.getConnection("jdbc:sqlite:file:/data/benchmark.db?mode=ro&immutable=1") - db!!.createStatement().execute("PRAGMA mmap_size=268435456") + }) { + install(DefaultHeaders) { + header("Server", "ktor") } - - // PostgreSQL connection pool - val dbUrl = System.getenv("DATABASE_URL") - if (!dbUrl.isNullOrEmpty()) { - try { - 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 config = HikariConfig() - config.driverClassName = "org.postgresql.Driver" - config.jdbcUrl = "jdbc:postgresql://$host:$port/$database" - config.username = userInfo[0] - config.password = if (userInfo.size > 1) userInfo[1] else "" - config.maximumPoolSize = 64 - config.minimumIdle = 16 - pgPool = HikariDataSource(config) - } catch (e: Exception) { - System.err.println("PG pool init failed: $e") - } + install(Compression) { + gzip() } - } + install(ContentNegotiation) { + json(appData.json) + } + install(WebSockets) + configureRouting(appData) + } + server.start(wait = true) } -fun main() { - AppData.load() - println("Ktor HttpArena server starting on :8080") +private fun Application.configureRouting(appData: AppData) { - embeddedServer(Netty, port = 8080, host = "0.0.0.0") { - install(DefaultHeaders) { - header("Server", "ktor") + fun ApplicationCall.sumQueryParams(): Long = + request.queryParameters.entries().sumOf { (_, v) -> + v.sumOf { it.toLongOrNull() ?: 0L } } - install(Compression) - routing { - get("/pipeline") { - call.respondText("ok", ContentType.Text.Plain) - } + routing { + /** + * Pipelined + * https://www.http-arena.com/docs/test-profiles/h1/isolated/pipelined/ + */ + get("/pipeline") { + call.respondText("ok", ContentType.Text.Plain) + } - get("/baseline11") { - val sum = sumQueryParams(call) - call.respondText(sum.toString(), ContentType.Text.Plain) - } + /** + * Baseline 1.1 + * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ + */ + get("/baseline11") { + call.respondText( + call.sumQueryParams().toString(), + ContentType.Text.Plain + ) + } - post("/baseline11") { - var sum = sumQueryParams(call) - val body = call.receiveText().trim() - body.toLongOrNull()?.let { sum += it } + /** + * Baseline 1.1 + * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ + */ + post("/baseline11") { + val sum = call.sumQueryParams() + val body = call.receiveText().trim().toLongOrNull() ?: run { call.respondText(sum.toString(), ContentType.Text.Plain) + return@post } + call.respondText( + (sum + body).toString(), + ContentType.Text.Plain + ) + } - get("/baseline2") { - val sum = sumQueryParams(call) - call.respondText(sum.toString(), ContentType.Text.Plain) - } + /** + * Baseline 2 + * https://www.http-arena.com/docs/test-profiles/h1/isolated/baseline/ + */ + get("/baseline2") { + call.respondText( + call.sumQueryParams().toString(), + ContentType.Text.Plain + ) + } - get("/json/{count}") { - if (AppData.dataset.isEmpty()) { - call.respondText("Dataset not loaded", ContentType.Text.Plain, HttpStatusCode.InternalServerError) - return@get - } - var count = call.parameters["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 - ) - } - val resp = JsonResponse(items = processed, count = count) - val body = AppData.json.encodeToString(JsonResponse.serializer(), resp).toByteArray() - call.respondBytes(body, ContentType.Application.Json) + /** + * JSON processing + * https://www.http-arena.com/docs/test-profiles/h1/isolated/json-processing/ + * 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 } - - get("/db") { - val conn = AppData.db - if (conn == null) { - call.respondText("Database not available", ContentType.Text.Plain, HttpStatusCode.InternalServerError) - return@get - } - val min = call.parameters["min"]?.toDoubleOrNull() ?: 10.0 - val max = call.parameters["max"]?.toDoubleOrNull() ?: 50.0 - - val items = mutableListOf() - synchronized(conn) { - val stmt = conn.prepareStatement( - "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT 50" - ) - stmt.setDouble(1, min) - stmt.setDouble(2, max) - val rs = stmt.executeQuery() - while (rs.next()) { - val tags = AppData.json.decodeFromString>(rs.getString(7)) - items.add( - DbItem( - id = rs.getInt(1), - name = rs.getString(2), - category = rs.getString(3), - price = rs.getInt(4), - quantity = rs.getInt(5), - active = rs.getInt(6) == 1, - tags = tags, - rating = RatingInfo(score = rs.getInt(8), count = rs.getInt(9)) - ) - ) - } - rs.close() - stmt.close() - } - val resp = DbResponse(items = items, count = items.size) - val body = AppData.json.encodeToString(DbResponse.serializer(), resp).toByteArray() - call.respondBytes(body, ContentType.Application.Json) + 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)) + } - get("/async-db") { - val pool = AppData.pgPool - if (pool == null) { - call.respondBytes("{\"items\":[],\"count\":0}".toByteArray(), ContentType.Application.Json) - return@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 = mutableListOf() - pool.connection.use { conn -> - val stmt = conn.prepareStatement( - "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT ?" - ) - stmt.setInt(1, min) - stmt.setInt(2, max) - stmt.setInt(3, limit) - val rs = stmt.executeQuery() - while (rs.next()) { - val tags = AppData.json.decodeFromString>(rs.getString(7)) - items.add( - DbItem( - id = rs.getInt(1), - name = rs.getString(2), - category = rs.getString(3), - price = rs.getInt(4), - quantity = rs.getInt(5), - active = rs.getBoolean(6), - tags = tags, - rating = RatingInfo(score = rs.getInt(8), count = rs.getInt(9)) - ) - ) - } - rs.close() - stmt.close() + /** + * 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) { + with(ItemTable) { + selectAll() + .where { price.between(min, max) } + .limit(limit) + .map(::toDbItem) + .toList() } - val resp = DbResponse(items = items, count = items.size) - val body = AppData.json.encodeToString(DbResponse.serializer(), resp).toByteArray() - call.respondBytes(body, ContentType.Application.Json) - } catch (_: Exception) { - call.respondBytes("{\"items\":[],\"count\":0}".toByteArray(), ContentType.Application.Json) } + call.respond( + DbResponse( + items = items, + count = items.size + ) + ) + } catch (e: Exception) { + log.error("Failed to load items from DB", e) + call.respondBytes("{\"items\":[],\"count\":0}".toByteArray(), ContentType.Application.Json) } + } - post("/upload") { - val channel = call.receiveChannel() - var totalBytes = 0L - val buf = ByteArray(65536) - while (!channel.isClosedForRead) { - val read = channel.readAvailable(buf) - if (read > 0) totalBytes += read - } - call.respondText(totalBytes.toString(), ContentType.Text.Plain) - } + /** + * Upload 20MB + * https://www.http-arena.com/docs/test-profiles/h1/isolated/upload/ + */ + post("/upload") { + val channel = call.request.receiveChannel() + val totalBytes = channel.readTo(DevNull) + call.respondText( + totalBytes.toString(), + ContentType.Text.Plain + ) + } - get("/static/{filename}") { - val filename = call.parameters["filename"] - if (filename == null) { - call.respond(HttpStatusCode.NotFound) - return@get - } - val entry = AppData.staticFiles[filename] - if (entry == null) { - call.respond(HttpStatusCode.NotFound) - return@get - } - val ae = call.request.header(HttpHeaders.AcceptEncoding) ?: "" - if (entry.br != null && ae.contains("br")) { - call.response.header(HttpHeaders.ContentEncoding, "br") - call.respondBytes(entry.br, ContentType.parse(entry.contentType)) - } else if (entry.gz != null && ae.contains("gzip")) { - call.response.header(HttpHeaders.ContentEncoding, "gzip") - call.respondBytes(entry.gz, ContentType.parse(entry.contentType)) - } else { - call.respondBytes(entry.data, ContentType.parse(entry.contentType)) - } - } + /** + * Static files + * https://www.http-arena.com/docs/test-profiles/h1/isolated/static/ + */ + staticFiles("/static", File("/data/static")) { + preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP) + } + + /** + * Echo WebSocket + * https://www.http-arena.com/docs/test-profiles/ws/ + */ + webSocket("/ws") { + for (message in incoming) + send(message) } - }.start(wait = true) -} -private fun sumQueryParams(call: ApplicationCall): Long { - var sum = 0L - call.parameters.names().forEach { name -> - call.parameters[name]?.toLongOrNull()?.let { sum += it } } - return sum } - - diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/DBSchema.kt b/frameworks/ktor/src/main/kotlin/com/httparena/DBSchema.kt new file mode 100644 index 000000000..62557b6d4 --- /dev/null +++ b/frameworks/ktor/src/main/kotlin/com/httparena/DBSchema.kt @@ -0,0 +1,31 @@ +package com.httparena + +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.dao.id.UIntIdTable +import org.jetbrains.exposed.v1.json.jsonb + +object ItemTable: UIntIdTable("items") { + val name = text("name") + val category = text("category") + val price = integer("price") + val quantity = integer("quantity") + val active = bool("active") + val tags = jsonb>("tags", Json) + val ratingScore = integer("rating_score") + val ratingCount = integer("rating_count") + + fun toDbItem(row: ResultRow) = DbItem( + id = row[id].value.toInt(), + name = row[name], + category = row[category], + price = row[price], + quantity = row[quantity], + active = row[active], + tags = row[tags], + rating = RatingInfo( + row[ratingScore], + row[ratingCount] + ) + ) +} diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/DataTypes.kt b/frameworks/ktor/src/main/kotlin/com/httparena/DataTypes.kt new file mode 100644 index 000000000..442e668b2 --- /dev/null +++ b/frameworks/ktor/src/main/kotlin/com/httparena/DataTypes.kt @@ -0,0 +1,58 @@ +package com.httparena + +import kotlinx.serialization.Serializable + +@Serializable +data class DatasetItem( + val id: Int, + val name: String, + val category: String, + val price: Int, + val quantity: Int, + val active: Boolean, + val tags: List, + val rating: RatingInfo +) + +@Serializable +data class RatingInfo( + val score: Int, + val count: Int +) + +@Serializable +data class ProcessedItem( + val id: Int, + val name: String, + val category: String, + val price: Int, + val quantity: Int, + val active: Boolean, + val tags: List, + val rating: RatingInfo, + val total: Long +) + +@Serializable +data class JsonResponse( + val items: List, + val count: Int +) + +@Serializable +data class DbItem( + val id: Int, + val name: String, + val category: String, + val price: Int, + val quantity: Int, + val active: Boolean, + val tags: List, + val rating: RatingInfo +) + +@Serializable +data class DbResponse( + val items: List, + val count: Int +) diff --git a/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt new file mode 100644 index 000000000..c4daad648 --- /dev/null +++ b/frameworks/ktor/src/main/kotlin/com/httparena/Utils.kt @@ -0,0 +1,117 @@ +package com.httparena + +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 java.io.File +import java.net.URI +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 + +object DevNull : RawSink { + override fun close() {} + override fun flush() {} + override fun write(source: Buffer, byteCount: Long) { + source.discard(byteCount) + } +} + +const val CERT_PATH = "/certs/server.crt" +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 } + + /** + * 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 pool = ConnectionPool( + ConnectionPoolConfiguration.builder(factory) + .initialSize(cpuCores * 2) + .maxSize(cpuCores * 2) + .validationQuery("") + .validationDepth(ValidationDepth.LOCAL) + .acquireRetry(0) + .build() + ) + R2dbcDatabase.connect( + connectionFactory = pool, + databaseConfig = R2dbcDatabaseConfig.Builder().apply { + explicitDialect = PostgreSQLDialect() + defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED + defaultReadOnly = true + } + ) + } + }?.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)) + + KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD, certs) + } + } +}