diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3d46fbc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.0.0 + - uses: gleam-lang/setup-erlang@v1.1.2 + with: + otp-version: "23.2" + - uses: gleam-lang/setup-gleam@v1.0.2 + with: + gleam-version: "0.18.0" + - run: gleam format --check src test + - run: gleam deps download + - run: gleam test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..170cca9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +build +erl_crash.dump diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e32a16 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# nerf + +Gleam bindings to the Erlang [gun][gun] HTTP/1.1, HTTP/2 and Websocket client. + +[gun]: https://hex.pm/packages/gun + +Currently this library is rather basic and only supports a portion of the +websocket API. Let us know if you need more. + +## Usage + +This package can be added to your Gleam project like so. + +```sh +gleam add nerf +``` + +Then use it in your Gleam application. + +```rust +import nerf/websocket + +pub fn main() { + // Connect + assert Ok(conn) = websocket.connect("example.com", "/ws", 8080, []) + + // Send some messages + websocket.send(conn, "Hello") + websocket.send(conn, "World") + + // Receive some messages + assert Ok(Text("Hello")) = websocket.receive(conn, 500) + assert Ok(Text("World")) = websocket.receive(conn, 500) + + // Close the connection + websocket.close(conn) +} +``` + +## Testing this library + +```sh +podman run --rm --detach -p 8080:8080 --name echo jmalloc/echo-server +gleam test +``` diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..218f5bb --- /dev/null +++ b/gleam.toml @@ -0,0 +1,15 @@ +name = "nerf" +version = "0.1.0" +licences = ["Apache-2.0"] +description = "Gleam bindings to the gun HTTP/1.1, HTTP/2 and Websocket client" +repository = { type = "github", user = "lpil", repo = "nerf" } +links = [{ title = "Website", href = "https://gleam.run" }] + +[dependencies] +gleam_stdlib = "~> 0.18" +gun = "> 1.3.0 and < 3.0.0" +gleam_http = "~> 2.1" +gleam_erlang = "~> 0.5" + +[dev-dependencies] +gleeunit = "~> 0.5" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..6edd94e --- /dev/null +++ b/manifest.toml @@ -0,0 +1,18 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "cowlib", version = "2.7.3", build_tools = ["rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "1E1A3D176D52DAEBBECBBCDFD27C27726076567905C2A9D7398C54DA9D225761" }, + { name = "gleam_erlang", version = "0.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "6DC8D506127BF332A48DBE1A286ACAAA0F2C7BF84B33BD9E6F3A8002EC9F649C" }, + { name = "gleam_http", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2DF02A2BD551B590D92ADA90A67CDEE60EB4BAD48B5EE10A9AB4CE180CBCE36" }, + { name = "gleam_stdlib", version = "0.18.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2938F996BBB25D75E973226846CDDFA33AB5590AFC8A9D043A356EA85272510D" }, + { name = "gleeunit", version = "0.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7FA7477D930178C1E59519DBDB5E086BE3A6B65F015B67DA94D30A323062154" }, + { name = "gun", version = "1.3.3", build_tools = ["rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "3106CE167F9C9723F849E4FB54EA4A4D814E3996AE243A1C828B256E749041E0" }, +] + +[requirements] +gleam_erlang = "~> 0.5" +gleam_http = "~> 2.1" +gleam_stdlib = "~> 0.18" +gleeunit = "~> 0.5" +gun = "> 1.3.0 and < 3.0.0" diff --git a/src/nerf.gleam b/src/nerf.gleam new file mode 100644 index 0000000..a2fa1b7 --- /dev/null +++ b/src/nerf.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() { + io.println("Hello from nerf!") +} diff --git a/src/nerf/gun.gleam b/src/nerf/gun.gleam new file mode 100644 index 0000000..bed4cf7 --- /dev/null +++ b/src/nerf/gun.gleam @@ -0,0 +1,43 @@ +//// Low level bindings to the gun API. You typically do not need to use this. +//// Prefer the other modules in this library. + +import gleam/http.{Header} +import gleam/erlang/charlist.{Charlist} +import gleam/dynamic.{Dynamic} + +pub external type StreamReference + +pub external type ConnectionPid + +pub fn open(host: String, port: Int) -> Result(ConnectionPid, Dynamic) { + open_erl(charlist.from_string(host), port) +} + +pub external fn open_erl(Charlist, Int) -> Result(ConnectionPid, Dynamic) = + "gun" "open" + +pub external fn await_up(ConnectionPid) -> Result(Dynamic, Dynamic) = + "gun" "await_up" + +pub external fn ws_upgrade( + ConnectionPid, + String, + List(Header), +) -> StreamReference = + "gun" "ws_upgrade" + +pub type Frame { + Close + Text(String) + Binary(BitString) +} + +external type OkAtom + +external fn ws_send_erl(ConnectionPid, Frame) -> OkAtom = + "gun" "ws_send" + +pub fn ws_send(pid: ConnectionPid, frame: Frame) -> Nil { + ws_send_erl(pid, frame) + Nil +} diff --git a/src/nerf/websocket.gleam b/src/nerf/websocket.gleam new file mode 100644 index 0000000..f04f05e --- /dev/null +++ b/src/nerf/websocket.gleam @@ -0,0 +1,63 @@ +import gleam/uri.{Uri} +import gleam/http.{Header} +import gleam/dynamic.{Dynamic} +import gleam/result +import nerf/gun.{ConnectionPid, StreamReference} + +pub opaque type Connection { + Connection(ref: StreamReference, pid: ConnectionPid) +} + +pub type Frame { + Close + Text(String) + Binary(BitString) +} + +pub fn connect( + hostname: String, + path: String, + on port: Int, + with headers: List(Header), +) -> Result(Connection, ConnectError) { + try pid = + gun.open(hostname, port) + |> result.map_error(ConnectionFailed) + try _ = + gun.await_up(pid) + |> result.map_error(ConnectionFailed) + + // Upgrade to websockets + let ref = gun.ws_upgrade(pid, path, headers) + let conn = Connection(pid: pid, ref: ref) + try _ = + await_upgrade(conn, 1000) + |> result.map_error(ConnectionFailed) + + // TODO: handle upgrade failure + // https://ninenines.eu/docs/en/gun/2.0/guide/websocket/ + // https://ninenines.eu/docs/en/gun/1.2/manual/gun_error/ + // https://ninenines.eu/docs/en/gun/1.2/manual/gun_response/ + Ok(conn) +} + +pub fn send(to conn: Connection, this message: String) -> Nil { + gun.ws_send(conn.pid, gun.Text(message)) +} + +pub external fn receive(from: Connection, within: Int) -> Result(Frame, Nil) = + "nerf_ffi" "ws_receive" + +external fn await_upgrade(from: Connection, within: Int) -> Result(Nil, Dynamic) = + "nerf_ffi" "ws_await_upgrade" + +// TODO: listen for close events +pub fn close(conn: Connection) -> Nil { + gun.ws_send(conn.pid, gun.Close) +} + +/// The URI of the websocket server to connect to +pub type ConnectError { + ConnectionRefused(status: Int, headers: List(Header)) + ConnectionFailed(reason: Dynamic) +} diff --git a/src/nerf_ffi.erl b/src/nerf_ffi.erl new file mode 100644 index 0000000..c860bc5 --- /dev/null +++ b/src/nerf_ffi.erl @@ -0,0 +1,35 @@ +-module(nerf_ffi). + +-export([ws_receive/2, ws_await_upgrade/2]). + +ws_receive({connection, Ref, Pid}, Timeout) + when is_reference(Ref) andalso is_pid(Pid) -> + receive + {gun_ws, Pid, Ref, close} -> {ok, close}; + {gun_ws, Pid, Ref, {close, _}} -> {ok, close}; + {gun_ws, Pid, Ref, {close, _, _}} -> {ok, close}; + {gun_ws, Pid, Ref, {text, _} = Frame} -> {ok, Frame}; + {gun_ws, Pid, Ref, {binary, _} = Frame} -> {ok, Frame} + after Timeout -> + {error, nil} + end. + +ws_await_upgrade({connection, Ref, Pid}, Timeout) + when is_reference(Ref) andalso is_pid(Pid) -> + receive + {gun_upgrade, Pid, Ref, [<<"websocket">>], _} -> + {ok, nil}; + + {gun_response, Pid, _, _, Status, Headers} -> + % TODO: return an error + exit({ws_upgrade_failed, Status, Headers}); + + {gun_error, Pid, Ref, Reason} -> + % TODO: return an error + exit({ws_upgrade_failed, Reason}) + + % TODO: Are other cases required? + after Timeout -> + % TODO: return an error + exit(timeout) + end. diff --git a/test/nerf_test.gleam b/test/nerf_test.gleam new file mode 100644 index 0000000..874f304 --- /dev/null +++ b/test/nerf_test.gleam @@ -0,0 +1,29 @@ +import gleam/erlang +import gleam/erlang/atom +import gleam/string +import gleeunit +import gleeunit/should +import nerf/websocket.{Text} + +pub fn main() { + assert Ok(_) = erlang.ensure_all_started(atom.create_from_string("nerf")) + gleeunit.main() +} + +pub fn echo_test() { + // Connect + assert Ok(conn) = websocket.connect("localhost", "/ws", 8080, []) + + // The server we're using sends a little hello message + assert Ok(Text(msg)) = websocket.receive(conn, 500) + assert True = string.starts_with(msg, "Request served by ") + + // Send some messages, the test server echos them back + websocket.send(conn, "Hello") + websocket.send(conn, "World") + assert Ok(Text("Hello")) = websocket.receive(conn, 500) + assert Ok(Text("World")) = websocket.receive(conn, 500) + + // Close the connection + websocket.close(conn) +}