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.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.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](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.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`. - **[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. - **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) - [Process Security Details](lyng.io.process.md#security-policy)
- [Console Module Details](lyng.io.console.md) - [Console Module Details](lyng.io.console.md)
- [HTTP Module Details](lyng.io.http.md) - [HTTP Module Details](lyng.io.http.md)
- [HTTP Server Module Details](lyng.io.http.server.md)
- [Transport Networking Details](lyng.io.net.md) - [Transport Networking Details](lyng.io.net.md)
- [WebSocket Module Details](lyng.io.ws.md) - [WebSocket Module Details](lyng.io.ws.md)

View File

@ -1,5 +1,6 @@
package net.sergeych.lyng.io.http.server package net.sergeych.lyng.io.http.server
import kotlinx.serialization.json.Json
import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade 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.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjList import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjNull import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjProperty 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.ObjString
import net.sergeych.lyng.obj.ObjTypeExpr
import net.sergeych.lyng.obj.ObjVoid import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs 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.http.createHttpTypesModule
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.pacman.ImportManager import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs 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.HttpResponse
import net.sergeych.lyngio.http.server.HttpServerConfig import net.sergeych.lyngio.http.server.HttpServerConfig
import net.sergeych.lyngio.http.server.HttpWebSocketSession 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.defaultReason
import net.sergeych.lyngio.http.server.startHttpServer import net.sergeych.lyngio.http.server.startHttpServer
import net.sergeych.lyngio.net.security.NetAccessDeniedException 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) { private suspend fun buildHttpServerModule(module: ModuleScope, policy: NetAccessPolicy) {
module.eval(Source(HTTP_SERVER_MODULE_NAME, http_serverLyng)) module.eval(Source(HTTP_SERVER_MODULE_NAME, http_serverLyng))
val serverExchangeClass = ObjServerExchange.type(module.requireClass("ServerExchange"))
module.addConst("HttpHeaders", ObjHttpHeaders.type) module.addConst("HttpHeaders", ObjHttpHeaders.type)
module.addConst("WsMessage", ObjWsMessage.type) module.addConst("WsMessage", ObjWsMessage.type)
module.addConst("ServerRequest", ObjServerRequest.type) module.addConst("ServerRequest", ObjServerRequest.type)
module.addConst("ServerExchange", ObjServerExchange.type) module.addConst("ServerExchange", serverExchangeClass)
module.addConst("ServerWebSocket", ObjServerWebSocket.type) module.addConst("ServerWebSocket", ObjServerWebSocket.type)
module.addConst("HttpServerHandle", ObjHttpServerHandle.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 { 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 intType = TypeDecl.Simple("Int", false)
private val bufferType = TypeDecl.Simple("Buffer", false) private val bufferType = TypeDecl.Simple("Buffer", false)
private val nullableBufferType = TypeDecl.Simple("Buffer", true) 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 voidType = TypeDecl.Simple("Void", false)
private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false) private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false)
private val serverRequestType = TypeDecl.Simple("ServerRequest", 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 val nullableAnyType = TypeDecl.TypeNullableAny
private fun listType(item: TypeDecl) = TypeDecl.Generic("List", listOf(item), false) 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) = private fun fnType(returnType: TypeDecl, vararg params: TypeDecl) =
TypeDecl.Function(receiver = null, params = params.toList(), returnType = returnType) TypeDecl.Function(receiver = null, params = params.toList(), returnType = returnType)
@ -147,48 +159,71 @@ private fun bridgeProperty(
private class ObjLyngHttpServer( private class ObjLyngHttpServer(
private val netPolicy: NetAccessPolicy, private val netPolicy: NetAccessPolicy,
private val exchangeClass: ObjClass,
) : Obj() { ) : Obj() {
private val methodRoutes = linkedMapOf<String, LinkedHashMap<String, RegisteredCallable>>() private val methodRoutes = linkedMapOf<String, LinkedHashMap<String, RegisteredCallable>>()
private val methodRegexRoutes = linkedMapOf<String, MutableList<RegisteredRegexRoute>>()
private val anyRoutes = linkedMapOf<String, RegisteredCallable>() private val anyRoutes = linkedMapOf<String, RegisteredCallable>()
private val anyRegexRoutes = mutableListOf<RegisteredRegexRoute>()
private val wsRoutes = linkedMapOf<String, RegisteredCallable>() private val wsRoutes = linkedMapOf<String, RegisteredCallable>()
private val wsRegexRoutes = mutableListOf<RegisteredRegexRoute>()
private var fallback: RegisteredCallable? = null private var fallback: RegisteredCallable? = null
private var handle: net.sergeych.lyngio.http.server.HttpServer? = null private var handle: net.sergeych.lyngio.http.server.HttpServer? = null
override val objClass: ObjClass override val objClass: ObjClass
get() = type(netPolicy) get() = type(netPolicy, exchangeClass)
companion object { companion object {
private val types = mutableMapOf<NetAccessPolicy, ObjClass>() private val types = mutableMapOf<Pair<NetAccessPolicy, ObjClass>, ObjClass>()
fun type(netPolicy: NetAccessPolicy): ObjClass = fun type(netPolicy: NetAccessPolicy, exchangeClass: ObjClass): ObjClass =
types.getOrPut(netPolicy) { types.getOrPut(netPolicy to exchangeClass) {
object : ObjClass("HttpServer") { object : ObjClass("HttpServer") {
override suspend fun callOn(scope: Scope): Obj { override suspend fun callOn(scope: Scope): Obj {
if (scope.args.list.isNotEmpty()) scope.raiseError("HttpServer() does not accept arguments") if (scope.args.list.isNotEmpty()) scope.raiseError("HttpServer() does not accept arguments")
return ObjLyngHttpServer(netPolicy) return ObjLyngHttpServer(netPolicy, exchangeClass)
} }
}.apply { }.apply {
val routeArgType = unionType(stringType, regexType)
val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType) val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType)
val webSocketHandlerType = fnType(nullableAnyType, serverWebSocketType, 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) 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) 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) 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) 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) 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) thisAs<ObjLyngHttpServer>().registerWs(this)
} }
bridgeFn(this, "wsPath", fnType(httpServerType, stringType, webSocketHandlerType)) {
thisAs<ObjLyngHttpServer>().registerTemplateWs(this)
}
bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) { bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) {
thisAs<ObjLyngHttpServer>().registerFallback(this) thisAs<ObjLyngHttpServer>().registerFallback(this)
} }
@ -203,37 +238,108 @@ private class ObjLyngHttpServer(
if (handle != null) scope.raiseIllegalState("HttpServer routes cannot be modified after listen()") if (handle != null) scope.raiseIllegalState("HttpServer routes cannot be modified after listen()")
} }
private fun requirePath(scope: ScopeFacade, index: Int): String { private fun requireRoutePattern(scope: ScopeFacade, index: Int): RoutePattern = when (val path = scope.args.list.getOrNull(index)) {
val path = scope.requiredArg<ObjString>(index).value is ObjString -> {
if (!path.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'") if (!path.value.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'")
return path 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 { private suspend fun registerRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope) ensureMutable(scope)
val path = requirePath(scope, 0) val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1]) val handler = captureCallable(scope.requireScope(), scope.args.list[1])
val routes = methodRoutes.getOrPut(method) { linkedMapOf() } when (path) {
if (routes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path") is RoutePattern.Exact -> {
routes[path] = handler 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 scope.thisObj
} }
private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard { private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope) ensureMutable(scope)
val path = requirePath(scope, 0) val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1]) val handler = captureCallable(scope.requireScope(), scope.args.list[1])
if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path") when (path) {
anyRoutes[path] = handler 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 scope.thisObj
} }
private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard { private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope) ensureMutable(scope)
val path = requirePath(scope, 0) val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1]) val handler = captureCallable(scope.requireScope(), scope.args.list[1])
if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path") when (path) {
wsRoutes[path] = handler 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 scope.thisObj
} }
@ -243,6 +349,12 @@ private class ObjLyngHttpServer(
scope.thisObj 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 { private suspend fun listen(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope) ensureMutable(scope)
val port = scope.requiredArg<ObjInt>(0).value.toInt() val port = scope.requiredArg<ObjInt>(0).value.toInt()
@ -263,38 +375,141 @@ private class ObjLyngHttpServer(
private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult { private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult {
val path = request.head.path val path = request.head.path
if (request.head.wantsWebSocketUpgrade) { if (request.head.wantsWebSocketUpgrade) {
wsRoutes[path]?.let { route -> wsRoutes[path]?.let { route ->
return HttpHandlerResult.WebSocket { session -> return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request) val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass)
route.call(ObjServerWebSocket(session), exchange) 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) val method = request.head.method.uppercase()
?: anyRoutes[path] val exactRoute = methodRoutes[method]?.get(path) ?: anyRoutes[path]
?: fallback if (exactRoute != null) {
val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass)
if (route == null) { exactRoute.call(exchange)
return HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) return exchangeResult(exactRoute === fallback, exchange)
} }
val exchange = ObjServerExchange(request) matchRegexRoute(methodRegexRoutes[method], path)?.let { matched ->
route.call(exchange) val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
return when (val result = exchange.result) { matched.handler.call(exchange)
is ExchangeResult.Http -> result.value return exchangeResult(false, exchange)
is ExchangeResult.WebSocket -> result.value }
ExchangeResult.Unhandled -> {
if (route === fallback) { matchRegexRoute(anyRegexRoutes, path)?.let { matched ->
HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
} else { matched.handler.call(exchange)
HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true)) 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 class ObjHttpServerHandle(
private val handle: net.sergeych.lyngio.http.server.HttpServer, private val handle: net.sergeych.lyngio.http.server.HttpServer,
) : Obj() { ) : Obj() {
@ -340,8 +555,14 @@ private class ObjServerRequest(
bridgeProperty(this, "path", stringType) { bridgeProperty(this, "path", stringType) {
ObjString(thisAs<ObjServerRequest>().request.head.path) ObjString(thisAs<ObjServerRequest>().request.head.path)
} }
bridgeProperty(this, "query", nullableStringType) { bridgeProperty(this, "pathParts", listType(stringType)) {
thisAs<ObjServerRequest>().request.head.query?.let(::ObjString) ?: ObjNull 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) { bridgeProperty(this, "headers", httpHeadersType) {
requestHeadersObj(thisAs<ObjServerRequest>().request.head.headers) requestHeadersObj(thisAs<ObjServerRequest>().request.head.headers)
@ -367,6 +588,9 @@ private sealed interface ExchangeResult {
private class ObjServerExchange( private class ObjServerExchange(
private val request: HttpRequest, private val request: HttpRequest,
private val routeMatch: ObjRegexMatch?,
private val routeParams: Map<String, String>,
private val type: ObjClass,
) : Obj() { ) : Obj() {
private val responseHeaders = linkedMapOf<String, MutableList<String>>() private val responseHeaders = linkedMapOf<String, MutableList<String>>()
var result: ExchangeResult = ExchangeResult.Unhandled var result: ExchangeResult = ExchangeResult.Unhandled
@ -376,63 +600,100 @@ private class ObjServerExchange(
get() = type get() = type
companion object { companion object {
val type = object : ObjClass("ServerExchange") { private val types = mutableMapOf<ObjClass, ObjClass>()
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("ServerExchange cannot be created directly") fun type(base: ObjClass): ObjClass =
} types.getOrPut(base) {
}.apply { object : ObjClass("ServerExchange") {
bridgeProperty(this, "request", serverRequestType) { override suspend fun callOn(scope: Scope): Obj {
ObjServerRequest(thisAs<ObjServerExchange>().request) scope.raiseError("ServerExchange cannot be created directly")
}
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)
} }
) }.apply {
ObjVoid 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) { private fun ensureMutable(scope: ScopeFacade) {
@ -512,3 +773,17 @@ private fun objBufferOrNull(scope: ScopeFacade, value: Obj, name: String): ObjBu
is ObjBuffer -> value is ObjBuffer -> value
else -> scope.raiseClassCastError("$name must be Buffer or null") 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, method = requestHead.method,
target = requestHead.target, target = requestHead.target,
path = requestHead.path, path = requestHead.path,
query = requestHead.query, queryString = requestHead.queryString,
version = requestHead.version, version = requestHead.version,
headers = headers, headers = headers,
contentLength = contentLength, contentLength = contentLength,
@ -48,7 +48,7 @@ private data class ParsedRequestLine(
val method: String, val method: String,
val target: String, val target: String,
val path: String, val path: String,
val query: String?, val queryString: String?,
val version: String, val version: String,
) )
@ -75,8 +75,8 @@ private fun parseRequestLine(line: String, config: HttpServerConfig): ParsedRequ
} }
val queryAt = target.indexOf('?') val queryAt = target.indexOf('?')
val path = if (queryAt >= 0) target.substring(0, queryAt) else target val path = if (queryAt >= 0) target.substring(0, queryAt) else target
val query = if (queryAt >= 0) target.substring(queryAt + 1) else null val queryString = if (queryAt >= 0) target.substring(queryAt + 1) else null
return ParsedRequestLine(method = method, target = target, path = path, query = query, version = version) return ParsedRequestLine(method = method, target = target, path = path, queryString = queryString, version = version)
} }
private suspend fun parseHeaders( private suspend fun parseHeaders(

View File

@ -40,13 +40,36 @@ internal data class HttpRequestHead(
val method: String, val method: String,
val target: String, val target: String,
val path: String, val path: String,
val query: String?, val queryString: String?,
val version: String, val version: String,
val headers: HttpHeaders, val headers: HttpHeaders,
val contentLength: Int?, val contentLength: Int?,
val wantsClose: Boolean, val wantsClose: Boolean,
val wantsWebSocketUpgrade: 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( internal data class HttpRequest(
val head: HttpRequestHead, val head: HttpRequestHead,
@ -84,6 +107,89 @@ internal interface HttpServer {
fun close() 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) { internal fun defaultReason(status: Int): String = when (status) {
101 -> "Switching Protocols" 101 -> "Switching Protocols"
200 -> "OK" 200 -> "OK"

View File

@ -71,6 +71,39 @@ class HttpParserTest {
assertEquals("ping", request.body.decodeToString()) 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( private suspend fun parse(
rawRequest: String, rawRequest: String,
config: HttpServerConfig = HttpServerConfig(), config: HttpServerConfig = HttpServerConfig(),

View File

@ -89,6 +89,232 @@ class LyngHttpServerModuleTest {
handle.invokeInstanceMethod(scope, "close") 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 { private suspend fun waitForPort(handle: Obj, scope: net.sergeych.lyng.Scope): Int {
repeat(100) { repeat(100) {
val port = runCatching { val port = runCatching {

View File

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