Extend HTTP server routing and JSON exchange helpers

This commit is contained in:
Sergey Chernov 2026-04-26 13:42:14 +03:00
parent b969edd30a
commit ca4a0d4b12
8 changed files with 981 additions and 116 deletions

210
docs/lyng.io.http.server.md Normal file
View File

@ -0,0 +1,210 @@
### lyng.io.http.server — Minimal HTTP/1.1 and WebSocket server
This module provides a small server-side HTTP API for Lyng scripts. It is implemented in `lyngio` on top of the existing TCP layer and is intended for embedded tools, local services, test fixtures, and lightweight app backends.
It supports:
- HTTP/1.1 request parsing
- keep-alive
- exact-path routing
- regex routing
- path-template routing with named parameters
- websocket upgrade and server-side sessions
It does not aim to replace a full reverse proxy. Typical deployment is behind nginx, Caddy, or another frontend that handles TLS and public-facing edge concerns.
> **Security note:** this module uses the same `NetAccessPolicy` capability model as raw TCP sockets. If scripts are allowed to listen on TCP, they can host an HTTP server.
---
#### Install the module into a Lyng session
Kotlin bootstrap example:
```kotlin
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope
import net.sergeych.lyng.io.http.server.createHttpServerModule
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
suspend fun bootstrapHttpServer() {
val session = EvalSession()
val scope: Scope = session.getScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
session.eval("import lyng.io.http.server")
}
```
---
#### Basic exact route
```lyng
import lyng.io.http.server
val server = HttpServer()
server.get("/hello") { ex ->
ex.setHeader("Content-Type", "text/plain")
ex.respondText(200, "hello")
}
server.listen(8080, "127.0.0.1")
```
---
#### Regex route
Regex routes match the whole request path, not a substring.
```lyng
server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) { ex ->
val m = ex.routeMatch!!
ex.respondText(200, "user=" + m[1] + ", post=" + m[2])
}
```
---
#### Path-template route
Path templates are sugar on top of regex routes. Template parameters are exposed as decoded `routeParams`.
```lyng
server.getPath("/users/{userId}/posts/{postId}") { ex ->
ex.respondText(
200,
ex.routeParams["userId"] + ":" + ex.routeParams["postId"]
)
}
```
Template rules:
- template must start with `/`
- a segment is either literal text or `{name}`
- parameter names must be valid identifiers
- parameter values match one path segment only
- parameter values use path decoding rules:
- valid percent-encoding is decoded
- `+` stays `+`
- malformed `%` stays literal
---
#### Request and exchange data
`ServerRequest` exposes parsed HTTP request data:
- `method: String`
- `target: String`
- `path: String`
- `pathParts: List<String>`
- `queryString: String?`
- `query: Map<String, String>`
- `headers: HttpHeaders`
- `body: Buffer`
`ServerExchange` exposes routing context and response controls:
- `request: ServerRequest`
- `routeMatch: RegexMatch?`
- `routeParams: Map<String, String>`
- `jsonBody<T>()`
- `respond(...)`
- `respondText(...)`
- `respondJson(body, status = 200)`
- `setHeader(...)`
- `addHeader(...)`
- `acceptWebSocket(...)`
For exact routes, `routeMatch` is `null` and `routeParams` is empty.
For regex routes, `routeMatch` is set and `routeParams` is empty.
For path-template routes, both `routeMatch` and `routeParams` are set.
---
#### JSON request/response helpers
For ordinary HTTP JSON APIs, `ServerExchange` includes two helpers:
- `jsonBody<T>()` decodes the request body with typed `Json.decodeAs(...)`
- `respondJson(body, status = 200)` sets JSON content type and responds with plain `toJsonString()`
Example:
```lyng
import lyng.io.http.server
closed class CreateUserRequest(name: String, age: Int)
closed class CreateUserResponse(id: Int, name: String, age: Int)
val server = HttpServer()
server.postPath("/api/users") { ex ->
val req = ex.jsonBody<CreateUserRequest>()
if (req.name.isBlank()) {
ex.respondJson({ error: "name must not be empty" }, 400)
return
}
ex.respondJson(CreateUserResponse(101, req.name, req.age), 201)
}
server.listen(8080, "127.0.0.1")
```
These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`.
---
#### Route precedence
Dispatch order is:
1. exact method route
2. exact `any` route
3. regex method route, registration order
4. regex `any` route, registration order
5. fallback
This means exact routes stay fast and always win over template or regex routes for the same path.
---
#### WebSocket routes
You can route websocket upgrades by exact path, regex, or path template:
```lyng
server.ws("/chat") { ws, ex ->
ws.sendText("hello")
ws.close()
}
server.wsPath("/ws/{room}") { ws, ex ->
ws.sendText("room=" + ex.routeParams["room"])
ws.close()
}
```
---
#### API surface
`HttpServer` route registration methods:
- `get(path: String|Regex, handler)`
- `getPath(pathTemplate: String, handler)`
- `post(path: String|Regex, handler)`
- `postPath(pathTemplate: String, handler)`
- `put(path: String|Regex, handler)`
- `putPath(pathTemplate: String, handler)`
- `delete(path: String|Regex, handler)`
- `deletePath(pathTemplate: String, handler)`
- `any(path: String|Regex, handler)`
- `anyPath(pathTemplate: String, handler)`
- `ws(path: String|Regex, handler)`
- `wsPath(pathTemplate: String, handler)`
- `fallback(handler)`
- `listen(port, host = null, backlog = 128)`

View File

@ -17,6 +17,7 @@
- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information.
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`.
- **[lyng.io.http.server](lyng.io.http.server.md):** Minimal HTTP/1.1 and WebSocket server. Provides `HttpServer`, `ServerRequest`, `ServerExchange`, and `ServerWebSocket`.
- **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`.
- **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`.
- **Shared networking type packages:** `lyng.io.http.types`, `lyng.io.ws.types`, and `lyng.io.net.types` expose reusable value types such as `HttpHeaders`, `WsMessage`, `IpVersion`, `SocketAddress`, and `Datagram` when host code wants type-only imports without installing the corresponding capability object module.
@ -120,6 +121,7 @@ For more details, see the specific module documentation:
- [Process Security Details](lyng.io.process.md#security-policy)
- [Console Module Details](lyng.io.console.md)
- [HTTP Module Details](lyng.io.http.md)
- [HTTP Server Module Details](lyng.io.http.server.md)
- [Transport Networking Details](lyng.io.net.md)
- [WebSocket Module Details](lyng.io.ws.md)

View File

@ -1,5 +1,6 @@
package net.sergeych.lyng.io.http.server
import kotlinx.serialization.json.Json
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
@ -14,9 +15,13 @@ import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjProperty
import net.sergeych.lyng.obj.ObjRegex
import net.sergeych.lyng.obj.ObjRegexMatch
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjTypeExpr
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
@ -24,6 +29,7 @@ import net.sergeych.lyng.io.http.ObjHttpHeaders
import net.sergeych.lyng.io.http.createHttpTypesModule
import net.sergeych.lyng.io.ws.ObjWsMessage
import net.sergeych.lyng.io.ws.createWsTypesModule
import net.sergeych.lyng.serialization.ObjJsonClass
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs
@ -34,6 +40,7 @@ import net.sergeych.lyngio.http.server.HttpRequest
import net.sergeych.lyngio.http.server.HttpResponse
import net.sergeych.lyngio.http.server.HttpServerConfig
import net.sergeych.lyngio.http.server.HttpWebSocketSession
import net.sergeych.lyngio.http.server.decodePathSegment
import net.sergeych.lyngio.http.server.defaultReason
import net.sergeych.lyngio.http.server.startHttpServer
import net.sergeych.lyngio.net.security.NetAccessDeniedException
@ -63,13 +70,14 @@ fun createHttpServer(policy: NetAccessPolicy, manager: ImportManager): Boolean =
private suspend fun buildHttpServerModule(module: ModuleScope, policy: NetAccessPolicy) {
module.eval(Source(HTTP_SERVER_MODULE_NAME, http_serverLyng))
val serverExchangeClass = ObjServerExchange.type(module.requireClass("ServerExchange"))
module.addConst("HttpHeaders", ObjHttpHeaders.type)
module.addConst("WsMessage", ObjWsMessage.type)
module.addConst("ServerRequest", ObjServerRequest.type)
module.addConst("ServerExchange", ObjServerExchange.type)
module.addConst("ServerExchange", serverExchangeClass)
module.addConst("ServerWebSocket", ObjServerWebSocket.type)
module.addConst("HttpServerHandle", ObjHttpServerHandle.type)
module.addConst("HttpServer", ObjLyngHttpServer.type(policy))
module.addConst("HttpServer", ObjLyngHttpServer.type(policy, serverExchangeClass))
}
private suspend inline fun ScopeFacade.httpServerGuard(crossinline block: suspend () -> Obj): Obj {
@ -102,6 +110,8 @@ private val boolType = TypeDecl.Simple("Bool", false)
private val intType = TypeDecl.Simple("Int", false)
private val bufferType = TypeDecl.Simple("Buffer", false)
private val nullableBufferType = TypeDecl.Simple("Buffer", true)
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 serverRequestType = TypeDecl.Simple("ServerRequest", false)
@ -113,6 +123,8 @@ private val httpServerType = TypeDecl.Simple("HttpServer", false)
private val nullableAnyType = TypeDecl.TypeNullableAny
private fun listType(item: TypeDecl) = TypeDecl.Generic("List", listOf(item), false)
private fun mapType(key: TypeDecl, value: TypeDecl) = TypeDecl.Generic("Map", listOf(key, value), false)
private fun unionType(vararg options: TypeDecl) = TypeDecl.Union(options.toList(), nullable = false)
private fun fnType(returnType: TypeDecl, vararg params: TypeDecl) =
TypeDecl.Function(receiver = null, params = params.toList(), returnType = returnType)
@ -147,48 +159,71 @@ private fun bridgeProperty(
private class ObjLyngHttpServer(
private val netPolicy: NetAccessPolicy,
private val exchangeClass: ObjClass,
) : Obj() {
private val methodRoutes = linkedMapOf<String, LinkedHashMap<String, RegisteredCallable>>()
private val methodRegexRoutes = linkedMapOf<String, MutableList<RegisteredRegexRoute>>()
private val anyRoutes = linkedMapOf<String, RegisteredCallable>()
private val anyRegexRoutes = mutableListOf<RegisteredRegexRoute>()
private val wsRoutes = linkedMapOf<String, RegisteredCallable>()
private val wsRegexRoutes = mutableListOf<RegisteredRegexRoute>()
private var fallback: RegisteredCallable? = null
private var handle: net.sergeych.lyngio.http.server.HttpServer? = null
override val objClass: ObjClass
get() = type(netPolicy)
get() = type(netPolicy, exchangeClass)
companion object {
private val types = mutableMapOf<NetAccessPolicy, ObjClass>()
private val types = mutableMapOf<Pair<NetAccessPolicy, ObjClass>, ObjClass>()
fun type(netPolicy: NetAccessPolicy): ObjClass =
types.getOrPut(netPolicy) {
fun type(netPolicy: NetAccessPolicy, exchangeClass: ObjClass): ObjClass =
types.getOrPut(netPolicy to exchangeClass) {
object : ObjClass("HttpServer") {
override suspend fun callOn(scope: Scope): Obj {
if (scope.args.list.isNotEmpty()) scope.raiseError("HttpServer() does not accept arguments")
return ObjLyngHttpServer(netPolicy)
return ObjLyngHttpServer(netPolicy, exchangeClass)
}
}.apply {
val routeArgType = unionType(stringType, regexType)
val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType)
val webSocketHandlerType = fnType(nullableAnyType, serverWebSocketType, serverExchangeType)
bridgeFn(this, "get", fnType(httpServerType, stringType, exchangeHandlerType)) {
bridgeFn(this, "get", fnType(httpServerType, routeArgType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("GET", this)
}
bridgeFn(this, "post", fnType(httpServerType, stringType, exchangeHandlerType)) {
bridgeFn(this, "getPath", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("GET", this)
}
bridgeFn(this, "post", fnType(httpServerType, routeArgType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("POST", this)
}
bridgeFn(this, "put", fnType(httpServerType, stringType, exchangeHandlerType)) {
bridgeFn(this, "postPath", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("POST", this)
}
bridgeFn(this, "put", fnType(httpServerType, routeArgType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("PUT", this)
}
bridgeFn(this, "delete", fnType(httpServerType, stringType, exchangeHandlerType)) {
bridgeFn(this, "putPath", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("PUT", this)
}
bridgeFn(this, "delete", fnType(httpServerType, routeArgType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerRoute("DELETE", this)
}
bridgeFn(this, "any", fnType(httpServerType, stringType, exchangeHandlerType)) {
bridgeFn(this, "deletePath", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("DELETE", this)
}
bridgeFn(this, "any", fnType(httpServerType, routeArgType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerAny(this)
}
bridgeFn(this, "ws", fnType(httpServerType, stringType, webSocketHandlerType)) {
bridgeFn(this, "anyPath", fnType(httpServerType, stringType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateAny(this)
}
bridgeFn(this, "ws", fnType(httpServerType, routeArgType, webSocketHandlerType)) {
thisAs<ObjLyngHttpServer>().registerWs(this)
}
bridgeFn(this, "wsPath", fnType(httpServerType, stringType, webSocketHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateWs(this)
}
bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerFallback(this)
}
@ -203,37 +238,108 @@ private class ObjLyngHttpServer(
if (handle != null) scope.raiseIllegalState("HttpServer routes cannot be modified after listen()")
}
private fun requirePath(scope: ScopeFacade, index: Int): String {
val path = scope.requiredArg<ObjString>(index).value
if (!path.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'")
return path
private fun requireRoutePattern(scope: ScopeFacade, index: Int): RoutePattern = when (val path = scope.args.list.getOrNull(index)) {
is ObjString -> {
if (!path.value.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'")
RoutePattern.Exact(path.value)
}
is ObjRegex -> RoutePattern.Regex(path)
else -> scope.raiseClassCastError("path must be String or Regex")
}
private suspend fun registerRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val path = requirePath(scope, 0)
val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
val routes = methodRoutes.getOrPut(method) { linkedMapOf() }
if (routes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path")
routes[path] = handler
when (path) {
is RoutePattern.Exact -> {
val routes = methodRoutes.getOrPut(method) { linkedMapOf() }
if (routes.containsKey(path.path)) scope.raiseIllegalArgument("duplicate route for $method ${path.path}")
routes[path.path] = handler
}
is RoutePattern.Regex -> {
val routes = methodRegexRoutes.getOrPut(method) { mutableListOf() }
if (routes.any { it.pattern.regex.pattern == path.regex.regex.pattern }) {
scope.raiseIllegalArgument("duplicate regex route for $method ${path.regex.regex.pattern}")
}
routes += RegisteredRegexRoute(path.regex, handler)
}
}
scope.thisObj
}
private suspend fun registerTemplateRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val template = requirePathTemplate(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
val routes = methodRegexRoutes.getOrPut(method) { mutableListOf() }
val compiled = compilePathTemplate(template, scope)
if (routes.any { it.identity == compiled.identity }) {
scope.raiseIllegalArgument("duplicate path route for $method $template")
}
routes += RegisteredRegexRoute(compiled.pattern, handler, compiled.paramNames, compiled.identity)
scope.thisObj
}
private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val path = requirePath(scope, 0)
val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path")
anyRoutes[path] = handler
when (path) {
is RoutePattern.Exact -> {
if (anyRoutes.containsKey(path.path)) scope.raiseIllegalArgument("duplicate route for ANY ${path.path}")
anyRoutes[path.path] = handler
}
is RoutePattern.Regex -> {
if (anyRegexRoutes.any { it.pattern.regex.pattern == path.regex.regex.pattern }) {
scope.raiseIllegalArgument("duplicate regex route for ANY ${path.regex.regex.pattern}")
}
anyRegexRoutes += RegisteredRegexRoute(path.regex, handler)
}
}
scope.thisObj
}
private suspend fun registerTemplateAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val template = requirePathTemplate(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
val compiled = compilePathTemplate(template, scope)
if (anyRegexRoutes.any { it.identity == compiled.identity }) {
scope.raiseIllegalArgument("duplicate path route for ANY $template")
}
anyRegexRoutes += RegisteredRegexRoute(compiled.pattern, handler, compiled.paramNames, compiled.identity)
scope.thisObj
}
private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val path = requirePath(scope, 0)
val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path")
wsRoutes[path] = handler
when (path) {
is RoutePattern.Exact -> {
if (wsRoutes.containsKey(path.path)) scope.raiseIllegalArgument("duplicate websocket route for ${path.path}")
wsRoutes[path.path] = handler
}
is RoutePattern.Regex -> {
if (wsRegexRoutes.any { it.pattern.regex.pattern == path.regex.regex.pattern }) {
scope.raiseIllegalArgument("duplicate websocket regex route for ${path.regex.regex.pattern}")
}
wsRegexRoutes += RegisteredRegexRoute(path.regex, handler)
}
}
scope.thisObj
}
private suspend fun registerTemplateWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val template = requirePathTemplate(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
val compiled = compilePathTemplate(template, scope)
if (wsRegexRoutes.any { it.identity == compiled.identity }) {
scope.raiseIllegalArgument("duplicate websocket path route for $template")
}
wsRegexRoutes += RegisteredRegexRoute(compiled.pattern, handler, compiled.paramNames, compiled.identity)
scope.thisObj
}
@ -243,6 +349,12 @@ private class ObjLyngHttpServer(
scope.thisObj
}
private fun requirePathTemplate(scope: ScopeFacade, index: Int): String {
val template = scope.requiredArg<ObjString>(index).value
if (!template.startsWith('/')) scope.raiseIllegalArgument("pathTemplate must start with '/'")
return template
}
private suspend fun listen(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
val port = scope.requiredArg<ObjInt>(0).value.toInt()
@ -263,38 +375,141 @@ private class ObjLyngHttpServer(
private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult {
val path = request.head.path
if (request.head.wantsWebSocketUpgrade) {
wsRoutes[path]?.let { route ->
wsRoutes[path]?.let { route ->
return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request)
val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass)
route.call(ObjServerWebSocket(session), exchange)
}
}
matchRegexRoute(wsRegexRoutes, path)?.let { matched ->
return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
matched.handler.call(ObjServerWebSocket(session), exchange)
}
}
}
val route = methodRoutes[request.head.method.uppercase()]?.get(path)
?: anyRoutes[path]
?: fallback
if (route == null) {
return HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
val method = request.head.method.uppercase()
val exactRoute = methodRoutes[method]?.get(path) ?: anyRoutes[path]
if (exactRoute != null) {
val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass)
exactRoute.call(exchange)
return exchangeResult(exactRoute === fallback, exchange)
}
val exchange = ObjServerExchange(request)
route.call(exchange)
return when (val result = exchange.result) {
is ExchangeResult.Http -> result.value
is ExchangeResult.WebSocket -> result.value
ExchangeResult.Unhandled -> {
if (route === fallback) {
HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
} else {
HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true))
}
matchRegexRoute(methodRegexRoutes[method], path)?.let { matched ->
val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
matched.handler.call(exchange)
return exchangeResult(false, exchange)
}
matchRegexRoute(anyRegexRoutes, path)?.let { matched ->
val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
matched.handler.call(exchange)
return exchangeResult(false, exchange)
}
val fallbackRoute = fallback ?: return HttpHandlerResult.Response(
HttpResponse(status = 404, body = "not found".encodeToByteArray())
)
val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass)
fallbackRoute.call(exchange)
return exchangeResult(true, exchange)
}
private fun exchangeResult(isFallback: Boolean, exchange: ObjServerExchange): HttpHandlerResult = when (val result = exchange.result) {
is ExchangeResult.Http -> result.value
is ExchangeResult.WebSocket -> result.value
ExchangeResult.Unhandled -> {
if (isFallback) {
HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray()))
} else {
HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true))
}
}
}
}
private sealed interface RoutePattern {
data class Exact(val path: String) : RoutePattern
data class Regex(val regex: ObjRegex) : RoutePattern
}
private data class RegisteredRegexRoute(
val pattern: ObjRegex,
val handler: RegisteredCallable,
val paramNames: List<String> = emptyList(),
val identity: String = "re:${pattern.regex.pattern}",
)
private data class MatchedRegexRoute(
val handler: RegisteredCallable,
val match: MatchResult,
val params: Map<String, String>,
)
private fun matchRegexRoute(routes: List<RegisteredRegexRoute>?, path: String): MatchedRegexRoute? {
if (routes == null) return null
for (route in routes) {
val match = route.pattern.regex.matchEntire(path) ?: continue
val params = if (route.paramNames.isEmpty()) {
emptyMap()
} else {
route.paramNames.withIndex().associateTo(linkedMapOf()) { (index, name) ->
name to decodePathSegment(match.groupValues[index + 1])
}
}
return MatchedRegexRoute(route.handler, match, params)
}
return null
}
private data class CompiledPathTemplate(
val pattern: ObjRegex,
val paramNames: List<String>,
val identity: String,
)
private fun compilePathTemplate(template: String, scope: ScopeFacade): CompiledPathTemplate {
val segments = if (template == "/") emptyList() else template.removePrefix("/").split('/')
val names = mutableListOf<String>()
val pattern = buildString {
append('^')
if (segments.isEmpty()) {
append('/')
} else {
for (segment in segments) {
append('/')
if (segment.startsWith('{') && segment.endsWith('}')) {
val name = segment.substring(1, segment.length - 1)
if (!isValidPathParamName(name)) {
scope.raiseIllegalArgument("invalid path parameter name: $name")
}
if (!names.add(name)) {
scope.raiseIllegalArgument("duplicate path parameter name: $name")
}
append("([^/]+)")
} else if ('{' in segment || '}' in segment) {
scope.raiseIllegalArgument("path template segments must be literal text or {name}")
} else {
append(Regex.escape(segment))
}
}
}
append('$')
}
return CompiledPathTemplate(
pattern = ObjRegex(Regex(pattern)),
paramNames = names,
identity = "path:$template"
)
}
private fun isValidPathParamName(name: String): Boolean =
name.isNotEmpty() &&
(name.first() == '_' || name.first().isLetter()) &&
name.drop(1).all { it == '_' || it.isLetterOrDigit() }
private class ObjHttpServerHandle(
private val handle: net.sergeych.lyngio.http.server.HttpServer,
) : Obj() {
@ -340,8 +555,14 @@ private class ObjServerRequest(
bridgeProperty(this, "path", stringType) {
ObjString(thisAs<ObjServerRequest>().request.head.path)
}
bridgeProperty(this, "query", nullableStringType) {
thisAs<ObjServerRequest>().request.head.query?.let(::ObjString) ?: ObjNull
bridgeProperty(this, "pathParts", listType(stringType)) {
ObjList(thisAs<ObjServerRequest>().request.head.pathParts.map(::ObjString).toMutableList())
}
bridgeProperty(this, "queryString", nullableStringType) {
thisAs<ObjServerRequest>().request.head.queryString?.let(::ObjString) ?: ObjNull
}
bridgeProperty(this, "query", mapType(stringType, stringType)) {
thisAs<ObjServerRequest>().request.head.query.toObjMap()
}
bridgeProperty(this, "headers", httpHeadersType) {
requestHeadersObj(thisAs<ObjServerRequest>().request.head.headers)
@ -367,6 +588,9 @@ private sealed interface ExchangeResult {
private class ObjServerExchange(
private val request: HttpRequest,
private val routeMatch: ObjRegexMatch?,
private val routeParams: Map<String, String>,
private val type: ObjClass,
) : Obj() {
private val responseHeaders = linkedMapOf<String, MutableList<String>>()
var result: ExchangeResult = ExchangeResult.Unhandled
@ -376,63 +600,100 @@ private class ObjServerExchange(
get() = type
companion object {
val type = object : ObjClass("ServerExchange") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("ServerExchange cannot be created directly")
}
}.apply {
bridgeProperty(this, "request", serverRequestType) {
ObjServerRequest(thisAs<ObjServerExchange>().request)
}
bridgeFn(this, "respond", fnType(voidType, intType, nullableBufferType)) {
val self = thisAs<ObjServerExchange>()
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
val body = args.list.getOrNull(1)?.let { objBufferOrNull(this, it, "body") }
self.setHttpResponse(status, body?.byteArray?.toByteArray() ?: ByteArray(0))
ObjVoid
}
bridgeFn(this, "respondText", fnType(voidType, intType, stringType)) {
val self = thisAs<ObjServerExchange>()
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
val bodyText = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "bodyText") } ?: ""
self.setHttpResponse(status, bodyText.encodeToByteArray())
ObjVoid
}
bridgeFn(this, "setHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value
val value = requiredArg<ObjString>(1).value
self.ensureMutable(this)
self.responseHeaders[name] = mutableListOf(value)
ObjVoid
}
bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value
val value = requiredArg<ObjString>(1).value
self.ensureMutable(this)
self.responseHeaders.getOrPut(name) { mutableListOf() }.add(value)
ObjVoid
}
bridgeFn(
this,
"acceptWebSocket",
fnType(voidType, fnType(nullableAnyType, serverWebSocketType, serverExchangeType))
) {
val self = thisAs<ObjServerExchange>()
val registered = captureCallable(requireScope(), args.list[0])
self.ensureMutable(this)
self.result = ExchangeResult.WebSocket(
HttpHandlerResult.WebSocket { session ->
registered.call(ObjServerWebSocket(session), self)
private val types = mutableMapOf<ObjClass, ObjClass>()
fun type(base: ObjClass): ObjClass =
types.getOrPut(base) {
object : ObjClass("ServerExchange") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("ServerExchange cannot be created directly")
}
)
ObjVoid
}.apply {
bridgeProperty(this, "request", serverRequestType) {
ObjServerRequest(thisAs<ObjServerExchange>().request)
}
bridgeProperty(this, "routeMatch", nullableRegexMatchType) {
thisAs<ObjServerExchange>().routeMatch ?: ObjNull
}
bridgeProperty(this, "routeParams", mapType(stringType, stringType)) {
thisAs<ObjServerExchange>().routeParams.toObjMap()
}
addFn(
"jsonBody",
callSignature = base.getInstanceMemberOrNull("jsonBody")?.callSignature
) {
val self = thisAs<ObjServerExchange>()
val targetType = resolveJsonTargetType(requireScope())
val text = self.request.body.decodeToString()
ObjJsonClass.decodeFromJsonElement(requireScope(), Json.parseToJsonElement(text), targetType)
}
bridgeFn(this, "respond", fnType(voidType, intType, nullableBufferType)) {
val self = thisAs<ObjServerExchange>()
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
val body = args.list.getOrNull(1)?.let { objBufferOrNull(this, it, "body") }
self.setHttpResponse(status, body?.byteArray?.toByteArray() ?: ByteArray(0))
ObjVoid
}
bridgeFn(this, "respondText", fnType(voidType, intType, stringType)) {
val self = thisAs<ObjServerExchange>()
val status = args.list.getOrNull(0)?.let { objToInt(this, it, "status") } ?: 200
val bodyText = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "bodyText") } ?: ""
self.setHttpResponse(status, bodyText.encodeToByteArray())
ObjVoid
}
addFn(
"respondJson",
callSignature = base.getInstanceMemberOrNull("respondJson")?.callSignature
) {
val self = thisAs<ObjServerExchange>()
val body = args.list.getOrNull(0) ?: ObjNull
val status = args.list.getOrNull(1)?.let { objToInt(this, it, "status") } ?: 200
self.ensureMutable(this)
self.responseHeaders["Content-Type"] = mutableListOf("application/json; charset=utf-8")
val bodyText = if (body === ObjNull) {
"null"
} else {
(body.invokeInstanceMethod(requireScope(), "toJsonString") as ObjString).value
}
self.setHttpResponse(status, bodyText.encodeToByteArray())
ObjVoid
}
bridgeFn(this, "setHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value
val value = requiredArg<ObjString>(1).value
self.ensureMutable(this)
self.responseHeaders[name] = mutableListOf(value)
ObjVoid
}
bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) {
val self = thisAs<ObjServerExchange>()
val name = requiredArg<ObjString>(0).value
val value = requiredArg<ObjString>(1).value
self.ensureMutable(this)
self.responseHeaders.getOrPut(name) { mutableListOf() }.add(value)
ObjVoid
}
bridgeFn(
this,
"acceptWebSocket",
fnType(voidType, fnType(nullableAnyType, serverWebSocketType, serverExchangeType))
) {
val self = thisAs<ObjServerExchange>()
val registered = captureCallable(requireScope(), args.list[0])
self.ensureMutable(this)
self.result = ExchangeResult.WebSocket(
HttpHandlerResult.WebSocket { session ->
registered.call(ObjServerWebSocket(session), self)
}
)
ObjVoid
}
bridgeFn(this, "isHandled", fnType(boolType)) {
ObjBool(thisAs<ObjServerExchange>().result !== ExchangeResult.Unhandled)
}
}
}
bridgeFn(this, "isHandled", fnType(boolType)) {
ObjBool(thisAs<ObjServerExchange>().result !== ExchangeResult.Unhandled)
}
}
}
private fun ensureMutable(scope: ScopeFacade) {
@ -512,3 +773,17 @@ private fun objBufferOrNull(scope: ScopeFacade, value: Obj, name: String): ObjBu
is ObjBuffer -> value
else -> scope.raiseClassCastError("$name must be Buffer or null")
}
private fun resolveJsonTargetType(scope: Scope): TypeDecl {
val explicit = scope.args.explicitTypeArgs.singleOrNull()
if (explicit != null) return explicit
val bound = scope["T"]?.value
return when (bound) {
is ObjTypeExpr -> bound.typeDecl
is ObjClass -> TypeDecl.Simple(bound.className, false)
else -> scope.raiseIllegalArgument("jsonBody requires exactly one type argument")
}
}
private fun Map<String, String>.toObjMap(): ObjMap =
ObjMap(entries.associate { ObjString(it.key) to ObjString(it.value) }.toMutableMap())

View File

@ -33,7 +33,7 @@ internal suspend fun parseHttpRequest(
method = requestHead.method,
target = requestHead.target,
path = requestHead.path,
query = requestHead.query,
queryString = requestHead.queryString,
version = requestHead.version,
headers = headers,
contentLength = contentLength,
@ -48,7 +48,7 @@ private data class ParsedRequestLine(
val method: String,
val target: String,
val path: String,
val query: String?,
val queryString: String?,
val version: String,
)
@ -75,8 +75,8 @@ private fun parseRequestLine(line: String, config: HttpServerConfig): ParsedRequ
}
val queryAt = target.indexOf('?')
val path = if (queryAt >= 0) target.substring(0, queryAt) else target
val query = if (queryAt >= 0) target.substring(queryAt + 1) else null
return ParsedRequestLine(method = method, target = target, path = path, query = query, version = version)
val queryString = if (queryAt >= 0) target.substring(queryAt + 1) else null
return ParsedRequestLine(method = method, target = target, path = path, queryString = queryString, version = version)
}
private suspend fun parseHeaders(

View File

@ -40,13 +40,36 @@ internal data class HttpRequestHead(
val method: String,
val target: String,
val path: String,
val query: String?,
val queryString: String?,
val version: String,
val headers: HttpHeaders,
val contentLength: Int?,
val wantsClose: Boolean,
val wantsWebSocketUpgrade: Boolean,
)
) {
private var pathPartsParsed = false
private var pathPartsCache: List<String> = emptyList()
private var queryParsed = false
private var queryCache: Map<String, String> = emptyMap()
val pathParts: List<String>
get() {
if (!pathPartsParsed) {
pathPartsCache = parsePathParts(path)
pathPartsParsed = true
}
return pathPartsCache
}
val query: Map<String, String>
get() {
if (!queryParsed) {
queryCache = parseQueryParameters(queryString)
queryParsed = true
}
return queryCache
}
}
internal data class HttpRequest(
val head: HttpRequestHead,
@ -84,6 +107,89 @@ internal interface HttpServer {
fun close()
}
internal fun parsePathParts(path: String): List<String> {
if (path.isEmpty() || path == "/") return emptyList()
val raw = if (path.startsWith('/')) path.substring(1) else path
if (raw.isEmpty()) return emptyList()
return raw.split('/').map(::decodePathSegment)
}
internal fun parseQueryParameters(queryString: String?): Map<String, String> {
if (queryString.isNullOrEmpty()) return emptyMap()
val result = linkedMapOf<String, String>()
var start = 0
while (start <= queryString.length) {
val nextAmp = queryString.indexOf('&', start).let { if (it >= 0) it else queryString.length }
if (nextAmp > start) {
val part = queryString.substring(start, nextAmp)
val eqAt = part.indexOf('=')
val rawKey = if (eqAt >= 0) part.substring(0, eqAt) else part
val rawValue = if (eqAt >= 0) part.substring(eqAt + 1) else ""
result[decodeQueryComponent(rawKey, plusAsSpace = true)] = decodeQueryComponent(rawValue, plusAsSpace = true)
}
if (nextAmp == queryString.length) break
start = nextAmp + 1
}
return result
}
internal fun decodePathSegment(value: String): String = decodeQueryComponent(value, plusAsSpace = false)
private fun decodeQueryComponent(value: String, plusAsSpace: Boolean): String {
if (value.isEmpty()) return value
val out = StringBuilder(value.length)
val bytes = ArrayList<Byte>()
fun flushBytes() {
if (bytes.isEmpty()) return
out.append(bytes.toByteArray().decodeToString())
bytes.clear()
}
var i = 0
while (i < value.length) {
when (val ch = value[i]) {
'+' -> {
flushBytes()
out.append(if (plusAsSpace) ' ' else '+')
i += 1
}
'%' -> {
val decoded = decodePercentByte(value, i)
if (decoded != null) {
bytes += decoded.first.toByte()
i = decoded.second
} else {
flushBytes()
out.append('%')
i += 1
}
}
else -> {
flushBytes()
out.append(ch)
i += 1
}
}
}
flushBytes()
return out.toString()
}
private fun decodePercentByte(value: String, offset: Int): Pair<Int, Int>? {
if (offset + 2 >= value.length) return null
val hi = value[offset + 1].hexDigitValueOrNull() ?: return null
val lo = value[offset + 2].hexDigitValueOrNull() ?: return null
return ((hi shl 4) or lo) to (offset + 3)
}
private fun Char.hexDigitValueOrNull(): Int? = when (this) {
in '0'..'9' -> code - '0'.code
in 'a'..'f' -> code - 'a'.code + 10
in 'A'..'F' -> code - 'A'.code + 10
else -> null
}
internal fun defaultReason(status: Int): String = when (status) {
101 -> "Switching Protocols"
200 -> "OK"

View File

@ -71,6 +71,39 @@ class HttpParserTest {
assertEquals("ping", request.body.decodeToString())
}
@Test
fun queryStringAndDecodedQueryMapAreExposed() = kotlinx.coroutines.test.runTest {
val request = parse(
"GET /echo?a=1&b=hello+world&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag HTTP/1.1\r\n" +
"Host: localhost\r\n\r\n"
)
assertEquals("a=1&b=hello+world&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag", request.head.queryString)
assertEquals("1", request.head.query["a"])
assertEquals("last", request.head.query["b"])
assertEquals("тест", request.head.query["utf"])
assertEquals("%GG%2", request.head.query["bad"])
assertEquals("", request.head.query["flag"])
}
@Test
fun missingQueryProducesEmptyMap() = kotlinx.coroutines.test.runTest {
val request = parse(
"GET /echo HTTP/1.1\r\n" +
"Host: localhost\r\n\r\n"
)
assertEquals(null, request.head.queryString)
assertEquals(emptyMap(), request.head.query)
}
@Test
fun pathPartsAreLazyDecodedWithoutPlusTranslation() = kotlinx.coroutines.test.runTest {
val request = parse(
"GET /one/two%20words/a+b/%GG/%D1%82%D0%B5%D1%81%D1%82 HTTP/1.1\r\n" +
"Host: localhost\r\n\r\n"
)
assertEquals(listOf("one", "two words", "a+b", "%GG", "тест"), request.head.pathParts)
}
private suspend fun parse(
rawRequest: String,
config: HttpServerConfig = HttpServerConfig(),

View File

@ -89,6 +89,232 @@ class LyngHttpServerModuleTest {
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun requestQueryStringAndQueryMapAreAvailableToLyng() = 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
val server = HttpServer()
server.get("/query") { ex ->
val q = ex.request.query
ex.respondText(
200,
(ex.request.queryString ?: "<null>") +
"|" + q.size +
"|" + (q["a"] ?: "<null>") +
"|" + (q["b"] ?: "<null>") +
"|" + (q["utf"] ?: "<null>") +
"|" + (q["bad"] ?: "<null>") +
"|" + (q["flag"] ?: "<null>")
)
}
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 /query?a=1&b=first&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag HTTP/1.1\r\n" +
"Host: localhost\r\nConnection: close\r\n\r\n"
)
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(
response.endsWith(
"a=1&b=first&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag|5|1|last|тест|%GG%2|"
),
response
)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /query HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("<null>|0|<null>|<null>|<null>|<null>|<null>"), response)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun regexRoutesExposeMatchAndPathPartsToLyng() = 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
val server = HttpServer()
server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) { ex ->
val m = ex.routeMatch!!
ex.respondText(
200,
m[1] +
"|" + m[2] +
"|" + ex.request.pathParts[0] +
"," + ex.request.pathParts[1] +
"," + ex.request.pathParts[2] +
"," + ex.request.pathParts[3]
)
}
server.get("/users/fixed/posts/9") { ex ->
ex.respondText(200, "fixed|" + (ex.routeMatch == null))
}
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 /users/42/posts/7 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("42|7|users,42,posts,7"), response)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /users/fixed/posts/9 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("fixed|true"), response)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun pathTemplateRoutesExposeDecodedRouteParamsAndKeepExactRoutesFirst() = 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
val server = HttpServer()
server.get("/users/fixed/posts/9") { ex ->
ex.respondText(200, "fixed|" + ex.routeParams.size)
}
server.getPath("/users/{userId}/posts/{postId}") { ex ->
ex.respondText(
200,
ex.routeParams["userId"] + "|" +
ex.routeParams["postId"] + "|" +
ex.request.pathParts[1] + "|" +
ex.request.pathParts[3] + "|" +
(ex.routeMatch != null)
)
}
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 /users/alice%20bob/posts/c+d HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("alice bob|c+d|alice bob|c+d|true"), response)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /users/fixed/posts/9 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("fixed|0"), response)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun jsonBodyAndRespondJsonSupportTypedJsonPostHandlers() = 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
closed class CreateUserRequest(name: String, age: Int)
closed class CreateUserResponse(id: Int, name: String, age: Int)
val server = HttpServer()
server.postPath("/api/users") { ex ->
val req = ex.jsonBody<CreateUserRequest>()
ex.respondJson(CreateUserResponse(101, req.name, req.age), 201)
}
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 {
val body = """{"name":"alice","age":30}"""
client.writeUtf8(
"POST /api/users HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${body.encodeToByteArray().size}\r\n" +
"Connection: close\r\n\r\n" +
body
)
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("201"), response)
assertTrue(response.contains("Content-Type: application/json; charset=utf-8"), response)
assertTrue(response.endsWith("""{"id":101,"name":"alice","age":30}"""), response)
} finally {
client.close()
}
handle.invokeInstanceMethod(scope, "close")
}
private suspend fun waitForPort(handle: Obj, scope: net.sergeych.lyng.Scope): Int {
repeat(100) {
val port = runCatching {

View File

@ -1,6 +1,7 @@
package lyng.io.http.server
import lyng.io.http.types
import lyng.serialization
import lyng.io.ws.types
/* Immutable parsed incoming server request. */
@ -8,7 +9,9 @@ extern class ServerRequest {
val method: String
val target: String
val path: String
val query: String?
val pathParts: List<String>
val queryString: String?
val query: Map<String, String>
val headers: HttpHeaders
val body: Buffer
fun text(): String
@ -27,8 +30,12 @@ extern class ServerWebSocket {
/* Mutable exchange object for one incoming request. */
extern class ServerExchange {
val request: ServerRequest
val routeMatch: RegexMatch?
val routeParams: Map<String, String>
fun jsonBody<T>(): T
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 setHeader(name: String, value: String): void
fun addHeader(name: String, value: String): void
fun acceptWebSocket(handler: (ServerWebSocket, ServerExchange) -> Object?): void
@ -43,12 +50,18 @@ extern class HttpServerHandle {
/* Exact-path HTTP/WebSocket server with built-in router. */
extern class HttpServer {
fun get(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun post(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun put(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun delete(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun any(path: String, handler: (ServerExchange) -> Object?): HttpServer
fun ws(path: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer
fun get(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer
fun getPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer
fun post(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer
fun postPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer
fun put(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer
fun putPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer
fun delete(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer
fun deletePath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer
fun any(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer
fun anyPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer
fun ws(path: String|Regex, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer
fun wsPath(pathTemplate: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer
fun fallback(handler: (ServerExchange) -> Object?): HttpServer
fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle
}