Add HTTP respondHtml sugar
This commit is contained in:
parent
c8e03d69ad
commit
739fdfc94b
@ -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(...)`
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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&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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user