From 24018efe67efc232458939bfc4d7e7328b183374 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Fri, 24 Apr 2026 18:22:08 +0200 Subject: [PATCH 1/3] feat(01-05): add HOCON config, Flyway migration dir, fail-loud Database.migrate - application.conf: HOCON with ktor.deployment.port (8080 + ${?PORT}) and database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD}) - db/migration/.gitkeep: placeholder so classpath:db/migration resolves - Database.kt: object Database.migrate(app) reads HOCON config, runs Flyway with baselineOnMigrate + validateOnMigrate + cleanDisabled, throws IllegalStateException on any failure (D-16 fail-loud contract) - SLF4J (not Kermit); server logs url+user only, never password --- .../main/kotlin/dev/ulfrx/recipe/Database.kt | 31 +++++++++++++++++++ server/src/main/resources/application.conf | 18 +++++++++++ .../src/main/resources/db/migration/.gitkeep | 0 3 files changed, 49 insertions(+) create mode 100644 server/src/main/kotlin/dev/ulfrx/recipe/Database.kt create mode 100644 server/src/main/resources/application.conf create mode 100644 server/src/main/resources/db/migration/.gitkeep diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt b/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt new file mode 100644 index 0000000..f7cde44 --- /dev/null +++ b/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt @@ -0,0 +1,31 @@ +package dev.ulfrx.recipe + +import io.ktor.server.application.Application +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory + +object Database { + private val log = LoggerFactory.getLogger(Database::class.java) + + fun migrate(app: Application) { + val url = app.environment.config.property("database.url").getString() + val user = app.environment.config.property("database.user").getString() + val password = app.environment.config.property("database.password").getString() + + log.info("Connecting to {} as {} and running Flyway migrations", url, user) + + runCatching { + Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .validateOnMigrate(true) + .cleanDisabled(true) + .load() + .migrate() + }.onFailure { ex -> + log.error("Flyway migration failed — cannot start server", ex) + throw IllegalStateException("Database unreachable or migration failed", ex) + } + } +} diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf new file mode 100644 index 0000000..c82668d --- /dev/null +++ b/server/src/main/resources/application.conf @@ -0,0 +1,18 @@ +ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ dev.ulfrx.recipe.ApplicationKt.module ] + } +} + +database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} +} diff --git a/server/src/main/resources/db/migration/.gitkeep b/server/src/main/resources/db/migration/.gitkeep new file mode 100644 index 0000000..e69de29 From daefe6c26d199b969d6349af351e891fe301e673 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Fri, 24 Apr 2026 18:22:37 +0200 Subject: [PATCH 2/3] refactor(01-05): rewrite Application.kt with ContentNegotiation, Flyway boot, /health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove wildcard Ktor imports (D-11 allWarningsAsErrors safety) — all imports explicit - Install ContentNegotiation { json() } for @Serializable response bodies - Call Database.migrate(this) at boot — fails loudly if Postgres unreachable - Extract configureRouting() extension so tests can compose routing without DB - Replace template root greeting with GET /health → {"status":"ok"} (D-16) - main() shape unchanged: embeddedServer(Netty, SERVER_PORT, "0.0.0.0", ...) --- .../kotlin/dev/ulfrx/recipe/Application.kt | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt b/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt index daab354..8687ae3 100644 --- a/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt +++ b/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt @@ -1,20 +1,36 @@ package dev.ulfrx.recipe -import io.ktor.server.application.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* -import io.ktor.server.response.* -import io.ktor.server.routing.* +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.serialization.Serializable fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) .start(wait = true) } +@Serializable +private data class Health(val status: String) + fun Application.module() { + install(ContentNegotiation) { + json() + } + Database.migrate(this) + configureRouting() +} + +fun Application.configureRouting() { routing { - get("/") { - call.respondText("Ktor: ${Greeting().greet()}") + get("/health") { + call.respond(Health(status = "ok")) } } -} \ No newline at end of file +} From 59d069591b206ff2b0644f19f9114e72dd006cd6 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Fri, 24 Apr 2026 18:23:14 +0200 Subject: [PATCH 3/3] test(01-05): rewrite ApplicationTest to assert GET /health without Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace testRoot template assertion with 'health endpoint returns 200 with status ok' - Compose only configureRouting() in testApplication — NOT Application.module() - This keeps the test independent of Database.migrate / running Postgres (D-11 test invariant) - Install ContentNegotiation { json() } inside application { } — production module() does it, but the test composes routing directly and must install the plugin itself - All imports explicit (D-11 allWarningsAsErrors); no wildcards - Body checked via substring for "status" + "ok" — robust to JSON field ordering Note: ./gradlew :server:test runtime verification deferred to Plan 07 (integration build) since build-logic/recipe.jvm.server plugin is being authored in parallel Plan 02 worktree. --- .../dev/ulfrx/recipe/ApplicationTest.kt | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt b/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt index 296e95f..6f9f71a 100644 --- a/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt +++ b/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt @@ -1,20 +1,30 @@ package dev.ulfrx.recipe -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlin.test.* +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class ApplicationTest { @Test - fun testRoot() = testApplication { + fun `health endpoint returns 200 with status ok`() = testApplication { application { - module() + install(ContentNegotiation) { + json() + } + configureRouting() } - val response = client.get("/") + val response = client.get("/health") assertEquals(HttpStatusCode.OK, response.status) - assertEquals("Ktor: ${Greeting().greet()}", response.bodyAsText()) + val body = response.bodyAsText() + assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body") + assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body") } -} \ No newline at end of file +}