Add verified Ktor-backed networking modules to lyngio
This commit is contained in:
1 parent
f168e9f6ed
commit
d409a4bb8b
35 files changed
+4025
-23
No files matched your search
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.http
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjBuffer
|
||||
import net.sergeych.lyng.obj.ObjClass
|
||||
import net.sergeych.lyng.obj.ObjImmutableMap
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjList
|
||||
import net.sergeych.lyng.obj.ObjMap
|
||||
import net.sergeych.lyng.obj.ObjMapEntry
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.obj.thisAs
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyng.raiseIllegalOperation
|
||||
import net.sergeych.lyng.requireNoArgs
|
||||
import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyngio.http.LyngHttpRequest
|
||||
import net.sergeych.lyngio.http.LyngHttpResponse
|
||||
import net.sergeych.lyngio.http.getSystemHttpEngine
|
||||
import net.sergeych.lyngio.http.security.HttpAccessDeniedException
|
||||
import net.sergeych.lyngio.http.security.HttpAccessOp
|
||||
import net.sergeych.lyngio.http.security.HttpAccessPolicy
|
||||
import net.sergeych.lyngio.stdlib_included.httpLyng
|
||||
|
||||
private const val HTTP_MODULE_NAME = "lyng.io.http"
|
||||
|
||||
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
||||
createHttpModule(policy, scope.importManager)
|
||||
|
||||
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
|
||||
|
||||
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
|
||||
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
||||
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
||||
buildHttpModule(module, policy)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
|
||||
|
||||
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
|
||||
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
||||
val engine = getSystemHttpEngine()
|
||||
|
||||
val headersType = ObjHttpHeaders.type
|
||||
val requestType = ObjHttpRequest.type
|
||||
val responseType = ObjHttpResponse.type
|
||||
|
||||
val httpType = object : ObjClass("Http") {}
|
||||
httpType.addClassFn("isSupported") {
|
||||
ObjBool(engine.isSupported)
|
||||
}
|
||||
httpType.addClassFn("request") {
|
||||
httpGuard {
|
||||
val req = requiredArg<ObjHttpRequest>(0)
|
||||
val built = req.toRequest(this)
|
||||
policy.require(HttpAccessOp.Request(built.method, built.url))
|
||||
ObjHttpResponse.from(engine.request(built))
|
||||
}
|
||||
}
|
||||
httpType.addClassFn("get") {
|
||||
httpGuard {
|
||||
val url = requiredArg<ObjString>(0).value
|
||||
val headers = parseHeaderEntries(args.list.drop(1))
|
||||
policy.require(HttpAccessOp.Request("GET", url))
|
||||
ObjHttpResponse.from(engine.request(LyngHttpRequest(method = "GET", url = url, headers = headers)))
|
||||
}
|
||||
}
|
||||
httpType.addClassFn("post") {
|
||||
httpGuard {
|
||||
val url = requiredArg<ObjString>(0).value
|
||||
val bodyText = requiredArg<ObjString>(1).value
|
||||
val contentType = args.list.getOrNull(2)?.let { objOrNullToString(this, it) }
|
||||
val headers = parseHeaderEntries(args.list.drop(3)).toMutableMap()
|
||||
if (contentType != null && "Content-Type" !in headers) headers["Content-Type"] = contentType
|
||||
policy.require(HttpAccessOp.Request("POST", url))
|
||||
ObjHttpResponse.from(
|
||||
engine.request(
|
||||
LyngHttpRequest(method = "POST", url = url, headers = headers, bodyText = bodyText)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
httpType.addClassFn("postBytes") {
|
||||
httpGuard {
|
||||
val url = requiredArg<ObjString>(0).value
|
||||
val body = requiredArg<ObjBuffer>(1).byteArray.toByteArray()
|
||||
val contentType = args.list.getOrNull(2)?.let { objOrNullToString(this, it) }
|
||||
val headers = parseHeaderEntries(args.list.drop(3)).toMutableMap()
|
||||
if (contentType != null && "Content-Type" !in headers) headers["Content-Type"] = contentType
|
||||
policy.require(HttpAccessOp.Request("POST", url))
|
||||
ObjHttpResponse.from(
|
||||
engine.request(
|
||||
LyngHttpRequest(method = "POST", url = url, headers = headers, bodyBytes = body)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.addConst("Http", httpType)
|
||||
module.addConst("HttpHeaders", headersType)
|
||||
module.addConst("HttpRequest", requestType)
|
||||
module.addConst("HttpResponse", responseType)
|
||||
}
|
||||
|
||||
private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -> Obj): Obj {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: HttpAccessDeniedException) {
|
||||
raiseIllegalOperation(e.reasonDetail ?: "http access denied")
|
||||
} catch (e: Exception) {
|
||||
raiseIllegalOperation(e.message ?: "http error")
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjHttpHeaders(
|
||||
singleValueHeaders: Map<String, String> = emptyMap(),
|
||||
private val allHeaders: Map<String, List<String>> = emptyMap(),
|
||||
) : Obj() {
|
||||
private val entries: LinkedHashMap<Obj, Obj> =
|
||||
LinkedHashMap(singleValueHeaders.entries.associate { ObjString(it.key) to ObjString(it.value) })
|
||||
|
||||
override val objClass: ObjClass
|
||||
get() = type
|
||||
|
||||
override suspend fun getAt(scope: Scope, index: Obj): Obj = findEntry(index)?.value ?: ObjNull
|
||||
|
||||
override suspend fun contains(scope: Scope, other: Obj): Boolean = findEntry(other) != null
|
||||
|
||||
override suspend fun defaultToString(scope: Scope): ObjString {
|
||||
val rendered = buildString {
|
||||
append("HttpHeaders(")
|
||||
var first = true
|
||||
for ((k, v) in entries) {
|
||||
if (!first) append(", ")
|
||||
append(k.toString(scope).value)
|
||||
append(" => ")
|
||||
append(v.toString(scope).value)
|
||||
first = false
|
||||
}
|
||||
append(")")
|
||||
}
|
||||
return ObjString(rendered)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val type = object : ObjClass("HttpHeaders", ObjMap.type) {
|
||||
override suspend fun callOn(scope: Scope): Obj = ObjHttpHeaders()
|
||||
}.apply {
|
||||
addFn("get") {
|
||||
val self = thisAs<ObjHttpHeaders>()
|
||||
val name = requiredArg<ObjString>(0).value
|
||||
self.firstValue(name)?.let(::ObjString) ?: ObjNull
|
||||
}
|
||||
addFn("getAll") {
|
||||
val self = thisAs<ObjHttpHeaders>()
|
||||
val name = requiredArg<ObjString>(0).value
|
||||
ObjList(self.valuesOf(name).map(::ObjString).toMutableList())
|
||||
}
|
||||
addFn("names") {
|
||||
val self = thisAs<ObjHttpHeaders>()
|
||||
ObjList(self.allHeaders.keys.map(::ObjString).toMutableList())
|
||||
}
|
||||
addFn("getOrNull") {
|
||||
val self = thisAs<ObjHttpHeaders>()
|
||||
val name = requiredArg<ObjString>(0).value
|
||||
self.firstValue(name)?.let(::ObjString) ?: ObjNull
|
||||
}
|
||||
addProperty("size", getter = { ObjInt(thisAs<ObjHttpHeaders>().entries.size.toLong()) })
|
||||
addProperty("keys", getter = { ObjList(thisAs<ObjHttpHeaders>().entries.keys.toMutableList()) })
|
||||
addProperty("values", getter = { ObjList(thisAs<ObjHttpHeaders>().entries.values.toMutableList()) })
|
||||
addFn("iterator") {
|
||||
ObjList(
|
||||
thisAs<ObjHttpHeaders>().entries.map { (k, v) -> ObjMapEntry(k, v) }.toMutableList()
|
||||
).invokeInstanceMethod(requireScope(), "iterator")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList()
|
||||
|
||||
private fun firstValue(name: String): String? = valuesOf(name).firstOrNull()
|
||||
|
||||
private fun lookupKey(name: String): String =
|
||||
allHeaders.keys.firstOrNull { it.equals(name, ignoreCase = true) } ?: name
|
||||
|
||||
private fun findEntry(index: Obj): Map.Entry<Obj, Obj>? {
|
||||
if (index is ObjString) {
|
||||
return entries.entries.firstOrNull { (k, _) ->
|
||||
(k as? ObjString)?.value?.equals(index.value, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
return entries.entries.firstOrNull { it.key == index }
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjHttpRequest(
|
||||
var method: String = "GET",
|
||||
var url: String = "",
|
||||
val headers: MutableMap<String, String> = linkedMapOf(),
|
||||
var bodyText: String? = null,
|
||||
var bodyBytes: ByteArray? = null,
|
||||
var timeoutMillis: Long? = null,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type
|
||||
|
||||
suspend fun toRequest(scope: ScopeFacade): LyngHttpRequest {
|
||||
if (bodyText != null && bodyBytes != null) {
|
||||
scope.raiseIllegalArgument("Only one of bodyText or bodyBytes may be set")
|
||||
}
|
||||
return LyngHttpRequest(
|
||||
method = method,
|
||||
url = url,
|
||||
headers = LinkedHashMap(headers),
|
||||
bodyText = bodyText,
|
||||
bodyBytes = bodyBytes,
|
||||
timeoutMillis = timeoutMillis,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val type = object : ObjClass("HttpRequest") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
if (scope.args.list.isNotEmpty()) scope.raiseError("HttpRequest() does not accept arguments")
|
||||
return ObjHttpRequest()
|
||||
}
|
||||
}.apply {
|
||||
addProperty("method",
|
||||
getter = { ObjString(thisAs<ObjHttpRequest>().method) },
|
||||
setter = { value ->
|
||||
thisAs<ObjHttpRequest>().method = objOrNullToString(this, value)
|
||||
?: raiseIllegalArgument("method cannot be null")
|
||||
}
|
||||
)
|
||||
addProperty("url",
|
||||
getter = { ObjString(thisAs<ObjHttpRequest>().url) },
|
||||
setter = { value ->
|
||||
thisAs<ObjHttpRequest>().url = objOrNullToString(this, value)
|
||||
?: raiseIllegalArgument("url cannot be null")
|
||||
}
|
||||
)
|
||||
addProperty("headers",
|
||||
getter = { thisAs<ObjHttpRequest>().headers.toObjMap() },
|
||||
setter = { value ->
|
||||
thisAs<ObjHttpRequest>().headers.clear()
|
||||
thisAs<ObjHttpRequest>().headers.putAll(mapObjToStrings(this, value))
|
||||
}
|
||||
)
|
||||
addProperty("bodyText",
|
||||
getter = { thisAs<ObjHttpRequest>().bodyText?.let(::ObjString) ?: ObjNull },
|
||||
setter = { value ->
|
||||
thisAs<ObjHttpRequest>().bodyText = objOrNullToString(this, value)
|
||||
}
|
||||
)
|
||||
addProperty("bodyBytes",
|
||||
getter = { thisAs<ObjHttpRequest>().bodyBytes?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull },
|
||||
setter = { value ->
|
||||
thisAs<ObjHttpRequest>().bodyBytes = when (value) {
|
||||
ObjNull -> null
|
||||
is ObjBuffer -> value.byteArray.toByteArray()
|
||||
else -> raiseClassCastError("bodyBytes must be Buffer or null")
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("timeoutMillis",
|
||||
getter = { thisAs<ObjHttpRequest>().timeoutMillis?.let { ObjInt(it) } ?: ObjNull },
|
||||
setter = { value ->
|
||||
thisAs<ObjHttpRequest>().timeoutMillis = when (value) {
|
||||
ObjNull -> null
|
||||
is ObjInt -> value.value
|
||||
else -> raiseClassCastError("timeoutMillis must be Int or null")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjHttpResponse(
|
||||
val status: Long,
|
||||
val statusText: String,
|
||||
val headers: ObjHttpHeaders,
|
||||
private val bodyBytes: ByteArray,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type
|
||||
|
||||
companion object {
|
||||
val type = object : ObjClass("HttpResponse") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("HttpResponse cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addProperty("status", getter = { ObjInt(thisAs<ObjHttpResponse>().status) })
|
||||
addProperty("statusText", getter = { ObjString(thisAs<ObjHttpResponse>().statusText) })
|
||||
addProperty("headers", getter = { thisAs<ObjHttpResponse>().headers })
|
||||
addFn("text") {
|
||||
ObjString(thisAs<ObjHttpResponse>().bodyBytes.decodeToString())
|
||||
}
|
||||
addFn("bytes") {
|
||||
ObjBuffer(thisAs<ObjHttpResponse>().bodyBytes.toUByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun from(response: LyngHttpResponse): ObjHttpResponse {
|
||||
val single = linkedMapOf<String, String>()
|
||||
response.headers.forEach { (name, values) ->
|
||||
if (values.isNotEmpty()) single.putIfAbsent(name, values.first())
|
||||
}
|
||||
return ObjHttpResponse(
|
||||
status = response.status.toLong(),
|
||||
statusText = response.statusText,
|
||||
headers = ObjHttpHeaders(singleValueHeaders = single, allHeaders = response.headers),
|
||||
bodyBytes = response.bodyBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ScopeFacade.parseHeaderEntries(values: List<Obj>): Map<String, String> {
|
||||
val out = linkedMapOf<String, String>()
|
||||
values.forEach { value ->
|
||||
when (value) {
|
||||
is ObjMapEntry -> {
|
||||
out[toStringOf(value.key).value] = toStringOf(value.value).value
|
||||
}
|
||||
else -> {
|
||||
if (!value.isInstanceOf(net.sergeych.lyng.obj.ObjArray)) {
|
||||
raiseIllegalArgument("headers entries must be MapEntry or [key, value]")
|
||||
}
|
||||
val size = (value.invokeInstanceMethod(requireScope(), "size") as ObjInt).value.toInt()
|
||||
if (size != 2) {
|
||||
raiseIllegalArgument("header entry array must contain exactly 2 items")
|
||||
}
|
||||
out[toStringOf(value.getAt(requireScope(), ObjInt.Zero)).value] =
|
||||
toStringOf(value.getAt(requireScope(), ObjInt.One)).value
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private suspend fun mapObjToStrings(scope: ScopeFacade, value: Obj): MutableMap<String, String> {
|
||||
val entries = when (value) {
|
||||
is ObjMap -> value.map
|
||||
is ObjImmutableMap -> value.map
|
||||
ObjNull -> return linkedMapOf()
|
||||
else -> scope.raiseClassCastError("headers must be Map<String, String>")
|
||||
}
|
||||
return entries.entries.associateTo(linkedMapOf()) { (k, v) ->
|
||||
scope.toStringOf(k).value to scope.toStringOf(v).value
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj): String? = when (value) {
|
||||
ObjNull -> null
|
||||
else -> scope.toStringOf(value).value
|
||||
}
|
||||
|
||||
private fun Map<String, String>.toObjMap(): ObjMap =
|
||||
ObjMap(entries.associate { ObjString(it.key) to ObjString(it.value) }.toMutableMap())
|
||||
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.net
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjBuffer
|
||||
import net.sergeych.lyng.obj.ObjClass
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjList
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.obj.thisAs
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyng.raiseIllegalOperation
|
||||
import net.sergeych.lyng.requireNoArgs
|
||||
import net.sergeych.lyngio.net.LyngDatagram
|
||||
import net.sergeych.lyngio.net.LyngIpVersion
|
||||
import net.sergeych.lyngio.net.LyngNetEngine
|
||||
import net.sergeych.lyngio.net.LyngSocketAddress
|
||||
import net.sergeych.lyngio.net.LyngTcpServer
|
||||
import net.sergeych.lyngio.net.LyngTcpSocket
|
||||
import net.sergeych.lyngio.net.LyngUdpSocket
|
||||
import net.sergeych.lyngio.net.getSystemNetEngine
|
||||
import net.sergeych.lyngio.net.security.NetAccessDeniedException
|
||||
import net.sergeych.lyngio.net.security.NetAccessOp
|
||||
import net.sergeych.lyngio.net.security.NetAccessPolicy
|
||||
import net.sergeych.lyngio.stdlib_included.netLyng
|
||||
|
||||
private const val NET_MODULE_NAME = "lyng.io.net"
|
||||
|
||||
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
||||
createNetModule(policy, scope.importManager)
|
||||
|
||||
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
|
||||
|
||||
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
||||
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
|
||||
manager.addPackage(NET_MODULE_NAME) { module ->
|
||||
buildNetModule(module, policy)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
|
||||
|
||||
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
|
||||
module.eval(Source(NET_MODULE_NAME, netLyng))
|
||||
val engine = getSystemNetEngine()
|
||||
val enumValues = NetEnumValues.load(module)
|
||||
|
||||
val netType = object : ObjClass("Net") {}
|
||||
netType.addClassFn("isSupported") { ObjBool(engine.isSupported) }
|
||||
netType.addClassFn("isTcpAvailable") { ObjBool(engine.isTcpAvailable) }
|
||||
netType.addClassFn("isTcpServerAvailable") { ObjBool(engine.isTcpServerAvailable) }
|
||||
netType.addClassFn("isUdpAvailable") { ObjBool(engine.isUdpAvailable) }
|
||||
netType.addClassFn("resolve") {
|
||||
netGuard {
|
||||
val host = requiredArg<ObjString>(0).value
|
||||
val port = requirePort(requiredArg<ObjInt>(1).value)
|
||||
policy.require(NetAccessOp.Resolve(host, port))
|
||||
ObjList(engine.resolve(host, port).map { ObjSocketAddress(it, enumValues) }.toMutableList())
|
||||
}
|
||||
}
|
||||
netType.addClassFn("tcpConnect") {
|
||||
netGuard {
|
||||
val host = requiredArg<ObjString>(0).value
|
||||
val port = requirePort(requiredArg<ObjInt>(1).value)
|
||||
val timeoutMillis = args.list.getOrNull(2)?.let { objOrNullToLong(this, it, "timeoutMillis") }
|
||||
val noDelay = args.list.getOrNull(3)?.let { objToBool(this, it, "noDelay") } ?: true
|
||||
policy.require(NetAccessOp.TcpConnect(host, port))
|
||||
ObjTcpSocket(engine.tcpConnect(host, port, timeoutMillis, noDelay), enumValues)
|
||||
}
|
||||
}
|
||||
netType.addClassFn("tcpListen") {
|
||||
netGuard {
|
||||
val port = requirePort(requiredArg<ObjInt>(0).value)
|
||||
val host = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "host") }
|
||||
val backlog = args.list.getOrNull(2)?.let { objToInt(this, it, "backlog") } ?: 128
|
||||
requirePositive(backlog, "backlog")
|
||||
val reuseAddress = args.list.getOrNull(3)?.let { objToBool(this, it, "reuseAddress") } ?: true
|
||||
policy.require(NetAccessOp.TcpListen(host, port, backlog))
|
||||
ObjTcpServer(engine.tcpListen(host, port, backlog, reuseAddress), enumValues)
|
||||
}
|
||||
}
|
||||
netType.addClassFn("udpBind") {
|
||||
netGuard {
|
||||
val port = args.list.getOrNull(0)?.let { objToInt(this, it, "port") } ?: 0
|
||||
requirePort(port)
|
||||
val host = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "host") }
|
||||
val reuseAddress = args.list.getOrNull(2)?.let { objToBool(this, it, "reuseAddress") } ?: true
|
||||
policy.require(NetAccessOp.UdpBind(host, port))
|
||||
ObjUdpSocket(engine.udpBind(host, port, reuseAddress), enumValues)
|
||||
}
|
||||
}
|
||||
|
||||
module.addConst("Net", netType)
|
||||
module.addConst("SocketAddress", ObjSocketAddress.type(enumValues))
|
||||
module.addConst("Datagram", ObjDatagram.type(enumValues))
|
||||
module.addConst("TcpSocket", ObjTcpSocket.type(enumValues))
|
||||
module.addConst("TcpServer", ObjTcpServer.type(enumValues))
|
||||
module.addConst("UdpSocket", ObjUdpSocket.type(enumValues))
|
||||
}
|
||||
|
||||
private suspend inline fun ScopeFacade.netGuard(crossinline block: suspend () -> Obj): Obj {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: NetAccessDeniedException) {
|
||||
raiseIllegalOperation(e.reasonDetail ?: "network access denied")
|
||||
} catch (e: Exception) {
|
||||
raiseIllegalOperation(e.message ?: "network error")
|
||||
}
|
||||
}
|
||||
|
||||
private class NetEnumValues(
|
||||
val ipv4: Obj,
|
||||
val ipv6: Obj,
|
||||
) {
|
||||
fun of(version: LyngIpVersion): Obj = when (version) {
|
||||
LyngIpVersion.IPV4 -> ipv4
|
||||
LyngIpVersion.IPV6 -> ipv6
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun load(module: ModuleScope): NetEnumValues {
|
||||
val ipVersionClass = module["IpVersion"]?.value as? ObjClass
|
||||
?: error("lyng.io.net.IpVersion is missing after declaration load")
|
||||
return NetEnumValues(
|
||||
ipv4 = ipVersionClass.readField(module, "IPV4").value,
|
||||
ipv6 = ipVersionClass.readField(module, "IPV6").value,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjSocketAddress(
|
||||
private val address: LyngSocketAddress,
|
||||
private val enumValues: NetEnumValues,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type(enumValues)
|
||||
|
||||
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
object : ObjClass("SocketAddress") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("SocketAddress cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addProperty("host", getter = { ObjString(thisAs<ObjSocketAddress>().address.host) })
|
||||
addProperty("port", getter = { ObjInt(thisAs<ObjSocketAddress>().address.port.toLong()) })
|
||||
addProperty("ipVersion", getter = { enumValues.of(thisAs<ObjSocketAddress>().address.ipVersion) })
|
||||
addProperty("resolved", getter = { ObjBool(thisAs<ObjSocketAddress>().address.resolved) })
|
||||
addFn("toString") { ObjString(renderAddress(thisAs<ObjSocketAddress>().address)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjDatagram(
|
||||
private val datagram: LyngDatagram,
|
||||
private val enumValues: NetEnumValues,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type(enumValues)
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
object : ObjClass("Datagram") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("Datagram cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addProperty("data", getter = {
|
||||
ObjBuffer(thisAs<ObjDatagram>().datagram.data.toUByteArray())
|
||||
})
|
||||
addProperty("address", getter = {
|
||||
ObjSocketAddress(thisAs<ObjDatagram>().datagram.address, enumValues)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjTcpSocket(
|
||||
private val socket: LyngTcpSocket,
|
||||
private val enumValues: NetEnumValues,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type(enumValues)
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
object : ObjClass("TcpSocket") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("TcpSocket cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addFn("isOpen") { ObjBool(thisAs<ObjTcpSocket>().socket.isOpen()) }
|
||||
addFn("localAddress") { ObjSocketAddress(thisAs<ObjTcpSocket>().socket.localAddress(), enumValues) }
|
||||
addFn("remoteAddress") { ObjSocketAddress(thisAs<ObjTcpSocket>().socket.remoteAddress(), enumValues) }
|
||||
addFn("read") {
|
||||
val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536
|
||||
requirePositive(maxBytes, "maxBytes")
|
||||
thisAs<ObjTcpSocket>().socket.read(maxBytes)?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
|
||||
}
|
||||
addFn("readLine") {
|
||||
thisAs<ObjTcpSocket>().socket.readLine()?.let(::ObjString) ?: ObjNull
|
||||
}
|
||||
addFn("write") {
|
||||
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
|
||||
thisAs<ObjTcpSocket>().socket.write(data)
|
||||
ObjVoid
|
||||
}
|
||||
addFn("writeUtf8") {
|
||||
val text = requiredArg<ObjString>(0).value
|
||||
thisAs<ObjTcpSocket>().socket.writeUtf8(text)
|
||||
ObjVoid
|
||||
}
|
||||
addFn("flush") {
|
||||
requireNoArgs()
|
||||
thisAs<ObjTcpSocket>().socket.flush()
|
||||
ObjVoid
|
||||
}
|
||||
addFn("close") {
|
||||
requireNoArgs()
|
||||
thisAs<ObjTcpSocket>().socket.close()
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjTcpServer(
|
||||
private val server: LyngTcpServer,
|
||||
private val enumValues: NetEnumValues,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type(enumValues)
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
object : ObjClass("TcpServer") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("TcpServer cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addFn("isOpen") { ObjBool(thisAs<ObjTcpServer>().server.isOpen()) }
|
||||
addFn("localAddress") { ObjSocketAddress(thisAs<ObjTcpServer>().server.localAddress(), enumValues) }
|
||||
addFn("accept") {
|
||||
ObjTcpSocket(thisAs<ObjTcpServer>().server.accept(), enumValues)
|
||||
}
|
||||
addFn("close") {
|
||||
requireNoArgs()
|
||||
thisAs<ObjTcpServer>().server.close()
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjUdpSocket(
|
||||
private val socket: LyngUdpSocket,
|
||||
private val enumValues: NetEnumValues,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type(enumValues)
|
||||
|
||||
companion object {
|
||||
private val types = mutableMapOf<NetEnumValues, ObjClass>()
|
||||
|
||||
fun type(enumValues: NetEnumValues): ObjClass =
|
||||
types.getOrPut(enumValues) {
|
||||
object : ObjClass("UdpSocket") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("UdpSocket cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addFn("isOpen") { ObjBool(thisAs<ObjUdpSocket>().socket.isOpen()) }
|
||||
addFn("localAddress") { ObjSocketAddress(thisAs<ObjUdpSocket>().socket.localAddress(), enumValues) }
|
||||
addFn("receive") {
|
||||
val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536
|
||||
requirePositive(maxBytes, "maxBytes")
|
||||
thisAs<ObjUdpSocket>().socket.receive(maxBytes)?.let { ObjDatagram(it, enumValues) } ?: ObjNull
|
||||
}
|
||||
addFn("send") {
|
||||
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
|
||||
val host = requiredArg<ObjString>(1).value
|
||||
val port = requirePort(requiredArg<ObjInt>(2).value)
|
||||
thisAs<ObjUdpSocket>().socket.send(data, host, port)
|
||||
ObjVoid
|
||||
}
|
||||
addFn("close") {
|
||||
requireNoArgs()
|
||||
thisAs<ObjUdpSocket>().socket.close()
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderAddress(address: LyngSocketAddress): String =
|
||||
if (address.ipVersion == LyngIpVersion.IPV6) "[${address.host}]:${address.port}" else "${address.host}:${address.port}"
|
||||
|
||||
private fun ScopeFacade.requirePort(value: Long): Int {
|
||||
if (value !in 0..65535) raiseIllegalArgument("port must be in 0..65535")
|
||||
return value.toInt()
|
||||
}
|
||||
|
||||
private fun ScopeFacade.requirePort(value: Int): Int {
|
||||
if (value !in 0..65535) raiseIllegalArgument("port must be in 0..65535")
|
||||
return value
|
||||
}
|
||||
|
||||
private fun ScopeFacade.requirePositive(value: Int, name: String) {
|
||||
if (value <= 0) raiseIllegalArgument("$name must be positive")
|
||||
}
|
||||
|
||||
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) {
|
||||
ObjNull -> null
|
||||
else -> scope.toStringOf(value).value
|
||||
}
|
||||
|
||||
private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) {
|
||||
is ObjInt -> value.value.toInt()
|
||||
else -> scope.raiseClassCastError("$name must be Int")
|
||||
}
|
||||
|
||||
private fun objToBool(scope: ScopeFacade, value: Obj, name: String): Boolean = when (value) {
|
||||
is ObjBool -> value.value
|
||||
else -> scope.raiseClassCastError("$name must be Bool")
|
||||
}
|
||||
|
||||
private fun objOrNullToLong(scope: ScopeFacade, value: Obj, name: String): Long? = when (value) {
|
||||
ObjNull -> null
|
||||
is ObjInt -> value.value
|
||||
else -> scope.raiseClassCastError("$name must be Int or null")
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.ws
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjBuffer
|
||||
import net.sergeych.lyng.obj.ObjClass
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import net.sergeych.lyng.obj.ObjMapEntry
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.obj.thisAs
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyng.raiseIllegalOperation
|
||||
import net.sergeych.lyng.requireNoArgs
|
||||
import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyngio.stdlib_included.wsLyng
|
||||
import net.sergeych.lyngio.ws.LyngWsEngine
|
||||
import net.sergeych.lyngio.ws.LyngWsMessage
|
||||
import net.sergeych.lyngio.ws.LyngWsSession
|
||||
import net.sergeych.lyngio.ws.getSystemWsEngine
|
||||
import net.sergeych.lyngio.ws.security.WsAccessDeniedException
|
||||
import net.sergeych.lyngio.ws.security.WsAccessOp
|
||||
import net.sergeych.lyngio.ws.security.WsAccessPolicy
|
||||
|
||||
private const val WS_MODULE_NAME = "lyng.io.ws"
|
||||
|
||||
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
||||
createWsModule(policy, scope.importManager)
|
||||
|
||||
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
|
||||
|
||||
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
||||
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
|
||||
manager.addPackage(WS_MODULE_NAME) { module ->
|
||||
buildWsModule(module, policy)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
|
||||
|
||||
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
|
||||
module.eval(Source(WS_MODULE_NAME, wsLyng))
|
||||
val engine = getSystemWsEngine()
|
||||
|
||||
val wsType = object : ObjClass("Ws") {}
|
||||
wsType.addClassFn("isSupported") { ObjBool(engine.isSupported) }
|
||||
wsType.addClassFn("connect") {
|
||||
wsGuard {
|
||||
val url = requiredArg<ObjString>(0).value
|
||||
val headers = parseHeaderEntries(args.list.drop(1))
|
||||
policy.require(WsAccessOp.Connect(url))
|
||||
ObjWsSession(url, engine.connect(url, headers), policy)
|
||||
}
|
||||
}
|
||||
|
||||
module.addConst("Ws", wsType)
|
||||
module.addConst("WsMessage", ObjWsMessage.type)
|
||||
module.addConst("WsSession", ObjWsSession.type)
|
||||
}
|
||||
|
||||
private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () -> Obj): Obj {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: WsAccessDeniedException) {
|
||||
raiseIllegalOperation(e.reasonDetail ?: "websocket access denied")
|
||||
} catch (e: Exception) {
|
||||
raiseIllegalOperation(e.message ?: "websocket error")
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjWsMessage(
|
||||
private val message: LyngWsMessage,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type
|
||||
|
||||
companion object {
|
||||
val type = object : ObjClass("WsMessage") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("WsMessage cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addProperty("isText", getter = { ObjBool(thisAs<ObjWsMessage>().message.isText) })
|
||||
addProperty("text", getter = {
|
||||
thisAs<ObjWsMessage>().message.text?.let(::ObjString) ?: ObjNull
|
||||
})
|
||||
addProperty("data", getter = {
|
||||
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjWsSession(
|
||||
private val targetUrl: String,
|
||||
private val session: LyngWsSession,
|
||||
private val policy: WsAccessPolicy,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = type
|
||||
|
||||
companion object {
|
||||
val type = object : ObjClass("WsSession") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
scope.raiseError("WsSession cannot be created directly")
|
||||
}
|
||||
}.apply {
|
||||
addFn("isOpen") {
|
||||
ObjBool(thisAs<ObjWsSession>().session.isOpen())
|
||||
}
|
||||
addFn("url") {
|
||||
ObjString(thisAs<ObjWsSession>().targetUrl)
|
||||
}
|
||||
addFn("sendText") {
|
||||
val self = thisAs<ObjWsSession>()
|
||||
val text = requiredArg<ObjString>(0).value
|
||||
self.policy.require(WsAccessOp.Send(self.targetUrl, text.encodeToByteArray().size, isText = true))
|
||||
self.session.sendText(text)
|
||||
ObjVoid
|
||||
}
|
||||
addFn("sendBytes") {
|
||||
val self = thisAs<ObjWsSession>()
|
||||
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
|
||||
self.policy.require(WsAccessOp.Send(self.targetUrl, data.size, isText = false))
|
||||
self.session.sendBytes(data)
|
||||
ObjVoid
|
||||
}
|
||||
addFn("receive") {
|
||||
val self = thisAs<ObjWsSession>()
|
||||
self.policy.require(WsAccessOp.Receive(self.targetUrl))
|
||||
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
|
||||
}
|
||||
addFn("close") {
|
||||
val self = thisAs<ObjWsSession>()
|
||||
val code = args.list.getOrNull(0)?.let { objToInt(this, it, "code") } ?: 1000
|
||||
val reason = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "reason") } ?: ""
|
||||
self.session.close(code, reason)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ScopeFacade.parseHeaderEntries(values: List<Obj>): Map<String, String> {
|
||||
val out = linkedMapOf<String, String>()
|
||||
values.forEach { value ->
|
||||
when (value) {
|
||||
is ObjMapEntry -> {
|
||||
out[toStringOf(value.key).value] = toStringOf(value.value).value
|
||||
}
|
||||
else -> {
|
||||
if (!value.isInstanceOf(net.sergeych.lyng.obj.ObjArray)) {
|
||||
raiseIllegalArgument("headers entries must be MapEntry or [key, value]")
|
||||
}
|
||||
val size = (value.invokeInstanceMethod(requireScope(), "size") as ObjInt).value.toInt()
|
||||
if (size != 2) {
|
||||
raiseIllegalArgument("header entry array must contain exactly 2 items")
|
||||
}
|
||||
out[toStringOf(value.getAt(requireScope(), ObjInt.Zero)).value] =
|
||||
toStringOf(value.getAt(requireScope(), ObjInt.One)).value
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) {
|
||||
ObjNull -> null
|
||||
else -> scope.toStringOf(value).value
|
||||
}
|
||||
|
||||
private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) {
|
||||
is ObjInt -> value.value.toInt()
|
||||
else -> scope.raiseClassCastError("$name must be Int")
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.http
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.timeout
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.headers
|
||||
import io.ktor.http.takeFrom
|
||||
|
||||
data class LyngHttpRequest(
|
||||
val method: String,
|
||||
val url: String,
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
val bodyText: String? = null,
|
||||
val bodyBytes: ByteArray? = null,
|
||||
val timeoutMillis: Long? = null,
|
||||
)
|
||||
|
||||
data class LyngHttpResponse(
|
||||
val status: Int,
|
||||
val statusText: String,
|
||||
val headers: Map<String, List<String>>,
|
||||
val bodyBytes: ByteArray,
|
||||
)
|
||||
|
||||
interface LyngHttpEngine {
|
||||
val isSupported: Boolean
|
||||
suspend fun request(request: LyngHttpRequest): LyngHttpResponse
|
||||
}
|
||||
|
||||
private object UnsupportedHttpEngine : LyngHttpEngine {
|
||||
override val isSupported: Boolean = false
|
||||
|
||||
override suspend fun request(request: LyngHttpRequest): LyngHttpResponse {
|
||||
throw UnsupportedOperationException("HTTP client is not supported on this runtime")
|
||||
}
|
||||
}
|
||||
|
||||
private object KtorLyngHttpEngine : LyngHttpEngine {
|
||||
private val clientResult by lazy {
|
||||
runCatching {
|
||||
HttpClient(CIO) {
|
||||
expectSuccess = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val isSupported: Boolean
|
||||
get() = clientResult.isSuccess
|
||||
|
||||
override suspend fun request(request: LyngHttpRequest): LyngHttpResponse {
|
||||
val httpClient = clientResult.getOrElse {
|
||||
throw UnsupportedOperationException(it.message ?: "HTTP client is not supported")
|
||||
}
|
||||
|
||||
val response = httpClient.request {
|
||||
applyRequest(request)
|
||||
}
|
||||
return LyngHttpResponse(
|
||||
status = response.status.value,
|
||||
statusText = response.status.description,
|
||||
headers = response.headers.entries().associate { it.key to it.value.toList() },
|
||||
bodyBytes = response.body<ByteArray>(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) {
|
||||
method = HttpMethod.parse(request.method.uppercase())
|
||||
url.takeFrom(request.url)
|
||||
headers {
|
||||
request.headers.forEach { (name, value) -> append(name, value) }
|
||||
}
|
||||
request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } }
|
||||
when {
|
||||
request.bodyBytes != null && request.bodyText != null ->
|
||||
throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set")
|
||||
request.bodyBytes != null -> setBody(request.bodyBytes)
|
||||
request.bodyText != null -> setBody(request.bodyText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSystemHttpEngine(): LyngHttpEngine = KtorLyngHttpEngine
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.http.security
|
||||
|
||||
import net.sergeych.lyngio.fs.security.AccessContext
|
||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||
import net.sergeych.lyngio.fs.security.Decision
|
||||
|
||||
sealed interface HttpAccessOp {
|
||||
data class Request(val method: String, val url: String) : HttpAccessOp
|
||||
}
|
||||
|
||||
class HttpAccessDeniedException(
|
||||
val op: HttpAccessOp,
|
||||
val reasonDetail: String? = null,
|
||||
) : IllegalStateException("HTTP access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
|
||||
|
||||
interface HttpAccessPolicy {
|
||||
suspend fun check(op: HttpAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
|
||||
|
||||
suspend fun require(op: HttpAccessOp, ctx: AccessContext = AccessContext()) {
|
||||
val res = check(op, ctx)
|
||||
if (!res.isAllowed()) throw HttpAccessDeniedException(op, res.reason)
|
||||
}
|
||||
}
|
||||
|
||||
object PermitAllHttpAccessPolicy : HttpAccessPolicy {
|
||||
override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision =
|
||||
AccessDecision(Decision.Allow)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.net
|
||||
|
||||
enum class LyngIpVersion {
|
||||
IPV4,
|
||||
IPV6,
|
||||
}
|
||||
|
||||
data class LyngSocketAddress(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val ipVersion: LyngIpVersion,
|
||||
val resolved: Boolean,
|
||||
)
|
||||
|
||||
data class LyngDatagram(
|
||||
val data: ByteArray,
|
||||
val address: LyngSocketAddress,
|
||||
)
|
||||
|
||||
interface LyngTcpSocket {
|
||||
fun isOpen(): Boolean
|
||||
fun localAddress(): LyngSocketAddress
|
||||
fun remoteAddress(): LyngSocketAddress
|
||||
suspend fun read(maxBytes: Int): ByteArray?
|
||||
suspend fun readLine(): String?
|
||||
suspend fun write(data: ByteArray)
|
||||
suspend fun writeUtf8(text: String)
|
||||
suspend fun flush()
|
||||
fun close()
|
||||
}
|
||||
|
||||
interface LyngTcpServer {
|
||||
fun isOpen(): Boolean
|
||||
fun localAddress(): LyngSocketAddress
|
||||
suspend fun accept(): LyngTcpSocket
|
||||
fun close()
|
||||
}
|
||||
|
||||
interface LyngUdpSocket {
|
||||
fun isOpen(): Boolean
|
||||
fun localAddress(): LyngSocketAddress
|
||||
suspend fun receive(maxBytes: Int): LyngDatagram?
|
||||
suspend fun send(data: ByteArray, host: String, port: Int)
|
||||
fun close()
|
||||
}
|
||||
|
||||
interface LyngNetEngine {
|
||||
val isSupported: Boolean
|
||||
val isTcpAvailable: Boolean
|
||||
val isTcpServerAvailable: Boolean
|
||||
val isUdpAvailable: Boolean
|
||||
|
||||
suspend fun resolve(host: String, port: Int): List<LyngSocketAddress>
|
||||
suspend fun tcpConnect(host: String, port: Int, timeoutMillis: Long?, noDelay: Boolean): LyngTcpSocket
|
||||
suspend fun tcpListen(host: String?, port: Int, backlog: Int, reuseAddress: Boolean): LyngTcpServer
|
||||
suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket
|
||||
}
|
||||
|
||||
internal object UnsupportedLyngNetEngine : LyngNetEngine {
|
||||
override val isSupported: Boolean = false
|
||||
override val isTcpAvailable: Boolean = false
|
||||
override val isTcpServerAvailable: Boolean = false
|
||||
override val isUdpAvailable: Boolean = false
|
||||
|
||||
override suspend fun resolve(host: String, port: Int): List<LyngSocketAddress> {
|
||||
throw UnsupportedOperationException("Raw networking is not supported on this runtime")
|
||||
}
|
||||
|
||||
override suspend fun tcpConnect(host: String, port: Int, timeoutMillis: Long?, noDelay: Boolean): LyngTcpSocket {
|
||||
throw UnsupportedOperationException("TCP client sockets are not supported on this runtime")
|
||||
}
|
||||
|
||||
override suspend fun tcpListen(host: String?, port: Int, backlog: Int, reuseAddress: Boolean): LyngTcpServer {
|
||||
throw UnsupportedOperationException("TCP server sockets are not supported on this runtime")
|
||||
}
|
||||
|
||||
override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket {
|
||||
throw UnsupportedOperationException("UDP sockets are not supported on this runtime")
|
||||
}
|
||||
}
|
||||
|
||||
expect fun getSystemNetEngine(): LyngNetEngine
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.net.security
|
||||
|
||||
import net.sergeych.lyngio.fs.security.AccessContext
|
||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||
import net.sergeych.lyngio.fs.security.Decision
|
||||
|
||||
sealed interface NetAccessOp {
|
||||
data class Resolve(val host: String, val port: Int) : NetAccessOp
|
||||
data class TcpConnect(val host: String, val port: Int) : NetAccessOp
|
||||
data class TcpListen(val host: String?, val port: Int, val backlog: Int) : NetAccessOp
|
||||
data class UdpBind(val host: String?, val port: Int) : NetAccessOp
|
||||
}
|
||||
|
||||
class NetAccessDeniedException(
|
||||
val op: NetAccessOp,
|
||||
val reasonDetail: String? = null,
|
||||
) : IllegalStateException("Network access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
|
||||
|
||||
interface NetAccessPolicy {
|
||||
suspend fun check(op: NetAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
|
||||
|
||||
suspend fun require(op: NetAccessOp, ctx: AccessContext = AccessContext()) {
|
||||
val res = check(op, ctx)
|
||||
if (!res.isAllowed()) throw NetAccessDeniedException(op, res.reason)
|
||||
}
|
||||
}
|
||||
|
||||
object PermitAllNetAccessPolicy : NetAccessPolicy {
|
||||
override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision =
|
||||
AccessDecision(Decision.Allow)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.ws
|
||||
|
||||
data class LyngWsMessage(
|
||||
val isText: Boolean,
|
||||
val text: String? = null,
|
||||
val data: ByteArray? = null,
|
||||
)
|
||||
|
||||
interface LyngWsSession {
|
||||
fun isOpen(): Boolean
|
||||
fun url(): String
|
||||
suspend fun sendText(text: String)
|
||||
suspend fun sendBytes(data: ByteArray)
|
||||
suspend fun receive(): LyngWsMessage?
|
||||
suspend fun close(code: Int, reason: String)
|
||||
}
|
||||
|
||||
interface LyngWsEngine {
|
||||
val isSupported: Boolean
|
||||
suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession
|
||||
}
|
||||
|
||||
internal object UnsupportedLyngWsEngine : LyngWsEngine {
|
||||
override val isSupported: Boolean = false
|
||||
|
||||
override suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession {
|
||||
throw UnsupportedOperationException("WebSocket client is not supported on this runtime")
|
||||
}
|
||||
}
|
||||
|
||||
expect fun getSystemWsEngine(): LyngWsEngine
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.ws.security
|
||||
|
||||
import net.sergeych.lyngio.fs.security.AccessContext
|
||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||
import net.sergeych.lyngio.fs.security.Decision
|
||||
|
||||
sealed interface WsAccessOp {
|
||||
data class Connect(val url: String) : WsAccessOp
|
||||
data class Send(val url: String, val bytes: Int, val isText: Boolean) : WsAccessOp
|
||||
data class Receive(val url: String) : WsAccessOp
|
||||
}
|
||||
|
||||
class WsAccessDeniedException(
|
||||
val op: WsAccessOp,
|
||||
val reasonDetail: String? = null,
|
||||
) : IllegalStateException("WebSocket access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
|
||||
|
||||
interface WsAccessPolicy {
|
||||
suspend fun check(op: WsAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
|
||||
|
||||
suspend fun require(op: WsAccessOp, ctx: AccessContext = AccessContext()) {
|
||||
val res = check(op, ctx)
|
||||
if (!res.isAllowed()) throw WsAccessDeniedException(op, res.reason)
|
||||
}
|
||||
}
|
||||
|
||||
object PermitAllWsAccessPolicy : WsAccessPolicy {
|
||||
override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision =
|
||||
AccessDecision(Decision.Allow)
|
||||
}
|
||||
Reference in new issue
Block a user