diff --git a/example_with_targets/README.md b/example_with_targets/README.md index c0c00a5..14fd75e 100644 --- a/example_with_targets/README.md +++ b/example_with_targets/README.md @@ -76,20 +76,27 @@ input_query = "b.graphql" export = "function_b" ``` -- `target`: the API-specific handle for the target implemented by the Wasm function -- `input_query`: the path to the target-specific input query file -- `export` (optional): the name of the Wasm function export to run - - default: the target handle as `snake_case` +- `target`: The API-specific handle for the target implemented by the Wasm function. +- `input_query`: The path to the target-specific input query file. +- `export` (optional): The name of the Wasm function export to run. + - default: The target handle as `snake_case`. ## `shopify_function_target` usage ### Arguments -- `query_path`: the path to the input query file for the target -- `schema_path`: the path to the API schema file for the target -- `target` (optional): the API-specific handle for the target if the function name does not match the target handle as `snake_case` -- `module_name` (optional): the name of the generated module - - default: the target handle as `snake_case` +- `query_path`: A path to a GraphQL query, whose result will be used + as the input for the function invocation. The query MUST be named "Input". +- `schema_path`: A path to Shopify's GraphQL schema definition. Use the CLI + to download a fresh copy. +- `target` (optional): The API-specific handle for the target if the function name does not match the target handle as `snake_case`. +- `module_name` (optional): The name of the generated module. + - default: The target handle as `snake_case` +- `extern_enums` (optional): A list of Enums for which an external type should be used. + For those, code generation will be skipped. This is useful for large enums + which can increase binary size, or for enums shared between multiple targets. + Example: `extern_enums = ["LanguageCode"]` + - default: `["LanguageCode", "CountryCode", "CurrencyCode"]` ### `src/lib.rs` diff --git a/shopify_function/src/enums.rs b/shopify_function/src/enums.rs new file mode 100644 index 0000000..33ed864 --- /dev/null +++ b/shopify_function/src/enums.rs @@ -0,0 +1,3 @@ +pub type CountryCode = String; +pub type CurrencyCode = String; +pub type LanguageCode = String; diff --git a/shopify_function/src/lib.rs b/shopify_function/src/lib.rs index eb38bc4..931f739 100644 --- a/shopify_function/src/lib.rs +++ b/shopify_function/src/lib.rs @@ -18,11 +18,14 @@ pub use shopify_function_macro::{generate_types, shopify_function, shopify_function_target}; +#[doc(hidden)] +pub mod enums; /// Only used for struct generation. #[doc(hidden)] pub mod scalars; pub mod prelude { + pub use crate::enums::*; pub use crate::scalars::*; pub use shopify_function_macro::{generate_types, shopify_function, shopify_function_target}; } diff --git a/shopify_function/tests/fixtures/input.graphql b/shopify_function/tests/fixtures/input.graphql index 4b946a7..1030dfa 100644 --- a/shopify_function/tests/fixtures/input.graphql +++ b/shopify_function/tests/fixtures/input.graphql @@ -2,4 +2,5 @@ query Input { id num name + country } diff --git a/shopify_function/tests/fixtures/schema.graphql b/shopify_function/tests/fixtures/schema.graphql index 8f7da3e..93d9fd4 100644 --- a/shopify_function/tests/fixtures/schema.graphql +++ b/shopify_function/tests/fixtures/schema.graphql @@ -28,6 +28,7 @@ type Input { id: ID! num: Int name: String + country: CountryCode } """ @@ -51,3 +52,8 @@ The result of the function. input FunctionResult { name: String } + +enum CountryCode { + AC + CA +} diff --git a/shopify_function/tests/fixtures/schema_with_targets.graphql b/shopify_function/tests/fixtures/schema_with_targets.graphql index 660fba5..bbeb3c1 100644 --- a/shopify_function/tests/fixtures/schema_with_targets.graphql +++ b/shopify_function/tests/fixtures/schema_with_targets.graphql @@ -28,6 +28,7 @@ type Input { id: ID! num: Int name: String + country: CountryCode, targetAResult: Int @restrictTarget(only: ["test.target-b"]) } @@ -69,3 +70,8 @@ The result of API target B. input FunctionTargetBResult { name: String } + +enum CountryCode { + AC + CA +} diff --git a/shopify_function/tests/shopify_function.rs b/shopify_function/tests/shopify_function.rs index 281cb53..d6270a1 100644 --- a/shopify_function/tests/shopify_function.rs +++ b/shopify_function/tests/shopify_function.rs @@ -4,7 +4,8 @@ use shopify_function::Result; const FUNCTION_INPUT: &str = r#"{ "id": "gid://shopify/Order/1234567890", "num": 123, - "name": "test" + "name": "test", + "country": "CA" }"#; static mut FUNCTION_OUTPUT: Vec = vec![]; diff --git a/shopify_function/tests/shopify_function_target.rs b/shopify_function/tests/shopify_function_target.rs index 9213a0c..b20c5b2 100644 --- a/shopify_function/tests/shopify_function_target.rs +++ b/shopify_function/tests/shopify_function_target.rs @@ -4,7 +4,8 @@ use shopify_function::Result; const TARGET_A_INPUT: &str = r#"{ "id": "gid://shopify/Order/1234567890", "num": 123, - "name": "test" + "name": "test", + "country": "CA" }"#; static mut TARGET_A_OUTPUT: Vec = vec![]; @@ -24,8 +25,11 @@ fn test_target_a_export() { output_stream = unsafe { &mut TARGET_A_OUTPUT } )] fn target_a( - _input: target_a::input::ResponseData, + input: target_a::input::ResponseData, ) -> Result { + if input.country != Some("CA".to_string()) { + panic!("Expected CountryCode to be the CA String") + } Ok(target_a::output::FunctionTargetAResult { status: Some(200) }) } @@ -49,7 +53,7 @@ fn test_mod_b_export() { query_path = "./tests/fixtures/b.graphql", schema_path = "./tests/fixtures/schema_with_targets.graphql", input_stream = std::io::Cursor::new(TARGET_B_INPUT.as_bytes().to_vec()), - output_stream = unsafe { &mut TARGET_B_OUTPUT } + output_stream = unsafe { &mut TARGET_B_OUTPUT }, )] fn some_function( input: mod_b::input::ResponseData, @@ -58,3 +62,26 @@ fn some_function( name: Some(format!("new name: {}", input.id)), }) } + +// Verify that the CountryCode enum is generated when `extern_enums = []` +#[shopify_function_target( + target = "test.target-a", + module_name = "country_enum", + query_path = "./tests/fixtures/input.graphql", + schema_path = "./tests/fixtures/schema_with_targets.graphql", + extern_enums = [] +)] +fn _with_generated_country_code( + input: country_enum::input::ResponseData, +) -> Result { + use country_enum::*; + + let status = match input.country { + Some(input::CountryCode::CA) => 200, + _ => 201, + }; + + Ok(output::FunctionTargetAResult { + status: Some(status), + }) +} diff --git a/shopify_function_macro/src/lib.rs b/shopify_function_macro/src/lib.rs index d2eb415..96a2dcc 100644 --- a/shopify_function_macro/src/lib.rs +++ b/shopify_function_macro/src/lib.rs @@ -2,9 +2,12 @@ use convert_case::{Case, Casing}; use std::io::Write; use std::path::Path; -use proc_macro2::{Ident, Span, TokenStream, TokenTree}; +use proc_macro2::{Ident, Span, TokenStream}; use quote::{quote, ToTokens}; -use syn::{self, parse::Parse, parse::ParseStream, parse_macro_input, Expr, FnArg, LitStr, Token}; +use syn::{ + self, parse::Parse, parse::ParseStream, parse_macro_input, Expr, ExprArray, FnArg, LitStr, + Token, +}; #[derive(Clone, Default)] struct ShopifyFunctionArgs { @@ -123,6 +126,7 @@ struct ShopifyFunctionTargetArgs { schema_path: Option, input_stream: Option, output_stream: Option, + extern_enums: Option, } impl ShopifyFunctionTargetArgs { @@ -156,6 +160,54 @@ impl Parse for ShopifyFunctionTargetArgs { args.input_stream = Some(Self::parse::(&input)?); } else if lookahead.peek(kw::output_stream) { args.output_stream = Some(Self::parse::(&input)?); + } else if lookahead.peek(kw::extern_enums) { + args.extern_enums = Some(Self::parse::(&input)?); + } else { + return Err(lookahead.error()); + } + } + Ok(args) + } +} + +#[derive(Clone, Default)] +struct GenerateTypeArgs { + query_path: Option, + schema_path: Option, + input_stream: Option, + output_stream: Option, + extern_enums: Option, +} + +impl GenerateTypeArgs { + fn parse( + input: &ParseStream<'_>, + ) -> syn::Result { + input.parse::()?; + input.parse::()?; + let value: V = input.parse()?; + if input.lookahead1().peek(Token![,]) { + input.parse::()?; + } + Ok(value) + } +} + +impl Parse for GenerateTypeArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let lookahead = input.lookahead1(); + if lookahead.peek(kw::query_path) { + args.query_path = Some(Self::parse::(&input)?); + } else if lookahead.peek(kw::schema_path) { + args.schema_path = Some(Self::parse::(&input)?); + } else if lookahead.peek(kw::input_stream) { + args.input_stream = Some(Self::parse::(&input)?); + } else if lookahead.peek(kw::output_stream) { + args.output_stream = Some(Self::parse::(&input)?); + } else if lookahead.peek(kw::extern_enums) { + args.extern_enums = Some(Self::parse::(&input)?); } else { return Err(lookahead.error()); } @@ -213,6 +265,26 @@ fn extract_shopify_function_return_type(ast: &syn::ItemFn) -> Result<&syn::Ident Ok(&path.path.segments.last().as_ref().unwrap().ident) } +/// Generates code for a Function using an explicitly-named target. This will: +/// - Generate a module to host the generated types. +/// - Generate types based on the GraphQL schema for the Function input and output. +/// - Define a wrapper function that's exported to Wasm. The wrapper handles +/// decoding the input from STDIN, and encoding the output to STDOUT. +/// +/// +/// The macro takes the following parameters: +/// - `query_path`: A path to a GraphQL query, whose result will be used +/// as the input for the function invocation. The query MUST be named "Input". +/// - `schema_path`: A path to Shopify's GraphQL schema definition. Use the CLI +/// to download a fresh copy. +/// - `target` (optional): The API-specific handle for the target if the function name does not match the target handle as `snake_case` +/// - `module_name` (optional): The name of the generated module. +/// - default: The target handle as `snake_case` +/// - `extern_enums` (optional): A list of Enums for which an external type should be used. +/// For those, code generation will be skipped. This is useful for large enums +/// which can increase binary size, or for enums shared between multiple targets. +/// Example: `extern_enums = ["LanguageCode"]` +/// - default: `["LanguageCode", "CountryCode", "CurrencyCode"]` #[proc_macro_attribute] pub fn shopify_function_target( attr: proc_macro::TokenStream, @@ -239,17 +311,21 @@ pub fn shopify_function_target( let query_path = args.query_path.expect("No value given for query_path"); let schema_path = args.schema_path.expect("No value given for schema_path"); + let extern_enums = args.extern_enums.as_ref().map(extract_extern_enums); let output_query_file_name = format!(".{}{}", &target_handle_string, OUTPUT_QUERY_FILE_NAME); let input_struct = generate_struct( "Input", query_path.value().as_str(), schema_path.value().as_str(), + extern_enums.as_deref(), ); + let output_struct = generate_struct( "Output", &output_query_file_name, schema_path.value().as_str(), + extern_enums.as_deref(), ); if let Err(error) = extract_shopify_function_return_type(&ast) { return error.to_compile_error().into(); @@ -302,22 +378,6 @@ pub fn shopify_function_target( .into() } -fn extract_attr(attrs: &TokenStream, attr: &str) -> String { - let attrs: Vec = attrs.clone().into_iter().collect(); - let attr_index = attrs - .iter() - .position(|item| match item { - TokenTree::Ident(ident) => ident.to_string().as_str() == attr, - _ => false, - }) - .unwrap_or_else(|| panic!("No attribute with name {} found", attr)); - let value = attrs - .get(attr_index + 2) - .unwrap_or_else(|| panic!("No value given for {} attribute", attr)) - .to_string(); - value.as_str()[1..value.len() - 1].to_string() -} - const OUTPUT_QUERY_FILE_NAME: &str = ".output.graphql"; /// Generate the types to interact with Shopify's API. @@ -326,25 +386,45 @@ const OUTPUT_QUERY_FILE_NAME: &str = ".output.graphql"; /// modules generate Rust types from the GraphQL schema file for the Function input /// and output respectively. /// -/// The macro takes two parameters: +/// The macro takes the following parameters: /// - `query_path`: A path to a GraphQL query, whose result will be used /// as the input for the function invocation. The query MUST be named "Input". -/// - `schema_path`: A path to Shopify's GraphQL schema definition. You -/// can find it in the `example` folder of the repo, or use the CLI -/// to download a fresh copy (not implemented yet). +/// - `schema_path`: A path to Shopify's GraphQL schema definition. Use the CLI +/// to download a fresh copy. +/// - `extern_enums` (optional): A list of Enums for which an external type should be used. +/// For those, code generation will be skipped. This is useful for large enums +/// which can increase binary size, or for enums shared between multiple targets. +/// Example: `extern_enums = ["LanguageCode"]` +/// - default: `["LanguageCode", "CountryCode", "CurrencyCode"]` /// /// Note: This macro creates a file called `.output.graphql` in the root /// directory of the project. It can be safely added to your `.gitignore`. We /// hope we can avoid creating this file at some point in the future. #[proc_macro] pub fn generate_types(attr: proc_macro::TokenStream) -> proc_macro::TokenStream { - let params = TokenStream::from(attr); - - let query_path = extract_attr(¶ms, "query_path"); - let schema_path = extract_attr(¶ms, "schema_path"); - - let input_struct = generate_struct("Input", &query_path, &schema_path); - let output_struct = generate_struct("Output", OUTPUT_QUERY_FILE_NAME, &schema_path); + let args = parse_macro_input!(attr as GenerateTypeArgs); + + let query_path = args + .query_path + .expect("No value given for query_path") + .value(); + let schema_path = args + .schema_path + .expect("No value given for schema_path") + .value(); + let extern_enums = args.extern_enums.as_ref().map(extract_extern_enums); + let input_struct = generate_struct( + "Input", + query_path.as_str(), + schema_path.as_str(), + extern_enums.as_deref(), + ); + let output_struct = generate_struct( + "Output", + OUTPUT_QUERY_FILE_NAME, + schema_path.as_str(), + extern_enums.as_deref(), + ); let output_query = "mutation Output($result: FunctionResult!) {\n handleResult(result: $result)\n}\n"; @@ -357,9 +437,20 @@ pub fn generate_types(attr: proc_macro::TokenStream) -> proc_macro::TokenStream .into() } -fn generate_struct(name: &str, query_path: &str, schema_path: &str) -> TokenStream { +const DEFAULT_EXTERN_ENUMS: &[&str] = &["LanguageCode", "CountryCode", "CurrencyCode"]; + +fn generate_struct( + name: &str, + query_path: &str, + schema_path: &str, + extern_enums: Option<&[String]>, +) -> TokenStream { let name_ident = Ident::new(name, Span::mixed_site()); + let extern_enums = extern_enums + .map(|e| e.to_owned()) + .unwrap_or_else(|| DEFAULT_EXTERN_ENUMS.iter().map(|e| e.to_string()).collect()); + quote! { #[derive(graphql_client::GraphQLQuery, Clone, Debug, serde::Deserialize, PartialEq)] #[graphql( @@ -367,6 +458,7 @@ fn generate_struct(name: &str, query_path: &str, schema_path: &str) -> TokenStre schema_path = #schema_path, response_derives = "Clone,Debug,PartialEq,Deserialize,Serialize", variables_derives = "Clone,Debug,PartialEq,Deserialize", + extern_enums(#(#extern_enums),*), skip_serializing_none )] pub struct #name_ident; @@ -382,6 +474,24 @@ fn write_output_query_file(output_query_file_name: &str, contents: &str) { .unwrap_or_else(|_| panic!("Could not write to {}", output_query_file_name)); } +fn extract_extern_enums(extern_enums: &ExprArray) -> Vec { + let extern_enum_error_msg = r#"The `extern_enums` attribute expects comma separated string literals\n\n= help: use `extern_enums = ["Enum1", "Enum2"]`"#; + extern_enums + .elems + .iter() + .map(|expr| { + let value = match expr { + Expr::Lit(lit) => lit.lit.clone(), + _ => panic!("{}", extern_enum_error_msg), + }; + match value { + syn::Lit::Str(lit) => lit.value(), + _ => panic!("{}", extern_enum_error_msg), + } + }) + .collect() +} + #[cfg(test)] mod tests {} @@ -392,4 +502,5 @@ mod kw { syn::custom_keyword!(schema_path); syn::custom_keyword!(input_stream); syn::custom_keyword!(output_stream); + syn::custom_keyword!(extern_enums); }