diff --git a/Cargo.lock b/Cargo.lock index a19bca28c..4f6637ef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,6 +3046,7 @@ dependencies = [ "rstest_reuse", "serde", "serde_json", + "strum_macros", "thiserror", "uniffi", "url", diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt index 6134cfed1..aa352851d 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt @@ -91,4 +91,20 @@ class UsersEndpointTest { client.request { requestBuilder -> requestBuilder.users().listWithEditContext(params) } assert(result.wpErrorCode() is WpErrorCode.InvalidParam) } + + @Test + fun testUserListPagination() = runTest { + val firstPageResponse = client.request { requestBuilder -> + requestBuilder.users().listWithEditContext(params = UserListParams(perPage = 1u)) + }.assertSuccessAndRetrieveData() + assert(firstPageResponse.data.isNotEmpty()) + val nextPageResponse = client.request { requestBuilder -> + requestBuilder.users().listWithEditContext(firstPageResponse.nextPageParams!!) + }.assertSuccessAndRetrieveData() + assert(nextPageResponse.data.isNotEmpty()) + val prevPageResponse = client.request { requestBuilder -> + requestBuilder.users().listWithEditContext(nextPageResponse.prevPageParams!!) + }.assertSuccessAndRetrieveData() + assert(prevPageResponse.data.isNotEmpty()) + } } diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index 53ffbc9bb..070e1f12f 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -22,6 +22,7 @@ paste = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = [ "derive" ] } serde_json = { workspace = true } +strum_macros = { workspace = true } thiserror = { workspace = true } uniffi = { workspace = true } uuid = { workspace = true, features = [ "v4" ] } diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index 157e54cb4..02314f2a1 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -4,6 +4,7 @@ pub use api_client::{WpApiClient, WpApiRequestBuilder}; pub use api_error::{ParsedRequestError, RequestExecutionError, WpApiError, WpError, WpErrorCode}; pub use parsed_url::{ParseUrlError, ParsedUrl}; use plugins::*; +use std::str::FromStr; use url_query::AsQueryValue; use users::*; pub use uuid::{WpUuid, WpUuidParseError}; @@ -86,10 +87,38 @@ impl WpApiParamOrder { } } +impl FromStr for WpApiParamOrder { + type Err = EnumFromStrParsingError; + + fn from_str(s: &str) -> Result { + match s { + "asc" => Ok(Self::Asc), + "desc" => Ok(Self::Desc), + value => Err(EnumFromStrParsingError::UnknownVariant { + value: value.to_string(), + }), + } + } +} + trait SparseField { fn as_str(&self) -> &str; } +trait OptionFromStr { + type Err; + + fn option_from_str(s: &str) -> Result, Self::Err> + where + Self: Sized; +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, thiserror::Error)] +pub enum EnumFromStrParsingError { + #[error("'{}' is not a valid variant for this enum", value)] + UnknownVariant { value: String }, +} + #[macro_export] macro_rules! generate { ($type_name:ident) => { diff --git a/wp_api/src/request.rs b/wp_api/src/request.rs index 7e7534343..7eee4bf42 100644 --- a/wp_api/src/request.rs +++ b/wp_api/src/request.rs @@ -7,6 +7,7 @@ use url::Url; use crate::{ api_error::{ParsedRequestError, RequestExecutionError, WpError}, + url_query::{FromUrlQueryPairs, UrlQueryPairsMap}, WpApiError, WpAuthentication, }; @@ -21,10 +22,29 @@ const HEADER_KEY_WP_TOTAL_PAGES: &str = "X-WP-TotalPages"; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(transparent)] -pub struct ParsedResponse { - pub data: T, +pub struct ParsedResponse { + pub data: DataType, #[serde(skip)] pub header_map: Arc, + #[serde(skip)] + pub next_page_params: Option, + #[serde(skip)] + pub prev_page_params: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PaginationHeaderKey { + Next, + Prev, +} + +impl PaginationHeaderKey { + fn as_str(&self) -> &str { + match self { + Self::Next => "next", + Self::Prev => "prev", + } + } } #[derive(Debug)] @@ -322,15 +342,29 @@ impl WpNetworkResponse { .collect() } + pub fn get_pagination_header( + &self, + header_key: PaginationHeaderKey, + ) -> Option

{ + self.get_link_header(header_key.as_str()) + .first() + .and_then(|u| { + P::from_url_query_pairs(UrlQueryPairsMap::new( + u.query_pairs().into_iter().collect(), + )) + }) + } + pub fn body_as_string(&self) -> String { request_or_response_body_as_string(&self.body) } - pub fn parse(self) -> Result + pub fn parse(self) -> Result where - T: DeserializeOwned, - T: From>, - ParsedResponse: From, + ResponseType: DeserializeOwned, + ResponseType: From>, + ParsedResponse: From, + ParamsType: FromUrlQueryPairs, E: ParsedRequestError, { if let Some(err) = E::try_parse(&self.body, self.status_code) { @@ -340,9 +374,15 @@ impl WpNetworkResponse { serde_json::from_slice(&self.body) .map_err(|err| E::as_parse_error(err.to_string(), self.body_as_string())) .map(|x| { - let mut p = ParsedResponse::::from(x); - p.header_map = self.header_map; - T::from(p) + let mut parsed_response = ParsedResponse::::from(x); + if ParamsType::supports_pagination() { + parsed_response.next_page_params = + self.get_pagination_header(PaginationHeaderKey::Next); + parsed_response.prev_page_params = + self.get_pagination_header(PaginationHeaderKey::Prev); + } + parsed_response.header_map = self.header_map; + ResponseType::from(parsed_response) }) } diff --git a/wp_api/src/request/endpoint.rs b/wp_api/src/request/endpoint.rs index 49b6587ea..2bc6d7ba5 100644 --- a/wp_api/src/request/endpoint.rs +++ b/wp_api/src/request/endpoint.rs @@ -276,7 +276,7 @@ mod tests { fn wp_json_endpoint(base_url: &str) -> String { let mut url = base_url.to_string(); - if !url.ends_with("/") { + if !url.ends_with('/') { url.push('/') } url.push_str(WP_JSON_PATH_SEGMENTS.join("/").as_str()); diff --git a/wp_api/src/request/endpoint/users_endpoint.rs b/wp_api/src/request/endpoint/users_endpoint.rs index e04a24f2b..ff4a3a118 100644 --- a/wp_api/src/request/endpoint/users_endpoint.rs +++ b/wp_api/src/request/endpoint/users_endpoint.rs @@ -9,7 +9,7 @@ use super::{AsNamespace, DerivedRequest, WpNamespace}; #[derive(WpDerivedRequest)] enum UsersRequest { - #[contextual_get(url = "/users", params = &UserListParams, output = Vec, filter_by = crate::SparseUserField)] + #[contextual_paged(url = "/users", params = &UserListParams, output = Vec, filter_by = crate::SparseUserField)] List, #[post(url = "/users", params = &UserCreateParams, output = UserWithEditContext)] Create, diff --git a/wp_api/src/unit_test_common.rs b/wp_api/src/unit_test_common.rs index 965a5be86..305fdd601 100644 --- a/wp_api/src/unit_test_common.rs +++ b/wp_api/src/unit_test_common.rs @@ -1,4 +1,4 @@ -use crate::url_query::AppendUrlQueryPairs; +use crate::url_query::{AppendUrlQueryPairs, FromUrlQueryPairs, UrlQueryPairsMap}; use url::Url; #[cfg(test)] @@ -7,3 +7,18 @@ pub fn assert_expected_query_pairs(params: impl AppendUrlQueryPairs, expected_qu params.append_query_pairs(&mut url.query_pairs_mut()); assert_eq!(url.query(), Some(expected_query)); } + +#[cfg(test)] +pub fn assert_expected_and_from_query_pairs

(params: P, expected_query: &str) +where + P: AppendUrlQueryPairs + FromUrlQueryPairs + std::fmt::Debug + PartialEq, +{ + let mut url = Url::parse("https://example.com").unwrap(); + params.append_query_pairs(&mut url.query_pairs_mut()); + assert_eq!(url.query(), Some(expected_query)); + + let parsed_params = P::from_url_query_pairs(UrlQueryPairsMap::new( + url.query_pairs().into_iter().collect(), + )); + assert_eq!(Some(params), parsed_params); +} diff --git a/wp_api/src/url_query.rs b/wp_api/src/url_query.rs index 78f6f3244..3341c0dd8 100644 --- a/wp_api/src/url_query.rs +++ b/wp_api/src/url_query.rs @@ -1,6 +1,7 @@ +use std::{borrow::Cow, collections::HashMap, str::FromStr}; use url::{form_urlencoded, UrlQuery}; -use crate::impl_as_query_value_from_to_string; +use crate::{impl_as_query_value_from_to_string, OptionFromStr}; pub(crate) type QueryPairs<'a> = form_urlencoded::Serializer<'a, UrlQuery<'a>>; @@ -9,27 +10,39 @@ pub(crate) trait AppendUrlQueryPairs { } pub(crate) trait QueryPairsExtension { - fn append_query_value_pair(&mut self, key: &str, value: &T) -> &mut Self + fn append_query_value_pair<'a, T>(&mut self, key: impl Into<&'a str>, value: &T) -> &mut Self where T: AsQueryValue; - fn append_option_query_value_pair(&mut self, key: &str, value: Option<&T>) -> &mut Self + fn append_option_query_value_pair<'a, T>( + &mut self, + key: impl Into<&'a str>, + value: Option<&T>, + ) -> &mut Self where T: AsQueryValue; - fn append_vec_query_value_pair(&mut self, key: &str, value: &[T]) -> &mut Self + fn append_vec_query_value_pair<'a, T>( + &mut self, + key: impl Into<&'a str>, + value: &[T], + ) -> &mut Self where T: AsQueryValue; } impl QueryPairsExtension for QueryPairs<'_> { - fn append_query_value_pair(&mut self, key: &str, value: &T) -> &mut Self + fn append_query_value_pair<'a, T>(&mut self, key: impl Into<&'a str>, value: &T) -> &mut Self where T: AsQueryValue, { - self.append_pair(key, value.as_query_value().as_ref()); + self.append_pair(key.into(), value.as_query_value().as_ref()); self } - fn append_option_query_value_pair(&mut self, key: &str, value: Option<&T>) -> &mut Self + fn append_option_query_value_pair<'a, T>( + &mut self, + key: impl Into<&'a str>, + value: Option<&T>, + ) -> &mut Self where T: AsQueryValue, { @@ -39,7 +52,11 @@ impl QueryPairsExtension for QueryPairs<'_> { self } - fn append_vec_query_value_pair(&mut self, key: &str, value: &[T]) -> &mut Self + fn append_vec_query_value_pair<'a, T>( + &mut self, + key: impl Into<&'a str>, + value: &[T], + ) -> &mut Self where T: AsQueryValue, { @@ -50,7 +67,7 @@ impl QueryPairsExtension for QueryPairs<'_> { acc }); csv.pop(); // remove the last ',' - self.append_pair(key, &csv); + self.append_pair(key.into(), &csv); } self } @@ -76,6 +93,66 @@ impl AsQueryValue for String { } } +#[derive(Debug)] +pub struct UrlQueryPairsMap<'a> { + inner: HashMap, Cow<'a, str>>, +} + +impl<'a> UrlQueryPairsMap<'a> { + pub(crate) fn new(query_pairs: HashMap, Cow<'a, str>>) -> Self { + Self { inner: query_pairs } + } + + pub(crate) fn get<'b, T: FromStr>(&self, key: impl Into<&'b str>) -> Option { + self.inner.get(key.into()).and_then(|v| v.parse().ok()) + } + + pub(crate) fn get_using_option_from_str<'b, T: OptionFromStr>( + &self, + key: impl Into<&'b str>, + ) -> Option { + self.inner + .get(key.into()) + .and_then(|v| T::option_from_str(v).ok().flatten()) + } + + pub(crate) fn get_csv<'b, T: FromStr>(&self, key: impl Into<&'b str>) -> Vec { + self.inner + .get(key.into()) + .and_then(|v| { + v.split(',') + .map(|s| T::from_str(s).ok()) + .collect::>>() + }) + .unwrap_or_default() + } +} + +pub trait FromUrlQueryPairs +where + Self: Sized, +{ + fn from_url_query_pairs(query_pairs: UrlQueryPairsMap) -> Option; + + // Used to avoid unnecessary parsing of the `next` & `prev` headers for params types that don't + // support pagination. + // + // All manually implemented types should return `true` and the implementation for `()` should + // return `false` since `#[derive(WpDerivedRequest)]` will use `()` for parameter `P` of + // `ParsedRequest`. + fn supports_pagination() -> bool; +} + +impl FromUrlQueryPairs for () { + fn from_url_query_pairs(query_pairs: UrlQueryPairsMap) -> Option { + None + } + + fn supports_pagination() -> bool { + false + } +} + mod macro_helper { #[macro_export] macro_rules! impl_as_query_value_from_as_str { diff --git a/wp_api/src/users.rs b/wp_api/src/users.rs index d9659fdb2..82c0482ec 100644 --- a/wp_api/src/users.rs +++ b/wp_api/src/users.rs @@ -1,13 +1,17 @@ -use std::{collections::HashMap, fmt::Display}; +use std::{collections::HashMap, fmt::Display, num::ParseIntError, str::FromStr}; use serde::{Deserialize, Serialize}; +use strum_macros::IntoStaticStr; use wp_contextual::WpContextual; use crate::{ impl_as_query_value_for_new_type, impl_as_query_value_from_as_str, impl_as_query_value_from_to_string, - url_query::{AppendUrlQueryPairs, AsQueryValue, QueryPairs, QueryPairsExtension}, - WpApiParamOrder, + url_query::{ + AppendUrlQueryPairs, AsQueryValue, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, + UrlQueryPairsMap, + }, + EnumFromStrParsingError, OptionFromStr, WpApiParamOrder, }; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, uniffi::Enum)] @@ -40,6 +44,26 @@ impl WpApiParamUsersOrderBy { } } +impl FromStr for WpApiParamUsersOrderBy { + type Err = EnumFromStrParsingError; + + fn from_str(s: &str) -> Result { + match s { + "id" => Ok(Self::Id), + "include" => Ok(Self::Include), + "name" => Ok(Self::Name), + "registered_date" => Ok(Self::RegisteredDate), + "slug" => Ok(Self::Slug), + "include_slugs" => Ok(Self::IncludeSlugs), + "email" => Ok(Self::Email), + "url" => Ok(Self::Url), + value => Err(EnumFromStrParsingError::UnknownVariant { + value: value.to_string(), + }), + } + } +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, uniffi::Enum)] pub enum WpApiParamUsersWho { #[default] @@ -57,6 +81,19 @@ impl WpApiParamUsersWho { } } +impl OptionFromStr for WpApiParamUsersWho { + type Err = EnumFromStrParsingError; + + fn option_from_str(s: &str) -> Result, Self::Err> { + match s { + "authors" => Ok(Some(Self::Authors)), + value => Err(EnumFromStrParsingError::UnknownVariant { + value: value.to_string(), + }), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum WpApiParamUsersHasPublishedPosts { True, @@ -64,6 +101,20 @@ pub enum WpApiParamUsersHasPublishedPosts { PostTypes(Vec), } +impl FromStr for WpApiParamUsersHasPublishedPosts { + type Err = EnumFromStrParsingError; + + fn from_str(s: &str) -> Result { + match s { + "true" => Ok(Self::True), + "false" => Ok(Self::False), + value => Ok(Self::PostTypes( + value.split(',').map(|s| s.to_string()).collect(), + )), + } + } +} + impl_as_query_value_from_to_string!(WpApiParamUsersHasPublishedPosts); impl Display for WpApiParamUsersHasPublishedPosts { @@ -80,7 +131,7 @@ impl Display for WpApiParamUsersHasPublishedPosts { } } -#[derive(Debug, Default, uniffi::Record)] +#[derive(Debug, Default, PartialEq, Eq, uniffi::Record)] pub struct UserListParams { /// Current page of the collection. /// Default: `1` @@ -130,31 +181,85 @@ pub struct UserListParams { pub has_published_posts: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, IntoStaticStr)] +enum UserListParamsField { + #[strum(serialize = "page")] + Page, + #[strum(serialize = "per_page")] + PerPage, + #[strum(serialize = "search")] + Search, + #[strum(serialize = "exclude")] + Exclude, + #[strum(serialize = "include")] + Include, + #[strum(serialize = "offset")] + Offset, + #[strum(serialize = "order")] + Order, + #[strum(serialize = "orderby")] + Orderby, + #[strum(serialize = "slug")] + Slug, + #[strum(serialize = "roles")] + Roles, + #[strum(serialize = "capabilities")] + Capabilities, + #[strum(serialize = "who")] + Who, + #[strum(serialize = "has_published_posts")] + HasPublishedPosts, +} + impl AppendUrlQueryPairs for UserListParams { fn append_query_pairs(&self, query_pairs_mut: &mut QueryPairs) { query_pairs_mut - .append_option_query_value_pair("page", self.page.as_ref()) - .append_option_query_value_pair("per_page", self.per_page.as_ref()) - .append_option_query_value_pair("search", self.search.as_ref()) - .append_vec_query_value_pair("exclude", &self.exclude) - .append_vec_query_value_pair("include", &self.include) - .append_option_query_value_pair("offset", self.offset.as_ref()) - .append_option_query_value_pair("order", self.order.as_ref()) - .append_option_query_value_pair("orderby", self.orderby.as_ref()) - .append_vec_query_value_pair("slug", &self.slug) - .append_vec_query_value_pair("roles", &self.roles) - .append_vec_query_value_pair("capabilities", &self.capabilities) + .append_option_query_value_pair(UserListParamsField::Page, self.page.as_ref()) + .append_option_query_value_pair(UserListParamsField::PerPage, self.per_page.as_ref()) + .append_option_query_value_pair(UserListParamsField::Search, self.search.as_ref()) + .append_vec_query_value_pair(UserListParamsField::Exclude, &self.exclude) + .append_vec_query_value_pair(UserListParamsField::Include, &self.include) + .append_option_query_value_pair(UserListParamsField::Offset, self.offset.as_ref()) + .append_option_query_value_pair(UserListParamsField::Order, self.order.as_ref()) + .append_option_query_value_pair(UserListParamsField::Orderby, self.orderby.as_ref()) + .append_vec_query_value_pair(UserListParamsField::Slug, &self.slug) + .append_vec_query_value_pair(UserListParamsField::Roles, &self.roles) + .append_vec_query_value_pair(UserListParamsField::Capabilities, &self.capabilities) .append_option_query_value_pair( - "who", + UserListParamsField::Who, self.who.as_ref().and_then(|w| w.as_str()).as_ref(), ) .append_option_query_value_pair( - "has_published_posts", + UserListParamsField::HasPublishedPosts, self.has_published_posts.as_ref(), ); } } +impl FromUrlQueryPairs for UserListParams { + fn from_url_query_pairs(query_pairs: UrlQueryPairsMap) -> Option { + Some(UserListParams { + page: query_pairs.get(UserListParamsField::Page), + per_page: query_pairs.get(UserListParamsField::PerPage), + search: query_pairs.get(UserListParamsField::Search), + exclude: query_pairs.get_csv(UserListParamsField::Exclude), + include: query_pairs.get_csv(UserListParamsField::Include), + offset: query_pairs.get(UserListParamsField::Offset), + order: query_pairs.get(UserListParamsField::Order), + orderby: query_pairs.get(UserListParamsField::Orderby), + slug: query_pairs.get_csv(UserListParamsField::Slug), + roles: query_pairs.get_csv(UserListParamsField::Roles), + capabilities: query_pairs.get_csv(UserListParamsField::Capabilities), + who: query_pairs.get_using_option_from_str(UserListParamsField::Who), + has_published_posts: query_pairs.get(UserListParamsField::HasPublishedPosts), + }) + } + + fn supports_pagination() -> bool { + true + } +} + #[derive(Debug, Serialize, uniffi::Record)] pub struct UserCreateParams { /// Login name for the user. @@ -314,6 +419,14 @@ uniffi::custom_newtype!(UserId, i32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct UserId(pub i32); +impl FromStr for UserId { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + s.parse().map(Self) + } +} + impl std::fmt::Display for UserId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -365,7 +478,10 @@ pub struct SparseUser { #[cfg(test)] mod tests { use super::*; - use crate::{generate, unit_test_common::assert_expected_query_pairs}; + use crate::{ + generate, + unit_test_common::{assert_expected_and_from_query_pairs, assert_expected_query_pairs}, + }; use rstest::*; #[rstest] @@ -383,13 +499,41 @@ mod tests { #[case(generate!(UserListParams, (roles, vec!["author".to_string(), "editor".to_string()])), "roles=author%2Ceditor")] #[case(generate!(UserListParams, (slug, vec!["foo".to_string(), "bar".to_string()]), (roles, vec!["author".to_string(), "editor".to_string()])), "slug=foo%2Cbar&roles=author%2Ceditor")] #[case(generate!(UserListParams, (capabilities, vec!["edit_themes".to_string(), "delete_pages".to_string()])), "capabilities=edit_themes%2Cdelete_pages")] - #[case::who_all_param_should_be_empty(generate!(UserListParams, (who, Some(WpApiParamUsersWho::All))), "")] #[case(generate!(UserListParams, (who, Some(WpApiParamUsersWho::Authors))), "who=authors")] #[case(generate!(UserListParams, (has_published_posts, Some(WpApiParamUsersHasPublishedPosts::True))), "has_published_posts=true")] #[case(generate!(UserListParams, (has_published_posts, Some(WpApiParamUsersHasPublishedPosts::PostTypes(vec!["post".to_string(), "page".to_string()])))), "has_published_posts=post%2Cpage")] + #[case(UserListParams { + page: Some(11), + per_page: Some(22), + search: Some("s_q".to_string()), + exclude: vec![UserId(111), UserId(112)], + include: vec![UserId(211), UserId(212)], + offset: Some(311), + order: Some(WpApiParamOrder::Asc), + orderby: Some(WpApiParamUsersOrderBy::Email), + slug: vec!["s1".to_string(), "s2".to_string()], + roles: vec!["r1".to_string(), "r2".to_string()], + capabilities: vec!["c1".to_string(), "c2".to_string()], + who: Some(WpApiParamUsersWho::Authors), + has_published_posts: Some(WpApiParamUsersHasPublishedPosts::True), + }, "page=11&per_page=22&search=s_q&exclude=111%2C112&include=211%2C212&offset=311&order=asc&orderby=email&slug=s1%2Cs2&roles=r1%2Cr2&capabilities=c1%2Cc2&who=authors&has_published_posts=true")] #[trace] - fn test_user_list_params2(#[case] params: UserListParams, #[case] expected_query: &str) { - assert_expected_query_pairs(params, expected_query); + fn test_user_list_query_pairs(#[case] params: UserListParams, #[case] expected_query: &str) { + assert_expected_and_from_query_pairs(params, expected_query); + } + + #[test] + fn test_user_list_params_who_all_should_be_empty() { + // This test is separate from `test_user_list_query_pairs` because converting the empty + // query back to `UserListParams` will result in `who: None` instead of + // `who: WpApiParamUsersWho::All`. + assert_expected_query_pairs( + UserListParams { + who: Some(WpApiParamUsersWho::All), + ..Default::default() + }, + "", + ); } #[test] diff --git a/wp_api_integration_tests/tests/test_users_immut.rs b/wp_api_integration_tests/tests/test_users_immut.rs index 92cf1a207..ab44a01af 100644 --- a/wp_api_integration_tests/tests/test_users_immut.rs +++ b/wp_api_integration_tests/tests/test_users_immut.rs @@ -182,6 +182,35 @@ async fn retrieve_me_with_view_context() { assert_eq!(FIRST_USER_ID, user.id); } +#[tokio::test] +#[rstest] +#[parallel] +#[case(UserListParams { per_page: Some(1), ..Default::default() })] +#[case(UserListParams { per_page: Some(1), order: Some(WpApiParamOrder::Desc), ..Default::default() })] +#[case(UserListParams { per_page: Some(1), orderby: Some(WpApiParamUsersOrderBy::Email), ..Default::default() })] +async fn paginate_list_users_with_edit_context(#[case] params: UserListParams) { + let first_page_response = api_client() + .users() + .list_with_edit_context(¶ms) + .await + .assert_response(); + assert!(!first_page_response.data.is_empty()); + let next_page_params = first_page_response.next_page_params.unwrap(); + let next_page_response = api_client() + .users() + .list_with_edit_context(&next_page_params) + .await + .assert_response(); + assert!(!next_page_response.data.is_empty()); + let prev_page_params = next_page_response.prev_page_params.unwrap(); + let prev_page_response = api_client() + .users() + .list_with_edit_context(&prev_page_params) + .await + .assert_response(); + assert!(!prev_page_response.data.is_empty()); +} + #[template] #[rstest] #[case(None)] diff --git a/wp_derive_request_builder/src/generate.rs b/wp_derive_request_builder/src/generate.rs index c38990808..7a16adeb4 100644 --- a/wp_derive_request_builder/src/generate.rs +++ b/wp_derive_request_builder/src/generate.rs @@ -94,6 +94,32 @@ fn generate_async_request_executor( &variant.variant_ident, &context_and_filter_handler, ); + let response_params_type = response_params_type(variant.attr.params.as_ref(), variant.attr.request_type); + let response_pagination_params_fields = response_params_type.as_ref().map(|p| { + quote! { + #[serde(skip)] + pub next_page_params: Option<#p>, + #[serde(skip)] + pub prev_page_params: Option<#p>, + } + }); + let from_concrete_response_impl_for_pagination_params = response_params_type.as_ref().map(|_| { + quote! { + next_page_params: value.next_page_params, + prev_page_params: value.prev_page_params, + } + }).unwrap_or(quote! { + next_page_params: None, + prev_page_params: None, + }); + let from_parsed_response_impl_for_pagination_params = response_params_type.as_ref().map(|_| { + quote! { + next_page_params: value.next_page_params, + prev_page_params: value.prev_page_params, + } + }); + // Generic type

of `ParsedResponse` can't be `None` + let parsed_response_params_type = response_params_type.unwrap_or(quote! { () }); quote! { #[derive(Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] #[serde(transparent)] @@ -101,20 +127,23 @@ fn generate_async_request_executor( pub data: #output_type, #[serde(skip)] pub header_map: std::sync::Arc, + #response_pagination_params_fields } - impl From<#response_type_ident> for crate::request::ParsedResponse<#output_type> { + impl From<#response_type_ident> for crate::request::ParsedResponse<#output_type, #parsed_response_params_type> { fn from(value: #response_type_ident) -> Self { Self { data: value.data, header_map: value.header_map, + #from_concrete_response_impl_for_pagination_params } } } - impl From> for #response_type_ident { - fn from(value: crate::request::ParsedResponse<#output_type>) -> Self { + impl From> for #response_type_ident { + fn from(value: crate::request::ParsedResponse<#output_type, #parsed_response_params_type>) -> Self { Self { data: value.data, header_map: value.header_map, + #from_parsed_response_impl_for_pagination_params } } } @@ -301,7 +330,8 @@ impl ContextAndFilterHandler { } v } - crate::parse::RequestType::ContextualGet => { + crate::parse::RequestType::ContextualGet + | crate::parse::RequestType::ContextualPaged => { let mut v = vec![]; WpContext::iter().for_each(|context| { v.push(Self::NoFilterTakeContextAsFunctionName(context)); 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 index cff4fc3fe..50c385eb3 100644 --- a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs +++ b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs @@ -111,10 +111,11 @@ pub fn fn_provided_param( // 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(), + RequestType::ContextualGet + | RequestType::ContextualPaged + | RequestType::Delete + | RequestType::Get => tokens, + RequestType::Post => TokenStream::new(), }, PartOf::RequestBuilder | PartOf::RequestExecutor => tokens, } @@ -204,10 +205,11 @@ fn fn_arg_provided_params( // 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(), + RequestType::ContextualGet + | RequestType::ContextualPaged + | RequestType::Delete + | RequestType::Get => tokens, + RequestType::Post => TokenStream::new(), }, PartOf::RequestBuilder | PartOf::RequestExecutor => tokens, } @@ -266,7 +268,10 @@ pub fn fn_body_query_pairs( request_type: RequestType, ) -> TokenStream { match request_type { - RequestType::ContextualGet | RequestType::Delete | RequestType::Get => { + RequestType::ContextualGet + | RequestType::ContextualPaged + | RequestType::Delete + | RequestType::Get => { if let Some(params_type) = params_type { let is_option = if let Some(TokenTree::Ident(ref ident)) = params_type.tokens.clone().into_iter().next() @@ -354,7 +359,7 @@ pub fn fn_body_build_request_from_url( request_type: RequestType, ) -> TokenStream { match request_type { - RequestType::ContextualGet | RequestType::Get => quote! { + RequestType::ContextualGet | RequestType::ContextualPaged | RequestType::Get => quote! { self.inner.get(url) }, RequestType::Delete => quote! { @@ -405,6 +410,30 @@ pub fn ident_response_type( ) } +pub fn response_params_type( + params_type: Option<&ParamsType>, + request_type: RequestType, +) -> Option { + if request_type == RequestType::ContextualPaged { + let p = params_type.expect("`contextual_paged` should only be used when the request has a params type. This is validated during parsing, so this panic should never occur."); + Some( + p.tokens + .clone() + .into_iter() + .filter(|t| { + if let TokenTree::Punct(punct) = t { + punct.as_char() != '&' + } else { + true + } + }) + .collect::(), + ) + } else { + None + } +} + #[cfg(test)] mod tests { #![allow(clippy::too_many_arguments)] @@ -1131,6 +1160,37 @@ mod tests { ); } + #[rstest] + #[case(Some(ParamsType { tokens: quote!{ UserListParams } }), RequestType::ContextualPaged, Some("UserListParams"))] + #[case(Some(ParamsType { tokens: quote!{ &UserListParams } }), RequestType::ContextualPaged, Some("UserListParams"))] + #[case(Some(ParamsType { tokens: quote!{ wp_api::users::UserListParams } }), RequestType::ContextualPaged, Some("wp_api :: users :: UserListParams"))] + #[case(Some(ParamsType { tokens: quote!{ &wp_api::users::UserListParams } }), RequestType::ContextualPaged, Some("wp_api :: users :: UserListParams"))] + #[case(None, RequestType::ContextualGet, None)] + #[case(Some(ParamsType { tokens: quote!{ UserListParams } }), RequestType::ContextualGet, None)] + #[case(None, RequestType::Delete, None)] + #[case(Some(ParamsType { tokens: quote!{ UserListParams } }), RequestType::Delete, None)] + #[case(None, RequestType::Get, None)] + #[case(Some(ParamsType { tokens: quote!{ UserListParams } }), RequestType::Get, None)] + #[case(None, RequestType::Post, None)] + #[case(Some(ParamsType { tokens: quote!{ UserListParams } }), RequestType::Post, None)] + fn test_response_params_type( + #[case] params_type: Option, + #[case] request_type: RequestType, + #[case] expected_str: Option<&str>, + ) { + assert_eq!( + response_params_type(params_type.as_ref(), request_type).map(|t| t.to_string()), + expected_str.map(|e| e.to_string()) + ); + } + + #[test] + fn test_response_params_type_panic() { + let result = + std::panic::catch_unwind(|| response_params_type(None, RequestType::ContextualPaged)); + assert!(result.is_err()); + } + fn referenced_params_type(str: &str) -> Option { let ident = format_ident!("{}", str); Some(ParamsType { diff --git a/wp_derive_request_builder/src/lib.rs b/wp_derive_request_builder/src/lib.rs index 56f43ce86..eeb54c4f2 100644 --- a/wp_derive_request_builder/src/lib.rs +++ b/wp_derive_request_builder/src/lib.rs @@ -8,7 +8,10 @@ mod generate; mod parse; mod variant_attr; -#[proc_macro_derive(WpDerivedRequest, attributes(contextual_get, delete, get, post))] +#[proc_macro_derive( + WpDerivedRequest, + attributes(contextual_get, contextual_paged, delete, get, post) +)] pub fn derive(input: TokenStream) -> TokenStream { let parsed_enum = parse_macro_input!(input as parse::ParsedEnum); diff --git a/wp_derive_request_builder/src/parse.rs b/wp_derive_request_builder/src/parse.rs index 7fc270f3f..56d695553 100644 --- a/wp_derive_request_builder/src/parse.rs +++ b/wp_derive_request_builder/src/parse.rs @@ -44,9 +44,10 @@ impl Parse for ParsedVariant { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum RequestType { ContextualGet, + ContextualPaged, Delete, Get, Post, diff --git a/wp_derive_request_builder/src/variant_attr.rs b/wp_derive_request_builder/src/variant_attr.rs index 9027d9b14..17a987214 100644 --- a/wp_derive_request_builder/src/variant_attr.rs +++ b/wp_derive_request_builder/src/variant_attr.rs @@ -33,7 +33,7 @@ impl ParsedVariantAttribute { params: Option>, output: Vec, filter_by: Option>, - ) -> Self { + ) -> Result { let non_empty_token_tree_or_none = |tokens: Option>| -> Option> { tokens.and_then(|tokens| { @@ -44,18 +44,23 @@ impl ParsedVariantAttribute { } }) }; + let params_type = non_empty_token_tree_or_none(params).map(|tokens| ParamsType { + tokens: TokenStream::from_iter(tokens), + }); - Self { + if request_type == RequestType::ContextualPaged && params_type.is_none() { + return Err(ItemVariantAttributeParseError::ContextualPagedRequiresParamsType); + } + + Ok(Self { request_type, url_parts, - params: non_empty_token_tree_or_none(params).map(|tokens| ParamsType { - tokens: TokenStream::from_iter(tokens), - }), + params: params_type, output, filter_by: non_empty_token_tree_or_none(filter_by).map(|tokens| FilterByType { tokens: TokenStream::from_iter(tokens), }), - } + }) } // Parses the attribute and finds the [syn::MetaList] @@ -111,6 +116,7 @@ impl ParsedVariantAttribute { match path_segment.ident.to_string().as_str() { "contextual_get" => Ok(RequestType::ContextualGet), + "contextual_paged" => Ok(RequestType::ContextualPaged), "delete" => Ok(RequestType::Delete), "get" => Ok(RequestType::Get), "post" => Ok(RequestType::Post), @@ -278,18 +284,21 @@ impl Parse for ParsedVariantAttribute { let url_parts = UrlPart::split(url_str.to_string(), &meta_list_span)?; - Ok(ParsedVariantAttribute::new( + ParsedVariantAttribute::new( request_type, url_parts, params_tokens, output, filter_by_tokens, - )) + ) + .map_err(|e| e.into_syn_error(meta_list_span)) } } #[derive(Debug, thiserror::Error)] enum ItemVariantAttributeParseError { + #[error("#[contextual_paged(params = ?)] is missing `params` type")] + ContextualPagedRequiresParamsType, #[error("Missing variant attribute")] MissingAttr, #[error("Only a single attribute is supported")] @@ -306,7 +315,7 @@ enum ItemVariantAttributeParseError { UrlShouldBeLiteral, #[error("Missing (output = crate::Foo)")] MissingOutput, - #[error("Only 'contextual_get', 'get', 'post' & 'delete' are supported")] + #[error("Only 'contextual_get', 'contextual_paged', 'get', 'post' & 'delete' are supported")] UnsupportedRequestType, } diff --git a/wp_derive_request_builder/tests/fail/contextual_paged_missing_params_type.rs b/wp_derive_request_builder/tests/fail/contextual_paged_missing_params_type.rs new file mode 100644 index 000000000..b543bcc10 --- /dev/null +++ b/wp_derive_request_builder/tests/fail/contextual_paged_missing_params_type.rs @@ -0,0 +1,7 @@ +#[derive(wp_derive_request_builder::WpDerivedRequest)] +enum UsersRequest { + #[contextual_paged(url = "/users", output = Vec)] + List, +} + +fn main() {} diff --git a/wp_derive_request_builder/tests/fail/contextual_paged_missing_params_type.stderr b/wp_derive_request_builder/tests/fail/contextual_paged_missing_params_type.stderr new file mode 100644 index 000000000..0db11c16d --- /dev/null +++ b/wp_derive_request_builder/tests/fail/contextual_paged_missing_params_type.stderr @@ -0,0 +1,5 @@ +error: #[contextual_paged(params = ?)] is missing `params` type + --> tests/fail/contextual_paged_missing_params_type.rs:3:7 + | +3 | #[contextual_paged(url = "/users", output = Vec)] + | ^^^^^^^^^^^^^^^^