diff --git a/Cargo.lock b/Cargo.lock index 360e2b8bc..d4535b0d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3023,6 +3023,7 @@ dependencies = [ "uniffi", "url", "wp_contextual", + "wp_derive_request_builder", ] [[package]] @@ -3046,6 +3047,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", + "rstest", "serde", "strum", "strum_macros", @@ -3053,6 +3055,7 @@ dependencies = [ "thiserror", "trybuild", "uniffi", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b00ff707a..38f8b6fc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" thiserror = "1.0" uniffi = "0.27.3" +url = "2.5" diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index 6921a5d24..06d0165fa 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -12,13 +12,14 @@ async-trait = "0.1" base64 = { workspace = true } http = { workspace = true } indoc = "2.0" -url = "2.5" +url = { workspace = true } parse_link_header = "0.3" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } uniffi = { workspace = true } wp_contextual = { path = "../wp_contextual" } +wp_derive_request_builder = { path = "../wp_derive_request_builder", features = [ "generate_request_builder" ] } [dev-dependencies] chrono = { version = "0.4" } diff --git a/wp_api/src/request/endpoint.rs b/wp_api/src/request/endpoint.rs index 5cadc46a4..b901fdb28 100644 --- a/wp_api/src/request/endpoint.rs +++ b/wp_api/src/request/endpoint.rs @@ -83,6 +83,22 @@ impl ApiBaseUrl { .expect("ApiBaseUrl is already parsed, so this can't result in an error") } + pub fn by_extending_and_splitting_by_forward_slash(&self, segments: I) -> Url + where + I: IntoIterator, + I::Item: AsRef, + { + self.url + .clone() + .extend(segments.into_iter().flat_map(|s| { + s.as_ref() + .split('/') + .map(str::to_string) + .collect::>() + })) + .expect("ApiBaseUrl is already parsed, so this can't result in an error") + } + fn as_str(&self) -> &str { self.url.as_str() } diff --git a/wp_api/src/request/endpoint/users_endpoint.rs b/wp_api/src/request/endpoint/users_endpoint.rs index 489e42632..6f86dfab1 100644 --- a/wp_api/src/request/endpoint/users_endpoint.rs +++ b/wp_api/src/request/endpoint/users_endpoint.rs @@ -4,6 +4,36 @@ use crate::{SparseUserField, UserDeleteParams, UserId, UserListParams, WpContext use super::{ApiBaseUrl, ApiEndpointUrl, UrlExtension}; +// Temporary mod to showcase `wp_derive_request_builder` +// The generated code can be inspected by running: +// ``` +// cargo expand request::endpoint::users_endpoint::generated -p wp_api +// ``` +mod generated { + use super::*; + + #[derive(wp_derive_request_builder::WpDerivedRequest)] + #[SparseField(SparseUserField)] + enum UsersRequest { + #[contextual_get(url = "/users", params = &UserListParams, output = Vec)] + List, + #[post(url = "/users", params = &crate::UserCreateParams, output = UserWithEditContext)] + Create, + #[delete(url = "/users/", params = &UserDeleteParams, output = crate::UserDeleteResponse)] + Delete, + #[delete(url = "/users/me", params = &UserDeleteParams, output = crate::UserDeleteResponse)] + DeleteMe, + #[contextual_get(url = "/users/", output = crate::SparseUser)] + Retrieve, + #[contextual_get(url = "/users/me", output = crate::SparseUser)] + RetrieveMe, + #[post(url = "/users/", params = &crate::UserUpdateParams, output = UserWithEditContext)] + Update, + #[post(url = "/users/me", params = &crate::UserUpdateParams, output = UserWithEditContext)] + UpdateMe, + } +} + #[derive(Debug)] pub(crate) struct UsersEndpoint { api_base_url: Arc, diff --git a/wp_derive_request_builder/Cargo.toml b/wp_derive_request_builder/Cargo.toml index 15949b5f0..4eef4a581 100644 --- a/wp_derive_request_builder/Cargo.toml +++ b/wp_derive_request_builder/Cargo.toml @@ -15,7 +15,9 @@ name = "tests" path = "tests/parser_tests.rs" [dev-dependencies] +rstest = { workspace = true } trybuild = { version = "1.0", features = ["diff"] } +url = { workspace = true } [dependencies] convert_case = "0.6" diff --git a/wp_derive_request_builder/src/generate.rs b/wp_derive_request_builder/src/generate.rs new file mode 100644 index 000000000..b11e9d72c --- /dev/null +++ b/wp_derive_request_builder/src/generate.rs @@ -0,0 +1,175 @@ +use std::fmt::Display; + +use helpers_to_generate_tokens::*; +use proc_macro2::{Span, TokenStream}; +use proc_macro_crate::{crate_name, FoundCrate}; +use quote::{format_ident, quote}; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; +use syn::Ident; + +use crate::{ + parse::{ParsedEnum, ParsedVariant, RequestType}, + sparse_field_attr::SparseFieldAttr, +}; + +mod helpers_to_generate_tokens; + +pub(crate) fn generate_types(parsed_enum: &ParsedEnum) -> syn::Result { + let config = Config::new(parsed_enum); + + Ok(generate_endpoint_type(&config, parsed_enum)) +} + +fn generate_endpoint_type(config: &Config, parsed_enum: &ParsedEnum) -> TokenStream { + let api_base_url_type = &config.api_base_url_type; + let endpoint_ident = format_ident!("{}Endpoint", parsed_enum.enum_ident); + + let functions = parsed_enum.variants.iter().map(|variant| { + let url_parts = variant.attr.url_parts.as_slice(); + let params_type = &variant.attr.params; + let request_type = variant.attr.request_type; + let url_from_endpoint = fn_body_get_url_from_api_base_url(url_parts); + let query_pairs = fn_body_query_pairs(params_type, request_type); + + ContextAndFilterHandler::from_request_type(request_type) + .into_iter() + .map(|context_and_filter_handler| { + let fn_signature = fn_signature( + PartOf::Endpoint, + &variant.variant_ident, + url_parts, + params_type, + request_type, + context_and_filter_handler, + &config.sparse_field_type, + ); + let context_query_pair = + fn_body_context_query_pairs(&config.crate_ident, context_and_filter_handler); + let api_endpoint_url_type = &config.api_endpoint_url_type; + let fields_query_pairs = + fn_body_fields_query_pairs(&config.crate_ident, context_and_filter_handler); + quote! { + pub #fn_signature -> #api_endpoint_url_type { + #url_from_endpoint + #context_query_pair + #query_pairs + #fields_query_pairs + url.into() + } + } + }) + .collect::() + }); + + quote! { + #[derive(Debug)] + pub struct #endpoint_ident { + api_base_url: #api_base_url_type, + } + + impl #endpoint_ident { + pub fn new(api_base_url: #api_base_url_type) -> Self { + Self { api_base_url } + } + + #(#functions)* + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum PartOf { + Endpoint, + RequestBuilder, +} + +#[derive(Debug, Clone, Copy)] +pub enum ContextAndFilterHandler { + None, + NoFilterTakeContextAsArgument, + NoFilterTakeContextAsFunctionName(WpContext), + FilterTakeContextAsArgument, + FilterNoContext, +} + +impl ContextAndFilterHandler { + fn from_request_type(request_type: RequestType) -> Vec { + match request_type { + crate::parse::RequestType::ContextualGet => { + let mut v: Vec = WpContext::iter() + .map(Self::NoFilterTakeContextAsFunctionName) + .collect(); + v.push(ContextAndFilterHandler::FilterTakeContextAsArgument); + v + } + crate::parse::RequestType::Get => { + vec![ + ContextAndFilterHandler::NoFilterTakeContextAsArgument, + ContextAndFilterHandler::FilterTakeContextAsArgument, + ] + } + crate::parse::RequestType::Delete | crate::parse::RequestType::Post => { + vec![ + ContextAndFilterHandler::None, + ContextAndFilterHandler::FilterNoContext, + ] + } + } + } +} + +#[derive(Debug, Clone, Copy, EnumIter)] +pub enum WpContext { + Edit, + Embed, + View, +} + +impl Display for WpContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + WpContext::Edit => "Edit", + WpContext::Embed => "Embed", + WpContext::View => "View", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug)] +pub struct Config { + pub crate_ident: Ident, + pub api_base_url_type: TokenStream, + pub api_endpoint_url_type: TokenStream, + pub request_builder_type: TokenStream, + pub endpoint_ident: Ident, + pub request_builder_ident: Ident, + pub sparse_field_type: SparseFieldAttr, +} + +impl Config { + fn new(parsed_enum: &ParsedEnum) -> Self { + let crate_name = "wp_api"; + let found_crate = proc_macro_crate::crate_name(crate_name) + .unwrap_or_else(|_| panic!("{} is not present in `Cargo.toml`", crate_name)); + + let crate_ident = match found_crate { + FoundCrate::Itself => format_ident!("crate"), + FoundCrate::Name(name) => Ident::new(&name, Span::call_site()), + }; + let api_base_url_type = + quote! { std::sync::Arc<#crate_ident::request::endpoint::ApiBaseUrl> }; + let api_endpoint_url_type = quote! { #crate_ident::request::endpoint::ApiEndpointUrl }; + let request_builder_type = quote! { std::sync::Arc<#crate_ident::request::RequestBuilder> }; + Self { + crate_ident, + api_base_url_type, + api_endpoint_url_type, + request_builder_type, + endpoint_ident: format_ident!("{}Endpoint", parsed_enum.enum_ident), + request_builder_ident: format_ident!("{}Builder", parsed_enum.enum_ident), + sparse_field_type: parsed_enum.sparse_field_attr.clone(), + } + } +} diff --git a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs new file mode 100644 index 000000000..f4263f15b --- /dev/null +++ b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs @@ -0,0 +1,912 @@ +use convert_case::{Case, Casing}; +use proc_macro2::{Span, TokenStream, TokenTree}; +use quote::{format_ident, quote}; +use syn::Ident; + +use super::{ContextAndFilterHandler, PartOf, WpContext}; +use crate::{ + parse::{ParsedEnum, RequestType}, + sparse_field_attr::SparseFieldAttr, + variant_attr::{ParamsType, UrlPart}, +}; + +pub fn endpoint_ident(parsed_enum: &ParsedEnum) -> Ident { + format_ident!("{}Endpoint", parsed_enum.enum_ident) +} + +pub fn request_builder_ident(parsed_enum: &ParsedEnum) -> Ident { + format_ident!("{}Builder", parsed_enum.enum_ident) +} + +pub fn api_base_url_type(crate_ident: &Ident) -> TokenStream { + quote! { std::sync::Arc<#crate_ident::request::endpoint::ApiBaseUrl> } +} + +pub fn request_builder_type(crate_ident: &Ident) -> TokenStream { + quote! { std::sync::Arc<#crate_ident::request::RequestBuilder> } +} + +pub fn fn_signature( + part_of: PartOf, + variant_ident: &Ident, + url_parts: &[UrlPart], + params_type: &ParamsType, + request_type: RequestType, + context_and_filter_handler: ContextAndFilterHandler, + sparse_field_type: &SparseFieldAttr, +) -> TokenStream { + let fn_name = fn_name(variant_ident, context_and_filter_handler); + let url_params = fn_url_params(url_parts); + let context_param = fn_context_param(context_and_filter_handler); + let provided_param = fn_provided_param(part_of, params_type, request_type); + let fields_param = fn_fields_param(context_and_filter_handler, sparse_field_type); + quote! { fn #fn_name(&self, #url_params #context_param #provided_param #fields_param) } +} + +pub fn fn_url_params(url_parts: &[UrlPart]) -> TokenStream { + let params = url_parts.iter().filter_map(|p| { + if let UrlPart::Dynamic(p) = p { + let p_ident = format_ident!("{}", p); + let p_upper_camel_case = format_ident!("{}", p.to_case(Case::UpperCamel)); + Some(quote! { #p_ident: #p_upper_camel_case }) + } else { + None + } + }); + quote! { #(#params,)* } +} + +pub fn fn_provided_param( + part_of: PartOf, + params_type: &ParamsType, + request_type: RequestType, +) -> TokenStream { + if let Some(params_type) = params_type.tokens() { + let tokens = { + let params_type_token_stream = TokenStream::from_iter(params_type.clone()); + quote! { params: #params_type_token_stream, } + }; + match part_of { + // Endpoints don't need the params type if it's a Post request because params will + // be part of the body. + PartOf::Endpoint => match request_type { + crate::parse::RequestType::ContextualGet + | crate::parse::RequestType::Delete + | crate::parse::RequestType::Get => tokens, + crate::parse::RequestType::Post => TokenStream::new(), + }, + PartOf::RequestBuilder => tokens, + } + } else { + TokenStream::new() + } +} + +pub fn fn_context_param(context_and_filter_handler: ContextAndFilterHandler) -> TokenStream { + match context_and_filter_handler { + ContextAndFilterHandler::None + | ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(_) + | ContextAndFilterHandler::FilterNoContext => TokenStream::new(), + ContextAndFilterHandler::NoFilterTakeContextAsArgument + | ContextAndFilterHandler::FilterTakeContextAsArgument => quote! { context: WpContext, }, + } +} + +pub fn fn_fields_param( + context_and_filter_handler: ContextAndFilterHandler, + sparse_field_type: &SparseFieldAttr, +) -> TokenStream { + match context_and_filter_handler { + ContextAndFilterHandler::None + | ContextAndFilterHandler::NoFilterTakeContextAsArgument + | ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(_) => TokenStream::new(), + ContextAndFilterHandler::FilterTakeContextAsArgument + | ContextAndFilterHandler::FilterNoContext => { + let sparse_field_type: &TokenStream = &sparse_field_type.tokens; + quote! { fields: &[#sparse_field_type] } + } + } +} + +pub fn fn_name( + variant_ident: &Ident, + context_and_filter_handler: ContextAndFilterHandler, +) -> Ident { + let basic_fn_name = format_ident!("{}", variant_ident.to_string().to_case(Case::Snake)); + match context_and_filter_handler { + ContextAndFilterHandler::None | ContextAndFilterHandler::NoFilterTakeContextAsArgument => { + basic_fn_name + } + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(context) => format_ident!( + "{}_with_{}_context", + basic_fn_name, + context.to_string().to_lowercase() + ), + ContextAndFilterHandler::FilterTakeContextAsArgument + | ContextAndFilterHandler::FilterNoContext => { + format_ident!("filter_{}", basic_fn_name) + } + } +} + +pub fn fn_body_get_url_from_endpoint( + variant_ident: &Ident, + url_parts: &[UrlPart], + params_type: &ParamsType, + request_type: RequestType, + context_and_filter_handler: ContextAndFilterHandler, +) -> TokenStream { + let fn_name = fn_name(variant_ident, context_and_filter_handler); + let fn_arg_url_parts = fn_arg_url_parts(url_parts); + let fn_arg_context = fn_arg_context(context_and_filter_handler); + let fn_arg_provided_params = + fn_arg_provided_params(PartOf::Endpoint, params_type, request_type); + let fn_arg_fields = fn_arg_fields(context_and_filter_handler); + + quote! { + let url = self.endpoint.#fn_name(#fn_arg_url_parts #fn_arg_context #fn_arg_provided_params #fn_arg_fields); + } +} + +fn fn_arg_url_parts(url_parts: &[UrlPart]) -> TokenStream { + url_parts + .iter() + .filter_map(|url_part| match url_part { + UrlPart::Dynamic(dynamic_part) => { + let d = format_ident!("{}", dynamic_part); + Some(quote! { #d, }) + } + UrlPart::Static(_) => None, + }) + .collect::() +} + +fn fn_arg_context(context_and_filter_handler: ContextAndFilterHandler) -> TokenStream { + match context_and_filter_handler { + ContextAndFilterHandler::None + | ContextAndFilterHandler::FilterNoContext + | ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(_) => TokenStream::new(), + ContextAndFilterHandler::NoFilterTakeContextAsArgument + | ContextAndFilterHandler::FilterTakeContextAsArgument => { + quote! { context, } + } + } +} + +fn fn_arg_provided_params( + part_of: PartOf, + params_type: &ParamsType, + request_type: RequestType, +) -> TokenStream { + if params_type.tokens().is_some() { + let tokens = quote! { params, }; + match part_of { + // Endpoints don't need the params type if it's a Post request because params will + // be part of the body. + PartOf::Endpoint => match request_type { + crate::parse::RequestType::ContextualGet + | crate::parse::RequestType::Delete + | crate::parse::RequestType::Get => tokens, + crate::parse::RequestType::Post => TokenStream::new(), + }, + PartOf::RequestBuilder => tokens, + } + } else { + TokenStream::new() + } +} + +fn fn_arg_fields(context_and_filter_handler: ContextAndFilterHandler) -> TokenStream { + match context_and_filter_handler { + ContextAndFilterHandler::None + | ContextAndFilterHandler::NoFilterTakeContextAsArgument + | ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(_) => TokenStream::new(), + ContextAndFilterHandler::FilterTakeContextAsArgument + | ContextAndFilterHandler::FilterNoContext => quote! { fields, }, + } +} + +pub fn fn_body_get_url_from_api_base_url(url_parts: &[UrlPart]) -> TokenStream { + let url_parts = url_parts + .iter() + .map(|part| match part { + UrlPart::Dynamic(dynamic_part) => { + let ident = format_ident!("{}", dynamic_part); + quote! { &#ident.to_string() } + } + UrlPart::Static(static_part) => quote! { #static_part }, + }) + .collect::>(); + quote! { + let mut url = self.api_base_url.by_extending_and_splitting_by_forward_slash([ #(#url_parts,)* ]); + } +} + +pub fn fn_body_query_pairs(params_type: &ParamsType, request_type: RequestType) -> TokenStream { + match request_type { + RequestType::ContextualGet | RequestType::Delete | RequestType::Get => { + if let Some(tokens) = params_type.tokens() { + let is_option = if let Some(TokenTree::Ident(ref ident)) = tokens.first() { + // TODO: This won't work with `std::option::Option` or `core::option::Option` + *ident == "Option" + } else { + false + }; + if is_option { + quote! { + if let Some(params) = params { + url.query_pairs_mut().extend_pairs(params.query_pairs()); + } + } + } else { + quote! { url.query_pairs_mut().extend_pairs(params.query_pairs()); } + } + } else { + TokenStream::new() + } + } + RequestType::Post => TokenStream::new(), + } +} + +pub fn fn_body_fields_query_pairs( + crate_ident: &Ident, + context_and_filter_handler: ContextAndFilterHandler, +) -> TokenStream { + match context_and_filter_handler { + ContextAndFilterHandler::None + | ContextAndFilterHandler::NoFilterTakeContextAsArgument + | ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(_) => TokenStream::new(), + ContextAndFilterHandler::FilterTakeContextAsArgument + | ContextAndFilterHandler::FilterNoContext => quote! { + use #crate_ident::SparseField; + url.query_pairs_mut().append_pair( + "_fields", + fields + .iter() + .map(|f| f.as_str()) + .collect::>() + .join(",") + .as_str(), + ); + }, + } +} + +pub fn fn_body_context_query_pairs( + crate_ident: &Ident, + context_and_filter_handler: ContextAndFilterHandler, +) -> TokenStream { + match context_and_filter_handler { + ContextAndFilterHandler::None | ContextAndFilterHandler::FilterNoContext => { + TokenStream::new() + } + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(context) => { + let context = format_ident!("{}", context.to_string()); + quote! { + url.query_pairs_mut().append_pair("context", #crate_ident::WpContext::#context.as_str()); + } + } + ContextAndFilterHandler::NoFilterTakeContextAsArgument + | ContextAndFilterHandler::FilterTakeContextAsArgument => quote! { + url.query_pairs_mut().append_pair("context", context.as_str()); + }, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::too_many_arguments)] + use crate::sparse_field_attr; + + use super::*; + use rstest::rstest; + use syn::parse_quote; + + #[rstest] + #[case(&[UrlPart::Static("users".to_string())], "")] + #[case(&[UrlPart::Dynamic("user_id".to_string())], "user_id : UserId ,")] + #[case(&[UrlPart::Static("users".to_string()), UrlPart::Dynamic("user_id".to_string())], "user_id : UserId ,")] + #[case(&[UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string())], "user_id : UserId , user_type : UserType ,")] + fn test_fn_url_params(#[case] url_parts: &[UrlPart], #[case] expected_str: &str) { + assert_eq!(fn_url_params(url_parts).to_string(), expected_str); + } + + #[rstest] + #[case(PartOf::Endpoint, &ParamsType::new(None), RequestType::Get, "")] + #[case(PartOf::Endpoint, &ParamsType::new(Some(vec![])), RequestType::Get, "")] + #[case( + PartOf::Endpoint, + &referenced_params_type("UserCreateParams"), + RequestType::Post, + "" + )] + #[case( + PartOf::RequestBuilder, + &referenced_params_type("UserCreateParams"), + RequestType::Post, + "params : &UserCreateParams ," + )] + #[case( + PartOf::Endpoint, + &referenced_params_type("UserListParams"), + RequestType::Get, + "params : &UserListParams ," + )] + #[case( + PartOf::Endpoint, + &referenced_params_type("UserListParams"), + RequestType::Get, + "params : &UserListParams ," + )] + fn test_fn_provided_param( + #[case] part_of: PartOf, + #[case] params_type: &ParamsType, + #[case] request_type: RequestType, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_provided_param(part_of, params_type, request_type).to_string(), + expected_str + ); + } + + #[rstest] + #[case(ContextAndFilterHandler::None, "")] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "" + )] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsArgument, + "context : WpContext ," + )] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Embed), + "" + )] + #[case( + ContextAndFilterHandler::FilterTakeContextAsArgument, + "context : WpContext ," + )] + fn test_fn_context_param( + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_context_param(context_and_filter_handler).to_string(), + expected_str + ); + } + + #[rstest] + #[case("List", ContextAndFilterHandler::None, "list")] + #[case( + "List", + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "list_with_edit_context" + )] + #[case("List", ContextAndFilterHandler::NoFilterTakeContextAsArgument, "list")] + #[case( + "ListContents", + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Embed), + "list_contents_with_embed_context" + )] + #[case( + "List", + ContextAndFilterHandler::FilterTakeContextAsArgument, + "filter_list" + )] + fn test_fn_name( + #[case] ident: &str, + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_name(&format_ident!("{}", ident), context_and_filter_handler).to_string(), + expected_str + ); + } + + #[rstest] + #[case(ContextAndFilterHandler::None, quote! { SparseUserField }, "")] + #[case(ContextAndFilterHandler::NoFilterTakeContextAsArgument, quote! { SparseUserField }, "")] + #[case(ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::View), quote! { SparseUserField }, "")] + #[case( + ContextAndFilterHandler::FilterTakeContextAsArgument, + quote! { SparseUserField }, + "fields : & [SparseUserField]" + )] + #[case( + ContextAndFilterHandler::FilterNoContext, + quote! { crate::SparseUserField }, + "fields : & [crate :: SparseUserField]" + )] + fn test_fn_fields_param( + #[case] context_and_filter_handler: ContextAndFilterHandler, + // Don't use the `sparse_field_type` fixture so we can test multi segment sparse field type + #[case] sparse_field_type: TokenStream, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_fields_param( + context_and_filter_handler, + &SparseFieldAttr { + tokens: sparse_field_type, + } + ) + .to_string(), + expected_str + ); + } + + #[rstest] + #[case(url_static_users(), "")] + #[case(url_users_with_user_id(), "user_id ,")] + #[case(vec![UrlPart::Static("users".into()), UrlPart::Dynamic("user_id".into()), UrlPart::Dynamic("user_type".into())], "user_id , user_type ,")] + fn test_fn_arg_url_parts(#[case] url_parts: Vec, #[case] expected_str: &str) { + assert_eq!(fn_arg_url_parts(&url_parts).to_string(), expected_str); + } + + #[rstest] + #[case(ContextAndFilterHandler::None, "")] + #[case(ContextAndFilterHandler::NoFilterTakeContextAsArgument, "context ,")] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "" + )] + #[case(ContextAndFilterHandler::FilterTakeContextAsArgument, "context ,")] + #[case(ContextAndFilterHandler::FilterNoContext, "")] + fn test_fn_arg_context( + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_arg_context(context_and_filter_handler).to_string(), + expected_str + ); + } + + #[rstest] + #[case(PartOf::Endpoint, &ParamsType::new(None), RequestType::Get, "")] + #[case(PartOf::Endpoint, &referenced_params_type("UserCreateParams"), RequestType::Post, "")] + #[case(PartOf::RequestBuilder, &referenced_params_type("UserCreateParams"), RequestType::Post, "params ,")] + #[case(PartOf::Endpoint, &referenced_params_type("UserListParams"), RequestType::ContextualGet, "params ,")] + #[case(PartOf::Endpoint, &referenced_params_type("UserListParams"), RequestType::ContextualGet, "params ,")] + #[case(PartOf::Endpoint, &referenced_params_type("UserListParams"), RequestType::Delete, "params ,")] + #[case(PartOf::Endpoint, &referenced_params_type("UserListParams"), RequestType::Get, "params ,")] + #[case(PartOf::Endpoint, &referenced_params_type("UserListParams"), RequestType::Post, "")] + fn test_fn_arg_provided_params( + #[case] part_of: PartOf, + #[case] params_type: &ParamsType, + #[case] request_type: RequestType, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_arg_provided_params(part_of, params_type, request_type).to_string(), + expected_str + ); + } + + #[rstest] + #[case(ContextAndFilterHandler::None, "")] + #[case(ContextAndFilterHandler::NoFilterTakeContextAsArgument, "")] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "" + )] + #[case(ContextAndFilterHandler::FilterTakeContextAsArgument, "fields ,")] + #[case(ContextAndFilterHandler::FilterNoContext, "fields ,")] + fn test_fn_arg_fields( + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_arg_fields(context_and_filter_handler).to_string(), + expected_str + ); + } + + #[rstest] + #[case( + PartOf::Endpoint, + format_ident!("Create"), + url_static_users(), + &ParamsType::new(None), + RequestType::Post, + ContextAndFilterHandler::None, + "fn create (& self ,)")] + #[case( + PartOf::Endpoint, + format_ident!("Create"), + url_static_users(), + &referenced_params_type("UserCreateParams"), + RequestType::Post, + ContextAndFilterHandler::FilterNoContext, + "fn filter_create (& self , fields : & [SparseUserField])")] + #[case( + PartOf::RequestBuilder, + format_ident!("Create"), + url_static_users(), + &referenced_params_type("UserCreateParams"), + RequestType::Post, + ContextAndFilterHandler::FilterNoContext, + "fn filter_create (& self , params : &UserCreateParams , fields : & [SparseUserField])")] + #[case( + PartOf::Endpoint, + format_ident!("Delete"), + url_users_with_user_id(), + &referenced_params_type("UserDeleteParams"), + RequestType::Delete, + ContextAndFilterHandler::None, + "fn delete (& self , user_id : UserId , params : &UserDeleteParams ,)")] + #[case( + PartOf::Endpoint, + format_ident!("Delete"), + url_users_with_user_id(), + &referenced_params_type("UserDeleteParams"), + RequestType::Delete, + ContextAndFilterHandler::FilterNoContext, + "fn filter_delete (& self , user_id : UserId , params : &UserDeleteParams , fields : & [SparseUserField])")] + #[case( + PartOf::Endpoint, + format_ident!("DeleteMe"), + url_static_users(), + &referenced_params_type("UserDeleteParams"), + RequestType::Delete, + ContextAndFilterHandler::None, + "fn delete_me (& self , params : &UserDeleteParams ,)")] + #[case( + PartOf::Endpoint, + format_ident!("List"), + url_static_users(), + &referenced_params_type("UserListParams"), + RequestType::ContextualGet, + ContextAndFilterHandler::NoFilterTakeContextAsArgument, + "fn list (& self , context : WpContext , params : &UserListParams ,)")] + #[case( + PartOf::Endpoint, + format_ident!("List"), + url_static_users(), + &referenced_params_type("UserListParams"), + RequestType::ContextualGet, + ContextAndFilterHandler::FilterTakeContextAsArgument, + "fn filter_list (& self , context : WpContext , params : &UserListParams , fields : & [SparseUserField])")] + #[case( + PartOf::Endpoint, + format_ident!("Retrieve"), + url_users_with_user_id(), + &ParamsType::new(None), + RequestType::ContextualGet, + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Embed), + "fn retrieve_with_embed_context (& self , user_id : UserId ,)")] + #[case( + PartOf::Endpoint, + format_ident!("Retrieve"), + url_users_with_user_id(), + &ParamsType::new(None), + RequestType::ContextualGet, + ContextAndFilterHandler::FilterTakeContextAsArgument, + "fn filter_retrieve (& self , user_id : UserId , context : WpContext , fields : & [SparseUserField])")] + #[case( + PartOf::Endpoint, + format_ident!("Update"), + url_users_with_user_id(), + &referenced_params_type("UserUpdateParams"), + RequestType::Post, + ContextAndFilterHandler::None, + "fn update (& self , user_id : UserId ,)")] + #[case( + PartOf::Endpoint, + format_ident!("Update"), + url_users_with_user_id(), + &referenced_params_type("UserUpdateParams"), + RequestType::Post, + ContextAndFilterHandler::FilterNoContext, + "fn filter_update (& self , user_id : UserId , fields : & [SparseUserField])")] + #[case( + PartOf::RequestBuilder, + format_ident!("Update"), + url_users_with_user_id(), + &referenced_params_type("UserUpdateParams"), + RequestType::Post, + ContextAndFilterHandler::None, + "fn update (& self , user_id : UserId , params : &UserUpdateParams ,)")] + #[case( + PartOf::RequestBuilder, + format_ident!("Update"), + url_users_with_user_id(), + &referenced_params_type("UserUpdateParams"), + RequestType::Post, + ContextAndFilterHandler::FilterNoContext, + "fn filter_update (& self , user_id : UserId , params : &UserUpdateParams , fields : & [SparseUserField])")] + #[case( + PartOf::RequestBuilder, + format_ident!("List"), + url_static_users(), + &referenced_params_type("UserListParams"), + RequestType::ContextualGet, + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "fn list_with_edit_context (& self , params : &UserListParams ,)")] + fn test_fn_signature( + #[case] part_of: PartOf, + #[case] variant_ident: Ident, + #[case] url_parts: Vec, + #[case] params_type: &ParamsType, + #[case] request_type: RequestType, + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + sparse_field_type: SparseFieldAttr, + ) { + assert_eq!( + fn_signature( + part_of, + &variant_ident, + &url_parts, + params_type, + request_type, + context_and_filter_handler, + &sparse_field_type, + ) + .to_string(), + expected_str + ); + } + + #[rstest] + #[case( + format_ident!("Create"), + url_static_users(), + &referenced_params_type("UserCreateParams"), + RequestType::Post, + ContextAndFilterHandler::None, + "let url = self . endpoint . create () ;")] + #[case( + format_ident!("Create"), + url_static_users(), + &referenced_params_type("UserCreateParams"), + RequestType::Post, + ContextAndFilterHandler::FilterNoContext, + "let url = self . endpoint . filter_create (fields ,) ;")] + #[case( + format_ident!("Delete"), + url_users_with_user_id(), + &referenced_params_type("UserDeleteParams"), + RequestType::Delete, + ContextAndFilterHandler::None, + "let url = self . endpoint . delete (user_id , params ,) ;")] + #[case( + format_ident!("Delete"), + url_users_with_user_id(), + &referenced_params_type("UserDeleteParams"), + RequestType::Delete, + ContextAndFilterHandler::FilterNoContext, + "let url = self . endpoint . filter_delete (user_id , params , fields ,) ;")] + #[case( + format_ident!("DeleteMe"), + url_static_users(), + &referenced_params_type("UserDeleteParams"), + RequestType::Delete, + ContextAndFilterHandler::None, + "let url = self . endpoint . delete_me (params ,) ;")] + #[case( + format_ident!("List"), + url_static_users(), + &referenced_params_type("UserListParams"), + RequestType::ContextualGet, + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "let url = self . endpoint . list_with_edit_context (params ,) ;")] + #[case( + format_ident!("List"), + url_static_users(), + &referenced_params_type("UserListParams"), + RequestType::ContextualGet, + ContextAndFilterHandler::FilterTakeContextAsArgument, + "let url = self . endpoint . filter_list (context , params , fields ,) ;")] + #[case( + format_ident!("Retrieve"), + url_users_with_user_id(), + &ParamsType::new(None), + RequestType::ContextualGet, + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Embed), + "let url = self . endpoint . retrieve_with_embed_context (user_id ,) ;")] + #[case( + format_ident!("Retrieve"), + url_users_with_user_id(), + &ParamsType::new(None), + RequestType::ContextualGet, + ContextAndFilterHandler::FilterTakeContextAsArgument, + "let url = self . endpoint . filter_retrieve (user_id , context , fields ,) ;")] + #[case( + format_ident!("Update"), + url_users_with_user_id(), + &referenced_params_type("UserUpdateParams"), + RequestType::Post, + ContextAndFilterHandler::None, + "let url = self . endpoint . update (user_id ,) ;")] + #[case( + format_ident!("Update"), + url_users_with_user_id(), + &referenced_params_type("UserUpdateParams"), + RequestType::Post, + ContextAndFilterHandler::FilterNoContext, + "let url = self . endpoint . filter_update (user_id , fields ,) ;")] + fn test_fn_body_get_url_from_endpoint( + #[case] variant_ident: Ident, + #[case] url_parts: Vec, + #[case] params_type: &ParamsType, + #[case] request_type: RequestType, + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_body_get_url_from_endpoint( + &variant_ident, + &url_parts, + params_type, + request_type, + context_and_filter_handler + ) + .to_string(), + expected_str + ); + } + + #[rstest] + #[case( + url_static_users(), + "let mut url = self . api_base_url . by_extending_and_splitting_by_forward_slash ([\"users\" ,]) ;" + )] + #[case( + url_users_with_user_id(), + "let mut url = self . api_base_url . by_extending_and_splitting_by_forward_slash ([\"users\" , & user_id . to_string () ,]) ;" + )] + #[case( + url_users_with_user_id(), + "let mut url = self . api_base_url . by_extending_and_splitting_by_forward_slash ([\"users\" , & user_id . to_string () ,]) ;" + )] + #[case( + vec![UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string())], + "let mut url = self . api_base_url . by_extending_and_splitting_by_forward_slash ([& user_id . to_string () , & user_type . to_string () ,]) ;" + )] + #[case( + vec![UrlPart::Static("users".to_string()), UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string()), ], + "let mut url = self . api_base_url . by_extending_and_splitting_by_forward_slash ([\"users\" , & user_id . to_string () , & user_type . to_string () ,]) ;" + )] + #[case( + vec![UrlPart::Static("users".to_string()), UrlPart::Static("me".to_string()), UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string()), ], + "let mut url = self . api_base_url . by_extending_and_splitting_by_forward_slash ([\"users\" , \"me\" , & user_id . to_string () , & user_type . to_string () ,]) ;" + )] + fn test_fn_body_get_url_from_api_base_url( + #[case] url_parts: Vec, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_body_get_url_from_api_base_url(&url_parts).to_string(), + expected_str + ); + } + + #[rstest] + #[case(&ParamsType::new(None), RequestType::ContextualGet, "")] + #[case( + &referenced_params_type("UserListParams"), + RequestType::ContextualGet, + "url . query_pairs_mut () . extend_pairs (params . query_pairs ()) ;" + )] + #[case( + &option_referenced_params_type("UserListParams"), + RequestType::ContextualGet, + "if let Some (params) = params { url . query_pairs_mut () . extend_pairs (params . query_pairs ()) ; }" + )] + #[case( + &option_referenced_params_type("UserListParams"), + RequestType::Post, + "" + )] + fn test_fn_body_query_pairs( + #[case] params: &ParamsType, + #[case] request_type: RequestType, + #[case] expected_str: &str, + ) { + assert_eq!( + fn_body_query_pairs(params, request_type).to_string(), + expected_str + ); + } + + #[rstest] + #[case(ContextAndFilterHandler::None, true)] + #[case(ContextAndFilterHandler::NoFilterTakeContextAsArgument, true)] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + true + )] + #[case(ContextAndFilterHandler::FilterTakeContextAsArgument, false)] + #[case(ContextAndFilterHandler::FilterNoContext, false)] + fn test_fn_body_fields_query_pairs( + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] is_empty: bool, + ) { + // Test if the `_fields` query pair is included or not + // Since this query pair is static, there is no need to compare the string value + let crate_ident = format_ident!("crate"); + assert_eq!( + fn_body_fields_query_pairs(&crate_ident, context_and_filter_handler).is_empty(), + is_empty + ); + } + + #[rstest] + #[case(ContextAndFilterHandler::None, "")] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsArgument, + "url . query_pairs_mut () . append_pair (\"context\" , context . as_str ()) ;" + )] + #[case( + ContextAndFilterHandler::NoFilterTakeContextAsFunctionName(WpContext::Edit), + "url . query_pairs_mut () . append_pair (\"context\" , crate :: WpContext :: Edit . as_str ()) ;" + )] + #[case( + ContextAndFilterHandler::FilterTakeContextAsArgument, + "url . query_pairs_mut () . append_pair (\"context\" , context . as_str ()) ;" + )] + #[case(ContextAndFilterHandler::FilterNoContext, "")] + fn test_fn_body_context_query_pairs( + #[case] context_and_filter_handler: ContextAndFilterHandler, + #[case] expected_str: &str, + ) { + let crate_ident = format_ident!("crate"); + assert_eq!( + fn_body_context_query_pairs(&crate_ident, context_and_filter_handler).to_string(), + expected_str + ); + } + + #[rstest::fixture] + fn sparse_field_type() -> SparseFieldAttr { + SparseFieldAttr { + tokens: quote! { SparseUserField }, + } + } + + fn referenced_params_type(str: &str) -> ParamsType { + ParamsType::new(Some(vec![ + proc_macro2::TokenTree::Punct(proc_macro2::Punct::new( + '&', + proc_macro2::Spacing::Joint, + )), + format_ident!("{}", str).into(), + ])) + } + + fn option_referenced_params_type(str: &str) -> ParamsType { + ParamsType::new(Some(vec![ + format_ident!("Option").into(), + proc_macro2::TokenTree::Punct(proc_macro2::Punct::new( + '<', + proc_macro2::Spacing::Joint, + )), + proc_macro2::TokenTree::Punct(proc_macro2::Punct::new( + '&', + proc_macro2::Spacing::Joint, + )), + format_ident!("{}", str).into(), + proc_macro2::TokenTree::Punct(proc_macro2::Punct::new( + '>', + proc_macro2::Spacing::Joint, + )), + ])) + } + + fn url_static_users() -> Vec { + vec![UrlPart::Static("users".into())] + } + + fn url_users_with_user_id() -> Vec { + vec![ + UrlPart::Static("users".into()), + UrlPart::Dynamic("user_id".into()), + ] + } +} diff --git a/wp_derive_request_builder/src/lib.rs b/wp_derive_request_builder/src/lib.rs index a4ec6e826..0786209d1 100644 --- a/wp_derive_request_builder/src/lib.rs +++ b/wp_derive_request_builder/src/lib.rs @@ -2,6 +2,7 @@ use proc_macro::TokenStream; use syn::parse_macro_input; +mod generate; mod parse; mod sparse_field_attr; mod variant_attr; @@ -11,12 +12,13 @@ mod variant_attr; attributes(SparseField, get, post, delete, contextual_get) )] pub fn derive(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as parse::ParsedEnum); + let parsed_enum = parse_macro_input!(input as parse::ParsedEnum); if cfg!(feature = "generate_request_builder") { - dbg!("{:#?}", input); - // TODO: Generate endpoint & request builder - TokenStream::new() + //dbg!("{:#?}", parsed_enum.clone()); + generate::generate_types(&parsed_enum) + .unwrap_or_else(|err| err.into_compile_error()) + .into() } else { TokenStream::new() } diff --git a/wp_derive_request_builder/src/parse.rs b/wp_derive_request_builder/src/parse.rs index 5a1a43d34..815591d6f 100644 --- a/wp_derive_request_builder/src/parse.rs +++ b/wp_derive_request_builder/src/parse.rs @@ -13,11 +13,11 @@ use syn::{ use crate::{sparse_field_attr::SparseFieldAttr, variant_attr::ParsedVariantAttribute}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ParsedEnum { - sparse_field_attr: SparseFieldAttr, - enum_ident: Ident, - variants: Punctuated, + pub sparse_field_attr: SparseFieldAttr, + pub enum_ident: Ident, + pub variants: Punctuated, } impl Parse for ParsedEnum { @@ -35,10 +35,10 @@ impl Parse for ParsedEnum { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ParsedVariant { - attr: ParsedVariantAttribute, - variant_ident: Ident, + pub attr: ParsedVariantAttribute, + pub variant_ident: Ident, } impl Parse for ParsedVariant { diff --git a/wp_derive_request_builder/src/sparse_field_attr.rs b/wp_derive_request_builder/src/sparse_field_attr.rs index df1e27d1c..f7247952e 100644 --- a/wp_derive_request_builder/src/sparse_field_attr.rs +++ b/wp_derive_request_builder/src/sparse_field_attr.rs @@ -5,9 +5,9 @@ use syn::{ Attribute, Ident, Meta, MetaList, Result, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SparseFieldAttr { - tokens: TokenStream, + pub tokens: TokenStream, } impl Parse for SparseFieldAttr { diff --git a/wp_derive_request_builder/src/variant_attr.rs b/wp_derive_request_builder/src/variant_attr.rs index 9d590ac80..c38f42dd5 100644 --- a/wp_derive_request_builder/src/variant_attr.rs +++ b/wp_derive_request_builder/src/variant_attr.rs @@ -1,4 +1,4 @@ -use proc_macro2::{TokenStream, TokenTree}; +use proc_macro2::{Span, TokenStream, TokenTree}; use syn::{ parse::{Parse, ParseStream}, spanned::Spanned, @@ -7,12 +7,37 @@ use syn::{ use crate::parse::RequestType; -#[derive(Debug)] +// Use a wrapper for ParamsType to indicate that in case the `params` is Some, there is at least +// one token in it +#[derive(Debug, Clone)] +pub struct ParamsType { + tokens: Option>, +} + +impl ParamsType { + pub fn new(tokens: Option>) -> Self { + Self { + tokens: tokens.and_then(|tokens| { + if tokens.is_empty() { + None + } else { + Some(tokens) + } + }), + } + } + + pub fn tokens(&self) -> Option<&Vec> { + self.tokens.as_ref() + } +} + +#[derive(Debug, Clone)] pub struct ParsedVariantAttribute { - request_type: RequestType, - url: String, - params: Option>, - output: Vec, + pub request_type: RequestType, + pub url_parts: Vec, + pub params: ParamsType, + pub output: Vec, } impl ParsedVariantAttribute { @@ -232,10 +257,12 @@ impl Parse for ParsedVariantAttribute { ItemVariantAttributeParseError::MissingOutput.into_syn_error(input.span()) })?; + let url_parts = UrlPart::split(url_str.to_string(), &meta_list_span)?; + Ok(Self { request_type, - url: url_str.to_string(), - params: params_tokens, + url_parts, + params: ParamsType::new(params_tokens), output, }) } @@ -268,3 +295,60 @@ impl ItemVariantAttributeParseError { syn::Error::new(span, self.to_string()) } } + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum UrlPart { + Dynamic(String), + Static(String), +} + +impl UrlPart { + fn split(mut url: String, error_span: &Span) -> syn::Result> { + if url.starts_with('"') && url.ends_with('"') { + url.pop(); + url.remove(0); + } else { + return Err( + ItemVariantAttributeParseError::UrlShouldBeLiteral.into_syn_error(*error_span) + ); + } + let parts = url + .split('/') + .filter_map(|p| { + if p.starts_with('<') && p.ends_with('>') { + let mut p = p.to_string(); + // Remove first and last character + p.pop(); + p.remove(0); + Some(Self::Dynamic(p)) + } else { + let p = p.trim(); + if p.is_empty() { + None + } else { + Some(Self::Static(p.to_string())) + } + } + }) + .collect::>(); + Ok(parts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("\"users\"", &[UrlPart::Static("users".to_string())])] + #[case("\"\"", &[UrlPart::Dynamic("user_id".to_string())])] + #[case("\"users/\"", &[UrlPart::Static("users".to_string()), UrlPart::Dynamic("user_id".to_string())])] + #[case("\"users//\"", &[UrlPart::Static("users".to_string()), UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string())])] + fn test_fn_url_params(#[case] input: &str, #[case] expected_url_parts: &[UrlPart]) { + assert_eq!( + UrlPart::split(input.into(), &proc_macro2::Span::call_site()).unwrap(), + expected_url_parts + ); + } +}