diff --git a/docs/lyng.io.http.server.md b/docs/lyng.io.http.server.md index 514054c..c5057c9 100644 --- a/docs/lyng.io.http.server.md +++ b/docs/lyng.io.http.server.md @@ -38,6 +38,7 @@ Route handlers use `RequestContext` as the receiver, so inside handlers you norm - `jsonBody()` - `respondJson(...)` +- `respondHtml { ... }` - `respondText(...)` - `setHeader(...)` - `request.path` @@ -45,6 +46,41 @@ Route handlers use `RequestContext` as the receiver, so inside handlers you norm This keeps ordinary HTTP endpoints compact and avoids passing an explicit request or exchange parameter through every route lambda. +## HTML Response Sugar + +Use `respondHtml { ... }` to render an HTML document with the `lyng.io.html` DSL and send it as `text/html; charset=utf-8`. + +```lyng +import lyng.io.http.server +import lyng.io.html + +val server = HttpServer() + +server.get("/") { + respondHtml { + head { + title { +"Lyng status" } + } + body { + h3 { +"Service is running" } + p { +("Path: " + request.path) } + } + } +} + +server.listen(8080, "127.0.0.1") +``` + +Pass `code:` when the route should return a non-200 status: + +```lyng +server.get("/accepted") { + respondHtml(code: 202) { + body { h3 { +"Accepted" } } + } +} +``` + ## JSON API Sugar For ordinary JSON APIs, `RequestContext` includes two primary helpers: @@ -54,25 +90,28 @@ For ordinary JSON APIs, `RequestContext` includes two primary helpers: These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`. -### Typed JSON POST +### Typed JSON POST With Route Params ```lyng import lyng.io.http.server -closed class CreateUserRequest(name: String, age: Int) -closed class CreateUserResponse(id: Int, name: String, age: Int) +closed class CreateResultRequest(title: String, score: Int) +closed class CreateResultResponse(id: String, userId: String, title: String, score: Int) val server = HttpServer() -server.postPath("/api/users") { - val req = jsonBody() +server.postPath("/api/users/{userId}/results") { + val req = jsonBody() - if (req.name.isBlank()) { - respondJson({ error: "name must not be empty" }, 400) + if (req.title.isBlank()) { + respondJson({ error: "title must not be empty" }, 400) return } - respondJson(CreateUserResponse(101, req.name, req.age), 201) + respondJson( + CreateResultResponse("r-101", routeParams["userId"], req.title, req.score), + 201 + ) } server.listen(8080, "127.0.0.1") @@ -118,6 +157,7 @@ server.listen(8080, "127.0.0.1") - `respond(...)` - `respondText(...)` - `respondJson(body, status = 200)` +- `respondHtml(code: 200) { ... }` - `setHeader(...)` - `addHeader(...)` - `acceptWebSocket(...)` diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt index 3660f15..f2476d7 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt @@ -2,6 +2,7 @@ package net.sergeych.lyng.io.http.server import kotlinx.serialization.json.Json import net.sergeych.lyng.ModuleScope +import net.sergeych.lyng.Pos import net.sergeych.lyng.Scope import net.sergeych.lyng.ScopeFacade import net.sergeych.lyng.Source @@ -28,6 +29,7 @@ import net.sergeych.lyng.obj.requiredArg import net.sergeych.lyng.obj.thisAs import net.sergeych.lyng.io.http.ObjHttpHeaders import net.sergeych.lyng.io.http.createHttpTypesModule +import net.sergeych.lyng.io.html.createHtmlModule import net.sergeych.lyng.io.ws.ObjWsMessage import net.sergeych.lyng.io.ws.createWsTypesModule import net.sergeych.lyng.serialization.ObjJsonClass @@ -60,6 +62,7 @@ fun createHttpServer(policy: NetAccessPolicy, scope: Scope): Boolean = createHtt fun createHttpServerModule(policy: NetAccessPolicy, manager: ImportManager): Boolean { createHttpTypesModule(manager) createWsTypesModule(manager) + createHtmlModule(manager) if (manager.packageNames.contains(HTTP_SERVER_MODULE_NAME)) return false manager.addPackage(HTTP_SERVER_MODULE_NAME) { module -> buildHttpServerModule(module, policy) @@ -119,6 +122,7 @@ private val regexType = TypeDecl.Simple("Regex", false) private val nullableRegexMatchType = TypeDecl.Simple("RegexMatch", true) private val voidType = TypeDecl.Simple("Void", false) private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false) +private val htmlTagType = TypeDecl.Simple("HtmlTag", false) private val serverRequestType = TypeDecl.Simple("ServerRequest", false) private val requestContextType = TypeDecl.Simple("RequestContext", false) private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false) @@ -876,6 +880,33 @@ private class ObjServerExchange( self.setHttpResponse(status, bodyText.encodeToByteArray()) ObjVoid } + bridgeFn( + this, + "respondHtml", + base.getInstanceMemberOrNull("respondHtml")?.typeDecl as? TypeDecl.Function + ?: fnType(voidType, intType, receiverFnType(htmlTagType, voidType)), + callSignature = receiverCallSignature("HtmlTag") + ) { + val self = thisAs() + val first = args.list.getOrNull(0) + val second = args.list.getOrNull(1) + val status = args.named["code"]?.let { objToInt(this, it, "code") } ?: when { + first is ObjInt && second != null -> first.value.toInt() + second is ObjInt -> second.value.toInt() + else -> 200 + } + val builder = when { + first is ObjInt -> second + else -> first + } ?: raiseIllegalArgument("respondHtml requires a builder") + val htmlModule = requireScope().importManager.createModuleScope(Pos.builtIn, "lyng.io.html") + val htmlFn = htmlModule.get("html")?.value ?: raiseIllegalState("lyng.io.html.html is not available") + val bodyText = (call(htmlFn, Arguments(listOf(builder))) as ObjString).value + self.ensureMutable(this) + self.responseHeaders["Content-Type"] = mutableListOf("text/html; charset=utf-8") + self.setHttpResponse(status, bodyText.encodeToByteArray()) + ObjVoid + } bridgeFn(this, "setHeader", fnType(voidType, stringType, stringType)) { val self = thisAs() val name = requiredArg(0).value diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt index 292c511..3064bb0 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt @@ -315,6 +315,53 @@ class LyngHttpServerModuleTest { handle.invokeInstanceMethod(scope, "close") } + @Test + fun respondHtmlRendersHtmlDslAndSetsContentType() = runBlocking { + val engine = getSystemNetEngine() + if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking + + val scope = Script.newScope() + createHttpServerModule(PermitAllNetAccessPolicy, scope) + + val code = """ + import lyng.io.http.server + import lyng.io.html + + val server = HttpServer() + server.getPath("/html/{name}") { + respondHtml(code: 202) { + head { title { +"Greeting" } } + body { + h3 { +("Hello, " + routeParams["name"]) } + } + } + } + server.listen(0, "127.0.0.1") + """.trimIndent() + + val handle = Compiler.compile(code).execute(scope) + val port = waitForPort(handle, scope) + + val client = engine.tcpConnect("127.0.0.1", port, 2_000, true) + try { + client.writeUtf8("GET /html/alice%26bob HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + client.flush() + val response = readHttpResponse(client) + assertTrue(response.contains("202"), response) + assertTrue(response.contains("Content-Type: text/html; charset=utf-8"), response) + assertTrue( + response.endsWith( + "Greeting

Hello, alice&bob

" + ), + response + ) + } finally { + client.close() + } + + handle.invokeInstanceMethod(scope, "close") + } + @Test fun routerMountPreservesBuiltInRoutingSemantics() = runBlocking { val engine = getSystemNetEngine() diff --git a/lyngio/stdlib/lyng/io/http_server.lyng b/lyngio/stdlib/lyng/io/http_server.lyng index a8d6b67..35d6cf1 100644 --- a/lyngio/stdlib/lyng/io/http_server.lyng +++ b/lyngio/stdlib/lyng/io/http_server.lyng @@ -3,6 +3,7 @@ package lyng.io.http.server import lyng.io.http.types import lyng.serialization import lyng.io.ws.types +import lyng.io.html /* Immutable parsed incoming server request. */ extern class ServerRequest { @@ -36,6 +37,7 @@ extern class RequestContext { fun respond(status: Int = 200, body: Buffer? = null): void fun respondText(status: Int = 200, bodyText: String = ""): void fun respondJson(body: Object?, status: Int = 200): void + fun respondHtml(code: Int = 200, builder: HtmlTag.()->void): void fun setHeader(name: String, value: String): void fun addHeader(name: String, value: String): void fun acceptWebSocket(handler: RequestContext.(ServerWebSocket) -> Object?): void diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 3078137..dae90d8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -853,7 +853,16 @@ class Compiler( else -> null } if (name == null) return null - val signature = callSignatureForName(name) + val signature = when (left) { + is ImplicitThisMemberRef -> { + val receiverType = left.preferredThisTypeName() ?: implicitReceiverTypeForMember(name) + receiverType + ?.let { resolveClassByName(it) } + ?.getInstanceMemberOrNull(name) + ?.callSignature + } + else -> null + } ?: callSignatureForName(name) return signature?.tailBlockReceiverType ?: if (name == "flow") "FlowBuilder" else null }