diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 038305bd..e64f5c04 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --all + args: --workspace --exclude gloo-net browser_tests: name: Browser Tests @@ -63,6 +63,59 @@ jobs: - name: Run tests run: | for x in $(ls crates); do + # gloo-net is tested separately + if [[ "$x" == "net" ]]; then + continue + fi wasm-pack test --headless --firefox --chrome crates/$x --all-features wasm-pack test --headless --firefox --chrome crates/$x --no-default-features done + + test-net: + name: Test gloo-net + runs-on: ubuntu-latest + services: + httpbin: + image: kennethreitz/httpbin@sha256:599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b + ports: + - 8080:80 + echo_server: + image: jmalloc/echo-server@sha256:c461e7e54d947a8777413aaf9c624b4ad1f1bac5d8272475da859ae82c1abd7d + ports: + - 8081:8080 + + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: clippy + target: wasm32-unknown-unknown + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-test-${{ hashFiles('**/Cargo.toml') }} + restore-keys: | + cargo-${{ runner.os }}-test- + cargo-${{ runner.os }}- + - name: Run browser tests + env: + HTTPBIN_URL: "http://localhost:8080" + ECHO_SERVER_URL: "ws://localhost:8081" + run: wasm-pack test --chrome --firefox --headless + + - name: Run browser tests + env: + HTTPBIN_URL: "http://localhost:8080" + ECHO_SERVER_URL: "ws://localhost:8081" + uses: actions-rs/cargo@v1 + with: + command: test diff --git a/Cargo.toml b/Cargo.toml index 8f0f4e8a..9c268ba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ gloo-console = { version = "0.2.1", path = "crates/console" } gloo-utils = { version = "0.1.1", path = "crates/utils" } gloo-history = { version = "0.1.0", path = "crates/history" } gloo-worker = { version = "0.1.0", path = "crates/worker" } +gloo-net = { path = "crates/net" } [features] default = [] @@ -41,4 +42,5 @@ members = [ "crates/utils", "crates/history", "crates/worker", + "crates/net", ] diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml new file mode 100644 index 00000000..1486baa8 --- /dev/null +++ b/crates/net/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "gloo-net" +version = "0.1.0" +authors = ["Rust and WebAssembly Working Group", "Muhammad Hamza "] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/hamza1311/reqwasm" +description = "HTTP requests library for WASM Apps" +readme = "README.md" +keywords = ["requests", "http", "wasm", "websockets"] +categories = ["wasm", "web-programming::http-client", "api-bindings"] + +[package.metadata.docs.rs] +all-features = true + +[dependencies] +wasm-bindgen = "0.2" +web-sys = "0.3" +js-sys = "0.3" +gloo-utils = { version = "0.1", path = "../utils" } + +wasm-bindgen-futures = "0.4" +futures-core = { version = "0.3", optional = true } +futures-sink = { version = "0.3", optional = true } + +thiserror = "1.0" + +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } + +futures-channel = { version = "0.3", optional = true } +pin-project = { version = "1.0", optional = true } + +[dev-dependencies] +wasm-bindgen-test = "0.3" +futures = "0.3" + +[features] +default = ["json", "websocket", "http"] + +# Enables `.json()` on `Response` +json = ["wasm-bindgen/serde-serialize", "serde", "serde_json"] +# Enables the WebSocket API +websocket = [ + 'web-sys/WebSocket', + 'web-sys/ErrorEvent', + 'web-sys/FileReader', + 'web-sys/MessageEvent', + 'web-sys/ProgressEvent', + 'web-sys/CloseEvent', + 'web-sys/BinaryType', + 'web-sys/Blob', + "futures-channel", + "pin-project", + "futures-core", + "futures-sink", +] +# Enables the HTTP API +http = [ + 'web-sys/Headers', + 'web-sys/Request', + 'web-sys/RequestInit', + 'web-sys/RequestMode', + 'web-sys/Response', + 'web-sys/Window', + 'web-sys/RequestCache', + 'web-sys/RequestCredentials', + 'web-sys/ObserverCallback', + 'web-sys/RequestRedirect', + 'web-sys/ReferrerPolicy', + 'web-sys/AbortSignal', + 'web-sys/ReadableStream', + 'web-sys/Blob', + 'web-sys/FormData', +] diff --git a/crates/net/README.md b/crates/net/README.md new file mode 100644 index 00000000..f5921590 --- /dev/null +++ b/crates/net/README.md @@ -0,0 +1,57 @@ +
+ +

gloo-net

+ +

+ Crates.io version + Download + docs.rs docs +

+ +

+ API Docs + | + Contributing + | + Chat +

+ +Built with 🦀🕸 by The Rust and WebAssembly Working Group +
+ +HTTP requests library for WASM Apps. It provides idiomatic Rust bindings for the `web_sys` `fetch` and `WebSocket` API + +## Examples + +### HTTP + +```rust +let resp = Request::get("/path") + .send() + .await + .unwrap(); +assert_eq!(resp.status(), 200); +``` + +### WebSocket + +```rust +use reqwasm::websocket::{Message, futures::WebSocket}; +use wasm_bindgen_futures::spawn_local; +use futures::{SinkExt, StreamExt}; + +let mut ws = WebSocket::open("wss://echo.websocket.org").unwrap(); +let (mut write, mut read) = ws.split(); + +spawn_local(async move { + write.send(Message::Text(String::from("test"))).await.unwrap(); + write.send(Message::Text(String::from("test 2"))).await.unwrap(); +}); + +spawn_local(async move { + while let Some(msg) = read.next().await { + console_log!(format!("1. {:?}", msg)) + } + console_log!("WebSocket Closed") +}) +``` diff --git a/crates/net/src/error.rs b/crates/net/src/error.rs new file mode 100644 index 00000000..3c254c21 --- /dev/null +++ b/crates/net/src/error.rs @@ -0,0 +1,40 @@ +use gloo_utils::errors::JsError; +use thiserror::Error as ThisError; + +/// All the errors returned by this crate. +#[derive(Debug, ThisError)] +pub enum Error { + /// Error returned by JavaScript. + #[error("{0}")] + JsError(JsError), + /// Error returned by `serde` during deserialization. + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + #[error("{0}")] + SerdeError( + #[source] + #[from] + serde_json::Error, + ), +} + +#[cfg(any(feature = "http", feature = "websocket"))] +pub(crate) use conversion::*; +#[cfg(any(feature = "http", feature = "websocket"))] +mod conversion { + use gloo_utils::errors::JsError; + use std::convert::TryFrom; + use wasm_bindgen::JsValue; + + #[cfg(feature = "http")] + pub(crate) fn js_to_error(js_value: JsValue) -> super::Error { + super::Error::JsError(js_to_js_error(js_value)) + } + + pub(crate) fn js_to_js_error(js_value: JsValue) -> JsError { + match JsError::try_from(js_value) { + Ok(error) => error, + Err(_) => unreachable!("JsValue passed is not an Error type -- this is a bug"), + } + } +} diff --git a/crates/net/src/http.rs b/crates/net/src/http.rs new file mode 100644 index 00000000..a32dc5d3 --- /dev/null +++ b/crates/net/src/http.rs @@ -0,0 +1,310 @@ +//! Wrapper around the `fetch` API. +//! +//! # Example +//! +//! ``` +//! # use reqwasm::http::Request; +//! # async fn no_run() { +//! let resp = Request::get("/path") +//! .send() +//! .await +//! .unwrap(); +//! assert_eq!(resp.status(), 200); +//! # } +//! ``` + +use crate::{js_to_error, Error}; +use js_sys::{ArrayBuffer, Uint8Array}; +use std::fmt; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::window; + +#[cfg(feature = "json")] +#[cfg_attr(docsrs, doc(cfg(feature = "json")))] +use serde::de::DeserializeOwned; + +pub use web_sys::{ + AbortSignal, FormData, Headers, ObserverCallback, ReadableStream, ReferrerPolicy, RequestCache, + RequestCredentials, RequestMode, RequestRedirect, +}; + +#[allow( + missing_docs, + missing_debug_implementations, + clippy::upper_case_acronyms +)] +/// Valid request methods. +#[derive(Clone, Copy, Debug)] +pub enum Method { + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH, +} + +impl fmt::Display for Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Method::GET => "GET", + Method::HEAD => "HEAD", + Method::POST => "POST", + Method::PUT => "PUT", + Method::DELETE => "DELETE", + Method::CONNECT => "CONNECT", + Method::OPTIONS => "OPTIONS", + Method::TRACE => "TRACE", + Method::PATCH => "PATCH", + }; + write!(f, "{}", s) + } +} + +/// A request. +pub struct Request { + options: web_sys::RequestInit, + headers: web_sys::Headers, + url: String, +} + +impl Request { + /// Creates a new request with a url. + pub fn new(url: &str) -> Self { + Self { + options: web_sys::RequestInit::new(), + headers: web_sys::Headers::new().expect("headers"), + url: url.into(), + } + } + + /// Sets the body. + pub fn body(mut self, body: impl Into) -> Self { + self.options.body(Some(&body.into())); + self + } + + /// Sets the request cache. + pub fn cache(mut self, cache: RequestCache) -> Self { + self.options.cache(cache); + self + } + + /// Sets the request credentials. + pub fn credentials(mut self, credentials: RequestCredentials) -> Self { + self.options.credentials(credentials); + self + } + + /// Sets a header. + pub fn header(self, key: &str, value: &str) -> Self { + self.headers.set(key, value).expect("set header"); + self + } + + /// Sets the request integrity. + pub fn integrity(mut self, integrity: &str) -> Self { + self.options.integrity(integrity); + self + } + + /// Sets the request method. + pub fn method(mut self, method: Method) -> Self { + self.options.method(&method.to_string()); + self + } + + /// Sets the request mode. + pub fn mode(mut self, mode: RequestMode) -> Self { + self.options.mode(mode); + self + } + + /// Sets the observer callback. + pub fn observe(mut self, observe: &ObserverCallback) -> Self { + self.options.observe(observe); + self + } + + /// Sets the request redirect. + pub fn redirect(mut self, redirect: RequestRedirect) -> Self { + self.options.redirect(redirect); + self + } + + /// Sets the request referrer. + pub fn referrer(mut self, referrer: &str) -> Self { + self.options.referrer(referrer); + self + } + + /// Sets the request referrer policy. + pub fn referrer_policy(mut self, referrer_policy: ReferrerPolicy) -> Self { + self.options.referrer_policy(referrer_policy); + self + } + + /// Sets the request abort signal. + pub fn abort_signal(mut self, signal: Option<&AbortSignal>) -> Self { + self.options.signal(signal); + self + } + + /// Executes the request. + pub async fn send(mut self) -> Result { + self.options.headers(&self.headers); + + let request = web_sys::Request::new_with_str_and_init(&self.url, &self.options) + .map_err(js_to_error)?; + + let promise = window().unwrap().fetch_with_request(&request); + let response = JsFuture::from(promise).await.map_err(js_to_error)?; + match response.dyn_into::() { + Ok(response) => Ok(Response { + response: response.unchecked_into(), + }), + Err(e) => panic!("fetch returned {:?}, not `Response` - this is a bug", e), + } + } + + /// Creates a new [`GET`][Method::GET] `Request` with url. + pub fn get(url: &str) -> Self { + Self::new(url).method(Method::GET) + } + + /// Creates a new [`POST`][Method::POST] `Request` with url. + pub fn post(url: &str) -> Self { + Self::new(url).method(Method::POST) + } + + /// Creates a new [`PUT`][Method::PUT] `Request` with url. + pub fn put(url: &str) -> Self { + Self::new(url).method(Method::PUT) + } + + /// Creates a new [`DELETE`][Method::DELETE] `Request` with url. + pub fn delete(url: &str) -> Self { + Self::new(url).method(Method::DELETE) + } + + /// Creates a new [`PATCH`][Method::PATCH] `Request` with url. + pub fn patch(url: &str) -> Self { + Self::new(url).method(Method::PATCH) + } +} + +impl fmt::Debug for Request { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Request").field("url", &self.url).finish() + } +} + +/// The [`Request`]'s response +pub struct Response { + response: web_sys::Response, +} + +impl Response { + /// Gets the url. + pub fn url(&self) -> String { + self.response.url() + } + + /// Whether the request was redirected. + pub fn redirected(&self) -> bool { + self.response.redirected() + } + + /// Gets the status. + pub fn status(&self) -> u16 { + self.response.status() + } + + /// Whether the response was `ok`. + pub fn ok(&self) -> bool { + self.response.ok() + } + + /// Gets the status text. + pub fn status_text(&self) -> String { + self.response.status_text() + } + + /// Gets the headers. + pub fn headers(&self) -> Headers { + self.response.headers() + } + + /// Whether the body was used. + pub fn body_used(&self) -> bool { + self.response.body_used() + } + + /// Gets the body. + pub fn body(&self) -> Option { + self.response.body() + } + + /// Gets the raw [`Response`][web_sys::Response] object. + pub fn as_raw(&self) -> &web_sys::Response { + &self.response + } + + /// Gets the form data. + pub async fn form_data(&self) -> Result { + let promise = self.response.form_data().map_err(js_to_error)?; + let val = JsFuture::from(promise).await.map_err(js_to_error)?; + Ok(FormData::from(val)) + } + + /// Gets and parses the json. + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + pub async fn json(&self) -> Result { + let promise = self.response.json().map_err(js_to_error)?; + let json = JsFuture::from(promise).await.map_err(js_to_error)?; + + Ok(json.into_serde()?) + } + + /// Gets the response text. + pub async fn text(&self) -> Result { + let promise = self.response.text().unwrap(); + let val = JsFuture::from(promise).await.map_err(js_to_error)?; + let string = js_sys::JsString::from(val); + Ok(String::from(&string)) + } + + /// Gets the binary response + /// + /// This works by obtaining the response as an `ArrayBuffer`, creating a `Uint8Array` from it + /// and then converting it to `Vec` + pub async fn binary(&self) -> Result, Error> { + let promise = self.response.array_buffer().map_err(js_to_error)?; + let array_buffer: ArrayBuffer = JsFuture::from(promise) + .await + .map_err(js_to_error)? + .unchecked_into(); + let typed_buff: Uint8Array = Uint8Array::new(&array_buffer); + let mut body = vec![0; typed_buff.length() as usize]; + typed_buff.copy_to(&mut body); + Ok(body) + } +} + +impl fmt::Debug for Response { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Response") + .field("url", &self.url()) + .field("redirected", &self.redirected()) + .field("status", &self.status()) + .field("headers", &self.headers()) + .field("body_used", &self.body_used()) + .finish() + } +} diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs new file mode 100644 index 00000000..d3cabced --- /dev/null +++ b/crates/net/src/lib.rs @@ -0,0 +1,21 @@ +//! HTTP requests library for WASM apps. It provides idiomatic Rust bindings for the `web_sys` +//! `fetch` and `WebSocket` API. +//! +//! See module level documentation for [`http`] and [`websocket`] to learn more. + +#![deny( + missing_docs, + missing_debug_implementations, + missing_copy_implementations +)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +mod error; +#[cfg(feature = "http")] +#[cfg_attr(docsrs, doc(cfg(feature = "http")))] +pub mod http; +#[cfg(feature = "websocket")] +#[cfg_attr(docsrs, doc(cfg(feature = "websocket")))] +pub mod websocket; + +pub use error::*; diff --git a/crates/net/src/websocket/events.rs b/crates/net/src/websocket/events.rs new file mode 100644 index 00000000..5261ab76 --- /dev/null +++ b/crates/net/src/websocket/events.rs @@ -0,0 +1,12 @@ +//! WebSocket Events + +/// Data emitted by `onclose` event +#[derive(Clone, Debug)] +pub struct CloseEvent { + /// Close code + pub code: u16, + /// Close reason + pub reason: String, + /// If the websockets was closed cleanly + pub was_clean: bool, +} diff --git a/crates/net/src/websocket/futures.rs b/crates/net/src/websocket/futures.rs new file mode 100644 index 00000000..5a9a9085 --- /dev/null +++ b/crates/net/src/websocket/futures.rs @@ -0,0 +1,302 @@ +//! The wrapper around `WebSocket` API using the Futures API to be used in async rust +//! +//! # Example +//! +//! ```rust +//! use reqwasm::websocket::{Message, futures::WebSocket}; +//! use wasm_bindgen_futures::spawn_local; +//! use futures::{SinkExt, StreamExt}; +//! +//! # macro_rules! console_log { +//! # ($($expr:expr),*) => {{}}; +//! # } +//! # fn no_run() { +//! let mut ws = WebSocket::open("wss://echo.websocket.org").unwrap(); +//! let (mut write, mut read) = ws.split(); +//! +//! spawn_local(async move { +//! write.send(Message::Text(String::from("test"))).await.unwrap(); +//! write.send(Message::Text(String::from("test 2"))).await.unwrap(); +//! }); +//! +//! spawn_local(async move { +//! while let Some(msg) = read.next().await { +//! console_log!(format!("1. {:?}", msg)) +//! } +//! console_log!("WebSocket Closed") +//! }) +//! # } +//! ``` +use crate::js_to_js_error; +use crate::websocket::{events::CloseEvent, Message, State, WebSocketError}; +use futures_channel::mpsc; +use futures_core::{ready, Stream}; +use futures_sink::Sink; +use gloo_utils::errors::JsError; +use pin_project::{pin_project, pinned_drop}; +use std::cell::RefCell; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll, Waker}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use web_sys::{BinaryType, MessageEvent}; + +/// Wrapper around browser's WebSocket API. +#[allow(missing_debug_implementations)] +#[pin_project(PinnedDrop)] +pub struct WebSocket { + ws: web_sys::WebSocket, + sink_waker: Rc>>, + #[pin] + message_receiver: mpsc::UnboundedReceiver, + #[allow(clippy::type_complexity)] + closures: Rc<( + Closure, + Closure, + Closure, + Closure, + )>, +} + +impl WebSocket { + /// Establish a WebSocket connection. + /// + /// This function may error in the following cases: + /// - The port to which the connection is being attempted is being blocked. + /// - The URL is invalid. + /// + /// The error returned is [`JsError`]. See the + /// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#exceptions_thrown) + /// to learn more. + pub fn open(url: &str) -> Result { + let waker: Rc>> = Rc::new(RefCell::new(None)); + let ws = web_sys::WebSocket::new(url).map_err(js_to_js_error)?; + + // We rely on this because the other type Blob can be converted to Vec only through a + // promise which makes it awkward to use in our event callbacks where we want to guarantee + // the order of the events stays the same. + ws.set_binary_type(BinaryType::Arraybuffer); + + let (sender, receiver) = mpsc::unbounded(); + + let open_callback: Closure = { + let waker = Rc::clone(&waker); + Closure::wrap(Box::new(move || { + if let Some(waker) = waker.borrow_mut().take() { + waker.wake(); + } + }) as Box) + }; + + ws.set_onopen(Some(open_callback.as_ref().unchecked_ref())); + + let message_callback: Closure = { + let sender = sender.clone(); + Closure::wrap(Box::new(move |e: MessageEvent| { + let sender = sender.clone(); + let msg = parse_message(e); + let _ = sender.unbounded_send(StreamMessage::Message(msg)); + }) as Box) + }; + + ws.set_onmessage(Some(message_callback.as_ref().unchecked_ref())); + + let error_callback: Closure = { + let sender = sender.clone(); + Closure::wrap(Box::new(move |_e: web_sys::Event| { + let sender = sender.clone(); + let _ = sender.unbounded_send(StreamMessage::ErrorEvent); + }) as Box) + }; + + ws.set_onerror(Some(error_callback.as_ref().unchecked_ref())); + + let close_callback: Closure = { + Closure::wrap(Box::new(move |e: web_sys::CloseEvent| { + let sender = sender.clone(); + let close_event = CloseEvent { + code: e.code(), + reason: e.reason(), + was_clean: e.was_clean(), + }; + let _ = sender.unbounded_send(StreamMessage::CloseEvent(close_event)); + let _ = sender.unbounded_send(StreamMessage::ConnectionClose); + }) as Box) + }; + + ws.set_onclose(Some(close_callback.as_ref().unchecked_ref())); + + Ok(Self { + ws, + sink_waker: waker, + message_receiver: receiver, + closures: Rc::new(( + open_callback, + message_callback, + error_callback, + close_callback, + )), + }) + } + + /// Closes the websocket. + /// + /// See the [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#parameters) + /// to learn about parameters passed to this function and when it can return an `Err(_)` + pub fn close(self, code: Option, reason: Option<&str>) -> Result<(), JsError> { + let result = match (code, reason) { + (None, None) => self.ws.close(), + (Some(code), None) => self.ws.close_with_code(code), + (Some(code), Some(reason)) => self.ws.close_with_code_and_reason(code, reason), + // default code is 1005 so we use it, + // see: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#parameters + (None, Some(reason)) => self.ws.close_with_code_and_reason(1005, reason), + }; + result.map_err(js_to_js_error) + } + + /// The current state of the websocket. + pub fn state(&self) -> State { + let ready_state = self.ws.ready_state(); + match ready_state { + 0 => State::Connecting, + 1 => State::Open, + 2 => State::Closing, + 3 => State::Closed, + _ => unreachable!(), + } + } + + /// The extensions in use. + pub fn extensions(&self) -> String { + self.ws.extensions() + } + + /// The sub-protocol in use. + pub fn protocol(&self) -> String { + self.ws.protocol() + } +} + +#[derive(Clone)] +enum StreamMessage { + ErrorEvent, + CloseEvent(CloseEvent), + Message(Message), + ConnectionClose, +} + +fn parse_message(event: MessageEvent) -> Message { + if let Ok(array_buffer) = event.data().dyn_into::() { + let array = js_sys::Uint8Array::new(&array_buffer); + Message::Bytes(array.to_vec()) + } else if let Ok(txt) = event.data().dyn_into::() { + Message::Text(String::from(&txt)) + } else { + unreachable!("message event, received Unknown: {:?}", event.data()); + } +} + +impl Sink for WebSocket { + type Error = WebSocketError; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let ready_state = self.ws.ready_state(); + if ready_state == 0 { + *self.sink_waker.borrow_mut() = Some(cx.waker().clone()); + Poll::Pending + } else { + Poll::Ready(Ok(())) + } + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + let result = match item { + Message::Bytes(bytes) => self.ws.send_with_u8_array(&bytes), + Message::Text(message) => self.ws.send_with_str(&message), + }; + match result { + Ok(_) => Ok(()), + Err(e) => Err(WebSocketError::MessageSendError(js_to_js_error(e))), + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl Stream for WebSocket { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let msg = ready!(self.project().message_receiver.poll_next(cx)); + match msg { + Some(StreamMessage::Message(msg)) => Poll::Ready(Some(Ok(msg))), + Some(StreamMessage::ErrorEvent) => { + Poll::Ready(Some(Err(WebSocketError::ConnectionError))) + } + Some(StreamMessage::CloseEvent(e)) => { + Poll::Ready(Some(Err(WebSocketError::ConnectionClose(e)))) + } + Some(StreamMessage::ConnectionClose) => Poll::Ready(None), + None => Poll::Ready(None), + } + } +} + +#[pinned_drop] +impl PinnedDrop for WebSocket { + fn drop(self: Pin<&mut Self>) { + self.ws.close().unwrap(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{SinkExt, StreamExt}; + use wasm_bindgen_futures::spawn_local; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + const ECHO_SERVER_URL: &str = env!("ECHO_SERVER_URL"); + + #[wasm_bindgen_test] + fn websocket_works() { + let ws = WebSocket::open(ECHO_SERVER_URL).unwrap(); + let (mut sender, mut receiver) = ws.split(); + + spawn_local(async move { + sender + .send(Message::Text(String::from("test 1"))) + .await + .unwrap(); + sender + .send(Message::Text(String::from("test 2"))) + .await + .unwrap(); + }); + + spawn_local(async move { + // ignore first message + // the echo-server used sends it's info in the first message + // let _ = ws.next().await; + + assert_eq!( + receiver.next().await.unwrap().unwrap(), + Message::Text("test 1".to_string()) + ); + assert_eq!( + receiver.next().await.unwrap().unwrap(), + Message::Text("test 2".to_string()) + ); + }); + } +} diff --git a/crates/net/src/websocket/mod.rs b/crates/net/src/websocket/mod.rs new file mode 100644 index 00000000..41696e1d --- /dev/null +++ b/crates/net/src/websocket/mod.rs @@ -0,0 +1,63 @@ +//! Wrapper around `WebSocket` API +//! +//! This API is provided in the following flavors: +//! - [Futures API][futures] + +pub mod events; +pub mod futures; + +use events::CloseEvent; +use gloo_utils::errors::JsError; +use std::fmt; + +/// Message sent to and received from WebSocket. +#[derive(Debug, PartialEq, Clone)] +pub enum Message { + /// String message + Text(String), + /// ArrayBuffer parsed into bytes + Bytes(Vec), +} + +/// The state of the websocket. +/// +/// See [`WebSocket.readyState` on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState) +/// to learn more. +#[derive(Copy, Clone, Debug)] +pub enum State { + /// The connection has not yet been established. + Connecting, + /// The WebSocket connection is established and communication is possible. + Open, + /// The connection is going through the closing handshake, or the close() method has been + /// invoked. + Closing, + /// The connection has been closed or could not be opened. + Closed, +} + +/// Error returned by WebSocket +#[derive(Debug)] +#[non_exhaustive] +pub enum WebSocketError { + /// The `error` event + ConnectionError, + /// The `close` event + ConnectionClose(CloseEvent), + /// Message failed to send. + MessageSendError(JsError), +} + +impl fmt::Display for WebSocketError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WebSocketError::ConnectionError => write!(f, "WebSocket connection failed"), + WebSocketError::ConnectionClose(e) => write!( + f, + "WebSocket Closed: code: {}, reason: {}", + e.code, e.reason + ), + WebSocketError::MessageSendError(e) => write!(f, "{}", e), + } + } +} diff --git a/crates/net/tests/http.rs b/crates/net/tests/http.rs new file mode 100644 index 00000000..a2672423 --- /dev/null +++ b/crates/net/tests/http.rs @@ -0,0 +1,101 @@ +use reqwasm::http::*; +use serde::{Deserialize, Serialize}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +const HTTPBIN_URL: &str = env!("HTTPBIN_URL"); + +#[wasm_bindgen_test] +async fn fetch() { + let resp = Request::get(&format!("{}/get", HTTPBIN_URL)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[wasm_bindgen_test] +async fn fetch_json() { + #[derive(Deserialize, Debug)] + struct HttpBin { + url: String, + } + + let url = format!("{}/get", HTTPBIN_URL); + let resp = Request::get(&url).send().await.unwrap(); + let json: HttpBin = resp.json().await.unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(json.url, url); +} + +#[wasm_bindgen_test] +async fn auth_valid_bearer() { + let resp = Request::get(&format!("{}/get", HTTPBIN_URL)) + .header("Authorization", "Bearer token") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); +} + +#[wasm_bindgen_test] +async fn gzip_response() { + #[derive(Deserialize, Debug)] + struct HttpBin { + gzipped: bool, + } + + let resp = Request::get(&format!("{}/gzip", HTTPBIN_URL)) + .send() + .await + .unwrap(); + let json: HttpBin = resp.json().await.unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(json.gzipped, true); +} + +#[wasm_bindgen_test] +async fn post_json() { + #[derive(Serialize, Deserialize, Debug)] + struct Payload { + data: String, + } + + #[derive(Deserialize, Debug)] + struct HttpBin { + json: Payload, + } + + let resp = Request::post(&format!("{}/anything", HTTPBIN_URL)) + .body( + serde_json::to_string(&Payload { + data: "data".to_string(), + }) + .unwrap(), + ) + .send() + .await + .unwrap(); + let json: HttpBin = resp.json().await.unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(json.json.data, "data"); +} + +#[wasm_bindgen_test] +async fn fetch_binary() { + #[derive(Deserialize, Debug)] + struct HttpBin { + data: String, + } + + let resp = Request::post(&format!("{}/post", HTTPBIN_URL)) + .send() + .await + .unwrap(); + let json = resp.binary().await.unwrap(); + assert_eq!(resp.status(), 200); + let json: HttpBin = serde_json::from_slice(&json).unwrap(); + assert_eq!(json.data, ""); // default is empty string +} diff --git a/src/lib.rs b/src/lib.rs index 655f5c61..57a8382d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub use gloo_dialogs as dialogs; pub use gloo_events as events; pub use gloo_file as file; pub use gloo_history as history; +pub use gloo_net as net; pub use gloo_render as render; pub use gloo_storage as storage; pub use gloo_timers as timers; diff --git a/website/src/pages/__index.md b/website/src/pages/__index.md index 82803b72..ac17a3d9 100644 --- a/website/src/pages/__index.md +++ b/website/src/pages/__index.md @@ -9,11 +9,13 @@ as [API documentation](https://docs.rs/gloo/). 2. [`dialog`](https://crates.io/crates/gloo-dialog) 3. [`event`](https://crates.io/crates/gloo-event) 4. [`file`](https://crates.io/crates/gloo-file) -4. [`history`](https://crates.io/crates/gloo-history) -5. [`storage`](https://crates.io/crates/gloo-storage) -6. [`timer`](https://crates.io/crates/gloo-timer) -6. [`utils`](https://crates.io/crates/gloo-utils) -6. [`worker`](https://crates.io/crates/gloo-worker) +5. [`history`](https://crates.io/crates/gloo-history) +6. [`net`](https://crates.io/crates/gloo-net) +7. [`render`](https://crates.io/crates/gloo-render) +8. [`storage`](https://crates.io/crates/gloo-storage) +9. [`timer`](https://crates.io/crates/gloo-timer) +10. [`utils`](https://crates.io/crates/gloo-utils) +11. [`worker`](https://crates.io/crates/gloo-worker) ## Using Gloo