From 2c90484e0a3ab1d3918ef377e98c359408bc5040 Mon Sep 17 00:00:00 2001 From: Adam Petro Date: Wed, 19 Aug 2026 15:53:46 -0400 Subject: [PATCH] Add serde support to the Deserialize derive macro The Deserialize derive macro writes code only for structs with named fields. Some types are difficult to write with this macro, for example enums and the shape of a JSON metafield. This change adds the `#[shopify_function(serde)]` attribute. The macro then writes an implementation that uses serde. The type keeps its own name. Then the `custom_scalar_overrides` argument of a query can refer to the type. A generic wrapper type cannot do this, because the argument does not accept generic arguments. The new `serde_adapter` module holds a serde deserializer for the input of the Wasm API. The deserializer reads the input values directly. It does not build an intermediate JSON tree. The public `from_value` function keeps the error message from serde. serde was already in the dependency tree, because serde_json needs it. This change makes the dependency direct. The size of the Wasm output does not change. Assisted-By: devx/019ff170-a20d-7ca9-9f19-e3273cbb2420 --- Cargo.lock | 1 + shopify_function/Cargo.toml | 4 + shopify_function/src/lib.rs | 4 + shopify_function/src/serde_adapter.rs | 465 ++++++++++++++++++ shopify_function/tests/serde_adapter_test.rs | 228 +++++++++ .../serde_custom_scalar_override_test.rs | 82 +++ shopify_function_macro/src/lib.rs | 145 +++++- 7 files changed, 911 insertions(+), 18 deletions(-) create mode 100644 shopify_function/src/serde_adapter.rs create mode 100644 shopify_function/tests/serde_adapter_test.rs create mode 100644 shopify_function/tests/serde_custom_scalar_override_test.rs 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,