diff --git a/docs/lyng.io.http.server.md b/docs/lyng.io.http.server.md new file mode 100644 index 0000000..f49ef2a --- /dev/null +++ b/docs/lyng.io.http.server.md @@ -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` +- `queryString: String?` +- `query: Map` +- `headers: HttpHeaders` +- `body: Buffer` + +`ServerExchange` exposes routing context and response controls: + +- `request: ServerRequest` +- `routeMatch: RegexMatch?` +- `routeParams: Map` +- `jsonBody()` +- `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()` 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() + + 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)` diff --git a/docs/lyngio.md b/docs/lyngio.md index 3447450..da96883 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -17,6 +17,7 @@ - **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information. - **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events. - **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`. +- **[lyng.io.http.server](lyng.io.http.server.md):** Minimal HTTP/1.1 and WebSocket server. Provides `HttpServer`, `ServerRequest`, `ServerExchange`, and `ServerWebSocket`. - **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`. - **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`. - **Shared networking type packages:** `lyng.io.http.types`, `lyng.io.ws.types`, and `lyng.io.net.types` expose reusable value types such as `HttpHeaders`, `WsMessage`, `IpVersion`, `SocketAddress`, and `Datagram` when host code wants type-only imports without installing the corresponding capability object module. @@ -120,6 +121,7 @@ For more details, see the specific module documentation: - [Process Security Details](lyng.io.process.md#security-policy) - [Console Module Details](lyng.io.console.md) - [HTTP Module Details](lyng.io.http.md) +- [HTTP Server Module Details](lyng.io.http.server.md) - [Transport Networking Details](lyng.io.net.md) - [WebSocket Module Details](lyng.io.ws.md) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt index ca49c42..751280a 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModule.kt @@ -1,5 +1,6 @@ package net.sergeych.lyng.io.http.server +import kotlinx.serialization.json.Json import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Scope import net.sergeych.lyng.ScopeFacade @@ -14,9 +15,13 @@ import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjExternCallable import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjList +import net.sergeych.lyng.obj.ObjMap import net.sergeych.lyng.obj.ObjNull import net.sergeych.lyng.obj.ObjProperty +import net.sergeych.lyng.obj.ObjRegex +import net.sergeych.lyng.obj.ObjRegexMatch import net.sergeych.lyng.obj.ObjString +import net.sergeych.lyng.obj.ObjTypeExpr import net.sergeych.lyng.obj.ObjVoid import net.sergeych.lyng.obj.requiredArg import net.sergeych.lyng.obj.thisAs @@ -24,6 +29,7 @@ import net.sergeych.lyng.io.http.ObjHttpHeaders import net.sergeych.lyng.io.http.createHttpTypesModule import net.sergeych.lyng.io.ws.ObjWsMessage import net.sergeych.lyng.io.ws.createWsTypesModule +import net.sergeych.lyng.serialization.ObjJsonClass import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.raiseIllegalOperation import net.sergeych.lyng.requireNoArgs @@ -34,6 +40,7 @@ import net.sergeych.lyngio.http.server.HttpRequest import net.sergeych.lyngio.http.server.HttpResponse import net.sergeych.lyngio.http.server.HttpServerConfig import net.sergeych.lyngio.http.server.HttpWebSocketSession +import net.sergeych.lyngio.http.server.decodePathSegment import net.sergeych.lyngio.http.server.defaultReason import net.sergeych.lyngio.http.server.startHttpServer import net.sergeych.lyngio.net.security.NetAccessDeniedException @@ -63,13 +70,14 @@ fun createHttpServer(policy: NetAccessPolicy, manager: ImportManager): Boolean = private suspend fun buildHttpServerModule(module: ModuleScope, policy: NetAccessPolicy) { module.eval(Source(HTTP_SERVER_MODULE_NAME, http_serverLyng)) + val serverExchangeClass = ObjServerExchange.type(module.requireClass("ServerExchange")) module.addConst("HttpHeaders", ObjHttpHeaders.type) module.addConst("WsMessage", ObjWsMessage.type) module.addConst("ServerRequest", ObjServerRequest.type) - module.addConst("ServerExchange", ObjServerExchange.type) + module.addConst("ServerExchange", serverExchangeClass) module.addConst("ServerWebSocket", ObjServerWebSocket.type) module.addConst("HttpServerHandle", ObjHttpServerHandle.type) - module.addConst("HttpServer", ObjLyngHttpServer.type(policy)) + module.addConst("HttpServer", ObjLyngHttpServer.type(policy, serverExchangeClass)) } private suspend inline fun ScopeFacade.httpServerGuard(crossinline block: suspend () -> Obj): Obj { @@ -102,6 +110,8 @@ private val boolType = TypeDecl.Simple("Bool", false) private val intType = TypeDecl.Simple("Int", false) private val bufferType = TypeDecl.Simple("Buffer", false) private val nullableBufferType = TypeDecl.Simple("Buffer", true) +private val regexType = TypeDecl.Simple("Regex", false) +private val nullableRegexMatchType = TypeDecl.Simple("RegexMatch", true) private val voidType = TypeDecl.Simple("Void", false) private val httpHeadersType = TypeDecl.Simple("HttpHeaders", false) private val serverRequestType = TypeDecl.Simple("ServerRequest", false) @@ -113,6 +123,8 @@ private val httpServerType = TypeDecl.Simple("HttpServer", false) private val nullableAnyType = TypeDecl.TypeNullableAny private fun listType(item: TypeDecl) = TypeDecl.Generic("List", listOf(item), false) +private fun mapType(key: TypeDecl, value: TypeDecl) = TypeDecl.Generic("Map", listOf(key, value), false) +private fun unionType(vararg options: TypeDecl) = TypeDecl.Union(options.toList(), nullable = false) private fun fnType(returnType: TypeDecl, vararg params: TypeDecl) = TypeDecl.Function(receiver = null, params = params.toList(), returnType = returnType) @@ -147,48 +159,71 @@ private fun bridgeProperty( private class ObjLyngHttpServer( private val netPolicy: NetAccessPolicy, + private val exchangeClass: ObjClass, ) : Obj() { private val methodRoutes = linkedMapOf>() + private val methodRegexRoutes = linkedMapOf>() private val anyRoutes = linkedMapOf() + private val anyRegexRoutes = mutableListOf() private val wsRoutes = linkedMapOf() + private val wsRegexRoutes = mutableListOf() private var fallback: RegisteredCallable? = null private var handle: net.sergeych.lyngio.http.server.HttpServer? = null override val objClass: ObjClass - get() = type(netPolicy) + get() = type(netPolicy, exchangeClass) companion object { - private val types = mutableMapOf() + private val types = mutableMapOf, ObjClass>() - fun type(netPolicy: NetAccessPolicy): ObjClass = - types.getOrPut(netPolicy) { + fun type(netPolicy: NetAccessPolicy, exchangeClass: ObjClass): ObjClass = + types.getOrPut(netPolicy to exchangeClass) { object : ObjClass("HttpServer") { override suspend fun callOn(scope: Scope): Obj { if (scope.args.list.isNotEmpty()) scope.raiseError("HttpServer() does not accept arguments") - return ObjLyngHttpServer(netPolicy) + return ObjLyngHttpServer(netPolicy, exchangeClass) } }.apply { + val routeArgType = unionType(stringType, regexType) val exchangeHandlerType = fnType(nullableAnyType, serverExchangeType) val webSocketHandlerType = fnType(nullableAnyType, serverWebSocketType, serverExchangeType) - bridgeFn(this, "get", fnType(httpServerType, stringType, exchangeHandlerType)) { + bridgeFn(this, "get", fnType(httpServerType, routeArgType, exchangeHandlerType)) { thisAs().registerRoute("GET", this) } - bridgeFn(this, "post", fnType(httpServerType, stringType, exchangeHandlerType)) { + bridgeFn(this, "getPath", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerTemplateRoute("GET", this) + } + bridgeFn(this, "post", fnType(httpServerType, routeArgType, exchangeHandlerType)) { thisAs().registerRoute("POST", this) } - bridgeFn(this, "put", fnType(httpServerType, stringType, exchangeHandlerType)) { + bridgeFn(this, "postPath", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerTemplateRoute("POST", this) + } + bridgeFn(this, "put", fnType(httpServerType, routeArgType, exchangeHandlerType)) { thisAs().registerRoute("PUT", this) } - bridgeFn(this, "delete", fnType(httpServerType, stringType, exchangeHandlerType)) { + bridgeFn(this, "putPath", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerTemplateRoute("PUT", this) + } + bridgeFn(this, "delete", fnType(httpServerType, routeArgType, exchangeHandlerType)) { thisAs().registerRoute("DELETE", this) } - bridgeFn(this, "any", fnType(httpServerType, stringType, exchangeHandlerType)) { + bridgeFn(this, "deletePath", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerTemplateRoute("DELETE", this) + } + bridgeFn(this, "any", fnType(httpServerType, routeArgType, exchangeHandlerType)) { thisAs().registerAny(this) } - bridgeFn(this, "ws", fnType(httpServerType, stringType, webSocketHandlerType)) { + bridgeFn(this, "anyPath", fnType(httpServerType, stringType, exchangeHandlerType)) { + thisAs().registerTemplateAny(this) + } + bridgeFn(this, "ws", fnType(httpServerType, routeArgType, webSocketHandlerType)) { thisAs().registerWs(this) } + bridgeFn(this, "wsPath", fnType(httpServerType, stringType, webSocketHandlerType)) { + thisAs().registerTemplateWs(this) + } bridgeFn(this, "fallback", fnType(httpServerType, exchangeHandlerType)) { thisAs().registerFallback(this) } @@ -203,37 +238,108 @@ private class ObjLyngHttpServer( if (handle != null) scope.raiseIllegalState("HttpServer routes cannot be modified after listen()") } - private fun requirePath(scope: ScopeFacade, index: Int): String { - val path = scope.requiredArg(index).value - if (!path.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'") - return path + private fun requireRoutePattern(scope: ScopeFacade, index: Int): RoutePattern = when (val path = scope.args.list.getOrNull(index)) { + is ObjString -> { + if (!path.value.startsWith('/')) scope.raiseIllegalArgument("path must start with '/'") + RoutePattern.Exact(path.value) + } + is ObjRegex -> RoutePattern.Regex(path) + else -> scope.raiseClassCastError("path must be String or Regex") } private suspend fun registerRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard { ensureMutable(scope) - val path = requirePath(scope, 0) + val path = requireRoutePattern(scope, 0) val handler = captureCallable(scope.requireScope(), scope.args.list[1]) - val routes = methodRoutes.getOrPut(method) { linkedMapOf() } - if (routes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for $method $path") - routes[path] = handler + when (path) { + is RoutePattern.Exact -> { + val routes = methodRoutes.getOrPut(method) { linkedMapOf() } + if (routes.containsKey(path.path)) scope.raiseIllegalArgument("duplicate route for $method ${path.path}") + routes[path.path] = handler + } + is RoutePattern.Regex -> { + val routes = methodRegexRoutes.getOrPut(method) { mutableListOf() } + if (routes.any { it.pattern.regex.pattern == path.regex.regex.pattern }) { + scope.raiseIllegalArgument("duplicate regex route for $method ${path.regex.regex.pattern}") + } + routes += RegisteredRegexRoute(path.regex, handler) + } + } + scope.thisObj + } + + private suspend fun registerTemplateRoute(method: String, scope: ScopeFacade): Obj = scope.httpServerGuard { + ensureMutable(scope) + val template = requirePathTemplate(scope, 0) + val handler = captureCallable(scope.requireScope(), scope.args.list[1]) + val routes = methodRegexRoutes.getOrPut(method) { mutableListOf() } + val compiled = compilePathTemplate(template, scope) + if (routes.any { it.identity == compiled.identity }) { + scope.raiseIllegalArgument("duplicate path route for $method $template") + } + routes += RegisteredRegexRoute(compiled.pattern, handler, compiled.paramNames, compiled.identity) scope.thisObj } private suspend fun registerAny(scope: ScopeFacade): Obj = scope.httpServerGuard { ensureMutable(scope) - val path = requirePath(scope, 0) + val path = requireRoutePattern(scope, 0) val handler = captureCallable(scope.requireScope(), scope.args.list[1]) - if (anyRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate route for ANY $path") - anyRoutes[path] = handler + when (path) { + is RoutePattern.Exact -> { + if (anyRoutes.containsKey(path.path)) scope.raiseIllegalArgument("duplicate route for ANY ${path.path}") + anyRoutes[path.path] = handler + } + is RoutePattern.Regex -> { + if (anyRegexRoutes.any { it.pattern.regex.pattern == path.regex.regex.pattern }) { + scope.raiseIllegalArgument("duplicate regex route for ANY ${path.regex.regex.pattern}") + } + anyRegexRoutes += RegisteredRegexRoute(path.regex, handler) + } + } + scope.thisObj + } + + private suspend fun registerTemplateAny(scope: ScopeFacade): Obj = scope.httpServerGuard { + ensureMutable(scope) + val template = requirePathTemplate(scope, 0) + val handler = captureCallable(scope.requireScope(), scope.args.list[1]) + val compiled = compilePathTemplate(template, scope) + if (anyRegexRoutes.any { it.identity == compiled.identity }) { + scope.raiseIllegalArgument("duplicate path route for ANY $template") + } + anyRegexRoutes += RegisteredRegexRoute(compiled.pattern, handler, compiled.paramNames, compiled.identity) scope.thisObj } private suspend fun registerWs(scope: ScopeFacade): Obj = scope.httpServerGuard { ensureMutable(scope) - val path = requirePath(scope, 0) + val path = requireRoutePattern(scope, 0) val handler = captureCallable(scope.requireScope(), scope.args.list[1]) - if (wsRoutes.containsKey(path)) scope.raiseIllegalArgument("duplicate websocket route for $path") - wsRoutes[path] = handler + when (path) { + is RoutePattern.Exact -> { + if (wsRoutes.containsKey(path.path)) scope.raiseIllegalArgument("duplicate websocket route for ${path.path}") + wsRoutes[path.path] = handler + } + is RoutePattern.Regex -> { + if (wsRegexRoutes.any { it.pattern.regex.pattern == path.regex.regex.pattern }) { + scope.raiseIllegalArgument("duplicate websocket regex route for ${path.regex.regex.pattern}") + } + wsRegexRoutes += RegisteredRegexRoute(path.regex, handler) + } + } + scope.thisObj + } + + private suspend fun registerTemplateWs(scope: ScopeFacade): Obj = scope.httpServerGuard { + ensureMutable(scope) + val template = requirePathTemplate(scope, 0) + val handler = captureCallable(scope.requireScope(), scope.args.list[1]) + val compiled = compilePathTemplate(template, scope) + if (wsRegexRoutes.any { it.identity == compiled.identity }) { + scope.raiseIllegalArgument("duplicate websocket path route for $template") + } + wsRegexRoutes += RegisteredRegexRoute(compiled.pattern, handler, compiled.paramNames, compiled.identity) scope.thisObj } @@ -243,6 +349,12 @@ private class ObjLyngHttpServer( scope.thisObj } + private fun requirePathTemplate(scope: ScopeFacade, index: Int): String { + val template = scope.requiredArg(index).value + if (!template.startsWith('/')) scope.raiseIllegalArgument("pathTemplate must start with '/'") + return template + } + private suspend fun listen(scope: ScopeFacade): Obj = scope.httpServerGuard { ensureMutable(scope) val port = scope.requiredArg(0).value.toInt() @@ -263,38 +375,141 @@ private class ObjLyngHttpServer( private suspend fun dispatchRequest(request: HttpRequest): HttpHandlerResult { val path = request.head.path if (request.head.wantsWebSocketUpgrade) { - wsRoutes[path]?.let { route -> + wsRoutes[path]?.let { route -> return HttpHandlerResult.WebSocket { session -> - val exchange = ObjServerExchange(request) + val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass) route.call(ObjServerWebSocket(session), exchange) } } + matchRegexRoute(wsRegexRoutes, path)?.let { matched -> + return HttpHandlerResult.WebSocket { session -> + val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass) + matched.handler.call(ObjServerWebSocket(session), exchange) + } + } } - val route = methodRoutes[request.head.method.uppercase()]?.get(path) - ?: anyRoutes[path] - ?: fallback - - if (route == null) { - return HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) + val method = request.head.method.uppercase() + val exactRoute = methodRoutes[method]?.get(path) ?: anyRoutes[path] + if (exactRoute != null) { + val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass) + exactRoute.call(exchange) + return exchangeResult(exactRoute === fallback, exchange) } - val exchange = ObjServerExchange(request) - route.call(exchange) - return when (val result = exchange.result) { - is ExchangeResult.Http -> result.value - is ExchangeResult.WebSocket -> result.value - ExchangeResult.Unhandled -> { - if (route === fallback) { - HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) - } else { - HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true)) - } + matchRegexRoute(methodRegexRoutes[method], path)?.let { matched -> + val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass) + matched.handler.call(exchange) + return exchangeResult(false, exchange) + } + + matchRegexRoute(anyRegexRoutes, path)?.let { matched -> + val exchange = ObjServerExchange(request, ObjRegexMatch(matched.match), matched.params, exchangeClass) + matched.handler.call(exchange) + return exchangeResult(false, exchange) + } + + val fallbackRoute = fallback ?: return HttpHandlerResult.Response( + HttpResponse(status = 404, body = "not found".encodeToByteArray()) + ) + val exchange = ObjServerExchange(request, null, emptyMap(), exchangeClass) + fallbackRoute.call(exchange) + return exchangeResult(true, exchange) + } + + private fun exchangeResult(isFallback: Boolean, exchange: ObjServerExchange): HttpHandlerResult = when (val result = exchange.result) { + is ExchangeResult.Http -> result.value + is ExchangeResult.WebSocket -> result.value + ExchangeResult.Unhandled -> { + if (isFallback) { + HttpHandlerResult.Response(HttpResponse(status = 404, body = "not found".encodeToByteArray())) + } else { + HttpHandlerResult.Response(HttpResponse(status = 500, body = "route handler did not handle exchange".encodeToByteArray(), close = true)) } } } } +private sealed interface RoutePattern { + data class Exact(val path: String) : RoutePattern + data class Regex(val regex: ObjRegex) : RoutePattern +} + +private data class RegisteredRegexRoute( + val pattern: ObjRegex, + val handler: RegisteredCallable, + val paramNames: List = emptyList(), + val identity: String = "re:${pattern.regex.pattern}", +) + +private data class MatchedRegexRoute( + val handler: RegisteredCallable, + val match: MatchResult, + val params: Map, +) + +private fun matchRegexRoute(routes: List?, 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, + val identity: String, +) + +private fun compilePathTemplate(template: String, scope: ScopeFacade): CompiledPathTemplate { + val segments = if (template == "/") emptyList() else template.removePrefix("/").split('/') + val names = mutableListOf() + val pattern = buildString { + append('^') + if (segments.isEmpty()) { + append('/') + } else { + for (segment in segments) { + append('/') + if (segment.startsWith('{') && segment.endsWith('}')) { + val name = segment.substring(1, segment.length - 1) + if (!isValidPathParamName(name)) { + scope.raiseIllegalArgument("invalid path parameter name: $name") + } + if (!names.add(name)) { + scope.raiseIllegalArgument("duplicate path parameter name: $name") + } + append("([^/]+)") + } else if ('{' in segment || '}' in segment) { + scope.raiseIllegalArgument("path template segments must be literal text or {name}") + } else { + append(Regex.escape(segment)) + } + } + } + append('$') + } + return CompiledPathTemplate( + pattern = ObjRegex(Regex(pattern)), + paramNames = names, + identity = "path:$template" + ) +} + +private fun isValidPathParamName(name: String): Boolean = + name.isNotEmpty() && + (name.first() == '_' || name.first().isLetter()) && + name.drop(1).all { it == '_' || it.isLetterOrDigit() } + private class ObjHttpServerHandle( private val handle: net.sergeych.lyngio.http.server.HttpServer, ) : Obj() { @@ -340,8 +555,14 @@ private class ObjServerRequest( bridgeProperty(this, "path", stringType) { ObjString(thisAs().request.head.path) } - bridgeProperty(this, "query", nullableStringType) { - thisAs().request.head.query?.let(::ObjString) ?: ObjNull + bridgeProperty(this, "pathParts", listType(stringType)) { + ObjList(thisAs().request.head.pathParts.map(::ObjString).toMutableList()) + } + bridgeProperty(this, "queryString", nullableStringType) { + thisAs().request.head.queryString?.let(::ObjString) ?: ObjNull + } + bridgeProperty(this, "query", mapType(stringType, stringType)) { + thisAs().request.head.query.toObjMap() } bridgeProperty(this, "headers", httpHeadersType) { requestHeadersObj(thisAs().request.head.headers) @@ -367,6 +588,9 @@ private sealed interface ExchangeResult { private class ObjServerExchange( private val request: HttpRequest, + private val routeMatch: ObjRegexMatch?, + private val routeParams: Map, + private val type: ObjClass, ) : Obj() { private val responseHeaders = linkedMapOf>() var result: ExchangeResult = ExchangeResult.Unhandled @@ -376,63 +600,100 @@ private class ObjServerExchange( get() = type companion object { - val type = object : ObjClass("ServerExchange") { - override suspend fun callOn(scope: Scope): Obj { - scope.raiseError("ServerExchange cannot be created directly") - } - }.apply { - bridgeProperty(this, "request", serverRequestType) { - ObjServerRequest(thisAs().request) - } - bridgeFn(this, "respond", fnType(voidType, intType, nullableBufferType)) { - val self = thisAs() - 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() - 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() - val name = requiredArg(0).value - val value = requiredArg(1).value - self.ensureMutable(this) - self.responseHeaders[name] = mutableListOf(value) - ObjVoid - } - bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) { - val self = thisAs() - val name = requiredArg(0).value - val value = requiredArg(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() - val registered = captureCallable(requireScope(), args.list[0]) - self.ensureMutable(this) - self.result = ExchangeResult.WebSocket( - HttpHandlerResult.WebSocket { session -> - registered.call(ObjServerWebSocket(session), self) + private val types = mutableMapOf() + + fun type(base: ObjClass): ObjClass = + types.getOrPut(base) { + object : ObjClass("ServerExchange") { + override suspend fun callOn(scope: Scope): Obj { + scope.raiseError("ServerExchange cannot be created directly") } - ) - ObjVoid + }.apply { + bridgeProperty(this, "request", serverRequestType) { + ObjServerRequest(thisAs().request) + } + bridgeProperty(this, "routeMatch", nullableRegexMatchType) { + thisAs().routeMatch ?: ObjNull + } + bridgeProperty(this, "routeParams", mapType(stringType, stringType)) { + thisAs().routeParams.toObjMap() + } + addFn( + "jsonBody", + callSignature = base.getInstanceMemberOrNull("jsonBody")?.callSignature + ) { + val self = thisAs() + 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() + 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() + 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() + 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() + val name = requiredArg(0).value + val value = requiredArg(1).value + self.ensureMutable(this) + self.responseHeaders[name] = mutableListOf(value) + ObjVoid + } + bridgeFn(this, "addHeader", fnType(voidType, stringType, stringType)) { + val self = thisAs() + val name = requiredArg(0).value + val value = requiredArg(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() + 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().result !== ExchangeResult.Unhandled) + } + } } - bridgeFn(this, "isHandled", fnType(boolType)) { - ObjBool(thisAs().result !== ExchangeResult.Unhandled) - } - } } private fun ensureMutable(scope: ScopeFacade) { @@ -512,3 +773,17 @@ private fun objBufferOrNull(scope: ScopeFacade, value: Obj, name: String): ObjBu is ObjBuffer -> value else -> scope.raiseClassCastError("$name must be Buffer or null") } + +private fun resolveJsonTargetType(scope: Scope): TypeDecl { + val explicit = scope.args.explicitTypeArgs.singleOrNull() + if (explicit != null) return explicit + val bound = scope["T"]?.value + return when (bound) { + is ObjTypeExpr -> bound.typeDecl + is ObjClass -> TypeDecl.Simple(bound.className, false) + else -> scope.raiseIllegalArgument("jsonBody requires exactly one type argument") + } +} + +private fun Map.toObjMap(): ObjMap = + ObjMap(entries.associate { ObjString(it.key) to ObjString(it.value) }.toMutableMap()) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt index 2aa4f23..2c9c554 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpParser.kt @@ -33,7 +33,7 @@ internal suspend fun parseHttpRequest( method = requestHead.method, target = requestHead.target, path = requestHead.path, - query = requestHead.query, + queryString = requestHead.queryString, version = requestHead.version, headers = headers, contentLength = contentLength, @@ -48,7 +48,7 @@ private data class ParsedRequestLine( val method: String, val target: String, val path: String, - val query: String?, + val queryString: String?, val version: String, ) @@ -75,8 +75,8 @@ private fun parseRequestLine(line: String, config: HttpServerConfig): ParsedRequ } val queryAt = target.indexOf('?') val path = if (queryAt >= 0) target.substring(0, queryAt) else target - val query = if (queryAt >= 0) target.substring(queryAt + 1) else null - return ParsedRequestLine(method = method, target = target, path = path, query = query, version = version) + val queryString = if (queryAt >= 0) target.substring(queryAt + 1) else null + return ParsedRequestLine(method = method, target = target, path = path, queryString = queryString, version = version) } private suspend fun parseHeaders( diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt index 4672855..772b23f 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/server/HttpServer.kt @@ -40,13 +40,36 @@ internal data class HttpRequestHead( val method: String, val target: String, val path: String, - val query: String?, + val queryString: String?, val version: String, val headers: HttpHeaders, val contentLength: Int?, val wantsClose: Boolean, val wantsWebSocketUpgrade: Boolean, -) +) { + private var pathPartsParsed = false + private var pathPartsCache: List = emptyList() + private var queryParsed = false + private var queryCache: Map = emptyMap() + + val pathParts: List + get() { + if (!pathPartsParsed) { + pathPartsCache = parsePathParts(path) + pathPartsParsed = true + } + return pathPartsCache + } + + val query: Map + get() { + if (!queryParsed) { + queryCache = parseQueryParameters(queryString) + queryParsed = true + } + return queryCache + } +} internal data class HttpRequest( val head: HttpRequestHead, @@ -84,6 +107,89 @@ internal interface HttpServer { fun close() } +internal fun parsePathParts(path: String): List { + 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 { + if (queryString.isNullOrEmpty()) return emptyMap() + val result = linkedMapOf() + 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() + + 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? { + if (offset + 2 >= value.length) return null + val hi = value[offset + 1].hexDigitValueOrNull() ?: return null + val lo = value[offset + 2].hexDigitValueOrNull() ?: return null + return ((hi shl 4) or lo) to (offset + 3) +} + +private fun Char.hexDigitValueOrNull(): Int? = when (this) { + in '0'..'9' -> code - '0'.code + in 'a'..'f' -> code - 'a'.code + 10 + in 'A'..'F' -> code - 'A'.code + 10 + else -> null +} + internal fun defaultReason(status: Int): String = when (status) { 101 -> "Switching Protocols" 200 -> "OK" diff --git a/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt index be6e212..6553d62 100644 --- a/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt +++ b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/server/HttpParserTest.kt @@ -71,6 +71,39 @@ class HttpParserTest { assertEquals("ping", request.body.decodeToString()) } + @Test + fun queryStringAndDecodedQueryMapAreExposed() = kotlinx.coroutines.test.runTest { + val request = parse( + "GET /echo?a=1&b=hello+world&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag HTTP/1.1\r\n" + + "Host: localhost\r\n\r\n" + ) + assertEquals("a=1&b=hello+world&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag", request.head.queryString) + assertEquals("1", request.head.query["a"]) + assertEquals("last", request.head.query["b"]) + assertEquals("тест", request.head.query["utf"]) + assertEquals("%GG%2", request.head.query["bad"]) + assertEquals("", request.head.query["flag"]) + } + + @Test + fun missingQueryProducesEmptyMap() = kotlinx.coroutines.test.runTest { + val request = parse( + "GET /echo HTTP/1.1\r\n" + + "Host: localhost\r\n\r\n" + ) + assertEquals(null, request.head.queryString) + assertEquals(emptyMap(), request.head.query) + } + + @Test + fun pathPartsAreLazyDecodedWithoutPlusTranslation() = kotlinx.coroutines.test.runTest { + val request = parse( + "GET /one/two%20words/a+b/%GG/%D1%82%D0%B5%D1%81%D1%82 HTTP/1.1\r\n" + + "Host: localhost\r\n\r\n" + ) + assertEquals(listOf("one", "two words", "a+b", "%GG", "тест"), request.head.pathParts) + } + private suspend fun parse( rawRequest: String, config: HttpServerConfig = HttpServerConfig(), diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt index 34322ea..123a136 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/http/server/LyngHttpServerModuleTest.kt @@ -89,6 +89,232 @@ class LyngHttpServerModuleTest { handle.invokeInstanceMethod(scope, "close") } + @Test + fun requestQueryStringAndQueryMapAreAvailableToLyng() = runBlocking { + val engine = getSystemNetEngine() + if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking + + val scope = Script.newScope() + createHttpServerModule(PermitAllNetAccessPolicy, scope) + + val code = """ + import lyng.io.http.server + + val server = HttpServer() + server.get("/query") { ex -> + val q = ex.request.query + ex.respondText( + 200, + (ex.request.queryString ?: "") + + "|" + q.size + + "|" + (q["a"] ?: "") + + "|" + (q["b"] ?: "") + + "|" + (q["utf"] ?: "") + + "|" + (q["bad"] ?: "") + + "|" + (q["flag"] ?: "") + ) + } + 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("|0|||||"), 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() + ex.respondJson(CreateUserResponse(101, req.name, req.age), 201) + } + server.listen(0, "127.0.0.1") + """.trimIndent() + + val handle = Compiler.compile(code).execute(scope) + val port = waitForPort(handle, scope) + + val client = engine.tcpConnect("127.0.0.1", port, 2_000, true) + try { + val body = """{"name":"alice","age":30}""" + client.writeUtf8( + "POST /api/users HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${body.encodeToByteArray().size}\r\n" + + "Connection: close\r\n\r\n" + + body + ) + client.flush() + val response = readHttpResponse(client) + assertTrue(response.contains("201"), response) + assertTrue(response.contains("Content-Type: application/json; charset=utf-8"), response) + assertTrue(response.endsWith("""{"id":101,"name":"alice","age":30}"""), response) + } finally { + client.close() + } + + handle.invokeInstanceMethod(scope, "close") + } + private suspend fun waitForPort(handle: Obj, scope: net.sergeych.lyng.Scope): Int { repeat(100) { val port = runCatching { diff --git a/lyngio/stdlib/lyng/io/http_server.lyng b/lyngio/stdlib/lyng/io/http_server.lyng index 28f18dc..39c2d0c 100644 --- a/lyngio/stdlib/lyng/io/http_server.lyng +++ b/lyngio/stdlib/lyng/io/http_server.lyng @@ -1,6 +1,7 @@ package lyng.io.http.server import lyng.io.http.types +import lyng.serialization import lyng.io.ws.types /* Immutable parsed incoming server request. */ @@ -8,7 +9,9 @@ extern class ServerRequest { val method: String val target: String val path: String - val query: String? + val pathParts: List + val queryString: String? + val query: Map val headers: HttpHeaders val body: Buffer fun text(): String @@ -27,8 +30,12 @@ extern class ServerWebSocket { /* Mutable exchange object for one incoming request. */ extern class ServerExchange { val request: ServerRequest + val routeMatch: RegexMatch? + val routeParams: Map + fun jsonBody(): T fun respond(status: Int = 200, body: Buffer? = null): void fun respondText(status: Int = 200, bodyText: String = ""): void + fun respondJson(body: Object?, status: Int = 200): void fun setHeader(name: String, value: String): void fun addHeader(name: String, value: String): void fun acceptWebSocket(handler: (ServerWebSocket, ServerExchange) -> Object?): void @@ -43,12 +50,18 @@ extern class HttpServerHandle { /* Exact-path HTTP/WebSocket server with built-in router. */ extern class HttpServer { - fun get(path: String, handler: (ServerExchange) -> Object?): HttpServer - fun post(path: String, handler: (ServerExchange) -> Object?): HttpServer - fun put(path: String, handler: (ServerExchange) -> Object?): HttpServer - fun delete(path: String, handler: (ServerExchange) -> Object?): HttpServer - fun any(path: String, handler: (ServerExchange) -> Object?): HttpServer - fun ws(path: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer + fun get(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer + fun getPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer + fun post(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer + fun postPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer + fun put(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer + fun putPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer + fun delete(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer + fun deletePath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer + fun any(path: String|Regex, handler: (ServerExchange) -> Object?): HttpServer + fun anyPath(pathTemplate: String, handler: (ServerExchange) -> Object?): HttpServer + fun ws(path: String|Regex, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer + fun wsPath(pathTemplate: String, handler: (ServerWebSocket, ServerExchange) -> Object?): HttpServer fun fallback(handler: (ServerExchange) -> Object?): HttpServer fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle }