Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gloo-utils): Lift serde-serialization from wasm-bindgen #242

Merged
merged 11 commits into from
Aug 21, 2022
Merged
2 changes: 1 addition & 1 deletion crates/console/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ categories = ["api-bindings", "development-tools::profiling", "wasm"]
wasm-bindgen = "0.2"
js-sys = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
gloo-utils = { version = "0.1", path = "../utils", features = ["serde"] }
[dependencies.web-sys]
version = "0.3"
features = [
Expand Down
3 changes: 2 additions & 1 deletion crates/console/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub use timer::Timer;

#[doc(hidden)]
pub mod __macro {
use gloo_utils::format::JsValueSerdeExt;
pub use js_sys::Array;
pub use wasm_bindgen::JsValue;
use wasm_bindgen::UnwrapThrowExt;
Expand All @@ -38,7 +39,7 @@ pub mod __macro {
data: impl serde::Serialize,
columns: impl IntoIterator<Item = &'a str>,
) {
let data = js_sys::JSON::parse(&serde_json::to_string(&data).unwrap_throw()).unwrap_throw();
let data = <JsValue as JsValueSerdeExt>::from_serde(&data).unwrap_throw();
let columns = columns.into_iter().map(JsValue::from_str).collect();

crate::externs::table_with_data_and_columns(data, columns);
Expand Down
2 changes: 1 addition & 1 deletion crates/net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ rustdoc-args = ["--cfg", "docsrs"]
wasm-bindgen = "0.2"
web-sys = "0.3"
js-sys = "0.3"
gloo-utils = { version = "0.1", path = "../utils" }
gloo-utils = { version = "0.1", path = "../utils", features = ["serde"] }

wasm-bindgen-futures = "0.4"
futures-core = { version = "0.3", optional = true }
Expand Down
16 changes: 6 additions & 10 deletions crates/net/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ use wasm_bindgen_futures::JsFuture;
#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
use serde::de::DeserializeOwned;

#[cfg(feature = "json")]
#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
use gloo_utils::format::JsValueSerdeExt;

pub use headers::Headers;
pub use query::QueryParams;
pub use web_sys::{
Expand Down Expand Up @@ -391,16 +395,8 @@ impl Response {
#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
pub async fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
let promise = self.response.json().map_err(js_to_error)?;
let json = JsFuture::from(promise)
.await
.map_err(js_to_error)
.and_then(|json| {
js_sys::JSON::stringify(&json)
.map(String::from)
.map_err(js_to_error)
})?;

Ok(serde_json::from_str(&json)?)
let json = JsFuture::from(promise).await.map_err(js_to_error)?;
Ok(JsValueSerdeExt::into_serde(&json)?)
}

/// Reads the response as a String.
Expand Down
17 changes: 7 additions & 10 deletions crates/net/src/websocket/futures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,13 @@ impl WebSocket {
url: &str,
protocols: &[S],
) -> Result<Self, JsError> {
let map_err = |err| {
js_sys::Error::new(&format!(
"Failed to convert protocols to Javascript value: {}",
err
))
};
let json = serde_json::to_string(protocols)
.map_err(|e| map_err(e.to_string()))
.map(|d| js_sys::JSON::parse(&d).unwrap_throw())?;

let json = <JsValue as gloo_utils::format::JsValueSerdeExt>::from_serde(protocols)
.map_err(|err| {
js_sys::Error::new(&format!(
"Failed to convert protocols to Javascript value: {}",
err
))
})?;
Self::setup(web_sys::WebSocket::new_with_str_sequence(url, &json))
}

Expand Down
11 changes: 11 additions & 0 deletions crates/utils/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ categories = ["api-bindings", "wasm"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
serde = { version = "1.0", optional = true }
serde_json = {version = "1.0", optional = true }

[dependencies.web-sys]
version = "0.3"
Expand All @@ -27,5 +29,14 @@ features = [
"Element",
]

[features]
default = ["serde"]
serde = ["dep:serde", "dep:serde_json"]

[dev-dependencies]
wasm-bindgen-test = "0.3"
serde_derive = "1.0"

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
114 changes: 114 additions & 0 deletions crates/utils/src/format/json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#![cfg(feature = "serde")]

use js_sys::JsString;
use wasm_bindgen::{prelude::wasm_bindgen, JsValue, UnwrapThrowExt};
mod private {
pub trait Sealed {}
impl Sealed for wasm_bindgen::JsValue {}
}

// Turns out `JSON.stringify(undefined) === undefined`, so if
// we're passed `undefined` reinterpret it as `null` for JSON
// purposes.
#[wasm_bindgen(
inline_js = "export function serialize(obj) { return JSON.stringify(obj === undefined ? null : obj) }"
Copy link
Collaborator

@futursolo futursolo Aug 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline JS snippets could cause issues for non-ES module targets.
no-modules target is still the only target supported by gloo-worker.

See: https://rustwasm.github.io/docs/wasm-bindgen/examples/without-a-bundler.html#using-the-older---target-no-modules

Instead of going through JSON::stringify and serde_json, I would suggest using serde-wasm-bindgen. It supports non-serializable types (e.g.: ES2015 Map) and has a smaller bundle footprint than serde_json.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@futursolo oh well, back to what I had before.

So if you use serde_wasm_bindgen, then you don't really need this. The goal is to provide a drop-in replacement for JsValue::{from_serde/to_serde} and an easier way to use js_sys::JSON::.

Plus, in the deprecation thread, someone said they had a bad time with serde-wasm-bindgen. I think it makes sense: it calls to JS for every field which is slow.

)]
extern "C" {
#[wasm_bindgen(catch)]
fn serialize(obj: &JsValue) -> Result<JsString, JsValue>;
}

/// Extenstion trait to provide conversion between [`JsValue`](wasm_bindgen::JsValue) and [`serde`].
///
/// Usage of this API requires activating the `serde` feature of the `gloo-utils` crate.
ranile marked this conversation as resolved.
Show resolved Hide resolved
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub trait JsValueSerdeExt: private::Sealed {
/// Creates a new `JsValue` from the JSON serialization of the object `t`
/// provided.
///
/// This function will serialize the provided value `t` to a JSON string,
/// send the JSON string to JS, parse it into a JS object, and then return
/// a handle to the JS object. This is unlikely to be super speedy so it's
/// not recommended for large payloads, but it's a nice to have in some
/// situations!
///
/// Usage of this API requires activating the `serde` feature of
/// the `gloo-utils` crate.
/// # Example
///
/// ```rust
/// use wasm_bindgen::JsValue;
/// use gloo_utils::format::JsValueSerdeExt;
///
/// # fn no_run() {
/// assert_eq!(JsValue::from("bar").into_serde::<String>().unwrap(), "bar");
/// # }
/// ```
/// # Errors
///
/// Returns any error encountered when serializing `T` into JSON.
///
/// # Panics
///
/// Panics if [`serde_json`](serde_json::to_string) generated JSON that couldn't be parsed by [`js_sys`].
/// Uses [`unwrap_throw`](UnwrapThrowExt::unwrap_throw) from [`wasm_bindgen::UnwrapThrowExt`].
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
fn from_serde<T>(t: &T) -> serde_json::Result<JsValue>
where
T: serde::ser::Serialize + ?Sized;

/// Invokes `JSON.stringify` on this value and then parses the resulting
/// JSON into an arbitrary Rust value.
///
/// This function will first call `JSON.stringify` on the `JsValue` itself.
/// The resulting string is then passed into Rust which then parses it as
/// JSON into the resulting value. If given `undefined`, object will be silentrly changed to
/// null to avoid panic.
///
/// Usage of this API requires activating the `serde` feature of
/// the `gloo-utils` crate.
///
/// # Example
///
/// ```rust
/// use wasm_bindgen::JsValue;
/// use gloo_utils::format::JsValueSerdeExt;
///
/// # fn no_run() {
/// let array = vec![1,2,3];
/// let obj = JsValue::from_serde(&array);
/// # }
/// ```
///
/// # Errors
///
/// Returns any error encountered when parsing the JSON into a `T`.
///
/// # Panics
///
/// Panics if [`js_sys`] couldn't stringify the JsValue. Uses [`unwrap_throw`](UnwrapThrowExt::unwrap_throw)
/// from [`wasm_bindgen::UnwrapThrowExt`].
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
#[allow(clippy::wrong_self_convention)]
fn into_serde<T>(&self) -> serde_json::Result<T>
where
T: for<'a> serde::de::Deserialize<'a>;
}

impl JsValueSerdeExt for JsValue {
fn from_serde<T>(t: &T) -> serde_json::Result<JsValue>
where
T: serde::ser::Serialize + ?Sized,
{
let s = serde_json::to_string(t)?;
Ok(js_sys::JSON::parse(&s).unwrap_throw())
}

fn into_serde<T>(&self) -> serde_json::Result<T>
where
T: for<'a> serde::de::Deserialize<'a>,
{
let s = serialize(self).map(String::from).unwrap_throw();
serde_json::from_str(&s)
}
}
7 changes: 7 additions & 0 deletions crates/utils/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
#![cfg_attr(docsrs, feature(doc_cfg))]

pub mod errors;
pub mod iter;
pub mod format {
mod json;
andoriyu marked this conversation as resolved.
Show resolved Hide resolved
#[cfg(feature = "serde")]
pub use json::JsValueSerdeExt;
}
use wasm_bindgen::UnwrapThrowExt;

/// Convenience function to avoid repeating expect logic.
Expand Down
25 changes: 25 additions & 0 deletions crates/utils/tests/serde.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function deepStrictEqual(left, right) {
var left_json = JSON.stringify(left);
var right_json = JSON.stringify(right);
if (left_json !== right_json) {
throw Error(`${left_json} != ${right_json}`)
}
}

export function verify_serde (a) {
deepStrictEqual(a, {
a: 0,
b: 'foo',
c: null,
d: { a: 1 }
});
};

export function make_js_value() {
return {
a: 2,
b: 'bar',
c: { a: 3 },
d: { a: 4 },
}
};
68 changes: 68 additions & 0 deletions crates/utils/tests/serde.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#![cfg(target_arch = "wasm32")]
#![cfg(feature = "serde")]
extern crate wasm_bindgen;
extern crate wasm_bindgen_test;

use wasm_bindgen::prelude::*;
use wasm_bindgen_test::*;

use gloo_utils::format::JsValueSerdeExt;

use serde_derive::{Deserialize, Serialize};

wasm_bindgen_test_configure!(run_in_browser);

#[wasm_bindgen(start)]
pub fn start() {
panic!();
}

#[wasm_bindgen(module = "/tests/serde.js")]
extern "C" {
fn verify_serde(val: JsValue);
fn make_js_value() -> JsValue;
}

#[derive(Deserialize, Serialize, Debug)]
pub struct SerdeFoo {
a: u32,
b: String,
c: Option<SerdeBar>,
d: SerdeBar,
}

#[derive(Deserialize, Serialize, Debug)]
pub struct SerdeBar {
a: u32,
}

#[wasm_bindgen_test]
fn from_serde() {
let js = JsValue::from_serde("foo").unwrap();
assert_eq!(js.as_string(), Some("foo".to_string()));

verify_serde(
JsValue::from_serde(&SerdeFoo {
a: 0,
b: "foo".to_string(),
c: None,
d: SerdeBar { a: 1 },
})
.unwrap(),
);
}

#[wasm_bindgen_test]
fn into_serde() {
let js_value = make_js_value();
let foo = js_value.into_serde::<SerdeFoo>().unwrap();
assert_eq!(foo.a, 2);
assert_eq!(foo.b, "bar");
assert!(foo.c.is_some());
assert_eq!(foo.c.as_ref().unwrap().a, 3);
assert_eq!(foo.d.a, 4);

assert_eq!(JsValue::from("bar").into_serde::<String>().unwrap(), "bar");
assert_eq!(JsValue::undefined().into_serde::<i32>().ok(), None);
assert_eq!(JsValue::null().into_serde::<i32>().ok(), None);
}