From 5d86b37740bb398ddfaadaa6122a3b7d2b7e499d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Bene=C5=A1?= Date: Sun, 1 Dec 2024 00:16:10 +0100 Subject: [PATCH 1/3] WIP: HTTP clients in JS (send requests, get responses) --- libraries/common/io/client.effekt | 161 ++++++++++++++++++++++++++++++ libraries/common/option.effekt | 5 + 2 files changed, 166 insertions(+) create mode 100644 libraries/common/io/client.effekt diff --git a/libraries/common/io/client.effekt b/libraries/common/io/client.effekt new file mode 100644 index 000000000..dca159e01 --- /dev/null +++ b/libraries/common/io/client.effekt @@ -0,0 +1,161 @@ +/// HTTP Clients: send a request via HTTP, get a response. +module io/client + +import bytearray +import io + +// TODO: mutable (native) map of headers instead? +// TODO: body a promise instead? (we get it as a promise anwyays, eh?) +record Response(status: Int, headers: List[(String, String)], body: String) + +interface HttpClient { + def get(url: String, body: Option[String]): Response + def post(url: String, body: Option[String]): Response +} + +def client[R] { program: () => R / HttpClient }: R = try { + program() +} with HttpClient { + def get(url, body) = resume(js::get(url, body)) + def post(url, body) = resume(js::post(url, body)) +} + +def get(url: String): Response / HttpClient = + do get(url, None()) + +def get(url: String, body: String): Response / HttpClient = + do get(url, Some(body)) + +def post(url: String): Response / HttpClient = + do post(url, None()) + +def post(url: String, body: String): Response / HttpClient = + do post(url, Some(body)) + +namespace js { + extern jsNode """ + const http = require('node:http'); + const https = require('node:https'); + const url = require('node:url'); + + function nodeRequest(method, requestUrl, headers, body) { + return new Promise((resolve, reject) => { + const parsedUrl = url.parse(requestUrl); // TODO: Deprecated? + const requestOptions = { + method: method, + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, + headers: headers + }; + + const clientModule = parsedUrl.protocol === 'https:' ? https : http; + + const req = clientModule.request(requestOptions, (res) => { + let responseBody = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + responseBody += chunk; + }); + res.on('end', () => { + resolve({ + status: res.statusCode, + headers: res.headers, + body: responseBody + }); + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + if (body) { + req.write(body); + } + req.end(); + }); + } + """ + + // Browser fetch API implementation + // TODO: Make this work properly! + extern jsWeb """ + function browserRequest(method, url, headers, body) { + const requestOptions = { + method: method, + headers: headers + }; + + if (body) { + requestOptions.body = body; + } + + return fetch(url, requestOptions).then(async (response) => { + const headers = {}; // TODO: do we really want to do this conversion? + for (let [key, value] of response.headers.entries()) { + headers[key] = value; + } + + return { + status: response.status, + headers: headers, + body: await response.text() + }; + }); + } + """ + + extern type NativeResponse + // js "{ status: Int, headers: Map[String, String], body: String }" + + extern pure def status(r: NativeResponse): Int = + js "${r}.status" + + extern type NativeHeader + // js "[{key: String, value: String}]" + + extern pure def unsafeHeaders(r: NativeResponse): Array[NativeHeader] = + js "Object.entries(${r}.headers)" + + extern pure def headerKey(h: NativeHeader): String = js"${h}[0]" + extern pure def headerValue(h: NativeHeader): String = js"${h}[1]" + + def headers(r: NativeResponse): List[(String, String)] = + r.unsafeHeaders.toList.map { h => (h.headerKey, h.headerValue) } + + extern pure def body(r: NativeResponse): String = + js "${r}.body" + + def toResponse(r: NativeResponse): Response = + Response(r.status, r.headers, r.body) + + + // Cross-platform request function with different implementations for Node.js and browser + extern async def request( + method: String, + url: String, + headers: List[(String, String)], + unsafeBody: String // really: 'String | undefined' + ): NativeResponse = + jsNode "$effekt.capture(callback => nodeRequest(${method}, ${url}, ${headers}, ${unsafeBody}).then(callback))" + jsWeb "$effekt.capture(callback => browserRequest(${method}, ${url}, ${headers}, ${unsafeBody}).then(callback))" + + def get(url: String, body: Option[String]): Response = + js::request("GET", url, [], optionToUndefined(body)).toResponse + + def post(url: String, body: Option[String]): Response = + js::request("POST", url, [], optionToUndefined(body)).toResponse +} + +namespace examples { + +def main() = { + with client; + + val response = get("https://effekt-lang.org") + println("Status: " ++ response.status.show) + println("Headers: " ++ response.headers.map { case (k, v) => k ++ " -> " ++ v }.show) + println("Body: " ++ response.body.substring(0, 128)) + } +} \ No newline at end of file diff --git a/libraries/common/option.effekt b/libraries/common/option.effekt index 7908c6f16..4d20f88c8 100644 --- a/libraries/common/option.effekt +++ b/libraries/common/option.effekt @@ -55,6 +55,11 @@ def option[A, E](proxy: on[E]) { p: => A / Exception[E] }: Option[A] = def undefinedToOption[A](value: A): Option[A] = if (value.isUndefined) { None() } else { Some(value) } +def optionToUndefined[A](value: Option[A]): A = value match { + case Some(value) => value + case None() => undefined() +} + // Show Instances // -------------- From 50b23e46dee59307b8874608fc4e56c393246d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Bene=C5=A1?= Date: Sun, 1 Dec 2024 00:23:17 +0100 Subject: [PATCH 2/3] More WIP work --- libraries/common/io/client.effekt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/libraries/common/io/client.effekt b/libraries/common/io/client.effekt index dca159e01..aa85d7a5f 100644 --- a/libraries/common/io/client.effekt +++ b/libraries/common/io/client.effekt @@ -124,6 +124,10 @@ namespace js { def headers(r: NativeResponse): List[(String, String)] = r.unsafeHeaders.toList.map { h => (h.headerKey, h.headerValue) } + // TODO: test, use + extern pure def makeNativeHeader(key: String, value: String): NativeHeader = + js"[${key}, ${value}]" + extern pure def body(r: NativeResponse): String = js "${r}.body" @@ -131,21 +135,22 @@ namespace js { Response(r.status, r.headers, r.body) - // Cross-platform request function with different implementations for Node.js and browser + /// Make a HTTP request extern async def request( method: String, url: String, - headers: List[(String, String)], + // headers: Array[NativeHeader], unsafeBody: String // really: 'String | undefined' ): NativeResponse = - jsNode "$effekt.capture(callback => nodeRequest(${method}, ${url}, ${headers}, ${unsafeBody}).then(callback))" - jsWeb "$effekt.capture(callback => browserRequest(${method}, ${url}, ${headers}, ${unsafeBody}).then(callback))" + jsNode "$effekt.capture(callback => nodeRequest(${method}, ${url}, {}, ${unsafeBody}).then(callback))" + jsWeb "$effekt.capture(callback => browserRequest(${method}, ${url}, {}, ${unsafeBody}).then(callback))" + // TODO: Allow users to specify 'headers' def get(url: String, body: Option[String]): Response = - js::request("GET", url, [], optionToUndefined(body)).toResponse + js::request("GET", url, optionToUndefined(body)).toResponse def post(url: String, body: Option[String]): Response = - js::request("POST", url, [], optionToUndefined(body)).toResponse + js::request("POST", url, optionToUndefined(body)).toResponse } namespace examples { From ed83919d5ddccbd1bcae9ac29bc4f35972162feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Bene=C5=A1?= Date: Sun, 1 Dec 2024 00:49:04 +0100 Subject: [PATCH 3/3] Headers --- libraries/common/io/client.effekt | 50 ++++++++++++++++++------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/libraries/common/io/client.effekt b/libraries/common/io/client.effekt index aa85d7a5f..f6f0b2c03 100644 --- a/libraries/common/io/client.effekt +++ b/libraries/common/io/client.effekt @@ -9,28 +9,40 @@ import io record Response(status: Int, headers: List[(String, String)], body: String) interface HttpClient { - def get(url: String, body: Option[String]): Response - def post(url: String, body: Option[String]): Response + def get(url: String, body: Option[String], headers: List[(String, String)]): Response + def post(url: String, body: Option[String], headers: List[(String, String)]): Response } def client[R] { program: () => R / HttpClient }: R = try { program() } with HttpClient { - def get(url, body) = resume(js::get(url, body)) - def post(url, body) = resume(js::post(url, body)) + def get(url, body, headers) = resume(js::get(url, body, headers)) + def post(url, body, headers) = resume(js::post(url, body, headers)) } def get(url: String): Response / HttpClient = - do get(url, None()) + do get(url, None(), []) def get(url: String, body: String): Response / HttpClient = - do get(url, Some(body)) + do get(url, Some(body), []) + +def get(url: String, headers: List[(String, String)]): Response / HttpClient = + do get(url, None(), headers) + +def get(url: String, body: String, headers: List[(String, String)]): Response / HttpClient = + do get(url, Some(body), headers) def post(url: String): Response / HttpClient = - do post(url, None()) + do post(url, None(), []) def post(url: String, body: String): Response / HttpClient = - do post(url, Some(body)) + do post(url, Some(body), []) + +def post(url: String, headers: List[(String, String)]): Response / HttpClient = + do post(url, None(), headers) + +def post(url: String, body: String, headers: List[(String, String)]): Response / HttpClient = + do post(url, Some(body), headers) namespace js { extern jsNode """ @@ -48,6 +60,7 @@ namespace js { path: parsedUrl.path, headers: headers }; + console.log(requestOptions); const clientModule = parsedUrl.protocol === 'https:' ? https : http; @@ -124,7 +137,6 @@ namespace js { def headers(r: NativeResponse): List[(String, String)] = r.unsafeHeaders.toList.map { h => (h.headerKey, h.headerValue) } - // TODO: test, use extern pure def makeNativeHeader(key: String, value: String): NativeHeader = js"[${key}, ${value}]" @@ -139,26 +151,24 @@ namespace js { extern async def request( method: String, url: String, - // headers: Array[NativeHeader], + headers: Array[NativeHeader], unsafeBody: String // really: 'String | undefined' ): NativeResponse = - jsNode "$effekt.capture(callback => nodeRequest(${method}, ${url}, {}, ${unsafeBody}).then(callback))" - jsWeb "$effekt.capture(callback => browserRequest(${method}, ${url}, {}, ${unsafeBody}).then(callback))" + jsNode "$effekt.capture(callback => nodeRequest(${method}, ${url}, Object.fromEntries(${headers}), ${unsafeBody}).then(callback))" + jsWeb "$effekt.capture(callback => browserRequest(${method}, ${url}, Object.fromEntries(${headers}), ${unsafeBody}).then(callback))" - // TODO: Allow users to specify 'headers' - def get(url: String, body: Option[String]): Response = - js::request("GET", url, optionToUndefined(body)).toResponse + def get(url: String, body: Option[String], headers: List[(String, String)]): Response = + js::request("GET", url, headers.map { case (k, v) => makeNativeHeader(k, v) }.fromList, optionToUndefined(body)).toResponse - def post(url: String, body: Option[String]): Response = - js::request("POST", url, optionToUndefined(body)).toResponse + def post(url: String, body: Option[String], headers: List[(String, String)]): Response = + js::request("POST", url, headers.map { case (k, v) => makeNativeHeader(k, v) }.fromList, optionToUndefined(body)).toResponse } namespace examples { - -def main() = { + def main() = { with client; - val response = get("https://effekt-lang.org") + val response = get("https://effekt-lang.org", [("User-Agent", "Effekt/dev")]) println("Status: " ++ response.status.show) println("Headers: " ++ response.headers.map { case (k, v) => k ++ " -> " ++ v }.show) println("Body: " ++ response.body.substring(0, 128))