From e9866518984683a8971d6adb178dc73b3e293b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Fri, 23 May 2025 14:53:16 +0200 Subject: [PATCH 01/30] Initial draft of a HTTP requests library --- libraries/common/io/requests.effekt | 283 ++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 libraries/common/io/requests.effekt diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt new file mode 100644 index 000000000..e79544d05 --- /dev/null +++ b/libraries/common/io/requests.effekt @@ -0,0 +1,283 @@ +import stream +import io +import io/error +import stringbuffer + +// Async iterables +// --------------- + +record EffektAsyncIterator[T](next: => Promise[Option[T]] at {io, async, global}) +def getNext[T](it: EffektAsyncIterator[T]): Option[T] = + (it.next)().await() +def each[T](it: EffektAsyncIterator[T]): Unit / emit[T] = { + while(it.getNext() is Some(x)) { do emit(x) } +} + +// Types +// ----- + +type Protocol { HTTP(); HTTPS() } +def show(p: Protocol): String = p match { + case HTTP() => "http" + case HTTPS() => "https" +} +type Method { GET(); POST() } +def show(m: Method): String = m match { + case GET() => "GET" + case POST() => "POST" +} +interface RequestBuilder { + def method(method: Method): Unit + def hostname(host: String): Unit + def path(path: String): Unit + def port(port: Int): Unit + def protocol(proto: Protocol): Unit + def header(key: String, value: String): Unit +} +interface ResponseReader { + def status(): Int + def body(): Unit / emit[Byte] + def getHeader(key: String): Option[String] + //def headers(): Unit / emit[(String, String)] +} + +record RequestError() + +// Backend-specific implementations +// -------------------------------- + +namespace js { + extern type AsyncIterator[T] + extern type IterableResult[T] + extern io def nextPromise[T](it: AsyncIterator[T]): Promise[IterableResult[T]] = + js "{ promise: ${it}.next() }" + extern def done[T](r: IterableResult[T]): Bool = + js "${r}.done" + extern io def unsafeValue[T](r: IterableResult[T]): T = + js "${r}.value" + extern def fromValue[T](v: T): IterableResult[T] = + js "{ value: ${v}, done: false }" + extern def done[T](): IterableResult[T] = + js "{ value: undefined, done: true }" + + def next[T](it: AsyncIterator[T]): IterableResult[T] = + it.nextPromise().await() + + def each[T](it: AsyncIterator[T]): Unit / emit[T] = { + while(it.next() is r and not(r.done)) { + do emit(r.unsafeValue()) + } + } + // broken if we resolve a promise inside + // extern def makeAsyncIterator[T](next: => Promise[IterableResult[T]] at {io, async, global}): AsyncIterator[T] = + // js """{ next: () => $effekt.runToplevel((ks,k) => ${next}(ks, k)) }""" + + // Native Byte Buffers + // ------------------- + extern type NativeBytes + extern pure def length(n: NativeBytes): Int = + js "${n}.length" + extern pure def get(n: NativeBytes, x: Int): Byte = + js "${n}[${x}]" + def each(n: NativeBytes): Unit / emit[Byte] = { + each(0, n.length){ i => + do emit(n.get(i)) + } + } + + // Event emitters + // -------------- + extern type Undefined + extern type EventEmitter + record Event[T](name: String) + namespace ev { + def data(): Event[js::NativeBytes] = Event("data") + def end(): Event[Undefined] = Event("end") + } + + extern io def unsafeOn[T](em: EventEmitter, ev: String, handler: T => Unit at {io, async, global}): Unit = + js "${em}.on(${ev}, (param) => $effekt.runToplevel((ks,k) => ${handler}(param, ks, k)))" + def on[T](em: EventEmitter, ev: Event[T], handler: T => Unit at {io, async, global}): Unit = + em.unsafeOn(ev.name, handler) + extern async def unsafeWait[T](em: EventEmitter, ev: String): T = + js "$effekt.capture(k => ${em}.on(${ev}, k))" + def wait[T](em: EventEmitter, ev: Event[T]): T = + em.unsafeWait(ev.name) + + // Dict-like JS objects + // -------------------- + extern type JsObj + extern def empty(): JsObj = + js "{}" + + extern io def set(obj: JsObj, key: String, value: Any): Unit = + js "${obj}[${key}] = ${value};" + extern io def set(obj: JsObj, key1: String, key2: String, value: Any): Unit = + js "${obj}[${key1}][${key2}] = ${value};" +} + +namespace jsNode { + extern jsNode """ + const http = require('node:http') + const https = require('node:https') + """ + + extern type NativeResponse + extern async def runHTTP(obj: js::JsObj): NativeResponse = + jsNode "$effekt.capture(callback => http.request(${obj}, callback).on('error', callback).end())" + extern async def runHTTPS(obj: js::JsObj): NativeResponse = + jsNode "$effekt.capture(callback => https.request(${obj}, callback).on('error', callback).end())" + + extern io def statusCode(r: NativeResponse): Int = + jsNode "${r}.statusCode" + extern pure def getHeader(r: NativeResponse, h: String): String = + jsNode "${r}.headers[${h}]" + + extern pure def events(r: NativeResponse): js::EventEmitter = jsNode "${r}" + + def getBody(r: NativeResponse): EffektAsyncIterator[js::NativeBytes] = { + val nextResolve = ref(promise::make()) + r.events.js::on(js::ev::data(), box { chunk => + val waitingResolve = nextResolve.get().await() + nextResolve.set(promise::make()) + waitingResolve.resolve(Some(chunk)) + }) + r.events.js::on(js::ev::end(), box { _ => + val waitingResolve = nextResolve.get().await() + nextResolve.set(promise::make()) + waitingResolve.resolve(None()) + }) + EffektAsyncIterator(box { + val resPromise = promise::make() + nextResolve.get().resolve(resPromise) + resPromise + }) + } + + extern io def isError(r: NativeResponse): Bool = + jsNode "(${r} instanceof Error)" + + // outside interface + def request[R]{ body: => Unit / RequestBuilder }{ k: {ResponseReader} => R }: R / Exception[RequestError] = { + val options = js::empty() + options.js::set("headers", js::empty()) + var protocol = HTTPS() + try body() with RequestBuilder { + def method(m) = resume(options.js::set("method", m.show)) + def hostname(n) = resume(options.js::set("hostname", n)) + def path(p) = resume(options.js::set("path", p)) + def port(p) = resume(options.js::set("port", p)) + def header(k, v) = resume(options.js::set("headers", k, v)) + def protocol(p) = resume(protocol = p) + } + val res = protocol match { + case HTTP() => runHTTP(options) + case HTTPS() => runHTTPS(options) + } + if(res.isError) { println(res.genericShow); do raise(RequestError(), "Request failed") } + + val resbody = res.getBody + def rr = new ResponseReader{ + def status() = res.statusCode + def body() = { + for[js::NativeBytes]{ resbody.each }{ b => b.js::each } + } + def getHeader(k) = undefinedToOption(res.getHeader(k)) + //def headers() = <> + } + k{rr} + } +} + +namespace jsWeb { + extern type RequestInit + extern type NativeResponse + extern async def run(url: String, obj: js::JsObj): NativeResponse = + jsWeb """$effekt.capture(k => fetch(${url}, ${obj}).then(k).catch(k))""" + extern pure def isError(r: NativeResponse): Bool = + jsWeb """(${r} instanceof Error)""" + + extern pure def statusCode(r: NativeResponse): Int = + jsWeb """${r}.status""" + extern pure def getHeader(r: NativeResponse, name: String): String = + jsWeb """${r}.headers.get(${name})""" + extern type Reader + extern io def getBody(r: NativeResponse): Reader = + jsWeb """${r}.body.getReader()""" + extern io def read(r: Reader): Promise[js::IterableResult[js::NativeBytes]] = + jsWeb """{ promise: ${r}.read() }""" + def each(r: Reader): Unit / emit[js::NativeBytes] = { + while(r.read().await() is r and not(r.js::done)) { + do emit(r.js::unsafeValue()) + } + } + + def request[R]{ body: => Unit / RequestBuilder }{ k: {ResponseReader} => R }: R / Exception[RequestError] = { + val options = js::empty() + options.js::set("headers", js::empty()) + var protocol = HTTPS() + var hostname = "" + var path = "/" + var port = 443 + try body() with RequestBuilder { + def method(m) = resume(options.js::set("method", m.show)) + def hostname(n) = resume(hostname = n) + def path(p) = resume(path = p) + def port(p) = resume(port = p) + def header(k, v) = resume(options.js::set("headers", k, v)) + def protocol(p) = resume(protocol = p) + } + val url = s"${protocol.show}://${hostname}:${port.show}${path}" + val res = run(url, options) + if(res.isError) { println(res.genericShow); do raise(RequestError(), "Request failed") } + + def rr = new ResponseReader { + def status() = res.statusCode + def body() = for[js::NativeBytes]{ res.getBody().each() }{ b => b.js::each } + def getHeader(k) = undefinedToOption(res.getHeader(k)) + } + k{rr} + } +} + +namespace internal { + extern pure def backend(): String = + jsNode { "js-node" } + jsWeb { "js-web" } +} + +def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: R / Exception[RequestError] = internal::backend() match { + case "js-node" => jsNode::request{body}{res} + case "js-web" => jsWeb::request{body}{res} + case _ => <> +} + +namespace example { + def main() = { + with on[RequestError].panic + with def res = request{ + do method(GET()) + do hostname("effekt-lang.org") + //do header("user-agent", "Effekt/script") // dont use this on js-web + do path("/") + do port(443) + } + if(res.status() == 200){ + println("OK") + println(res.getHeader("content-type").show{ x => x }) + with source[Byte]{ res.body() } + with decodeUTF8 + with stringBuffer + exhaustively{ + do read[Char]() match { + case '\n' => println(do flush()) + case c => + do write(c.show) + } + } + println(do flush()) + } else { + println(res.status().show) + } + } +} \ No newline at end of file From 3d61afa3f0c58aad3c109c1a58964aaa7a46dff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Fri, 23 May 2025 15:27:21 +0200 Subject: [PATCH 02/30] Allow setting the request body (non-streamingly) --- libraries/common/io/requests.effekt | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index e79544d05..b3c39d4ed 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -2,6 +2,7 @@ import stream import io import io/error import stringbuffer +import bytearray // Async iterables // --------------- @@ -33,6 +34,7 @@ interface RequestBuilder { def port(port: Int): Unit def protocol(proto: Protocol): Unit def header(key: String, value: String): Unit + def body{ writer: => Unit / emit[Byte] }: Unit } interface ResponseReader { def status(): Int @@ -123,10 +125,18 @@ namespace jsNode { """ extern type NativeResponse - extern async def runHTTP(obj: js::JsObj): NativeResponse = - jsNode "$effekt.capture(callback => http.request(${obj}, callback).on('error', callback).end())" - extern async def runHTTPS(obj: js::JsObj): NativeResponse = - jsNode "$effekt.capture(callback => https.request(${obj}, callback).on('error', callback).end())" + extern async def runHTTP(obj: js::JsObj, body: ByteArray): NativeResponse = + jsNode """$effekt.capture(callback => { + let req = http.request(${obj}, callback).on('error', callback); + req.write(${body}) + req.end() + })""" + extern async def runHTTPS(obj: js::JsObj, body: ByteArray): NativeResponse = + jsNode """$effekt.capture(callback => { + let req = https.request(${obj}, callback).on('error', callback); + req.write(${body}) + req.end(); + })""" extern io def statusCode(r: NativeResponse): Int = jsNode "${r}.statusCode" @@ -162,6 +172,7 @@ namespace jsNode { val options = js::empty() options.js::set("headers", js::empty()) var protocol = HTTPS() + var reqBody: ByteArray = allocate(0) try body() with RequestBuilder { def method(m) = resume(options.js::set("method", m.show)) def hostname(n) = resume(options.js::set("hostname", n)) @@ -169,10 +180,11 @@ namespace jsNode { def port(p) = resume(options.js::set("port", p)) def header(k, v) = resume(options.js::set("headers", k, v)) def protocol(p) = resume(protocol = p) + def body() = resume{ {wr} => reqBody = collectBytes{ wr } } } val res = protocol match { - case HTTP() => runHTTP(options) - case HTTPS() => runHTTPS(options) + case HTTP() => runHTTP(options, reqBody) + case HTTPS() => runHTTPS(options, reqBody) } if(res.isError) { println(res.genericShow); do raise(RequestError(), "Request failed") } @@ -226,6 +238,7 @@ namespace jsWeb { def port(p) = resume(port = p) def header(k, v) = resume(options.js::set("headers", k, v)) def protocol(p) = resume(protocol = p) + def body() = resume{ {wr} => options.js::set("body", collectBytes{wr}) } } val url = s"${protocol.show}://${hostname}:${port.show}${path}" val res = run(url, options) From 4664a5dca427d286409dff340817db70961da6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Fri, 23 May 2025 15:28:54 +0200 Subject: [PATCH 03/30] Add to acme --- examples/stdlib/acme.effekt | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/stdlib/acme.effekt b/examples/stdlib/acme.effekt index cffcd9178..252e7faf9 100644 --- a/examples/stdlib/acme.effekt +++ b/examples/stdlib/acme.effekt @@ -17,6 +17,7 @@ import io/console import io/error import io/filesystem import io/network +import io/requests import io/time import json import list From ade18e4a913bb9095fea225f2ec82be06d3c05b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Fri, 23 May 2025 15:31:30 +0200 Subject: [PATCH 04/30] jsWeb: the fetch API *does* give us a UInt8Array --- libraries/common/io/requests.effekt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index b3c39d4ed..16739ff5f 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -216,9 +216,9 @@ namespace jsWeb { extern type Reader extern io def getBody(r: NativeResponse): Reader = jsWeb """${r}.body.getReader()""" - extern io def read(r: Reader): Promise[js::IterableResult[js::NativeBytes]] = + extern io def read(r: Reader): Promise[js::IterableResult[ByteArray]] = jsWeb """{ promise: ${r}.read() }""" - def each(r: Reader): Unit / emit[js::NativeBytes] = { + def each(r: Reader): Unit / emit[ByteArray] = { while(r.read().await() is r and not(r.js::done)) { do emit(r.js::unsafeValue()) } @@ -246,7 +246,7 @@ namespace jsWeb { def rr = new ResponseReader { def status() = res.statusCode - def body() = for[js::NativeBytes]{ res.getBody().each() }{ b => b.js::each } + def body() = for[ByteArray]{ res.getBody().each() }{ b => b.each } def getHeader(k) = undefinedToOption(res.getHeader(k)) } k{rr} From bbdc92cd3f5060f5eabb571fbbf37031931a9474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Fri, 23 May 2025 15:46:50 +0200 Subject: [PATCH 05/30] some documentation, define default ports --- libraries/common/io/requests.effekt | 32 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 16739ff5f..81413a135 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -22,11 +22,18 @@ def show(p: Protocol): String = p match { case HTTP() => "http" case HTTPS() => "https" } +def defaultPort(p: Protocol): Int = p match { + case HTTP() => 80 + case HTTPS() => 443 +} type Method { GET(); POST() } def show(m: Method): String = m match { case GET() => "GET" case POST() => "POST" } +/// Interface to build HTTP requests. +/// +/// Each of method, hostname, path, port, and protocol must be called at least once! interface RequestBuilder { def method(method: Method): Unit def hostname(host: String): Unit @@ -36,19 +43,28 @@ interface RequestBuilder { def header(key: String, value: String): Unit def body{ writer: => Unit / emit[Byte] }: Unit } +/// Interface returned by HTTP requests. interface ResponseReader { + /// Gets the response HTTP status code def status(): Int + + /// Returns the body of the response by emitting Bytes + /// May be called at most once. def body(): Unit / emit[Byte] + + /// Get the specified HTTP header, key should be lower-case def getHeader(key: String): Option[String] + //def headers(): Unit / emit[(String, String)] } record RequestError() - // Backend-specific implementations // -------------------------------- namespace js { + // Async iterators in JS + // --------------------- extern type AsyncIterator[T] extern type IterableResult[T] extern io def nextPromise[T](it: AsyncIterator[T]): Promise[IterableResult[T]] = @@ -70,13 +86,14 @@ namespace js { do emit(r.unsafeValue()) } } - // broken if we resolve a promise inside + // broken if we resolve a promise inside, see issue #1016 // extern def makeAsyncIterator[T](next: => Promise[IterableResult[T]] at {io, async, global}): AsyncIterator[T] = // js """{ next: () => $effekt.runToplevel((ks,k) => ${next}(ks, k)) }""" // Native Byte Buffers // ------------------- extern type NativeBytes + // jsNode "Buffer" extern pure def length(n: NativeBytes): Int = js "${n}.length" extern pure def get(n: NativeBytes, x: Int): Byte = @@ -116,6 +133,8 @@ namespace js { js "${obj}[${key}] = ${value};" extern io def set(obj: JsObj, key1: String, key2: String, value: Any): Unit = js "${obj}[${key1}][${key2}] = ${value};" + extern io def isSet(obj: JsObj, key: String): Bool = + js "${obj}[${key}] !== undefined" } namespace jsNode { @@ -182,6 +201,7 @@ namespace jsNode { def protocol(p) = resume(protocol = p) def body() = resume{ {wr} => reqBody = collectBytes{ wr } } } + if(not(options.js::isSet("port"))) { options.js::set("port", protocol.defaultPort()) } val res = protocol match { case HTTP() => runHTTP(options, reqBody) case HTTPS() => runHTTPS(options, reqBody) @@ -230,17 +250,17 @@ namespace jsWeb { var protocol = HTTPS() var hostname = "" var path = "/" - var port = 443 + var port = None() try body() with RequestBuilder { def method(m) = resume(options.js::set("method", m.show)) def hostname(n) = resume(hostname = n) def path(p) = resume(path = p) - def port(p) = resume(port = p) + def port(p) = resume(port = Some(p)) def header(k, v) = resume(options.js::set("headers", k, v)) def protocol(p) = resume(protocol = p) def body() = resume{ {wr} => options.js::set("body", collectBytes{wr}) } } - val url = s"${protocol.show}://${hostname}:${port.show}${path}" + val url = s"${protocol.show}://${hostname}:${port.getOrElse{ protocol.defaultPort }.show}${path}" val res = run(url, options) if(res.isError) { println(res.genericShow); do raise(RequestError(), "Request failed") } @@ -273,7 +293,7 @@ namespace example { do hostname("effekt-lang.org") //do header("user-agent", "Effekt/script") // dont use this on js-web do path("/") - do port(443) + // do port(443) // optional } if(res.status() == 200){ println("OK") From 84da228156dde6de78654f0de87671f533bfae91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 12:25:57 +0200 Subject: [PATCH 06/30] Add simple URI parser library --- libraries/common/io/uri.effekt | 251 +++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 libraries/common/io/uri.effekt diff --git a/libraries/common/io/uri.effekt b/libraries/common/io/uri.effekt new file mode 100644 index 000000000..8193947bb --- /dev/null +++ b/libraries/common/io/uri.effekt @@ -0,0 +1,251 @@ +import scanner +import stream + +def hexDigit(for: Int): Char = for match { + case i and i >= 0 and i < 10 => ('0'.toInt + i).toChar + case i and i >= 0 and i < 16 => ('A'.toInt + (i - 10)).toChar + case _ => <> +} + +/// %-encodes the characters for which `shouldEncode` returns true. +/// Always %-encodes %. +def urlencode(s: String){ shouldEncode: Char => Bool }: String = collectString { + def encoded(c: Char): Unit = { + do emit('%') + val cc = c.toInt + if (cc >= 256){ panic("Unicode not supported") } // TODO + do emit((cc / 16).hexDigit) + do emit(mod(cc, 16).hexDigit) + } + for[Char]{ s.each }{ + case '%' => encoded('%') + case c and c.shouldEncode => encoded(c) + case other => do emit(other) + } +} + +/// gen-delims as per RFC 3986 +def isGenDelim(c: Char): Bool = c match { + case ':' => true + case '/' => true + case '?' => true + case '#' => true + case '[' => true + case ']' => true + case '@' => true + case _ => false +} + +/// sub-delims as per RFC 3986 +def isSubDelim(c: Char): Bool = c match { + case '!' => true + case '$' => true + case '&' => true + case '\'' => true + case '(' => true + case ')' => true + case '*' => true + case '+' => true + case ',' => true + case ';' => true + case '=' => true + case _ => false +} + +/// Encodes the string for urls using %-escapes +def urlencode(s: String): String = urlencode(s){ + case '%' => true + case ' ' => true + case c and c.isGenDelim || c.isSubDelim => true + case _ => false +} + +/// Unreserved characters as per RFC 3986 +def isUnreserved(c: Char): Bool = c match { + case c and c.isAlphanumeric => true + case '-' => true + case '.' => true + case '_' => true + case '~' => true + case _ => false +} + +/// Encodes the string for urls using %-escapes, +/// escaping everything that is not an unreserved character +/// as per RFC 3986. +def urlencodeStrict(s: String): String = + urlencode(s){ c => not(c.isUnreserved) } + + +/// Decodes %-escapes in the given string +def urldecode(s: String): String = collectString { + with feed(s) + with scanner[Char] + + exhaustively{ + do read[Char]() match { + case '%' => + val a = readHexDigit() + val b = readHexDigit() + do emit((a * 16 + b).toChar) + case o => do emit(o) + } + } +} + +interface URIBuilder { + def scheme(s: String): Unit + def userinfo(a: String): Unit + def host(h: String): Unit + def port(p: Int): Unit + def path(p: String): Unit + def query(q: String): Unit + def fragment(f: String): Unit +} + +def parseScheme(): String / { Scan[Char], stop } = { + with collectString + do emit(readIf{ c => c.isAlphabetic }) + readWhile{ c => c.isAlphanumeric || c == '+' || c == '-' || c == '.' } +} + +def unread[A, R](c: A){ body: => R / Scan[A] }: R / Scan[A] = { + var read = false + try body() with Scan[A] { + def peek() = if(read) { resume{do peek()} } else { resume{ () => c } } + def skip() = if(read) { resume{do skip[A]()} } else { resume{read = true} } + } +} +def unread[R](s: String){ body: => R / Scan[Char] }: R / Scan[Char] = { + var pos = 0 + try body() with Scan[Char] { + def peek() = if (pos < s.length) { resume{s.unsafeCharAt(pos)} } else { resume{do peek()} } + def skip() = if (pos < s.length) { resume{pos = pos + 1} } else { resume{do skip[Char]()} } + } +} + +def parseHostAndPort(): Unit / { URIBuilder, Scan[Char] } = { + try { + do peek[Char]() match { + case '[' => // IP-literal + // this is more permissive than the spec + do host(collectString{ readWhile{ c => c != ']' } } ++ "]") + readIf(']') + case _ => + do host(collectString{ readWhile{ + case '%' => true + case c and c.isUnreserved => true + case c and c.isSubDelim => true + case _ => false + } }) + } + } with stop { () => + do host("") + } + attempt{ + readIf(':') + do port(readInteger()) + }{ + // no port + () + } +} + +def parseAuthority(): Unit / { URIBuilder, Scan[Char] } = { + // try parsing as userinfo@... + val fst = collectString{ readWhile{ + case '%' => true + case ':' => true + case c and c.isUnreserved => true + case c and c.isSubDelim => true + case _ => false + } } + attempt{ // was userinfo + readIf('@') + do userinfo(fst) + parseHostAndPort() + }{ // was not userinfo + with unread(fst) + parseHostAndPort() + } +} + +def parsePathQueryFragment(): Unit / { URIBuilder, Scan[Char], Exception[WrongFormat] } = { + do path(collectString{ readWhile{ + case '?' => false + case '#' => false + case _ => true + }}) + boundary{ + readIf('?') + do query(collectString{ readWhile{ c => c != '#' }}) + } + boundary{ + readIf('#') + do fragment(collectString{ readWhile[Char]{ c => true } }) + } +} + +def parseURI(uri: String): Unit / { URIBuilder, Exception[WrongFormat] } = { + try { + with feed(uri) + with scanner[Char] + + do scheme(parseScheme()) + readIf(':') + + val c = read[Char]() + if (c == '/' and do peek[Char]() == '/'){ + // starts with `//` + readIf('/') + parseAuthority() + boundary{ + do peek[Char]() match { + case '?' => () + case '#' => () + case '/' => () + case _ => do raise(WrongFormat(), "Path must be empty or start with / if there is an authority component.") + } + } + parsePathQueryFragment() + } else { + with unread(c) + parsePathQueryFragment() + } + } with stop { () => + do raise(WrongFormat(), "Could not parse URI") + } +} + +namespace example { + def report(uri: String): Unit = { + with on[WrongFormat].panic + println(uri) + try parseURI(uri) with URIBuilder { + def scheme(s) = resume(println(" Scheme: " ++ s)) + def userinfo(u) = resume(println(" Userinfo: " ++ u)) + def host(h) = resume(println(" Host: " ++ h)) + def path(p) = resume(println(" Path: " ++ p)) + def port(p) = resume(println(" Port: " ++ p.show)) + def query(q) = resume(println(" Query: " ++ q)) + def fragment(q) = resume(println(" Fragment: " ++ q)) + } + } + def main() = { + println(urldecode("%2F%20%20!")) + println(urldecode(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) + println(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) + println(urldecode(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) + println(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) + + // examples from the spec + report("ftp://ftp.is.co.za/rfc/rfc1808.txt") + report("http://www.ietf.org/rfc/rfc2396.txt") + report("ldap://[2001:db8::7]/c=GB?objectClass?one") + report("mailto:John.Doe@example.com") + report("news:comp.infosystems.www.servers.unix") + report("tel:+1-816-555-1212") + report("telnet://192.0.2.16:80/") + report("urn:oasis:names:specification:docbook:dtd:xml:4.1") + } +} \ No newline at end of file From 15e5fe540387d870a106993dc2db72734ecdcbd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 12:38:12 +0200 Subject: [PATCH 07/30] Allow providing URI string instead of separate hostname and path --- libraries/common/io/requests.effekt | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 81413a135..abe10734c 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -3,6 +3,7 @@ import io import io/error import stringbuffer import bytearray +import io/uri // Async iterables // --------------- @@ -273,6 +274,27 @@ namespace jsWeb { } } +def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { + stringBuffer{ + try parseURI(uri) with URIBuilder { + def scheme(sc) = resume(sc match { + case "http" => do protocol(HTTP()) + case "HTTP" => do protocol(HTTP()) + case "https" => do protocol(HTTPS()) + case "HTTPS" => do protocol(HTTPS()) + case _ => do raise(WrongFormat(), "Unsupported protocol " ++ sc) + }) + def userinfo(u) = <> + def host(h) = resume(do hostname(h)) + def port(p) = resume(do port(p)) + def path(p) = resume(do write(p)) + def query(q) = { do write("?"); do write(q); resume(()) } + def fragment(f) = { do write("#"); do write(f); resume(()) } + } + do path(do flush()) + } +} + namespace internal { extern pure def backend(): String = jsNode { "js-node" } @@ -288,11 +310,13 @@ def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: namespace example { def main() = { with on[RequestError].panic + with on[WrongFormat].panic with def res = request{ do method(GET()) - do hostname("effekt-lang.org") + uri("https://effekt-lang.org/") + //do hostname("effekt-lang.org") //do header("user-agent", "Effekt/script") // dont use this on js-web - do path("/") + //do path("/") // do port(443) // optional } if(res.status() == 200){ From 3bd6d8f8bbfc5f934525672dca6972dad3b0f41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 13:23:12 +0200 Subject: [PATCH 08/30] Support basic auth url part --- libraries/common/io/requests.effekt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index abe10734c..07b469138 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -37,6 +37,7 @@ def show(m: Method): String = m match { /// Each of method, hostname, path, port, and protocol must be called at least once! interface RequestBuilder { def method(method: Method): Unit + def auth(authStr: String): Unit def hostname(host: String): Unit def path(path: String): Unit def port(port: Int): Unit @@ -195,6 +196,7 @@ namespace jsNode { var reqBody: ByteArray = allocate(0) try body() with RequestBuilder { def method(m) = resume(options.js::set("method", m.show)) + def auth(a) = resume(options.js::set("auth", a)) def hostname(n) = resume(options.js::set("hostname", n)) def path(p) = resume(options.js::set("path", p)) def port(p) = resume(options.js::set("port", p)) @@ -249,11 +251,13 @@ namespace jsWeb { val options = js::empty() options.js::set("headers", js::empty()) var protocol = HTTPS() + var auth = None() var hostname = "" var path = "/" var port = None() try body() with RequestBuilder { def method(m) = resume(options.js::set("method", m.show)) + def auth(a) = resume(auth = Some(a)) def hostname(n) = resume(hostname = n) def path(p) = resume(path = p) def port(p) = resume(port = Some(p)) @@ -261,7 +265,8 @@ namespace jsWeb { def protocol(p) = resume(protocol = p) def body() = resume{ {wr} => options.js::set("body", collectBytes{wr}) } } - val url = s"${protocol.show}://${hostname}:${port.getOrElse{ protocol.defaultPort }.show}${path}" + val authStr = auth.map{ a => a ++ "@" }.getOrElse{ "" } + val url = s"${protocol.show}://${authStr}${hostname}:${port.getOrElse{ protocol.defaultPort }.show}${path}" val res = run(url, options) if(res.isError) { println(res.genericShow); do raise(RequestError(), "Request failed") } @@ -284,7 +289,7 @@ def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { case "HTTPS" => do protocol(HTTPS()) case _ => do raise(WrongFormat(), "Unsupported protocol " ++ sc) }) - def userinfo(u) = <> + def userinfo(u) = resume(do auth(u)) def host(h) = resume(do hostname(h)) def port(p) = resume(do port(p)) def path(p) = resume(do write(p)) From c96103f2da6e3181f95d2f1e589e1c806f8eac77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 13:24:32 +0200 Subject: [PATCH 09/30] Add URI to acme --- examples/stdlib/acme.effekt | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/stdlib/acme.effekt b/examples/stdlib/acme.effekt index 252e7faf9..e6f568600 100644 --- a/examples/stdlib/acme.effekt +++ b/examples/stdlib/acme.effekt @@ -19,6 +19,7 @@ import io/filesystem import io/network import io/requests import io/time +import io/uri import json import list import map From d7e37e0a9d6d9d7a0949597efd64a2bebb33d2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 13:26:44 +0200 Subject: [PATCH 10/30] Move URI examples to tests --- examples/stdlib/io/uri.check | 35 ++++++++++++++++++++++++++++++++++ examples/stdlib/io/uri.effekt | 32 +++++++++++++++++++++++++++++++ libraries/common/io/uri.effekt | 33 -------------------------------- 3 files changed, 67 insertions(+), 33 deletions(-) create mode 100644 examples/stdlib/io/uri.check create mode 100644 examples/stdlib/io/uri.effekt diff --git a/examples/stdlib/io/uri.check b/examples/stdlib/io/uri.check new file mode 100644 index 000000000..f2e1e95fb --- /dev/null +++ b/examples/stdlib/io/uri.check @@ -0,0 +1,35 @@ +/ ! +Hallo Welt!/&^$@^*(&$)(*!_!_+") +Hallo%20Welt%21%2F%26^%24%40^%2A%28%26%24%29%28%2A%21_%21_%2B"%29 +Hallo Welt!/&^$@^*(&$)(*!_!_+") +Hallo%20Welt%21%2F%26%5E%24%40%5E%2A%28%26%24%29%28%2A%21_%21_%2B%22%29 +ftp://ftp.is.co.za/rfc/rfc1808.txt + Scheme: ftp + Host: ftp.is.co.za + Path: /rfc/rfc1808.txt +http://www.ietf.org/rfc/rfc2396.txt + Scheme: http + Host: www.ietf.org + Path: /rfc/rfc2396.txt +ldap://[2001:db8::7]/c=GB?objectClass?one + Scheme: ldap + Host: [2001:db8::7] + Path: /c=GB + Query: objectClass?one +mailto:John.Doe@example.com + Scheme: mailto + Path: John.Doe@example.com +news:comp.infosystems.www.servers.unix + Scheme: news + Path: comp.infosystems.www.servers.unix +tel:+1-816-555-1212 + Scheme: tel + Path: +1-816-555-1212 +telnet://192.0.2.16:80/ + Scheme: telnet + Host: 192.0.2.16 + Port: 80 + Path: / +urn:oasis:names:specification:docbook:dtd:xml:4.1 + Scheme: urn + Path: oasis:names:specification:docbook:dtd:xml:4.1 diff --git a/examples/stdlib/io/uri.effekt b/examples/stdlib/io/uri.effekt new file mode 100644 index 000000000..845acf0b4 --- /dev/null +++ b/examples/stdlib/io/uri.effekt @@ -0,0 +1,32 @@ +import io/uri + +def report(uri: String): Unit = { + with on[WrongFormat].panic + println(uri) + try parseURI(uri) with URIBuilder { + def scheme(s) = resume(println(" Scheme: " ++ s)) + def userinfo(u) = resume(println(" Userinfo: " ++ u)) + def host(h) = resume(println(" Host: " ++ h)) + def path(p) = resume(println(" Path: " ++ p)) + def port(p) = resume(println(" Port: " ++ p.show)) + def query(q) = resume(println(" Query: " ++ q)) + def fragment(q) = resume(println(" Fragment: " ++ q)) + } +} +def main() = { + println(urldecode("%2F%20%20!")) + println(urldecode(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) + println(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) + println(urldecode(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) + println(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) + + // examples from the spec + report("ftp://ftp.is.co.za/rfc/rfc1808.txt") + report("http://www.ietf.org/rfc/rfc2396.txt") + report("ldap://[2001:db8::7]/c=GB?objectClass?one") + report("mailto:John.Doe@example.com") + report("news:comp.infosystems.www.servers.unix") + report("tel:+1-816-555-1212") + report("telnet://192.0.2.16:80/") + report("urn:oasis:names:specification:docbook:dtd:xml:4.1") +} \ No newline at end of file diff --git a/libraries/common/io/uri.effekt b/libraries/common/io/uri.effekt index 8193947bb..1dfad77a1 100644 --- a/libraries/common/io/uri.effekt +++ b/libraries/common/io/uri.effekt @@ -215,37 +215,4 @@ def parseURI(uri: String): Unit / { URIBuilder, Exception[WrongFormat] } = { } with stop { () => do raise(WrongFormat(), "Could not parse URI") } -} - -namespace example { - def report(uri: String): Unit = { - with on[WrongFormat].panic - println(uri) - try parseURI(uri) with URIBuilder { - def scheme(s) = resume(println(" Scheme: " ++ s)) - def userinfo(u) = resume(println(" Userinfo: " ++ u)) - def host(h) = resume(println(" Host: " ++ h)) - def path(p) = resume(println(" Path: " ++ p)) - def port(p) = resume(println(" Port: " ++ p.show)) - def query(q) = resume(println(" Query: " ++ q)) - def fragment(q) = resume(println(" Fragment: " ++ q)) - } - } - def main() = { - println(urldecode("%2F%20%20!")) - println(urldecode(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) - println(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) - println(urldecode(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) - println(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) - - // examples from the spec - report("ftp://ftp.is.co.za/rfc/rfc1808.txt") - report("http://www.ietf.org/rfc/rfc2396.txt") - report("ldap://[2001:db8::7]/c=GB?objectClass?one") - report("mailto:John.Doe@example.com") - report("news:comp.infosystems.www.servers.unix") - report("tel:+1-816-555-1212") - report("telnet://192.0.2.16:80/") - report("urn:oasis:names:specification:docbook:dtd:xml:4.1") - } } \ No newline at end of file From 9a632a2859f23dc4a4de898ed9655749d5037ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 13:30:49 +0200 Subject: [PATCH 11/30] Make strict urlencode the default --- examples/stdlib/io/uri.effekt | 4 ++-- libraries/common/io/uri.effekt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/stdlib/io/uri.effekt b/examples/stdlib/io/uri.effekt index 845acf0b4..919d8a407 100644 --- a/examples/stdlib/io/uri.effekt +++ b/examples/stdlib/io/uri.effekt @@ -15,10 +15,10 @@ def report(uri: String): Unit = { } def main() = { println(urldecode("%2F%20%20!")) + println(urldecode(urlencodePermissive("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) + println(urlencodePermissive("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) println(urldecode(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) println(urlencode("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) - println(urldecode(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")"))) - println(urlencodeStrict("Hallo Welt!/&^$@^*(&$)(*!_!_+\")")) // examples from the spec report("ftp://ftp.is.co.za/rfc/rfc1808.txt") diff --git a/libraries/common/io/uri.effekt b/libraries/common/io/uri.effekt index 1dfad77a1..7028c4781 100644 --- a/libraries/common/io/uri.effekt +++ b/libraries/common/io/uri.effekt @@ -53,7 +53,7 @@ def isSubDelim(c: Char): Bool = c match { } /// Encodes the string for urls using %-escapes -def urlencode(s: String): String = urlencode(s){ +def urlencodePermissive(s: String): String = urlencode(s){ case '%' => true case ' ' => true case c and c.isGenDelim || c.isSubDelim => true @@ -73,7 +73,7 @@ def isUnreserved(c: Char): Bool = c match { /// Encodes the string for urls using %-escapes, /// escaping everything that is not an unreserved character /// as per RFC 3986. -def urlencodeStrict(s: String): String = +def urlencode(s: String): String = urlencode(s){ c => not(c.isUnreserved) } From 2fffb6bef3a43a97069e92fe3965098cb5647aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 13:47:39 +0200 Subject: [PATCH 12/30] Cleanup URI implementations, move parts into proper stdlibs --- libraries/common/char.effekt | 9 ++ libraries/common/io/uri.effekt | 241 ++++++++++++++++---------------- libraries/common/scanner.effekt | 19 +++ 3 files changed, 148 insertions(+), 121 deletions(-) diff --git a/libraries/common/char.effekt b/libraries/common/char.effekt index 24139ffc6..f22b3ca09 100644 --- a/libraries/common/char.effekt +++ b/libraries/common/char.effekt @@ -52,6 +52,15 @@ def digitValue(char: Char, base: Int): Int / Exception[WrongFormat] = { digit } +/// Encodes a number in the range 0..15 as a hex character. +/// +/// Panics on all other numbers. +def hexDigit(for: Int): Char = for match { + case i and i >= 0 and i < 10 => ('0'.toInt + i).toChar + case i and i >= 0 and i < 16 => ('A'.toInt + (i - 10)).toChar + case _ => panic(for.show ++ " is not in [0,16).") +} + /// Checks if the given character is an ASCII digit in base 10 /// Prefer using `digitValue(c: Char)` to get the numeric value out. def isDigit(char: Char): Bool = result[Int, WrongFormat] { digitValue(char) }.isSuccess diff --git a/libraries/common/io/uri.effekt b/libraries/common/io/uri.effekt index 7028c4781..95dad4864 100644 --- a/libraries/common/io/uri.effekt +++ b/libraries/common/io/uri.effekt @@ -1,19 +1,13 @@ import scanner import stream -def hexDigit(for: Int): Char = for match { - case i and i >= 0 and i < 10 => ('0'.toInt + i).toChar - case i and i >= 0 and i < 16 => ('A'.toInt + (i - 10)).toChar - case _ => <> -} - /// %-encodes the characters for which `shouldEncode` returns true. /// Always %-encodes %. def urlencode(s: String){ shouldEncode: Char => Bool }: String = collectString { def encoded(c: Char): Unit = { do emit('%') val cc = c.toInt - if (cc >= 256){ panic("Unicode not supported") } // TODO + if (cc >= 256 || cc < 0){ panic("Unicode not supported") } // TODO do emit((cc / 16).hexDigit) do emit(mod(cc, 16).hexDigit) } @@ -24,57 +18,61 @@ def urlencode(s: String){ shouldEncode: Char => Bool }: String = collectString { } } -/// gen-delims as per RFC 3986 -def isGenDelim(c: Char): Bool = c match { - case ':' => true - case '/' => true - case '?' => true - case '#' => true - case '[' => true - case ']' => true - case '@' => true - case _ => false -} +namespace urichars { + /// gen-delims as per RFC 3986 + def isGenDelim(c: Char): Bool = c match { + case ':' => true + case '/' => true + case '?' => true + case '#' => true + case '[' => true + case ']' => true + case '@' => true + case _ => false + } -/// sub-delims as per RFC 3986 -def isSubDelim(c: Char): Bool = c match { - case '!' => true - case '$' => true - case '&' => true - case '\'' => true - case '(' => true - case ')' => true - case '*' => true - case '+' => true - case ',' => true - case ';' => true - case '=' => true - case _ => false + /// sub-delims as per RFC 3986 + def isSubDelim(c: Char): Bool = c match { + case '!' => true + case '$' => true + case '&' => true + case '\'' => true + case '(' => true + case ')' => true + case '*' => true + case '+' => true + case ',' => true + case ';' => true + case '=' => true + case _ => false + } + + /// Unreserved characters as per RFC 3986 + def isUnreserved(c: Char): Bool = c match { + case c and c.isAlphanumeric => true + case '-' => true + case '.' => true + case '_' => true + case '~' => true + case _ => false + } } -/// Encodes the string for urls using %-escapes +/// Encodes the string for urls using %-escapes, +/// escaping only url delimiters and space characters def urlencodePermissive(s: String): String = urlencode(s){ case '%' => true case ' ' => true - case c and c.isGenDelim || c.isSubDelim => true + case c and c.urichars::isGenDelim || c.urichars::isSubDelim => true case _ => false } -/// Unreserved characters as per RFC 3986 -def isUnreserved(c: Char): Bool = c match { - case c and c.isAlphanumeric => true - case '-' => true - case '.' => true - case '_' => true - case '~' => true - case _ => false -} /// Encodes the string for urls using %-escapes, /// escaping everything that is not an unreserved character /// as per RFC 3986. def urlencode(s: String): String = - urlencode(s){ c => not(c.isUnreserved) } + urlencode(s){ c => not(c.urichars::isUnreserved) } /// Decodes %-escapes in the given string @@ -93,112 +91,113 @@ def urldecode(s: String): String = collectString { } } +/// Builder style representation of the parts of a URI. +/// +/// Many consumers expect the operations to be called in-order. interface URIBuilder { + /// URI schema, e.g. http, https, ftp, ... def scheme(s: String): Unit + /// Userinfo part for, e.g. basic auth, e.g. user:letmein, ... def userinfo(a: String): Unit + /// Hostname (or non-host authority part), e.g. effekt-lang.org, [::1], 127.0.0.1, ... def host(h: String): Unit + /// Port, e.g. 80, 443, ... def port(p: Int): Unit + /// Path-part of the URI, commonly something like /index.html def path(p: String): Unit + /// Query part of the URI, e.g. q=12 for ...?q=12 def query(q: String): Unit + /// Fragment part of the URI, e.g. a1 for ...#a1 def fragment(f: String): Unit } -def parseScheme(): String / { Scan[Char], stop } = { - with collectString - do emit(readIf{ c => c.isAlphabetic }) - readWhile{ c => c.isAlphanumeric || c == '+' || c == '-' || c == '.' } -} - -def unread[A, R](c: A){ body: => R / Scan[A] }: R / Scan[A] = { - var read = false - try body() with Scan[A] { - def peek() = if(read) { resume{do peek()} } else { resume{ () => c } } - def skip() = if(read) { resume{do skip[A]()} } else { resume{read = true} } +namespace internal { + def parseScheme(): String / { Scan[Char], stop } = { + with collectString + do emit(readIf{ c => c.isAlphabetic }) + readWhile{ c => c.isAlphanumeric || c == '+' || c == '-' || c == '.' } } -} -def unread[R](s: String){ body: => R / Scan[Char] }: R / Scan[Char] = { - var pos = 0 - try body() with Scan[Char] { - def peek() = if (pos < s.length) { resume{s.unsafeCharAt(pos)} } else { resume{do peek()} } - def skip() = if (pos < s.length) { resume{pos = pos + 1} } else { resume{do skip[Char]()} } - } -} -def parseHostAndPort(): Unit / { URIBuilder, Scan[Char] } = { - try { - do peek[Char]() match { - case '[' => // IP-literal - // this is more permissive than the spec - do host(collectString{ readWhile{ c => c != ']' } } ++ "]") - readIf(']') - case _ => - do host(collectString{ readWhile{ - case '%' => true - case c and c.isUnreserved => true - case c and c.isSubDelim => true - case _ => false - } }) + def parseHostAndPort(): Unit / { URIBuilder, Scan[Char] } = { + try { + do peek[Char]() match { + case '[' => // IP-literal + // this is more permissive than the spec + do host(collectString{ readWhile{ c => c != ']' } } ++ "]") + readIf(']') + case _ => + do host(collectString{ readWhile{ + case '%' => true + case c and c.urichars::isUnreserved => true + case c and c.urichars::isSubDelim => true + case _ => false + } }) + } + } with stop { () => + do host("") + } + attempt{ + readIf(':') + do port(readInteger()) + }{ + // no port + () } - } with stop { () => - do host("") - } - attempt{ - readIf(':') - do port(readInteger()) - }{ - // no port - () } -} -def parseAuthority(): Unit / { URIBuilder, Scan[Char] } = { - // try parsing as userinfo@... - val fst = collectString{ readWhile{ - case '%' => true - case ':' => true - case c and c.isUnreserved => true - case c and c.isSubDelim => true - case _ => false - } } - attempt{ // was userinfo - readIf('@') - do userinfo(fst) - parseHostAndPort() - }{ // was not userinfo - with unread(fst) - parseHostAndPort() + def parseAuthority(): Unit / { URIBuilder, Scan[Char] } = { + // try parsing as userinfo@... + val fst = collectString{ readWhile{ + case '%' => true + case ':' => true + case c and c.urichars::isUnreserved => true + case c and c.urichars::isSubDelim => true + case _ => false + } } + attempt{ // was userinfo + readIf('@') + do userinfo(fst) + parseHostAndPort() + }{ // was not userinfo + with unread(fst) + parseHostAndPort() + } } -} -def parsePathQueryFragment(): Unit / { URIBuilder, Scan[Char], Exception[WrongFormat] } = { - do path(collectString{ readWhile{ - case '?' => false - case '#' => false - case _ => true - }}) - boundary{ - readIf('?') - do query(collectString{ readWhile{ c => c != '#' }}) - } - boundary{ - readIf('#') - do fragment(collectString{ readWhile[Char]{ c => true } }) + def parsePathQueryFragment(): Unit / { URIBuilder, Scan[Char], Exception[WrongFormat] } = { + do path(collectString{ readWhile{ + case '?' => false + case '#' => false + case _ => true + }}) + boundary{ + readIf('?') + do query(collectString{ readWhile{ c => c != '#' }}) + } + boundary{ + readIf('#') + do fragment(collectString{ readWhile[Char]{ c => true } }) + } } } +/// Parse a (non-relative) URI into its parts, causing the respective URIBuilder events. +/// Should at least parse all RFC3986-compliant URIs. +/// +/// authority is returned as `host` even when it isn't one. def parseURI(uri: String): Unit / { URIBuilder, Exception[WrongFormat] } = { try { with feed(uri) with scanner[Char] - do scheme(parseScheme()) + do scheme(internal::parseScheme()) readIf(':') val c = read[Char]() if (c == '/' and do peek[Char]() == '/'){ // starts with `//` readIf('/') - parseAuthority() + internal::parseAuthority() boundary{ do peek[Char]() match { case '?' => () @@ -207,10 +206,10 @@ def parseURI(uri: String): Unit / { URIBuilder, Exception[WrongFormat] } = { case _ => do raise(WrongFormat(), "Path must be empty or start with / if there is an authority component.") } } - parsePathQueryFragment() + internal::parsePathQueryFragment() } else { with unread(c) - parsePathQueryFragment() + internal::parsePathQueryFragment() } } with stop { () => do raise(WrongFormat(), "Could not parse URI") diff --git a/libraries/common/scanner.effekt b/libraries/common/scanner.effekt index 802e86a88..d2f824278 100644 --- a/libraries/common/scanner.effekt +++ b/libraries/common/scanner.effekt @@ -118,6 +118,25 @@ def readInteger(): Int / Scan[Char] = readDecimal() } +/// Handle Scan[A] in the body to first read the given character, +/// then continue with the outside scanner. +def unread[A, R](c: A){ body: => R / Scan[A] }: R / Scan[A] = { + var read = false + try body() with Scan[A] { + def peek() = if(read) { resume{do peek()} } else { resume{ () => c } } + def skip() = if(read) { resume{do skip[A]()} } else { resume{read = true} } + } +} + +/// Handle Scan[Char] in the body to first read the given string, +/// then continue with the outside scanner. +def unread[R](s: String){ body: => R / Scan[Char] }: R / Scan[Char] = { + var pos = 0 + try body() with Scan[Char] { + def peek() = if (pos < s.length) { resume{s.unsafeCharAt(pos)} } else { resume{do peek()} } + def skip() = if (pos < s.length) { resume{pos = pos + 1} } else { resume{do skip[Char]()} } + } +} namespace returning { From 76ff9cfa4a2a8f4016b5d71c75065b0f3ea25446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 14:00:18 +0200 Subject: [PATCH 13/30] More docs, default headers --- libraries/common/io/requests.effekt | 32 ++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 07b469138..0e9e0af5f 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -18,6 +18,7 @@ def each[T](it: EffektAsyncIterator[T]): Unit / emit[T] = { // Types // ----- +/// HTTP-style Protocol for requests to send. type Protocol { HTTP(); HTTPS() } def show(p: Protocol): String = p match { case HTTP() => "http" @@ -27,6 +28,8 @@ def defaultPort(p: Protocol): Int = p match { case HTTP() => 80 case HTTPS() => 443 } + +/// HTTP request method type Method { GET(); POST() } def show(m: Method): String = m match { case GET() => "GET" @@ -34,15 +37,24 @@ def show(m: Method): String = m match { } /// Interface to build HTTP requests. /// -/// Each of method, hostname, path, port, and protocol must be called at least once! +/// Each of method, hostname, path, and protocol must be called at least once! +/// See `uri` for a simple way to fill those. interface RequestBuilder { + /// HTTP request method to use def method(method: Method): Unit + /// (optional) user authentication to use, e.g. user:letmein def auth(authStr: String): Unit + /// hostname of the request, e.g. effekt-lang.org def hostname(host: String): Unit + /// path of the request, e.g. /index.html def path(path: String): Unit + /// port to use, defaults to the standard port of the protocol def port(port: Int): Unit + /// Protocol to use, e.g. HTTP(), HTTPS() def protocol(proto: Protocol): Unit + /// Add the given request header def header(key: String, value: String): Unit + /// Write to the body of the request. May only be called once. def body{ writer: => Unit / emit[Byte] }: Unit } /// Interface returned by HTTP requests. @@ -279,6 +291,7 @@ namespace jsWeb { } } +/// Sets the values in the `RequestBuilder` from a given URI string. def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { stringBuffer{ try parseURI(uri) with URIBuilder { @@ -300,12 +313,24 @@ def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { } } +def defaultHeaders(): Unit / RequestBuilder = { + if(internal::backend() != "js-web"){ + do header("user-agent", "Effekt script / Script written in the Effekt language") + } + // TODO should we add more? +} + namespace internal { extern pure def backend(): String = jsNode { "js-node" } jsWeb { "js-web" } } +/// Make a HTTP(S) request on the backend-specific implementation, +/// then pass the response to the second block parameter to process. +/// +/// The Request body is buffered, the response is streaming (as far +/// as the backend implementation allows). def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: R / Exception[RequestError] = internal::backend() match { case "js-node" => jsNode::request{body}{res} case "js-web" => jsWeb::request{body}{res} @@ -319,10 +344,7 @@ namespace example { with def res = request{ do method(GET()) uri("https://effekt-lang.org/") - //do hostname("effekt-lang.org") - //do header("user-agent", "Effekt/script") // dont use this on js-web - //do path("/") - // do port(443) // optional + defaultHeaders() } if(res.status() == 200){ println("OK") From 8abbe5bbff15d6805e5d63bb0ca5566bb0bb2825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 14:49:42 +0200 Subject: [PATCH 14/30] Use better UA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jiří Beneš --- libraries/common/io/requests.effekt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 0e9e0af5f..269416cbb 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -315,7 +315,7 @@ def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { def defaultHeaders(): Unit / RequestBuilder = { if(internal::backend() != "js-web"){ - do header("user-agent", "Effekt script / Script written in the Effekt language") + do header("User-Agent", "Effekt-io-requests/0.1.0") } // TODO should we add more? } From 9c9baa261e83623b139e4c2f6761d7ea2e5049ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 16:44:06 +0200 Subject: [PATCH 15/30] Add simple API variant --- libraries/common/io/requests.effekt | 101 +++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 269416cbb..b6fc9ddf3 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -332,13 +332,101 @@ namespace internal { /// The Request body is buffered, the response is streaming (as far /// as the backend implementation allows). def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: R / Exception[RequestError] = internal::backend() match { + // TODO use a proper dispatch after #448 is resolved case "js-node" => jsNode::request{body}{res} case "js-web" => jsWeb::request{body}{res} case _ => <> } +// Simple API +// ---------- +interface HttpClient { + def get[R](url: String, body: Option[String], headers: List[(String, String)]){ r: {ResponseReader} => R }: R / Exception[RequestError] + def post[R](url: String, body: Option[String], headers: List[(String, String)]){ r: {ResponseReader} => R }: R / Exception[RequestError] +} + +def httpClient[R]{ body: => R / HttpClient }: R = { + try body() with HttpClient { + def get[R](url, body, headers) = resume { {k} => + with on[WrongFormat].default{ do raise(RequestError(), "Malformed URL") } + request{ + do method(GET()) + uri(url) + defaultHeaders() + body.foreach { data => + do body{ + with encodeUTF8 + data.each + } + } + headers.foreach { case (k, v) => + do header(k, v) + } + }{k} + } + def post[R](url, body, headers) = resume { {k} => + with on[WrongFormat].default{ do raise(RequestError(), "Malformed URL") } + request{ + do method(POST()) + uri(url) + defaultHeaders() + body.foreach { data => + do body{ + with encodeUTF8 + data.each + } + } + headers.foreach { case (k, v) => + do header(k, v) + } + }{k} + } + } +} + +/// GET the given URL with the given body and headers, emitting the response characters. +/// Raises an exception if anything goes wrong, including non-200 responses. +def getStreaming(url: String, body: Option[String], headers: List[(String, String)]): Unit / { emit[Char], Exception[RequestError], HttpClient } = + do get(url, body, headers){ {r} => + if(r.status() != 200) { do raise(RequestError(), "Non-200 response") } + with source[Byte]{ r.body() } + with decodeUTF8 + exhaustively{ do emit(do read[Char]()) } + } +/// GET the given URL with the given body and headers, returning the response body. +/// Raises an exception if anything goes wrong, including non-200 responses. +def get(url: String, body: Option[String], headers: List[(String, String)]): String / { Exception[RequestError], HttpClient } = + collectString{ getStreaming(url, body, headers) } +/// GET the given URL, returning the response body. +/// Raises an exception if anything goes wrong, including non-200 responses. +def get(url: String): String / { Exception[RequestError], HttpClient } = get(url, None(), Nil()) +/// GET the given URL, emitting the response characters. +/// Raises an exception if anything goes wrong, including non-200 responses. +def getStreaming(url: String): Unit / { emit[Char], Exception[RequestError], HttpClient } = getStreaming(url, None(), Nil()) + +/// POST to the given URL with the given body and headers, emitting the response characters. +/// Raises an exception if anything goes wrong, including non-200 responses. +def postStreaming(url: String, body: Option[String], headers: List[(String, String)]): Unit / { emit[Char], Exception[RequestError], HttpClient } = + do post(url, body, headers){ {r} => + if(r.status() != 200) { do raise(RequestError(), "Non-200 response") } + with source[Byte]{ r.body() } + with decodeUTF8 + exhaustively{ do emit(do read[Char]()) } + } +/// POST to the given URL with the given body and headers, returning the response body. +/// Raises an exception if anything goes wrong, including non-200 responses. +def post(url: String, body: Option[String], headers: List[(String, String)]): String / { Exception[RequestError], HttpClient } = + collectString{ postStreaming(url, body, headers) } +/// POST to the given URL, returning the response body. +/// Raises an exception if anything goes wrong, including non-200 responses. +def post(url: String): String / { Exception[RequestError], HttpClient } = post(url, None(), Nil()) +/// POST to the given URL, emitting the response characters. +/// Raises an exception if anything goes wrong, including non-200 responses. +def post(url: String): Unit / { emit[Char], Exception[RequestError], HttpClient } = postStreaming(url, None(), Nil()) + namespace example { - def main() = { + + def lowLevelApi() = { with on[RequestError].panic with on[WrongFormat].panic with def res = request{ @@ -364,4 +452,15 @@ namespace example { println(res.status().show) } } + def simpleApi() = { + with on[RequestError].panic + with httpClient + println(get("https://effekt-lang.org")) + } + def main() = { + println("Low-level: ") + lowLevelApi() + println("Simple: ") + simpleApi() + } } \ No newline at end of file From 24ac7772d4199248be259f7e94b0abb77a489b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 17:06:59 +0200 Subject: [PATCH 16/30] Remember when the response body is done and then always return None --- libraries/common/io/requests.effekt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index b6fc9ddf3..b7fdeb079 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -180,6 +180,7 @@ namespace jsNode { def getBody(r: NativeResponse): EffektAsyncIterator[js::NativeBytes] = { val nextResolve = ref(promise::make()) + val done = ref(false) r.events.js::on(js::ev::data(), box { chunk => val waitingResolve = nextResolve.get().await() nextResolve.set(promise::make()) @@ -187,12 +188,17 @@ namespace jsNode { }) r.events.js::on(js::ev::end(), box { _ => val waitingResolve = nextResolve.get().await() - nextResolve.set(promise::make()) + //nextResolve.set(promise::make()) waitingResolve.resolve(None()) + done.set(true) }) EffektAsyncIterator(box { val resPromise = promise::make() - nextResolve.get().resolve(resPromise) + if (done.get()) { + resPromise.resolve(None()) + } else { + nextResolve.get().resolve(resPromise) + } resPromise }) } From e289c3620d92d34351a584fbece31b05ef36f04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 26 May 2025 19:55:39 +0200 Subject: [PATCH 17/30] Add minimal API for generating requests and parsing responses to/from byte streams --- libraries/common/io/requests.effekt | 174 ++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index b7fdeb079..2c0b373b0 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -4,6 +4,8 @@ import io/error import stringbuffer import bytearray import io/uri +import scanner +import char // Async iterables // --------------- @@ -297,6 +299,135 @@ namespace jsWeb { } } +namespace plain { + def constructRequest{ body: => Unit / RequestBuilder }: Unit / emit[Byte] = { + def crlf() = { + do emit('\n'.toInt.toByte) + do emit('\r'.toInt.toByte) + } + + with encodeUTF8 + var method = GET() + var port = 80 + var path = "/" + var headers = Nil[(String, String)]() + var reqBody: ByteArray = allocate(0) + try body() with RequestBuilder { + def protocol(p) = p match { + case HTTP() => () + case _ => panic("Only HTTP is supported by constructRequest") + } + def method(m) = resume(method = m) + def hostname(n) = <> + def auth(a) = <> + def port(p) = resume(port = p) + def path(p) = resume(path = p) + def header(k, v) = resume(headers = Cons((k, v), headers)) + def body() = resume{ {b} => reqBody = collectBytes{ b() } } + } + // start line + method.show.each + do emit(' ') + path.each + do emit(' ') + "HTTP/1.1".each + crlf() + // field-line's + headers.reverse.foreach{ case (k,v) => + k.each + do emit(':') + do emit(' ') + v.each + crlf() + } + crlf() + // body + reqBody.each + } + + def parseHTTPVersion(): (Int, Int) / { Scan[Char], Exception[WrongFormat] } = { + returning::expect("HTTP version"){ + readIf('H'); readIf('T'); readIf('T'); readIf('P') + readIf('/') + val maj = tryRead{ c => digitValue(c) } + readIf('.') + val min = tryRead{ c => digitValue(c) } + (maj, min) + } + } + + def isHTTPTokenChar(c: Char): Bool = c match { + case '!' => true + case '#' => true + case '$' => true + case '%' => true + case '&' => true + case '\'' => true + case '*' => true + case '+' => true + case '-' => true + case '.' => true + case '^' => true + case '_' => true + case '`' => true + case '|' => true + case '~' => true + case d and d.isAlphanumeric => true + case _ => false + } + + def parseHeaderLine(): (String, String) / { Scan[Char], Exception[WrongFormat] } = { + with returning::expect("HTTP Header line") + val k = collectString { readWhile { c => c.isHTTPTokenChar() } } + readIf(':') + skipWhile { c => c == ' ' || c == '\t' } + val v = collectString { readWhile { c => c != '\n' } } + skipWhile { c => c == ' ' || c == '\t' } + (k, v) + } + + def parseResponse[R]{ body: {ResponseReader} => R }: R / { read[Byte], Exception[WrongFormat] } = { + with decodeUTF8 + with returning::scanner[Char, R] + + var statusCode = -1 + var headers: List[(String, String)] = Nil() + expect("HTTP response"){ + val version = parseHTTPVersion() + readIf(' ') + statusCode = readInteger() + if (statusCode > 599 || statusCode < 100) { do raise(WrongFormat(), "Invalid status code") } + readIf(' ') + val reason = collectString{ readWhile{ c => c != '\n' } } + readIf('\n') + readIf('\r') + headers = collectList { + while(do peek[Char]() != '\n') { + do emit(parseHeaderLine()) + readIf('\n') + readIf('\r') + } + } + readIf('\n') + readIf('\r') + } + + try body{res} with res: ResponseReader { + def status() = resume(statusCode) + def getHeader(key) = { // TODO should be case-insensitive + var r = None() + headers.foreach { + case (k, v) and k == key => r = Some(v) + case _ => () + } + resume(r) + } + def body() = resume { exhaustively{ do emit(do read[Byte]()) } } + } + } +} + + /// Sets the values in the `RequestBuilder` from a given URI string. def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { stringBuffer{ @@ -463,10 +594,53 @@ namespace example { with httpClient println(get("https://effekt-lang.org")) } + def plainApi(): Unit = { + with on[WrongFormat].panic + println(collectString{ + with source[Byte]{ + plain::constructRequest{ + do method(GET()) + uri("http://effekt-lang.org") + defaultHeaders() + } + } + with decodeUTF8 + exhaustively{ do emit(do read[Char]()) } + }) + with source[Byte]{ + def crlf() = { + do emit('\n'.toInt.toByte) + do emit('\r'.toInt.toByte) + } + with encodeUTF8; + "HTTP/1.1 200 OK".each; + crlf() + "content-type: text/plain".each; + crlf() + crlf() + "Hello!".each + } + with def res = plain::parseResponse + println(res.status()) + println(res.getHeader("content-type").show{ x => x }) + with source[Byte]{ res.body() } + with decodeUTF8 + with stringBuffer + exhaustively{ + do read[Char]() match { + case '\n' => println(do flush()) + case c => + do write(c.show) + } + } + println(do flush()) + } def main() = { println("Low-level: ") lowLevelApi() println("Simple: ") simpleApi() + println("Plain: ") + plainApi() } } \ No newline at end of file From f07db72eeb5147a2dabe479c67808f09f6251094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Tue, 27 May 2025 11:46:44 +0200 Subject: [PATCH 18/30] Extract events to async stream logic into helper function --- libraries/common/io/requests.effekt | 51 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 2c0b373b0..e9f93df66 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -139,6 +139,33 @@ namespace js { def wait[T](em: EventEmitter, ev: Event[T]): T = em.unsafeWait(ev.name) + /// Returns the data emitted via dataEv as an async stream until endEv + def asyncStreamOf[D, E](em: EventEmitter, dataEv: Event[D], endEv: Event[E]): (EffektAsyncIterator[D], Promise[E]) = { + val nextResolve = ref(promise::make()) + val done = ref(false) + val endPromise = promise::make() + em.on(dataEv, box { chunk => + val waitingResolve = nextResolve.get().await() + nextResolve.set(promise::make()) + waitingResolve.resolve(Some(chunk)) + }) + em.on(endEv, box { e => + val waitingResolve = nextResolve.get().await() + waitingResolve.resolve(None()) + done.set(true) + endPromise.resolve(e) + }) + (EffektAsyncIterator(box { + val resPromise = promise::make() + if(done.get()) { + resPromise.resolve(None()) + } else { + nextResolve.get().resolve(resPromise) + } + resPromise + }), endPromise) + } + // Dict-like JS objects // -------------------- extern type JsObj @@ -181,28 +208,8 @@ namespace jsNode { extern pure def events(r: NativeResponse): js::EventEmitter = jsNode "${r}" def getBody(r: NativeResponse): EffektAsyncIterator[js::NativeBytes] = { - val nextResolve = ref(promise::make()) - val done = ref(false) - r.events.js::on(js::ev::data(), box { chunk => - val waitingResolve = nextResolve.get().await() - nextResolve.set(promise::make()) - waitingResolve.resolve(Some(chunk)) - }) - r.events.js::on(js::ev::end(), box { _ => - val waitingResolve = nextResolve.get().await() - //nextResolve.set(promise::make()) - waitingResolve.resolve(None()) - done.set(true) - }) - EffektAsyncIterator(box { - val resPromise = promise::make() - if (done.get()) { - resPromise.resolve(None()) - } else { - nextResolve.get().resolve(resPromise) - } - resPromise - }) + val (res, _) = js::asyncStreamOf(r.events, js::ev::data(), js::ev::end()) + res } extern io def isError(r: NativeResponse): Bool = From 9018798ed1be823a624199c467f2c5443db17ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Tue, 27 May 2025 14:49:58 +0200 Subject: [PATCH 19/30] Arguably more understandable asyncStreamOf with a queue of waiting nexts --- libraries/common/io/requests.effekt | 31 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index e9f93df66..8b36dd273 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -141,28 +141,37 @@ namespace js { /// Returns the data emitted via dataEv as an async stream until endEv def asyncStreamOf[D, E](em: EventEmitter, dataEv: Event[D], endEv: Event[E]): (EffektAsyncIterator[D], Promise[E]) = { - val nextResolve = ref(promise::make()) + val waitingNexts = ref(Nil[Promise[Option[D]]]()) + val nextWasCalled = ref(promise::make()) val done = ref(false) val endPromise = promise::make() em.on(dataEv, box { chunk => - val waitingResolve = nextResolve.get().await() - nextResolve.set(promise::make()) - waitingResolve.resolve(Some(chunk)) + loop { {l} => + nextWasCalled.get().await() + nextWasCalled.set(promise::make()) + waitingNexts.get() match { + case Cons(hd, tl) => + waitingNexts.set(tl) + hd.resolve(Some(chunk)) + l.break() + case Nil() => () + } + } }) em.on(endEv, box { e => - val waitingResolve = nextResolve.get().await() - waitingResolve.resolve(None()) done.set(true) + waitingNexts.get().foreach{ p => p.resolve(None()) } endPromise.resolve(e) }) (EffektAsyncIterator(box { - val resPromise = promise::make() - if(done.get()) { - resPromise.resolve(None()) + val res = promise::make() + if (done.get()) { + res.resolve(None()) } else { - nextResolve.get().resolve(resPromise) + waitingNexts.set(waitingNexts.get().append([res])) + nextWasCalled.get().resolve(()) } - resPromise + res }), endPromise) } From a92c84583fbaad1ed4d2dc35b040b37ac6747b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Tue, 27 May 2025 15:09:08 +0200 Subject: [PATCH 20/30] Minor fix --- libraries/common/io/requests.effekt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 8b36dd273..f016a9758 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -148,13 +148,13 @@ namespace js { em.on(dataEv, box { chunk => loop { {l} => nextWasCalled.get().await() - nextWasCalled.set(promise::make()) waitingNexts.get() match { case Cons(hd, tl) => waitingNexts.set(tl) hd.resolve(Some(chunk)) l.break() - case Nil() => () + case Nil() => + nextWasCalled.set(promise::make()) } } }) From 73222570a6a6251957b313c7c8b0bf6fbc40c4ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 12:22:38 +0200 Subject: [PATCH 21/30] Fix syntax errors due to named parameters #1070 --- libraries/common/io/requests.effekt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index f016a9758..4786b074a 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -237,7 +237,7 @@ namespace jsNode { def path(p) = resume(options.js::set("path", p)) def port(p) = resume(options.js::set("port", p)) def header(k, v) = resume(options.js::set("headers", k, v)) - def protocol(p) = resume(protocol = p) + def protocol(p) = { protocol = p; resume(()) } def body() = resume{ {wr} => reqBody = collectBytes{ wr } } } if(not(options.js::isSet("port"))) { options.js::set("port", protocol.defaultPort()) } @@ -293,12 +293,12 @@ namespace jsWeb { var port = None() try body() with RequestBuilder { def method(m) = resume(options.js::set("method", m.show)) - def auth(a) = resume(auth = Some(a)) - def hostname(n) = resume(hostname = n) - def path(p) = resume(path = p) - def port(p) = resume(port = Some(p)) + def auth(a) = resume((auth = Some(a))) + def hostname(n) = resume((hostname = n)) + def path(p) = resume((path = p)) + def port(p) = resume((port = Some(p))) def header(k, v) = resume(options.js::set("headers", k, v)) - def protocol(p) = resume(protocol = p) + def protocol(p) = resume((protocol = p)) def body() = resume{ {wr} => options.js::set("body", collectBytes{wr}) } } val authStr = auth.map{ a => a ++ "@" }.getOrElse{ "" } @@ -333,12 +333,12 @@ namespace plain { case HTTP() => () case _ => panic("Only HTTP is supported by constructRequest") } - def method(m) = resume(method = m) + def method(m) = resume((method = m)) def hostname(n) = <> def auth(a) = <> - def port(p) = resume(port = p) - def path(p) = resume(path = p) - def header(k, v) = resume(headers = Cons((k, v), headers)) + def port(p) = resume((port = p)) + def path(p) = resume((path = p)) + def header(k, v) = resume((headers = Cons((k, v), headers))) def body() = resume{ {b} => reqBody = collectBytes{ b() } } } // start line From 88c2182a9f8878daf865ec7b492f89cd0b8f092b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 12:22:55 +0200 Subject: [PATCH 22/30] Properly dispatch to implementations --- libraries/common/io/requests.effekt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 4786b074a..e2bc6d2ae 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -477,6 +477,10 @@ namespace internal { extern pure def backend(): String = jsNode { "js-node" } jsWeb { "js-web" } + + extern pure def ifNode[R]{ thnNode: => R }{ thnWeb: => R }: R = + jsNode { thnNode() } + jsWeb { thnWeb() } } /// Make a HTTP(S) request on the backend-specific implementation, @@ -484,12 +488,8 @@ namespace internal { /// /// The Request body is buffered, the response is streaming (as far /// as the backend implementation allows). -def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: R / Exception[RequestError] = internal::backend() match { - // TODO use a proper dispatch after #448 is resolved - case "js-node" => jsNode::request{body}{res} - case "js-web" => jsWeb::request{body}{res} - case _ => <> -} +def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: R / Exception[RequestError] = + internal::ifNode{ jsNode::request{body}{res} }{ jsWeb::request{body}{res} } // Simple API // ---------- From 8da18f536f2aabcd47d5eb72b0497117e17730b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 12:55:27 +0200 Subject: [PATCH 23/30] Prepare dispatch for more cases --- libraries/common/io/requests.effekt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index e2bc6d2ae..620612d4a 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -478,9 +478,13 @@ namespace internal { jsNode { "js-node" } jsWeb { "js-web" } - extern pure def ifNode[R]{ thnNode: => R }{ thnWeb: => R }: R = + extern pure def ifJSNode[R]{ thnNode: => R }{ els: => R }: R = jsNode { thnNode() } + default { els() } + extern pure def ifJSWeb[R]{ thnWeb: => R }{ els: => R }: R = jsWeb { thnWeb() } + default { els() } + extern pure def unsupported[R](): R = } /// Make a HTTP(S) request on the backend-specific implementation, @@ -489,7 +493,9 @@ namespace internal { /// The Request body is buffered, the response is streaming (as far /// as the backend implementation allows). def request[R]{ body: => Unit / RequestBuilder }{ res: {ResponseReader} => R }: R / Exception[RequestError] = - internal::ifNode{ jsNode::request{body}{res} }{ jsWeb::request{body}{res} } + with internal::ifJSNode{ jsNode::request{body}{res} } + with internal::ifJSWeb{ jsWeb::request{body}{res} } + internal::unsupported[R]() // Simple API // ---------- From bb07dc19b95cf12f0dbf04fc65f30a5d92740431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 13:11:53 +0200 Subject: [PATCH 24/30] Dispatch properly for the UA on jsweb --- libraries/common/io/requests.effekt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 620612d4a..5e7a11f49 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -467,7 +467,9 @@ def uri(uri: String): Unit / { RequestBuilder, Exception[WrongFormat] } = { } def defaultHeaders(): Unit / RequestBuilder = { - if(internal::backend() != "js-web"){ + internal::ifJSWeb{ + () // do not set a user agent + }{ do header("User-Agent", "Effekt-io-requests/0.1.0") } // TODO should we add more? From 09d7de9012d4ea238886907efee5d9526f243bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 13:50:04 +0200 Subject: [PATCH 25/30] Improve plain api, add decoder for chunked encoding --- libraries/common/io/requests.effekt | 54 ++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 5e7a11f49..e063819fa 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -318,8 +318,8 @@ namespace jsWeb { namespace plain { def constructRequest{ body: => Unit / RequestBuilder }: Unit / emit[Byte] = { def crlf() = { - do emit('\n'.toInt.toByte) do emit('\r'.toInt.toByte) + do emit('\n'.toInt.toByte) } with encodeUTF8 @@ -397,11 +397,49 @@ namespace plain { val k = collectString { readWhile { c => c.isHTTPTokenChar() } } readIf(':') skipWhile { c => c == ' ' || c == '\t' } - val v = collectString { readWhile { c => c != '\n' } } + val v = collectString { readWhile { c => c != '\r' } } skipWhile { c => c == ' ' || c == '\t' } (k, v) } + /// Decode a chunked transfer coding as per RFC 2616. + /// Emits the contents as bytes, and headers from the trailer. + def unchunk(): Int / { emit[Byte], emit[(String, String)], read[Byte], Exception[WrongFormat] } = { + with decodeUTF8 + with returning::scanner[Char, Int] + var length = 0 + loop { {l} => + // chunk header + val size = returning::expect("chunk header"){ + val size = collectString{ readWhile{ c => c.isHexDigit } }.toInt(16) + val extensions = collectString{ readWhile{ c => c != '\r' } } // ignored + if(size == 0){ l.break() } + readIf('\r') + readIf('\n') + size + } + // chunk body + length = length + size + repeat(size){ + try { + do emit(do read[Byte]()) + } with stop { () => do raise(WrongFormat(), "Premature end of chunk in chunked encoding") } + } + } + // trailer headers + expect("Trailers or end of response"){ + while(do peek[Char]() != '\r') { + do emit(parseHeaderLine()) + readIf('\r') + readIf('\n') + } + readIf('\r') + readIf('\n') + } + // return total length + length + } + def parseResponse[R]{ body: {ResponseReader} => R }: R / { read[Byte], Exception[WrongFormat] } = { with decodeUTF8 with returning::scanner[Char, R] @@ -414,18 +452,18 @@ namespace plain { statusCode = readInteger() if (statusCode > 599 || statusCode < 100) { do raise(WrongFormat(), "Invalid status code") } readIf(' ') - val reason = collectString{ readWhile{ c => c != '\n' } } - readIf('\n') + val reason = collectString{ readWhile{ c => c != '\r' } } readIf('\r') + readIf('\n') headers = collectList { - while(do peek[Char]() != '\n') { + while(do peek[Char]() != '\r') { do emit(parseHeaderLine()) - readIf('\n') readIf('\r') + readIf('\n') } } - readIf('\n') readIf('\r') + readIf('\n') } try body{res} with res: ResponseReader { @@ -633,8 +671,8 @@ namespace example { }) with source[Byte]{ def crlf() = { - do emit('\n'.toInt.toByte) do emit('\r'.toInt.toByte) + do emit('\n'.toInt.toByte) } with encodeUTF8; "HTTP/1.1 200 OK".each; From a0fc6a2ef956e07297fbc719b156365fdcd468f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 14:12:18 +0200 Subject: [PATCH 26/30] Add TODO --- libraries/common/io/requests.effekt | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index e063819fa..d8ecd953a 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -392,6 +392,7 @@ namespace plain { case _ => false } + // TODO line continuations def parseHeaderLine(): (String, String) / { Scan[Char], Exception[WrongFormat] } = { with returning::expect("HTTP Header line") val k = collectString { readWhile { c => c.isHTTPTokenChar() } } From 9406e8819fb9c96cfb7489cc12726aefce0c323f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 14:24:23 +0200 Subject: [PATCH 27/30] Support multiline header fields --- libraries/common/io/requests.effekt | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index d8ecd953a..5436f57f2 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -391,15 +391,28 @@ namespace plain { case d and d.isAlphanumeric => true case _ => false } + def isLWS(c: Char): Bool = c match { + case '\t' => true + case ' ' => true + case _ => false + } - // TODO line continuations def parseHeaderLine(): (String, String) / { Scan[Char], Exception[WrongFormat] } = { with returning::expect("HTTP Header line") val k = collectString { readWhile { c => c.isHTTPTokenChar() } } readIf(':') - skipWhile { c => c == ' ' || c == '\t' } - val v = collectString { readWhile { c => c != '\r' } } - skipWhile { c => c == ' ' || c == '\t' } + skipWhile { c => c.isLWS } + val v = collectString { + readWhile { c => c != '\r' } + readIf('\r'); readIf('\n') + while(do peek[Char]().isLWS) { + // continuation line + skipWhile { c => c.isLWS } + do emit('\n') + readWhile { c => c != '\r'} + readIf('\r'); readIf('\n') + } + } (k, v) } @@ -431,8 +444,6 @@ namespace plain { expect("Trailers or end of response"){ while(do peek[Char]() != '\r') { do emit(parseHeaderLine()) - readIf('\r') - readIf('\n') } readIf('\r') readIf('\n') @@ -459,8 +470,6 @@ namespace plain { headers = collectList { while(do peek[Char]() != '\r') { do emit(parseHeaderLine()) - readIf('\r') - readIf('\n') } } readIf('\r') @@ -680,12 +689,18 @@ namespace example { crlf() "content-type: text/plain".each; crlf() + "x-multiline-test-header: This is the first line".each + crlf() + " and this is the second line".each + crlf() crlf() "Hello!".each } with def res = plain::parseResponse println(res.status()) println(res.getHeader("content-type").show{ x => x }) + println("X-Multiline-Test-Header:") + println(res.getHeader("x-multiline-test-header").show{ x => x }) with source[Byte]{ res.body() } with decodeUTF8 with stringBuffer From a3e6fa41362660f955434f97e01524c678b89594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 14:52:49 +0200 Subject: [PATCH 28/30] Minor improvements --- libraries/common/io/requests.effekt | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 5436f57f2..2dfab75d1 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -316,6 +316,7 @@ namespace jsWeb { } namespace plain { + /// Encode a request into its byte representation, will always use HTTP/1.1 def constructRequest{ body: => Unit / RequestBuilder }: Unit / emit[Byte] = { def crlf() = { do emit('\r'.toInt.toByte) @@ -452,6 +453,8 @@ namespace plain { length } + /// Parse a HTTP response from its byte representation. + /// Does not decode transfer encodings, will return everything as the body until eof (unless stopped first) def parseResponse[R]{ body: {ResponseReader} => R }: R / { read[Byte], Exception[WrongFormat] } = { with decodeUTF8 with returning::scanner[Char, R] @@ -476,16 +479,18 @@ namespace plain { readIf('\n') } + def getHeader(key: String) = { + var r = None() + headers.foreach { + case (k, v) and k == key => r = Some(v) + case _ => () + } + r + } + try body{res} with res: ResponseReader { def status() = resume(statusCode) - def getHeader(key) = { // TODO should be case-insensitive - var r = None() - headers.foreach { - case (k, v) and k == key => r = Some(v) - case _ => () - } - resume(r) - } + def getHeader(key) = resume(getHeader(key)) def body() = resume { exhaustively{ do emit(do read[Byte]()) } } } } From abca1494804914d264a622565eb47e0487efec46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 15:19:57 +0200 Subject: [PATCH 29/30] toLower/toUpper for ASCII --- libraries/common/char.effekt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/libraries/common/char.effekt b/libraries/common/char.effekt index f22b3ca09..599e4ce95 100644 --- a/libraries/common/char.effekt +++ b/libraries/common/char.effekt @@ -79,9 +79,21 @@ def isASCII(c: Char): Bool = { c.toInt < 128 } /// Checks if a given character is an ASCII lower alphabetic character def isLower(c: Char): Bool = { c >= 'a' && c <= 'z' } +/// Lower-cases the given ASCII character +def toLower(c: Char): Char = + if (c >= 'A' && c <= 'Z') { + ('a'.toInt + (c.toInt - 'A'.toInt)).toChar + } else { c } + /// Checks if a given character is an ASCII upper alphabetic character def isUpper(c: Char): Bool = { c >= 'A' && c <= 'Z' } +/// Upper-cases the given ASCII character +def toUpper(c: Char): Char = + if (c >= 'a' && c <= 'z') { + ('A'.toInt + (c.toInt - 'a'.toInt)).toChar + } else { c } + /// Checks if a given character is an ASCII alphabetic or numeric character def isAlphanumeric(c: Char): Bool = isDigit(c) || isLower(c) || isUpper(c) From cf88469bdcb1223e6a9dd1ce5b12fc125edf866d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcial=20Gai=C3=9Fert?= Date: Mon, 28 Jul 2025 15:36:12 +0200 Subject: [PATCH 30/30] Automatically decode chunked encoding --- libraries/common/io/requests.effekt | 58 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/libraries/common/io/requests.effekt b/libraries/common/io/requests.effekt index 2dfab75d1..c360fe381 100644 --- a/libraries/common/io/requests.effekt +++ b/libraries/common/io/requests.effekt @@ -440,6 +440,10 @@ namespace plain { do emit(do read[Byte]()) } with stop { () => do raise(WrongFormat(), "Premature end of chunk in chunked encoding") } } + expect("CRLF at end of chunk"){ + readIf('\r') + readIf('\n') + } } // trailer headers expect("Trailers or end of response"){ @@ -454,7 +458,7 @@ namespace plain { } /// Parse a HTTP response from its byte representation. - /// Does not decode transfer encodings, will return everything as the body until eof (unless stopped first) + /// Decodes chunked encoding and stops after Content-Length, if provided. def parseResponse[R]{ body: {ResponseReader} => R }: R / { read[Byte], Exception[WrongFormat] } = { with decodeUTF8 with returning::scanner[Char, R] @@ -479,19 +483,54 @@ namespace plain { readIf('\n') } + def lower(s: String) = collectString{ for{ s.each }{ c => do emit(c.toLower) } } + def getHeader(key: String) = { var r = None() headers.foreach { - case (k, v) and k == key => r = Some(v) + case (k, v) and k.lower == key => r = Some(v) case _ => () } r } - try body{res} with res: ResponseReader { - def status() = resume(statusCode) - def getHeader(key) = resume(getHeader(key)) - def body() = resume { exhaustively{ do emit(do read[Byte]()) } } + val transferEnc = getHeader("transfer-encoding") + val contentLength = getHeader("content-length") + + (transferEnc, contentLength) match { + case (_, Some(l)) and l.toInt is len => // use provided content-length + try body{res} with res: ResponseReader { + def status() = resume(statusCode) + def getHeader(key) = resume(getHeader(key.lower)) + def body() = resume { limit[Byte](len){ exhaustively{ do emit(do read[Byte]()) } } } + } + case (Some(enc), _) and enc == "chunked" => // decode chunked encoding // TODO support multiple and remove + var finalLength = None() + try body{res} with res: ResponseReader { + def status() = resume(statusCode) + def getHeader(key) = { + if(key.lower == "content-length") { + resume(finalLength) + } else if(key.lower == "transfer-encoding") { + resume(None()) + } else { + resume(getHeader(key.lower)) + } + } + def body() = { + resume { + headers = headers.append(collectList[(String, String)]{ + finalLength = Some(unchunk().show) + }) + } + } + } + case (_, _) => // read until end of input + try body{res} with res: ResponseReader { + def status() = resume(statusCode) + def getHeader(key) = resume(getHeader(key.lower)) + def body() = resume { exhaustively{ do emit(do read[Byte]()) } } + } } } } @@ -694,12 +733,19 @@ namespace example { crlf() "content-type: text/plain".each; crlf() + "Transfer-Encoding: chunked".each + crlf() "x-multiline-test-header: This is the first line".each crlf() " and this is the second line".each crlf() crlf() + "6".each + crlf() "Hello!".each + crlf() + "0".each + crlf() } with def res = plain::parseResponse println(res.status())