Compare commits

..

3 Commits

9 changed files with 1432 additions and 153 deletions

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

@ -0,0 +1,290 @@
### 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")
}
```
---
#### RequestContext Sugar
Route handlers use `RequestContext` as the receiver, so inside handlers you normally write direct calls such as:
- `jsonBody<T>()`
- `respondJson(...)`
- `respondText(...)`
- `setHeader(...)`
- `request.path`
- `routeParams["id"]`
This keeps ordinary HTTP endpoints compact and avoids passing an explicit request/exchange parameter through every route lambda.
---
#### JSON API Sugar
For ordinary JSON APIs, `RequestContext` includes two primary helpers:
- `jsonBody<T>()` decodes the request body with typed `Json.decodeAs(...)`
- `respondJson(body, status = 200)` sets JSON content type and responds with plain `toJsonString()`
These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`.
**Typed JSON POST**
```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") {
val req = jsonBody<CreateUserRequest>()
if (req.name.isBlank()) {
respondJson({ error: "name must not be empty" }, 400)
return
}
respondJson(CreateUserResponse(101, req.name, req.age), 201)
}
server.listen(8080, "127.0.0.1")
```
**JSON response with route params**
```lyng
import lyng.io.http.server
val server = HttpServer()
server.getPath("/api/users/{id}") {
respondJson({
id: routeParams["id"],
path: request.path,
ok: true
})
}
server.listen(8080, "127.0.0.1")
```
---
#### Request and Route 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`
`RequestContext` 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.
---
#### Reusable Routers
`Router` collects the same route kinds as `HttpServer`, but does not listen on sockets by itself.
Mount it into `HttpServer` or another `Router`.
```lyng
import lyng.io.http.server
val api = Router()
api.get("/health") {
respondText(200, "ok")
}
val users = Router()
users.getPath("/users/{id}") {
respondJson({ id: routeParams["id"] })
}
api.mount(users)
val server = HttpServer()
server.mount(api)
server.listen(8080, "127.0.0.1")
```
Mounted routers reuse the built-in server router. They are configuration-time composition, not an extra per-request Lyng dispatch layer.
---
#### WebSocket Routes
You can route websocket upgrades by exact path, regex, or path template:
```lyng
server.ws("/chat") { ws ->
ws.sendText("hello")
ws.close()
}
server.wsPath("/ws/{room}") { ws ->
ws.sendText("room=" + routeParams["room"])
ws.close()
}
```
---
#### Path-Template Routes
Path templates are sugar on top of regex routes. Template parameters are exposed as decoded `routeParams`.
```lyng
server.getPath("/users/{userId}/posts/{postId}") {
respondText(
200,
routeParams["userId"] + ":" + 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
---
#### Regex Routes
Regex routes match the whole request path, not a substring.
```lyng
server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) {
val m = routeMatch!!
respondText(200, "user=" + m[1] + ", post=" + m[2])
}
```
---
#### Basic Exact Route
```lyng
import lyng.io.http.server
val server = HttpServer()
server.get("/hello") {
setHeader("Content-Type", "text/plain")
respondText(200, "hello")
}
server.listen(8080, "127.0.0.1")
```
---
#### 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.
---
#### API Surface
`Router` 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)`
- `mount(router)`
`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)`
- `mount(router)`
- `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`, `Router`, `ServerRequest`, `RequestContext`, 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,10 +1,12 @@
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
import net.sergeych.lyng.Source import net.sergeych.lyng.Source
import net.sergeych.lyng.Arguments import net.sergeych.lyng.Arguments
import net.sergeych.lyng.CallSignature
import net.sergeych.lyng.TypeDecl import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.asFacade import net.sergeych.lyng.asFacade
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
@ -14,9 +16,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 +30,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 +41,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 +71,15 @@ 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 requestContextClass = ObjServerExchange.type(module.requireClass("RequestContext"))
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("RequestContext", requestContextClass)
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("Router", ObjLyngRouter.type(requestContextClass))
module.addConst("HttpServer", ObjLyngHttpServer.type(policy, requestContextClass))
} }
private suspend inline fun ScopeFacade.httpServerGuard(crossinline block: suspend () -> Obj): Obj { private suspend inline fun ScopeFacade.httpServerGuard(crossinline block: suspend () -> Obj): Obj {
@ -96,31 +106,46 @@ private fun captureCallable(scope: Scope, rawCallable: Obj): RegisteredCallable
private suspend fun RegisteredCallable.call(vararg args: Obj): Obj = private suspend fun RegisteredCallable.call(vararg args: Obj): Obj =
scope.asFacade().call(callable, Arguments(args.toList())) scope.asFacade().call(callable, Arguments(args.toList()))
private suspend fun RegisteredCallable.callWithReceiver(receiver: Obj, vararg args: Obj): Obj =
scope.asFacade().call(callable, Arguments(args.toList()), receiver)
private val stringType = TypeDecl.Simple("String", false) private val stringType = TypeDecl.Simple("String", false)
private val nullableStringType = TypeDecl.Simple("String", true) private val nullableStringType = TypeDecl.Simple("String", true)
private val boolType = TypeDecl.Simple("Bool", false) 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)
private val serverExchangeType = TypeDecl.Simple("ServerExchange", false) private val requestContextType = TypeDecl.Simple("RequestContext", false)
private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false) private val serverWebSocketType = TypeDecl.Simple("ServerWebSocket", false)
private val nullableServerWsMessageType = TypeDecl.Simple("WsMessage", true) private val nullableServerWsMessageType = TypeDecl.Simple("WsMessage", true)
private val httpServerHandleType = TypeDecl.Simple("HttpServerHandle", false) private val httpServerHandleType = TypeDecl.Simple("HttpServerHandle", false)
private val httpServerType = TypeDecl.Simple("HttpServer", false) private val httpServerType = TypeDecl.Simple("HttpServer", false)
private val routerType = TypeDecl.Simple("Router", 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)
private fun receiverFnType(receiver: TypeDecl, returnType: TypeDecl, vararg params: TypeDecl) =
TypeDecl.Function(receiver = receiver, params = params.toList(), returnType = returnType)
private fun receiverCallSignature(receiverTypeName: String) =
CallSignature(tailBlockReceiverType = receiverTypeName)
private fun bridgeFn( private fun bridgeFn(
owner: ObjClass, owner: ObjClass,
name: String, name: String,
typeDecl: TypeDecl.Function, typeDecl: TypeDecl.Function,
callSignature: net.sergeych.lyng.CallSignature? = null,
code: suspend ScopeFacade.() -> Obj, code: suspend ScopeFacade.() -> Obj,
) { ) {
owner.createField( owner.createField(
@ -128,6 +153,7 @@ private fun bridgeFn(
initialValue = ObjExternCallable.fromBridge { code() }, initialValue = ObjExternCallable.fromBridge { code() },
type = net.sergeych.lyng.obj.ObjRecord.Type.Fun, type = net.sergeych.lyng.obj.ObjRecord.Type.Fun,
typeDecl = typeDecl, typeDecl = typeDecl,
callSignature = callSignature,
) )
} }
@ -145,53 +171,289 @@ private fun bridgeProperty(
) )
} }
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 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 class RouteRegistry(
private val exchangeClass: ObjClass,
) {
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
suspend fun registerRoute(method: String, scope: ScopeFacade) {
val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
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)
}
}
}
suspend fun registerTemplateRoute(method: String, scope: ScopeFacade) {
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)
}
suspend fun registerAny(scope: ScopeFacade) {
val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
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)
}
}
}
suspend fun registerTemplateAny(scope: ScopeFacade) {
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)
}
suspend fun registerWs(scope: ScopeFacade) {
val path = requireRoutePattern(scope, 0)
val handler = captureCallable(scope.requireScope(), scope.args.list[1])
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)
}
}
}
suspend fun registerTemplateWs(scope: ScopeFacade) {
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)
}
suspend fun registerFallback(scope: ScopeFacade) {
fallback = captureCallable(scope.requireScope(), scope.args.list[0])
}
fun mount(scope: ScopeFacade, other: RouteRegistry) {
other.methodRoutes.forEach { (method, routes) ->
val target = methodRoutes.getOrPut(method) { linkedMapOf() }
routes.forEach { (path, handler) ->
if (target.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path")
target[path] = handler
}
}
other.methodRegexRoutes.forEach { (method, routes) ->
val target = methodRegexRoutes.getOrPut(method) { mutableListOf() }
routes.forEach { route ->
if (target.any { it.identity == route.identity }) {
scope.raiseIllegalArgument("duplicate route for $method ${route.identity.removePrefix("path:")}")
}
target += route
}
}
other.anyRoutes.forEach { (path, handler) ->
if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path")
anyRoutes[path] = handler
}
other.anyRegexRoutes.forEach { route ->
if (anyRegexRoutes.any { it.identity == route.identity }) {
scope.raiseIllegalArgument("duplicate route for ANY ${route.identity.removePrefix("path:")}")
}
anyRegexRoutes += route
}
other.wsRoutes.forEach { (path, handler) ->
if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path")
wsRoutes[path] = handler
}
other.wsRegexRoutes.forEach { route ->
if (wsRegexRoutes.any { it.identity == route.identity }) {
scope.raiseIllegalArgument("duplicate websocket route for ${route.identity.removePrefix("path:")}")
}
wsRegexRoutes += route
}
if (other.fallback != null) {
if (fallback != null) scope.raiseIllegalArgument("fallback is already defined")
fallback = other.fallback
}
}
suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult {
val path = request.head.path
if (request.head.wantsWebSocketUpgrade) {
wsRoutes[path]?.let { route ->
return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass)
route.callWithReceiver(exchange, ObjServerWebSocket(session))
}
}
matchRegexRoute(wsRegexRoutes, path)?.let { matched ->
return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
matched.handler.callWithReceiver(exchange, ObjServerWebSocket(session))
}
}
}
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.callWithReceiver(exchange)
return exchangeResult(exactRoute === fallback, exchange)
}
matchRegexRoute(methodRegexRoutes[method], path)?.let { matched ->
val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
matched.handler.callWithReceiver(exchange)
return exchangeResult(false, exchange)
}
matchRegexRoute(anyRegexRoutes, path)?.let { matched ->
val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass)
matched.handler.callWithReceiver(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.callWithReceiver(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 class ObjLyngHttpServer( private class ObjLyngHttpServer(
private val netPolicy: NetAccessPolicy, private val netPolicy: NetAccessPolicy,
private val requestContextClass: ObjClass,
) : Obj() { ) : Obj() {
private val methodRoutes = linkedMapOf<String, LinkedHashMap<String, RegisteredCallable>>() private val routes = RouteRegistry(requestContextClass)
private val anyRoutes = linkedMapOf<String, RegisteredCallable>()
private val wsRoutes = linkedMapOf<String, RegisteredCallable>()
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, requestContextClass)
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, requestContextClass: ObjClass): ObjClass =
types.getOrPut(netPolicy) { types.getOrPut(netPolicy to requestContextClass) {
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, requestContextClass)
} }
}.apply { }.apply {
val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType) val routeArgType = unionType(stringType, regexType)
val webSocketHandlerType = fnType(nullableAnyType, serverWebSocketType, serverExchangeType) val exchangeHandlerType = receiverFnType(requestContextType, nullableAnyType)
val webSocketHandlerType = receiverFnType(requestContextType, nullableAnyType, serverWebSocketType)
val exchangeHandlerSignature = receiverCallSignature("RequestContext")
bridgeFn(this, "get", fnType(httpServerType, stringType, exchangeHandlerType)) { bridgeFn(this, "get", fnType(httpServerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerRoute("GET", this) thisAs<ObjLyngHttpServer>().registerRoute("GET", this)
} }
bridgeFn(this, "post", fnType(httpServerType, stringType, exchangeHandlerType)) { bridgeFn(this, "getPath", fnType(httpServerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("GET", this)
}
bridgeFn(this, "post", fnType(httpServerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerRoute("POST", this) thisAs<ObjLyngHttpServer>().registerRoute("POST", this)
} }
bridgeFn(this, "put", fnType(httpServerType, stringType, exchangeHandlerType)) { bridgeFn(this, "postPath", fnType(httpServerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("POST", this)
}
bridgeFn(this, "put", fnType(httpServerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerRoute("PUT", this) thisAs<ObjLyngHttpServer>().registerRoute("PUT", this)
} }
bridgeFn(this, "delete", fnType(httpServerType, stringType, exchangeHandlerType)) { bridgeFn(this, "putPath", fnType(httpServerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("PUT", this)
}
bridgeFn(this, "delete", fnType(httpServerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerRoute("DELETE", this) thisAs<ObjLyngHttpServer>().registerRoute("DELETE", this)
} }
bridgeFn(this, "any", fnType(httpServerType, stringType, exchangeHandlerType)) { bridgeFn(this, "deletePath", fnType(httpServerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerTemplateRoute("DELETE", this)
}
bridgeFn(this, "any", fnType(httpServerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerAny(this) thisAs<ObjLyngHttpServer>().registerAny(this)
} }
bridgeFn(this, "ws", fnType(httpServerType, stringType, webSocketHandlerType)) { bridgeFn(this, "anyPath", fnType(httpServerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerTemplateAny(this)
}
bridgeFn(this, "ws", fnType(httpServerType, routeArgType, webSocketHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerWs(this) thisAs<ObjLyngHttpServer>().registerWs(this)
} }
bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) { bridgeFn(this, "wsPath", fnType(httpServerType, stringType, webSocketHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerTemplateWs(this)
}
bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngHttpServer>().registerFallback(this) thisAs<ObjLyngHttpServer>().registerFallback(this)
} }
bridgeFn(this, "mount", fnType(httpServerType, routerType)) {
thisAs<ObjLyngHttpServer>().mount(this)
}
bridgeFn(this, "listen", fnType(httpServerHandleType, intType, nullableStringType, intType)) { bridgeFn(this, "listen", fnType(httpServerHandleType, intType, nullableStringType, intType)) {
thisAs<ObjLyngHttpServer>().listen(this) thisAs<ObjLyngHttpServer>().listen(this)
} }
@ -203,43 +465,51 @@ 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 {
val path = scope.requiredArg<ObjString>(index).value
if (!path.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'")
return path
}
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) routes.registerRoute(method, scope)
val handler = captureCallable(scope.requireScope(), scope.args.list[1]) scope.thisObj
val routes = methodRoutes.getOrPut(method) { linkedMapOf() } }
if (routes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path")
routes[path] = handler private suspend fun registerTemplateRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
routes.registerTemplateRoute(method, scope)
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) routes.registerAny(scope)
val handler = captureCallable(scope.requireScope(), scope.args.list[1]) scope.thisObj
if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path") }
anyRoutes[path] = handler
private suspend fun registerTemplateAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
routes.registerTemplateAny(scope)
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) routes.registerWs(scope)
val handler = captureCallable(scope.requireScope(), scope.args.list[1]) scope.thisObj
if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path") }
wsRoutes[path] = handler
private suspend fun registerTemplateWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
routes.registerTemplateWs(scope)
scope.thisObj scope.thisObj
} }
private suspend fun registerFallback(scope: ScopeFacade): Obj = scope.httpServerGuard { private suspend fun registerFallback(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope) ensureMutable(scope)
fallback = captureCallable(scope.requireScope(), scope.args.list[0]) routes.registerFallback(scope)
scope.thisObj
}
private suspend fun mount(scope: ScopeFacade): Obj = scope.httpServerGuard {
ensureMutable(scope)
routes.mount(scope, scope.requiredArg<ObjLyngRouter>(0).routes)
scope.thisObj scope.thisObj
} }
@ -254,47 +524,204 @@ private class ObjLyngHttpServer(
val started = startHttpServer( val started = startHttpServer(
config = HttpServerConfig(host = host ?: "127.0.0.1", port = port, backlog = backlog), config = HttpServerConfig(host = host ?: "127.0.0.1", port = port, backlog = backlog),
) { request -> ) { request ->
dispatchRequest(request) routes.dispatchRequest(request)
} }
handle = started handle = started
ObjHttpServerHandle(started) ObjHttpServerHandle(started)
} }
}
private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult { private class ObjLyngRouter(
val path = request.head.path private val requestContextClass: ObjClass,
if (request.head.wantsWebSocketUpgrade) { ) : Obj() {
wsRoutes[path]?.let { route -> val routes = RouteRegistry(requestContextClass)
return HttpHandlerResult.WebSocket { session ->
val exchange = ObjServerExchange(request) override val objClass: ObjClass
route.call(ObjServerWebSocket(session), exchange) get() = type(requestContextClass)
companion object {
private val types = mutableMapOf<ObjClass, ObjClass>()
fun type(requestContextClass: ObjClass): ObjClass =
types.getOrPut(requestContextClass) {
object : ObjClass("Router") {
override suspend fun callOn(scope: Scope): Obj {
if (scope.args.list.isNotEmpty()) scope.raiseError("Router() does not accept arguments")
return ObjLyngRouter(requestContextClass)
}
}.apply {
val routeArgType = unionType(stringType, regexType)
val exchangeHandlerType = receiverFnType(requestContextType, nullableAnyType)
val webSocketHandlerType = receiverFnType(requestContextType, nullableAnyType, serverWebSocketType)
val exchangeHandlerSignature = receiverCallSignature("RequestContext")
bridgeFn(this, "get", fnType(routerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerRoute("GET", this)
}
bridgeFn(this, "getPath", fnType(routerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerTemplateRoute("GET", this)
}
bridgeFn(this, "post", fnType(routerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerRoute("POST", this)
}
bridgeFn(this, "postPath", fnType(routerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerTemplateRoute("POST", this)
}
bridgeFn(this, "put", fnType(routerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerRoute("PUT", this)
}
bridgeFn(this, "putPath", fnType(routerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerTemplateRoute("PUT", this)
}
bridgeFn(this, "delete", fnType(routerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerRoute("DELETE", this)
}
bridgeFn(this, "deletePath", fnType(routerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerTemplateRoute("DELETE", this)
}
bridgeFn(this, "any", fnType(routerType, routeArgType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerAny(this)
}
bridgeFn(this, "anyPath", fnType(routerType, stringType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerTemplateAny(this)
}
bridgeFn(this, "ws", fnType(routerType, routeArgType, webSocketHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerWs(this)
}
bridgeFn(this, "wsPath", fnType(routerType, stringType, webSocketHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerTemplateWs(this)
}
bridgeFn(this, "fallback", fnType(routerType, exchangeHandlerType), exchangeHandlerSignature) {
thisAs<ObjLyngRouter>().registerFallback(this)
}
bridgeFn(this, "mount", fnType(routerType, routerType)) {
thisAs<ObjLyngRouter>().mount(this)
}
} }
} }
} }
val route = methodRoutes[request.head.method.uppercase()]?.get(path) private suspend fun registerRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
?: anyRoutes[path] routes.registerRoute(method, scope)
?: fallback scope.thisObj
}
if (route == null) { private suspend fun registerTemplateRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard {
return HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) routes.registerTemplateRoute(method, scope)
} scope.thisObj
}
val exchange = ObjServerExchange(request) private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
route.call(exchange) routes.registerAny(scope)
return when (val result = exchange.result) { scope.thisObj
is ExchangeResult.Http -> result.value }
is ExchangeResult.WebSocket -> result.value
ExchangeResult.Unhandled -> { private suspend fun registerTemplateAny(scope: ScopeFacade): Obj = scope.httpServerGuard {
if (route === fallback) { routes.registerTemplateAny(scope)
HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) scope.thisObj
} else { }
HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true))
} private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
} routes.registerWs(scope)
} scope.thisObj
}
private suspend fun registerTemplateWs(scope: ScopeFacade): Obj = scope.httpServerGuard {
routes.registerTemplateWs(scope)
scope.thisObj
}
private suspend fun registerFallback(scope: ScopeFacade): Obj = scope.httpServerGuard {
routes.registerFallback(scope)
scope.thisObj
}
private suspend fun mount(scope: ScopeFacade): Obj = scope.httpServerGuard {
routes.mount(scope, scope.requiredArg<ObjLyngRouter>(0).routes)
scope.thisObj
} }
} }
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 +767,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 +800,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 +812,107 @@ 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("RequestContext") {
bridgeProperty(this, "request", serverRequestType) { override suspend fun callOn(scope: Scope): Obj {
ObjServerRequest(thisAs<ObjServerExchange>().request) scope.raiseError("RequestContext 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()
}
bridgeFn(
this,
"jsonBody",
base.getInstanceMemberOrNull("jsonBody")?.typeDecl as? TypeDecl.Function
?: fnType(nullableAnyType),
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
}
bridgeFn(
this,
"respondJson",
base.getInstanceMemberOrNull("respondJson")?.typeDecl as? TypeDecl.Function
?: fnType(voidType, nullableAnyType, intType),
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, receiverFnType(requestContextType, nullableAnyType, serverWebSocketType)),
receiverCallSignature("RequestContext")
) {
val self = thisAs<ObjServerExchange>()
val registered = captureCallable(requireScope(), args.list[0])
self.ensureMutable(this)
self.result = ExchangeResult.WebSocket(
HttpHandlerResult.WebSocket { session ->
registered.callWithReceiver(self, ObjServerWebSocket(session))
}
)
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 +992,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

@ -51,12 +51,12 @@ class LyngHttpServerModuleTest {
import lyng.io.http.server import lyng.io.http.server
val server = HttpServer() val server = HttpServer()
server.get("/hello") { ex -> server.get("/hello") {
ex.setHeader("Content-Type", "text/plain") setHeader("Content-Type", "text/plain")
ex.respondText(200, "hello from lyng") respondText(200, "hello from lyng")
} }
server.fallback { ex -> server.fallback {
ex.respondText(404, "miss:" + ex.request.path) respondText(404, "miss:" + request.path)
} }
server.listen(0, "127.0.0.1") server.listen(0, "127.0.0.1")
""".trimIndent() """.trimIndent()
@ -89,6 +89,302 @@ 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") {
val q = request.query
respondText(
200,
(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) {
val m = routeMatch!!
respondText(
200,
m[1] +
"|" + m[2] +
"|" + request.pathParts[0] +
"," + request.pathParts[1] +
"," + request.pathParts[2] +
"," + request.pathParts[3]
)
}
server.get("/users/fixed/posts/9") {
respondText(200, "fixed|" + (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") {
respondText(200, "fixed|" + routeParams.size)
}
server.getPath("/users/{userId}/posts/{postId}") {
respondText(
200,
routeParams["userId"] + "|" +
routeParams["postId"] + "|" +
request.pathParts[1] + "|" +
request.pathParts[3] + "|" +
(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") {
val req = jsonBody<CreateUserRequest>()
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")
}
@Test
fun routerMountPreservesBuiltInRoutingSemantics() = 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 api = Router()
api.get("/health") {
respondText(200, "ok")
}
val users = Router()
users.getPath("/users/{id}") {
respondText(200, "user:" + routeParams["id"])
}
users.fallback {
respondText(404, "router-miss:" + request.path)
}
api.mount(users)
val server = HttpServer()
server.mount(api)
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 /health 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("ok"), response)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /users/alice 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("user:alice"), response)
} finally {
client2.close()
}
val client3 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client3.writeUtf8("GET /missing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client3.flush()
val response = readHttpResponse(client3)
assertTrue(response.contains("404"), response)
assertTrue(response.endsWith("router-miss:/missing"), response)
} finally {
client3.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
@ -25,13 +28,17 @@ extern class ServerWebSocket {
} }
/* Mutable exchange object for one incoming request. */ /* Mutable exchange object for one incoming request. */
extern class ServerExchange { extern class RequestContext {
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: RequestContext.(ServerWebSocket) -> Object?): void
fun isHandled(): Bool fun isHandled(): Bool
} }
@ -41,14 +48,39 @@ extern class HttpServerHandle {
fun close(): void fun close(): void
} }
/* Exact-path HTTP/WebSocket server with built-in router. */ /* Reusable route collection mounted into HttpServer or other Router. */
extern class Router {
fun get(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun getPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun post(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun postPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun put(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun putPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun delete(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun deletePath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun any(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun anyPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun ws(path: String|Regex, handler: RequestContext.(ServerWebSocket) -> Object?): Router
fun wsPath(pathTemplate: String, handler: RequestContext.(ServerWebSocket) -> Object?): Router
fun fallback(handler: RequestContext.() -> Object?): Router
fun mount(router: Router): Router
}
/* 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: RequestContext.() -> Object?): HttpServer
fun post(path: String, handler: (ServerExchange) -> Object?): HttpServer fun getPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun put(path: String, handler: (ServerExchange) -> Object?): HttpServer fun post(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun delete(path: String, handler: (ServerExchange) -> Object?): HttpServer fun postPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun any(path: String, handler: (ServerExchange) -> Object?): HttpServer fun put(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun ws(path: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer fun putPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun fallback(handler: (ServerExchange) -> Object?): HttpServer fun delete(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun deletePath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun any(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun anyPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun ws(path: String|Regex, handler: RequestContext.(ServerWebSocket) -> Object?): HttpServer
fun wsPath(pathTemplate: String, handler: RequestContext.(ServerWebSocket) -> Object?): HttpServer
fun fallback(handler: RequestContext.() -> Object?): HttpServer
fun mount(router: Router): HttpServer
fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle
} }

View File

@ -3463,12 +3463,13 @@ class Compiler(
is FastLocalVarRef -> ref.name is FastLocalVarRef -> ref.name
is LocalSlotRef -> ref.name is LocalSlotRef -> ref.name
is FieldRef -> ref.name is FieldRef -> ref.name
is ImplicitThisMemberRef -> ref.name
else -> null else -> null
} }
if (name != null) { if (name != null) {
if (lookupGenericFunctionDecl(name) != null) return true if (lookupGenericFunctionDecl(name) != null) return true
if (name.firstOrNull()?.isUpperCase() == true) return true if (name.firstOrNull()?.isUpperCase() == true) return true
return ref is FieldRef return ref is FieldRef || ref is ImplicitThisMemberRef
} }
return ref is ConstRef && ref.constValue is ObjClass return ref is ConstRef && ref.constValue is ObjClass
} }
@ -3482,6 +3483,13 @@ class Compiler(
implicitItType: TypeDecl? = null, implicitItType: TypeDecl? = null,
expectedCallableType: TypeDecl.Function? = null expectedCallableType: TypeDecl.Function? = null
): ObjRef { ): ObjRef {
fun receiverTypeName(typeDecl: TypeDecl?): String? = when (typeDecl) {
is TypeDecl.Simple -> typeDecl.name
is TypeDecl.Generic -> typeDecl.name
else -> null
}
val effectiveExpectedReceiverType = expectedReceiverType ?: receiverTypeName(expectedCallableType?.receiver)
// lambda args are different: // lambda args are different:
val startPos = cc.currentPos() val startPos = cc.currentPos()
val label = lastLabel val label = lastLabel
@ -3545,7 +3553,13 @@ class Compiler(
val capturePlan = CapturePlan(paramSlotPlan, isFunction = true, propagateToParentFunction = lambdaDepth > 0) val capturePlan = CapturePlan(paramSlotPlan, isFunction = true, propagateToParentFunction = lambdaDepth > 0)
capturePlanStack.add(capturePlan) capturePlanStack.add(capturePlan)
val parsedBody = try { val parsedBody = try {
inCodeContext(CodeContext.Function("<lambda>", implicitThisMembers = true, implicitThisTypeName = expectedReceiverType)) { inCodeContext(
CodeContext.Function(
"<lambda>",
implicitThisMembers = true,
implicitThisTypeName = effectiveExpectedReceiverType
)
) {
val returnLabels = label?.let { setOf(it) } ?: emptySet() val returnLabels = label?.let { setOf(it) } ?: emptySet()
returnLabelStack.addLast(returnLabels) returnLabelStack.addLast(returnLabels)
try { try {
@ -3798,13 +3812,13 @@ class Compiler(
inferredReturnClass = returnClass, inferredReturnClass = returnClass,
inlineBodyRef = inlineBodyRef, inlineBodyRef = inlineBodyRef,
supportsDirectInvokeFastPath = supportsDirectInvokeFastPath, supportsDirectInvokeFastPath = supportsDirectInvokeFastPath,
preferredThisType = expectedReceiverType, preferredThisType = effectiveExpectedReceiverType,
wrapAsExtensionCallable = wrapAsExtensionCallable, wrapAsExtensionCallable = wrapAsExtensionCallable,
returnLabels = returnLabels, returnLabels = returnLabels,
pos = startPos pos = startPos
) )
val lambdaTypeDecl = TypeDecl.Function( val lambdaTypeDecl = TypeDecl.Function(
receiver = null, receiver = effectiveExpectedReceiverType?.let { TypeDecl.Simple(it, false) },
params = lambdaParamTypeDecls.toList(), params = lambdaParamTypeDecls.toList(),
returnType = inferredReturnDecl ?: returnClass?.let { TypeDecl.Simple(it.className, false) } ?: TypeDecl.TypeAny, returnType = inferredReturnDecl ?: returnClass?.let { TypeDecl.Simple(it.className, false) } ?: TypeDecl.TypeAny,
nullable = false nullable = false
@ -7121,7 +7135,19 @@ class Compiler(
} }
val result = when (left) { val result = when (left) {
is ImplicitThisMemberRef -> is ImplicitThisMemberRef ->
if (left.methodId == null && left.fieldId != null) { if (!explicitTypeArgs.isNullOrEmpty()) {
val explicitReceiver: ObjRef = (left.preferredThisTypeName() ?: implicitThisTypeName)
?.let { QualifiedThisRef(it, left.atPos) }
?: LocalVarRef("this", left.atPos)
MethodCallRef(
explicitReceiver,
left.name,
args,
detectedBlockArgument,
isOptional,
explicitTypeArgs
)
} else if (left.methodId == null && left.fieldId != null) {
CallRef(left, args, detectedBlockArgument, isOptional, explicitTypeArgs).also { callRef -> CallRef(left, args, detectedBlockArgument, isOptional, explicitTypeArgs).also { callRef ->
applyExplicitCallTypeArgs(callRef, explicitTypeArgs) applyExplicitCallTypeArgs(callRef, explicitTypeArgs)
} }