|
| 1 | +package com.metaobjects.integration.kotlin.api.jsonb |
| 2 | + |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper |
| 4 | +import com.metaobjects.integration.kotlin.PostgresContainer |
| 5 | +import com.sun.net.httpserver.HttpExchange |
| 6 | +import com.sun.net.httpserver.HttpServer |
| 7 | +import kotlinx.serialization.json.Json |
| 8 | +import kotlinx.serialization.json.JsonElement |
| 9 | +import org.jetbrains.exposed.sql.Database |
| 10 | +import org.jetbrains.exposed.sql.ResultRow |
| 11 | +import org.jetbrains.exposed.sql.SchemaUtils |
| 12 | +import org.jetbrains.exposed.sql.Table |
| 13 | +import org.jetbrains.exposed.sql.insert |
| 14 | +import org.jetbrains.exposed.sql.json.jsonb |
| 15 | +import org.jetbrains.exposed.sql.selectAll |
| 16 | +import org.jetbrains.exposed.sql.transactions.transaction |
| 17 | +import java.net.InetSocketAddress |
| 18 | +import java.sql.DriverManager |
| 19 | + |
| 20 | +/** |
| 21 | + * Minimal reference Kotlin/Exposed HTTP server implementing the cross-port REST |
| 22 | + * API contract for the `Document` entity from the `jsonb/` api-contract-conformance |
| 23 | + * sub-corpus — the `field.string @dbColumnType=jsonb` open-JSON-bag contract |
| 24 | + * (issue #98). |
| 25 | + * |
| 26 | + * Why hand-rolled (vs the `KotlinSpringControllerGenerator` output): same trade-off |
| 27 | + * as the base `AuthorApiServer` — pulling a Spring Boot context in for a 2-route |
| 28 | + * contract test is disproportionate. The handler logic and wire shape mirror the |
| 29 | + * generated controller; the GENERATED lane ([com.metaobjects.integration.kotlin.api.jsonb.generated.GeneratedDocumentControllerHarness]) |
| 30 | + * hosts the real artifact. |
| 31 | + * |
| 32 | + * The open-bag column is a real Postgres `JSONB` (Exposed `jsonb(...)`) whose |
| 33 | + * in-process Kotlin type is a kotlinx `JsonElement` — matching the generated |
| 34 | + * `DocumentTable`. The contract is object-in / object-out: a POSTed JSON object |
| 35 | + * reads back as the same object, never a JSON-encoded string. |
| 36 | + */ |
| 37 | +class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { |
| 38 | + |
| 39 | + private val db: Database = Database.connect(pg.jdbcUrl, user = pg.username, password = pg.password) |
| 40 | + private val mapper = ObjectMapper() |
| 41 | + private val httpServer: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) |
| 42 | + val baseUrl: String |
| 43 | + |
| 44 | + init { |
| 45 | + transaction(db) { SchemaUtils.create(DocumentTable) } |
| 46 | + |
| 47 | + httpServer.createContext("/api/documents") { exchange -> handle(exchange) } |
| 48 | + httpServer.executor = null |
| 49 | + httpServer.start() |
| 50 | + baseUrl = "http://127.0.0.1:${httpServer.address.port}" |
| 51 | + } |
| 52 | + |
| 53 | + override fun close() { |
| 54 | + httpServer.stop(0) |
| 55 | + } |
| 56 | + |
| 57 | + /** TRUNCATE + RESTART IDENTITY. */ |
| 58 | + fun truncate() { |
| 59 | + DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c -> |
| 60 | + c.createStatement().use { it.execute("""TRUNCATE TABLE "documents" RESTART IDENTITY""") } |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + /** |
| 65 | + * Apply the seed file's `rows[]` (each with a jsonb `payload` object), then bump |
| 66 | + * the serial sequence so the next implicit-id insert lands at `max(id) + 1` — so |
| 67 | + * the first scenario POST deterministically lands at id 3. |
| 68 | + */ |
| 69 | + fun applySeed(rows: List<Map<String, Any?>>) { |
| 70 | + truncate() |
| 71 | + transaction(db) { |
| 72 | + for (r in rows) { |
| 73 | + DocumentTable.insert { |
| 74 | + it[id] = (r["id"] as Number).toLong() |
| 75 | + it[title] = r["title"] as String |
| 76 | + it[payload] = toJsonElement(r["payload"]) |
| 77 | + } |
| 78 | + } |
| 79 | + } |
| 80 | + DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c -> |
| 81 | + c.createStatement().use { |
| 82 | + it.execute("SELECT setval(pg_get_serial_sequence('documents', 'id'), COALESCE((SELECT MAX(id) FROM documents), 1))") |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + // ----------------------------------------------------------------------- |
| 88 | + // HTTP handler — only the routes the jsonb scenario exercises (GET /{id}, POST). |
| 89 | + // ----------------------------------------------------------------------- |
| 90 | + |
| 91 | + private fun handle(exchange: HttpExchange) { |
| 92 | + try { |
| 93 | + doHandle(exchange) |
| 94 | + } catch (e: Exception) { |
| 95 | + sendJson(exchange, 500, mapOf("error" to "internal", "message" to (e.message ?: e.toString()))) |
| 96 | + } finally { |
| 97 | + exchange.close() |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + private fun doHandle(exchange: HttpExchange) { |
| 102 | + val method = exchange.requestMethod.uppercase() |
| 103 | + val rawPath = exchange.requestURI.rawPath ?: "" |
| 104 | + val idSegment = rawPath.removePrefix("/api/documents").trim('/').ifEmpty { null } |
| 105 | + |
| 106 | + when { |
| 107 | + method == "POST" && idSegment == null -> createDocument(exchange) |
| 108 | + method == "GET" && idSegment != null -> getDocument(exchange, idSegment) |
| 109 | + else -> sendJson(exchange, 404, mapOf("error" to "not_found")) |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + private fun getDocument(exchange: HttpExchange, idStr: String) { |
| 114 | + val id = idStr.toLongOrNull() |
| 115 | + ?: return sendJson(exchange, 400, mapOf("error" to "invalid_id")) |
| 116 | + val row = transaction(db) { |
| 117 | + DocumentTable.selectAll().where { DocumentTable.id eq id }.singleOrNull()?.let(::rowToMap) |
| 118 | + } |
| 119 | + if (row == null) sendJson(exchange, 404, mapOf("error" to "not_found")) |
| 120 | + else sendJson(exchange, 200, row) |
| 121 | + } |
| 122 | + |
| 123 | + private fun createDocument(exchange: HttpExchange) { |
| 124 | + @Suppress("UNCHECKED_CAST") |
| 125 | + val body = readJsonBody(exchange) as? Map<String, Any?> |
| 126 | + ?: return sendJson(exchange, 400, mapOf("error" to "validation")) |
| 127 | + val newId = transaction(db) { |
| 128 | + DocumentTable.insert { |
| 129 | + it[title] = body["title"] as String |
| 130 | + it[payload] = toJsonElement(body["payload"]) |
| 131 | + }[DocumentTable.id] |
| 132 | + } |
| 133 | + val row = transaction(db) { |
| 134 | + DocumentTable.selectAll().where { DocumentTable.id eq newId }.single().let(::rowToMap) |
| 135 | + } |
| 136 | + sendJson(exchange, 201, row) |
| 137 | + } |
| 138 | + |
| 139 | + // ----------------------------------------------------------------------- |
| 140 | + // Helpers |
| 141 | + // ----------------------------------------------------------------------- |
| 142 | + |
| 143 | + /** |
| 144 | + * Map a row to the wire shape. `payload` is surfaced as a PARSED JSON value |
| 145 | + * (object/array/scalar) — never a JSON-encoded string. The stored `JsonElement` |
| 146 | + * is re-parsed into Jackson-native types so the response serializer emits a plain |
| 147 | + * object (`{"k":"v"}`), exactly the cross-port contract the generated lane locks. |
| 148 | + */ |
| 149 | + private fun rowToMap(row: ResultRow): Map<String, Any?> = linkedMapOf( |
| 150 | + "id" to row[DocumentTable.id], |
| 151 | + "title" to row[DocumentTable.title], |
| 152 | + "payload" to fromJsonElement(row[DocumentTable.payload]), |
| 153 | + ) |
| 154 | + |
| 155 | + /** Jackson value (Map/List/scalar/null) → kotlinx `JsonElement` (the column type). */ |
| 156 | + private fun toJsonElement(value: Any?): JsonElement? = |
| 157 | + if (value == null) null else Json.parseToJsonElement(mapper.writeValueAsString(value)) |
| 158 | + |
| 159 | + /** kotlinx `JsonElement` → Jackson-native value (Map/List/scalar) for the response. */ |
| 160 | + private fun fromJsonElement(el: JsonElement?): Any? = |
| 161 | + if (el == null) null else mapper.readValue(el.toString(), Any::class.java) |
| 162 | + |
| 163 | + private fun readJsonBody(exchange: HttpExchange): Any? { |
| 164 | + val bytes = exchange.requestBody.readAllBytes() |
| 165 | + if (bytes.isEmpty()) return null |
| 166 | + return mapper.readValue(bytes, Any::class.java) |
| 167 | + } |
| 168 | + |
| 169 | + private fun sendJson(exchange: HttpExchange, status: Int, body: Any?) { |
| 170 | + val bytes = mapper.writeValueAsBytes(body) |
| 171 | + exchange.responseHeaders.set("Content-Type", "application/json") |
| 172 | + exchange.sendResponseHeaders(status, bytes.size.toLong()) |
| 173 | + exchange.responseBody.use { it.write(bytes) } |
| 174 | + } |
| 175 | + |
| 176 | + /** |
| 177 | + * Exposed table for `documents`. The `payload` column is a real Postgres `JSONB` |
| 178 | + * whose Kotlin type is a kotlinx `JsonElement` (the open-bag codec, mirroring the |
| 179 | + * generated `DocumentTable`: serialize via `JsonElement.toString()` = canonical |
| 180 | + * JSON, deserialize via `Json.parseToJsonElement`). Nullable because `payload` |
| 181 | + * carries no `@required` in the corpus. |
| 182 | + */ |
| 183 | + object DocumentTable : Table("documents") { |
| 184 | + val id = long("id").autoIncrement() |
| 185 | + val title = varchar("title", 200) |
| 186 | + val payload = jsonb("payload", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() |
| 187 | + |
| 188 | + override val primaryKey = PrimaryKey(id) |
| 189 | + } |
| 190 | +} |
0 commit comments