Add HTTP respondHtml sugar

This commit is contained in:
Sergey Chernov 2026-04-29 22:28:37 +03:00
parent c8e03d69ad
commit 739fdfc94b
5 changed files with 138 additions and 9 deletions

View File

@ -38,6 +38,7 @@ Route handlers use `RequestContext` as the receiver, so inside handlers you norm
- `jsonBody<T>()` - `jsonBody<T>()`
- `respondJson(...)` - `respondJson(...)`
- `respondHtml { ... }`
- `respondText(...)` - `respondText(...)`
- `setHeader(...)` - `setHeader(...)`
- `request.path` - `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. 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 ## JSON API Sugar
For ordinary JSON APIs, `RequestContext` includes two primary helpers: 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(...)`. These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`.
### Typed JSON POST ### Typed JSON POST With Route Params
```lyng ```lyng
import lyng.io.http.server import lyng.io.http.server
closed class CreateUserRequest(name: String, age: Int) closed class CreateResultRequest(title: String, score: Int)
closed class CreateUserResponse(id: Int, name: String, age: Int) closed class CreateResultResponse(id: String, userId: String, title: String, score: Int)
val server = HttpServer() val server = HttpServer()
server.postPath("/api/users") { server.postPath("/api/users/{userId}/results") {
val req = jsonBody<CreateUserRequest>() val req = jsonBody<CreateResultRequest>()
if (req.name.isBlank()) { if (req.title.isBlank()) {
respondJson({ error: "name must not be empty" }, 400) respondJson({ error: "title must not be empty" }, 400)
return 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") server.listen(8080, "127.0.0.1")
@ -118,6 +157,7 @@ server.listen(8080, "127.0.0.1")
- `respond(...)` - `respond(...)`
- `respondText(...)` - `respondText(...)`
- `respondJson(body, status = 200)` - `respondJson(body, status = 200)`
- `respondHtml(code: 200) { ... }`
- `setHeader(...)` - `setHeader(...)`
- `addHeader(...)` - `addHeader(...)`
- `acceptWebSocket(...)` - `acceptWebSocket(...)`

View File

@ -2,6 +2,7 @@ package net.sergeych.lyng.io.http.server
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source 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.obj.thisAs
import net.sergeych.lyng.io.http.ObjHttpHeaders import net.sergeych.lyng.io.http.ObjHttpHeaders
import net.sergeych.lyng.io.http.createHttpTypesModule 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.ObjWsMessage
import net.sergeych.lyng.io.ws.createWsTypesModule import net.sergeych.lyng.io.ws.createWsTypesModule
import net.sergeych.lyng.serialization.ObjJsonClass 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 { fun createHttpServerModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
createHttpTypesModule(manager) createHttpTypesModule(manager)
createWsTypesModule(manager) createWsTypesModule(manager)
createHtmlModule(manager)
if (manager.packageNames.contains(HTTP_SERVER_MODULE_NAME)) return false if (manager.packageNames.contains(HTTP_SERVER_MODULE_NAME)) return false
manager.addPackage(HTTP_SERVER_MODULE_NAME) { module -> manager.addPackage(HTTP_SERVER_MODULE_NAME) { module ->
buildHttpServerModule(module, policy) buildHttpServerModule(module, policy)
@ -119,6 +122,7 @@ private val regexType = TypeDecl.Simple("Regex", false)
private val nullableRegexMatchType = TypeDecl.Simple("RegexMatch", true) private val nullableRegexMatchType = TypeDecl.Simple("RegexMatch", true)
private val voidType = TypeDecl.Simple("Void", false) private val voidType = TypeDecl.Simple("Void", false)
private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false) private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false)
private val htmlTagType = TypeDecl.Simple("HtmlTag", false)
private val serverRequestType = TypeDecl.Simple("ServerRequest", false) private val serverRequestType = TypeDecl.Simple("ServerRequest", false)
private val requestContextType = TypeDecl.Simple("RequestContext", false) private val requestContextType = TypeDecl.Simple("RequestContext", false)
private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false) private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false)
@ -876,6 +880,33 @@ private class ObjServerExchange(
self.setHttpResponse(status, bodyText.encodeToByteArray()) self.setHttpResponse(status, bodyText.encodeToByteArray())
ObjVoid ObjVoid
} }
bridgeFn(
this,
"respondHtml",
base.getInstanceMemberOrNull("respondHtml")?.typeDecl as? TypeDecl.Function
?: fnType(voidType, intType, receiverFnType(htmlTagType, voidType)),
callSignature = receiverCallSignature("HtmlTag")
) {
val self = thisAs<ObjServerExchange>()
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)) { bridgeFn(this, "setHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>() val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value val name = requiredArg<ObjString>(0).value

View File

@ -315,6 +315,53 @@ class LyngHttpServerModuleTest {
handle.invokeInstanceMethod(scope, "close") 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(
"<!doctype html><html><head><title>Greeting</title></head><body><h3>Hello, alice&amp;bob</h3></body></html>"
),
response
)
} finally {
client.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test @Test
fun routerMountPreservesBuiltInRoutingSemantics() = runBlocking { fun routerMountPreservesBuiltInRoutingSemantics() = runBlocking {
val engine = getSystemNetEngine() val engine = getSystemNetEngine()

View File

@ -3,6 +3,7 @@ package lyng.io.http.server
import lyng.io.http.types import lyng.io.http.types
import lyng.serialization import lyng.serialization
import lyng.io.ws.types import lyng.io.ws.types
import lyng.io.html
/* Immutable parsed incoming server request. */ /* Immutable parsed incoming server request. */
extern class ServerRequest { extern class ServerRequest {
@ -36,6 +37,7 @@ extern class RequestContext {
fun respond(status: Int = 200, body: Buffer? = null): void fun respond(status: Int = 200, body: Buffer? = null): void
fun respondText(status: Int = 200, bodyText: String = ""): void fun respondText(status: Int = 200, bodyText: String = ""): void
fun respondJson(body: Object?, status: Int = 200): 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 setHeader(name: String, value: String): void
fun addHeader(name: String, value: String): void fun addHeader(name: String, value: String): void
fun acceptWebSocket(handler: RequestContext.(ServerWebSocket) -> Object?): void fun acceptWebSocket(handler: RequestContext.(ServerWebSocket) -> Object?): void

View File

@ -853,7 +853,16 @@ class Compiler(
else -> null else -> null
} }
if (name == null) return 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 return signature?.tailBlockReceiverType ?: if (name == "flow") "FlowBuilder" else null
} }