diff --git a/Cargo.lock b/Cargo.lock index 0adc628..519907d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,6 +1404,7 @@ name = "shopify_function" version = "2.2.0" dependencies = [ "ryu", + "serde", "serde_json", "shopify_function_macro", "shopify_function_wasm_api", diff --git a/shopify_function/Cargo.toml b/shopify_function/Cargo.toml index a8d47a7..b8dbd35 100644 --- a/shopify_function/Cargo.toml +++ b/shopify_function/Cargo.toml @@ -7,10 +7,14 @@ license = "MIT" description = "Crate to write Shopify Functions in Rust." [dependencies] +serde = "1.0" serde_json = "1.0" shopify_function_macro.workspace = true shopify_function_wasm_api = "0.3.1" +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } + # Use the `small` feature of ryu (transitive dependency through serde_json) # to shave off ~9kb of the Wasm binary size. [dependencies.ryu] diff --git a/shopify_function/src/lib.rs b/shopify_function/src/lib.rs index 2a062f3..39d8e37 100644 --- a/shopify_function/src/lib.rs +++ b/shopify_function/src/lib.rs @@ -19,6 +19,9 @@ //! /* ... */ //! } //! ``` +//! +//! For types that are easier to express with [`serde`], such as simple enums, add +//! `#[shopify_function(serde)]` to the type. See the [`serde_adapter`] module for more details. #[cfg(all(target_arch = "wasm32", target_os = "wasi", target_env = "p1"))] compile_error!("Compiling to wasm32-wasip1 is unsupported, change your target to wasm32-unknown-unknown instead"); @@ -26,6 +29,7 @@ compile_error!("Compiling to wasm32-wasip1 is unsupported, change your target to pub use shopify_function_macro::{shopify_function, typegen, Deserialize}; pub mod scalars; +pub mod serde_adapter; pub mod prelude { #[allow(deprecated)] diff --git a/shopify_function/src/serde_adapter.rs b/shopify_function/src/serde_adapter.rs new file mode 100644 index 0000000..755c23f --- /dev/null +++ b/shopify_function/src/serde_adapter.rs @@ -0,0 +1,465 @@ +//! Support for [`serde`] deserialization of Shopify Function inputs. +//! +//! Deserialization of the input normally goes through +//! [`shopify_function_wasm_api::Deserialize`]. This module adapts [`serde`] to that trait, for +//! types that are easier to express with `serde`, such as a simple enum or the shape of a JSON +//! metafield. +//! +//! Add `#[shopify_function(serde)]` to a type that derives both [`serde::Deserialize`] and +//! [`macro@crate::Deserialize`]. The type keeps its own name, so it can be named in the +//! `custom_scalar_overrides` argument of a query: +//! +//! ``` +//! use shopify_function::prelude::*; +//! use shopify_function::wasm_api::Deserialize as _; +//! +//! #[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +//! #[shopify_function(serde)] +//! #[serde(rename_all = "SCREAMING_SNAKE_CASE")] +//! enum Status { +//! Active, +//! Archived, +//! } +//! +//! let context = shopify_function::wasm_api::Context::new_with_input( +//! serde_json::json!("ARCHIVED"), +//! ); +//! let value = context.input_get().unwrap(); +//! +//! assert_eq!(Status::deserialize(&value).unwrap(), Status::Archived); +//! ``` +//! +//! To write the implementation by hand, call [`from_value`]. +//! +//! Because the input is read through the Wasm API, all strings are owned. Types that borrow from +//! the input, such as fields with `#[serde(borrow)]`, are not supported. + +use crate::wasm_api::{read, Value}; +use serde::de::{ + DeserializeSeed, EnumAccess, IntoDeserializer, MapAccess, SeqAccess, VariantAccess, Visitor, +}; +use std::fmt; + +#[doc(no_inline)] +pub use serde::de::DeserializeOwned; + +/// Deserializes a value with [`serde`]. +/// +/// The code that `#[shopify_function(serde)]` generates calls this function, and converts the +/// error into [`shopify_function_wasm_api::read::Error`], which cannot keep the message. Call this +/// function directly to keep the message. +pub fn from_value(value: &Value) -> Result { + T::deserialize(ValueDeserializer::new(*value)) +} + +/// An error that can occur when deserializing with [`serde`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + message: String, +} + +impl Error { + fn invalid_type(expected: &str, value: &Value) -> Self { + Self { + message: format!( + "invalid type: expected {expected}, found {}", + type_name(value) + ), + } + } + + /// Returns the error message. + pub fn message(&self) -> &str { + self.message.as_str() + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message.as_str()) + } +} + +impl std::error::Error for Error {} + +impl serde::de::Error for Error { + fn custom(message: T) -> Self { + Self { + message: message.to_string(), + } + } +} + +impl From for read::Error { + fn from(_: Error) -> Self { + // `read::Error` has no variant that can hold a message. + read::Error::InvalidType + } +} + +fn type_name(value: &Value) -> &'static str { + if value.is_null() { + "null" + } else if value.as_bool().is_some() { + "boolean" + } else if value.as_number().is_some() { + "number" + } else if value.is_array() { + "array" + } else if value.is_obj() { + "object" + } else if value.as_error().is_some() { + "error" + } else { + "string" + } +} + +/// A [`serde::Deserializer`] for a value read from the Shopify Function input. +struct ValueDeserializer { + value: Value, +} + +impl ValueDeserializer { + fn new(value: Value) -> Self { + Self { value } + } + + fn as_number(&self, expected: &str) -> Result { + self.value + .as_number() + .ok_or_else(|| Error::invalid_type(expected, &self.value)) + } + + fn as_string(&self, expected: &str) -> Result { + self.value + .as_string() + .ok_or_else(|| Error::invalid_type(expected, &self.value)) + } + + fn seq_access(&self) -> Result { + let len = self + .value + .array_len() + .ok_or_else(|| Error::invalid_type("array", &self.value))?; + Ok(SeqDeserializer { + value: self.value, + len, + index: 0, + }) + } + + fn map_access(&self) -> Result { + let len = self + .value + .obj_len() + .ok_or_else(|| Error::invalid_type("object", &self.value))?; + Ok(MapDeserializer { + value: self.value, + len, + index: 0, + }) + } +} + +/// Deserializes an integer, which the input represents as a 64-bit float. +macro_rules! deserialize_int { + ($method:ident, $ty:ty, $visit:ident) => { + fn $method>(self, visitor: V) -> Result { + let number = self.as_number(stringify!($ty))?; + if number.trunc() != number || number < <$ty>::MIN as f64 || number > <$ty>::MAX as f64 + { + return Err(::custom(format!( + "number {number} is out of range for {}", + stringify!($ty) + ))); + } + visitor.$visit(number as $ty) + } + }; +} + +impl<'de> serde::Deserializer<'de> for ValueDeserializer { + type Error = Error; + + fn deserialize_any>(self, visitor: V) -> Result { + if self.value.is_null() { + visitor.visit_unit() + } else if let Some(boolean) = self.value.as_bool() { + visitor.visit_bool(boolean) + } else if let Some(number) = self.value.as_number() { + visitor.visit_f64(number) + } else if self.value.is_array() { + visitor.visit_seq(self.seq_access()?) + } else if self.value.is_obj() { + visitor.visit_map(self.map_access()?) + } else if let Some(string) = self.value.as_string() { + visitor.visit_string(string) + } else { + Err(Error::invalid_type("a supported type", &self.value)) + } + } + + fn deserialize_bool>(self, visitor: V) -> Result { + let boolean = self + .value + .as_bool() + .ok_or_else(|| Error::invalid_type("boolean", &self.value))?; + visitor.visit_bool(boolean) + } + + deserialize_int!(deserialize_i8, i8, visit_i8); + deserialize_int!(deserialize_i16, i16, visit_i16); + deserialize_int!(deserialize_i32, i32, visit_i32); + deserialize_int!(deserialize_i64, i64, visit_i64); + deserialize_int!(deserialize_u8, u8, visit_u8); + deserialize_int!(deserialize_u16, u16, visit_u16); + deserialize_int!(deserialize_u32, u32, visit_u32); + deserialize_int!(deserialize_u64, u64, visit_u64); + + fn deserialize_f32>(self, visitor: V) -> Result { + visitor.visit_f32(self.as_number("f32")? as f32) + } + + fn deserialize_f64>(self, visitor: V) -> Result { + visitor.visit_f64(self.as_number("f64")?) + } + + fn deserialize_char>(self, visitor: V) -> Result { + let string = self.as_string("char")?; + let mut chars = string.chars(); + match (chars.next(), chars.next()) { + (Some(char), None) => visitor.visit_char(char), + _ => Err(::custom( + "expected a string with a single character", + )), + } + } + + fn deserialize_str>(self, visitor: V) -> Result { + visitor.visit_string(self.as_string("string")?) + } + + fn deserialize_string>(self, visitor: V) -> Result { + self.deserialize_str(visitor) + } + + fn deserialize_bytes>(self, _visitor: V) -> Result { + Err(::custom( + "bytes are not supported", + )) + } + + fn deserialize_byte_buf>(self, visitor: V) -> Result { + self.deserialize_bytes(visitor) + } + + fn deserialize_option>(self, visitor: V) -> Result { + if self.value.is_null() { + visitor.visit_none() + } else { + visitor.visit_some(self) + } + } + + fn deserialize_unit>(self, visitor: V) -> Result { + if self.value.is_null() { + visitor.visit_unit() + } else { + Err(Error::invalid_type("null", &self.value)) + } + } + + fn deserialize_unit_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + self.deserialize_unit(visitor) + } + + fn deserialize_newtype_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + visitor.visit_newtype_struct(self) + } + + fn deserialize_seq>(self, visitor: V) -> Result { + visitor.visit_seq(self.seq_access()?) + } + + fn deserialize_tuple>( + self, + _len: usize, + visitor: V, + ) -> Result { + self.deserialize_seq(visitor) + } + + fn deserialize_tuple_struct>( + self, + _name: &'static str, + _len: usize, + visitor: V, + ) -> Result { + self.deserialize_seq(visitor) + } + + fn deserialize_map>(self, visitor: V) -> Result { + visitor.visit_map(self.map_access()?) + } + + fn deserialize_struct>( + self, + _name: &'static str, + _fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_map(visitor) + } + + fn deserialize_enum>( + self, + _name: &'static str, + _variants: &'static [&'static str], + visitor: V, + ) -> Result { + if let Some(variant) = self.value.as_string() { + // A unit variant, which the input represents as a string. + visitor.visit_enum(variant.into_deserializer()) + } else if self.value.is_obj() { + // Any other variant, which the input represents as an object with a single property. + if self.value.obj_len() != Some(1) { + return Err(::custom( + "expected an object with a single property", + )); + } + let variant = self + .value + .get_obj_key_at_index(0) + .ok_or_else(|| Error::invalid_type("object", &self.value))?; + visitor.visit_enum(EnumDeserializer { + variant, + value: self.value.get_at_index(0), + }) + } else { + Err(Error::invalid_type("string or object", &self.value)) + } + } + + fn deserialize_identifier>(self, visitor: V) -> Result { + self.deserialize_str(visitor) + } + + fn deserialize_ignored_any>(self, visitor: V) -> Result { + visitor.visit_unit() + } +} + +struct SeqDeserializer { + value: Value, + len: usize, + index: usize, +} + +impl<'de> SeqAccess<'de> for SeqDeserializer { + type Error = Error; + + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Error> { + if self.index >= self.len { + return Ok(None); + } + let element = self.value.get_at_index(self.index); + self.index += 1; + seed.deserialize(ValueDeserializer::new(element)).map(Some) + } + + fn size_hint(&self) -> Option { + Some(self.len - self.index) + } +} + +struct MapDeserializer { + value: Value, + len: usize, + index: usize, +} + +impl<'de> MapAccess<'de> for MapDeserializer { + type Error = Error; + + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, Error> { + if self.index >= self.len { + return Ok(None); + } + let key = self + .value + .get_obj_key_at_index(self.index) + .ok_or_else(|| Error::invalid_type("object", &self.value))?; + seed.deserialize(key.into_deserializer()).map(Some) + } + + fn next_value_seed>(&mut self, seed: V) -> Result { + let value = self.value.get_at_index(self.index); + self.index += 1; + seed.deserialize(ValueDeserializer::new(value)) + } + + fn size_hint(&self) -> Option { + Some(self.len - self.index) + } +} + +struct EnumDeserializer { + variant: String, + value: Value, +} + +impl<'de> EnumAccess<'de> for EnumDeserializer { + type Error = Error; + type Variant = ValueDeserializer; + + fn variant_seed>( + self, + seed: V, + ) -> Result<(V::Value, Self::Variant), Error> { + let variant = seed.deserialize(self.variant.into_deserializer())?; + Ok((variant, ValueDeserializer::new(self.value))) + } +} + +impl<'de> VariantAccess<'de> for ValueDeserializer { + type Error = Error; + + fn unit_variant(self) -> Result<(), Error> { + if self.value.is_null() { + Ok(()) + } else { + Err(Error::invalid_type("null", &self.value)) + } + } + + fn newtype_variant_seed>(self, seed: T) -> Result { + seed.deserialize(self) + } + + fn tuple_variant>(self, _len: usize, visitor: V) -> Result { + serde::Deserializer::deserialize_seq(self, visitor) + } + + fn struct_variant>( + self, + _fields: &'static [&'static str], + visitor: V, + ) -> Result { + serde::Deserializer::deserialize_map(self, visitor) + } +} diff --git a/shopify_function/tests/serde_adapter_test.rs b/shopify_function/tests/serde_adapter_test.rs new file mode 100644 index 0000000..a23cb73 --- /dev/null +++ b/shopify_function/tests/serde_adapter_test.rs @@ -0,0 +1,228 @@ +use shopify_function::prelude::*; +use shopify_function::serde_adapter::from_value; +use shopify_function::wasm_api::Deserialize as _; +use std::collections::BTreeMap; + +fn value_from(input: serde_json::Value) -> shopify_function::wasm_api::Value { + let context = shopify_function::wasm_api::Context::new_with_input(input); + context.input_get().unwrap() +} + +#[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +#[shopify_function(serde)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Status { + Active, + ArchivedByMerchant, + #[serde(rename = "custom-name")] + Custom, + #[serde(other)] + Unknown, +} + +#[test] +fn test_simple_enum() { + for (input, expected) in [ + ("ACTIVE", Status::Active), + ("ARCHIVED_BY_MERCHANT", Status::ArchivedByMerchant), + ("custom-name", Status::Custom), + ("SOMETHING_ELSE", Status::Unknown), + ] { + let value = value_from(serde_json::json!(input)); + assert_eq!(Status::deserialize(&value).unwrap(), expected); + } +} + +#[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +#[shopify_function(serde)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Color { + Red, + Green, +} + +#[test] +fn test_simple_enum_with_unknown_value() { + let value = value_from(serde_json::json!("BLUE")); + Color::deserialize(&value).unwrap_err(); +} + +#[test] +fn test_simple_enum_with_non_string_value() { + let value = value_from(serde_json::json!(1)); + Color::deserialize(&value).unwrap_err(); +} + +#[test] +fn test_error_message_is_kept_by_from_value() { + let value = value_from(serde_json::json!("BLUE")); + let error = from_value::(&value).unwrap_err(); + assert!( + error.message().contains("BLUE"), + "unexpected message: {error}" + ); +} + +/// A type with the shape of a JSON metafield, which is the main reason to use `serde`. +#[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +#[shopify_function(serde)] +#[serde(rename_all = "camelCase")] +pub struct Configuration { + pub name: String, + pub quantity: i32, + pub price: f64, + pub enabled: bool, + pub color: Option, + #[serde(default)] + pub tags: Vec, + pub attributes: BTreeMap, + pub pair: (i32, String), +} + +#[test] +fn test_nested_types() { + let value = value_from(serde_json::json!({ + "name": "Discount", + "quantity": 2, + "price": 10.5, + "enabled": true, + "color": "RED", + // `tags` is missing, so the default is used + "attributes": { "a": "1", "b": "2" }, + "pair": [1, "one"], + })); + + assert_eq!( + Configuration::deserialize(&value).unwrap(), + Configuration { + name: "Discount".to_string(), + quantity: 2, + price: 10.5, + enabled: true, + color: Some(Color::Red), + tags: vec![], + attributes: BTreeMap::from([ + ("a".to_string(), "1".to_string()), + ("b".to_string(), "2".to_string()), + ]), + pair: (1, "one".to_string()), + } + ); +} + +#[test] +fn test_missing_field_is_an_error() { + let value = value_from(serde_json::json!({ "name": "Discount" })); + let error = from_value::(&value).unwrap_err(); + assert!( + error.message().contains("quantity"), + "unexpected message: {error}" + ); +} + +#[test] +fn test_number_out_of_range_is_an_error() { + let value = value_from(serde_json::json!({ + "name": "Discount", + "quantity": 1.5, + "price": 1.0, + "enabled": true, + "color": null, + "attributes": {}, + "pair": [1, "one"], + })); + from_value::(&value).unwrap_err(); +} + +#[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +#[shopify_function(serde)] +#[serde(rename_all = "camelCase")] +pub enum Operation { + Noop, + Add(i32), + Move { from: String, to: String }, +} + +#[test] +fn test_enum_with_fields() { + let value = value_from(serde_json::json!("noop")); + assert_eq!(Operation::deserialize(&value).unwrap(), Operation::Noop); + + let value = value_from(serde_json::json!({ "add": 2 })); + assert_eq!(Operation::deserialize(&value).unwrap(), Operation::Add(2)); + + let value = value_from(serde_json::json!({ "move": { "from": "a", "to": "b" } })); + assert_eq!( + Operation::deserialize(&value).unwrap(), + Operation::Move { + from: "a".to_string(), + to: "b".to_string() + } + ); +} + +#[test] +fn test_untagged_enum_and_json_value() { + #[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] + #[shopify_function(serde)] + #[serde(untagged)] + enum StringOrNumber { + Number(f64), + String(String), + } + + let value = value_from(serde_json::json!(2.5)); + assert_eq!( + StringOrNumber::deserialize(&value).unwrap(), + StringOrNumber::Number(2.5) + ); + + let value = value_from(serde_json::json!("two")); + assert_eq!( + StringOrNumber::deserialize(&value).unwrap(), + StringOrNumber::String("two".to_string()) + ); + + // `deserialize_any` also lets a fully dynamic type work + let value = value_from(serde_json::json!({ "a": [1, true, null, "x"] })); + assert_eq!( + from_value::(&value).unwrap(), + serde_json::json!({ "a": [1.0, true, null, "x"] }) + ); +} + +#[test] +fn test_generic_type() { + #[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] + #[shopify_function(serde)] + struct Wrapper { + value: T, + } + + let value = value_from(serde_json::json!({ "value": "ACTIVE" })); + assert_eq!( + Wrapper::::deserialize(&value).unwrap(), + Wrapper { + value: Status::Active + } + ); +} + +#[test] +fn test_function_input_can_use_serde() { + fn run(input: Configuration) -> shopify_function::Result { + Ok(input.quantity) + } + + let payload = r#"{ + "name": "Discount", + "quantity": 3, + "price": 1.0, + "enabled": true, + "color": null, + "attributes": {}, + "pair": [1, "one"] + }"#; + let result: i32 = shopify_function::run_function_with_input(run, payload).unwrap(); + assert_eq!(result, 3); +} diff --git a/shopify_function/tests/serde_custom_scalar_override_test.rs b/shopify_function/tests/serde_custom_scalar_override_test.rs new file mode 100644 index 0000000..9d552ad --- /dev/null +++ b/shopify_function/tests/serde_custom_scalar_override_test.rs @@ -0,0 +1,82 @@ +//! Tests that a type deserialized with `serde` can be the target of a `custom_scalar_overrides` +//! entry, which is the main reason to support `serde`. + +use shopify_function::prelude::*; +use shopify_function::wasm_api::Deserialize as _; + +/// The shape of a JSON metafield. +#[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +#[shopify_function(serde)] +#[serde(rename_all = "camelCase")] +pub struct Configuration { + pub minimum_quantity: i32, + pub strategy: Strategy, +} + +#[derive(serde::Deserialize, Deserialize, PartialEq, Debug)] +#[shopify_function(serde)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Strategy { + Cheapest, + MostExpensive, + #[serde(other)] + Unknown, +} + +#[typegen([ + scalar Json + + type Shop { + name: String! + configuration: Json! + fallbackConfiguration: Json + } + + type Query { + shop: Shop! + } +], enums_as_str = ["__TypeKind"])] +mod schema { + #[query([ + query Query { + shop { + name + configuration + fallbackConfiguration + } + } + ], custom_scalar_overrides = { + // The path is relative to the schema module, so `super` is the crate root. + "Query.shop.configuration" => super::Configuration, + "Query.shop.fallbackConfiguration" => super::Configuration, + })] + pub mod query {} +} + +#[test] +fn test_custom_scalar_override_with_serde() { + let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({ + "shop": { + "name": "Test shop", + "configuration": { + "minimumQuantity": 3, + "strategy": "MOST_EXPENSIVE", + }, + "fallbackConfiguration": null, + }, + })); + let value = context.input_get().unwrap(); + + let query = schema::query::Query::deserialize(&value).unwrap(); + let shop = query.shop(); + + assert_eq!(shop.name(), "Test shop"); + assert_eq!( + shop.configuration(), + &Configuration { + minimum_quantity: 3, + strategy: Strategy::MostExpensive, + } + ); + assert_eq!(shop.fallback_configuration(), None); +} diff --git a/shopify_function_macro/src/lib.rs b/shopify_function_macro/src/lib.rs index b663671..35d9f9f 100644 --- a/shopify_function_macro/src/lib.rs +++ b/shopify_function_macro/src/lib.rs @@ -13,7 +13,7 @@ use bluejay_typegen_codegen::{ use convert_case::{Case, Casing}; use proc_macro2::Span; use quote::{format_ident, quote, ToTokens}; -use syn::{parse_macro_input, parse_quote, FnArg}; +use syn::{parse_macro_input, parse_quote, spanned::Spanned, FnArg}; fn extract_shopify_function_return_type(ast: &syn::ItemFn) -> Result<&syn::Ident, syn::Error> { use syn::*; @@ -735,6 +735,13 @@ impl ShopifyFunctionCodeGenerator { /// /// The derive macro supports the following attributes: /// +/// - `#[shopify_function(serde)]` - Deserializes the type with `serde` instead of with generated +/// code. The type must also implement `serde::Deserialize`, usually with +/// `#[derive(serde::Deserialize)]`, and all of the `serde` attributes apply. Use this for types +/// that generated code does not support, such as enums, and for types that are the target of a +/// `custom_scalar_overrides` entry in a query. See the `shopify_function::serde_adapter` module +/// for more details. +/// /// - `#[shopify_function(rename_all = "camelCase")]` - Converts field names from snake_case in Rust /// to the specified case style ("camelCase", "snake_case", or "kebab-case") when deserializing. /// @@ -751,6 +758,20 @@ impl ShopifyFunctionCodeGenerator { /// fields gracefully by using their default values instead of returning an error. /// /// Note: Fields that use `#[shopify_function(default)]` must be a type that implements the `Default` trait. +/// +/// ### Example with `serde` +/// +/// ```ignore +/// #[derive(serde::Deserialize, Deserialize)] +/// #[shopify_function(serde)] +/// #[serde(rename_all = "SCREAMING_SNAKE_CASE")] +/// enum Status { +/// Active, // deserialized from "ACTIVE" +/// Archived, // deserialized from "ARCHIVED" +/// #[serde(other)] +/// Other, // deserialized from any other string +/// } +/// ``` #[proc_macro_derive(Deserialize, attributes(shopify_function))] pub fn derive_deserialize(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); @@ -788,28 +809,116 @@ fn parse_field_attributes(field: &syn::Field) -> syn::Result { Ok(attributes) } +#[derive(Default)] +struct ContainerAttributes { + rename_all: Option, + serde: Option, +} + +fn parse_container_attributes(input: &syn::DeriveInput) -> syn::Result { + let mut attributes = ContainerAttributes::default(); + + for attr in input.attrs.iter() { + if attr.path().is_ident("shopify_function") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + attributes.rename_all = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("serde") { + attributes.serde = Some(meta.path.span()); + Ok(()) + } else { + Err(meta.error("unrecognized container attribute")) + } + })?; + } + } + + if let (Some(serde), Some(rename_all)) = (attributes.serde, attributes.rename_all.as_ref()) { + let mut error = syn::Error::new_spanned( + rename_all, + "`rename_all` is not used with `serde`; use `#[serde(rename_all = \"...\")]` instead", + ); + error.combine(syn::Error::new(serde, "`serde` is set here")); + return Err(error); + } + + Ok(attributes) +} + +/// Generates an implementation that dispatches to `serde`, for a type that is annotated with +/// `#[shopify_function(serde)]`. +fn derive_deserialize_with_serde(input: &syn::DeriveInput) -> syn::Result { + if let Some(attr) = inner_shopify_function_attribute(input) { + return Err(syn::Error::new_spanned( + attr, + "`shopify_function` attributes on fields and variants are not used with `serde`; use `serde` attributes instead", + )); + } + + let name_ident = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + // The bound makes the error easier to understand for a type that does not implement + // `serde::Deserialize`, and lets generic types work. + let where_clause: syn::WhereClause = match where_clause { + Some(where_clause) => { + let mut where_clause = where_clause.clone(); + where_clause + .predicates + .push(parse_quote! { Self: shopify_function::serde_adapter::DeserializeOwned }); + where_clause + } + None => parse_quote! { where Self: shopify_function::serde_adapter::DeserializeOwned }, + }; + + Ok(parse_quote! { + impl #impl_generics shopify_function::wasm_api::Deserialize for #name_ident #type_generics #where_clause { + fn deserialize(value: &shopify_function::wasm_api::Value) -> ::std::result::Result { + shopify_function::serde_adapter::from_value(value).map_err(::std::convert::Into::into) + } + } + }) +} + +/// Returns the first `shopify_function` attribute on a field or a variant, if there is one. +fn inner_shopify_function_attribute(input: &syn::DeriveInput) -> Option<&syn::Attribute> { + fn attribute_of(attrs: &[syn::Attribute]) -> Option<&syn::Attribute> { + attrs + .iter() + .find(|attr| attr.path().is_ident("shopify_function")) + } + + fn attribute_of_fields(fields: &syn::Fields) -> Option<&syn::Attribute> { + fields.iter().find_map(|field| attribute_of(&field.attrs)) + } + + match &input.data { + syn::Data::Struct(data) => attribute_of_fields(&data.fields), + syn::Data::Enum(data) => data.variants.iter().find_map(|variant| { + attribute_of(&variant.attrs).or_else(|| attribute_of_fields(&variant.fields)) + }), + syn::Data::Union(data) => data + .fields + .named + .iter() + .find_map(|field| attribute_of(&field.attrs)), + } +} + fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result { + let container_attributes = parse_container_attributes(input)?; + + if container_attributes.serde.is_some() { + return derive_deserialize_with_serde(input); + } + match &input.data { syn::Data::Struct(data) => match &data.fields { syn::Fields::Named(fields) => { let name_ident = &input.ident; - let mut rename_all: Option = None; - - for attr in input.attrs.iter() { - if attr.path().is_ident("shopify_function") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") { - rename_all = Some(meta.value()?.parse()?); - Ok(()) - } else { - Err(meta.error("unrecognized repr")) - } - })?; - } - } - - let case_style = match rename_all { + let case_style = match container_attributes.rename_all { Some(rename_all) => match rename_all.value().as_str() { "camelCase" => Some(Case::Camel), "snake_case" => Some(Case::Snake), @@ -888,7 +997,7 @@ fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result< }, syn::Data::Enum(_) => Err(syn::Error::new_spanned( input, - "Enum types are not supported for deriving `Deserialize`", + "Enum types are only supported for deriving `Deserialize` with `#[shopify_function(serde)]`", )), syn::Data::Union(_) => Err(syn::Error::new_spanned( input,