Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pagination support for /users #375

Merged
merged 23 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1f5108d
Add next & prev page support to ParsedResponse
oguzkocer Oct 30, 2024
4da50af
Add FromUrlQueryPairs trait
oguzkocer Oct 31, 2024
5ceab3e
Fix clippy warning to use a single char instead of string
oguzkocer Oct 31, 2024
93fb65a
Mockup of FromUrlQueryPairs for UserListParams
oguzkocer Nov 1, 2024
dbd3889
Add Kotlin integration test for user list pagination
oguzkocer Nov 1, 2024
49fecbe
Address clippy warnings in user list pagination implementation
oguzkocer Nov 1, 2024
1661e6c
Introduce `contextual_get` attribute for WpDerivedRequest
oguzkocer Nov 1, 2024
a28d07f
Move WpDerivedRequest `response_params_type` to helper
oguzkocer Nov 1, 2024
c164a3f
Update FromUrlQueryPairs function signature to accept HashMap
oguzkocer Nov 1, 2024
8b220f6
Introduce UrlQueryPairsMap
oguzkocer Nov 2, 2024
1a5a71b
Implement UserListParamsField enum
oguzkocer Nov 2, 2024
04f7672
Update QueryPairsExtension functions to take Into<&'a str>
oguzkocer Nov 2, 2024
ff807cd
Implement FromUrlQueryPairs::supports_pagination and improve paginati…
oguzkocer Nov 3, 2024
cf72843
Use descriptive names for fn parse() & ParsedResponse generic types
oguzkocer Nov 3, 2024
0257692
Implement EnumFromStrParsingError
oguzkocer Nov 4, 2024
e3e7859
Unit test wp_derive_request_builder's response_params_type
oguzkocer Nov 4, 2024
00e1c1c
Throw parsing error if #[contextual_paged] is missing params type
oguzkocer Nov 4, 2024
adfe198
Unit test UsersListParams::from_query_pairs
oguzkocer Nov 5, 2024
2ec7bfd
Add OptionFromStr and implement for WpApiParamUsersWho
oguzkocer Nov 5, 2024
078f738
Move url query related traits and implementations to url_query mod
oguzkocer Nov 5, 2024
8ee9311
Rename UrlQueryPairsMap::get_option to get_using_option_from_str
oguzkocer Nov 5, 2024
60652b1
Implement from_str for WpApiParamUsersHasPublishedPosts::PostTypes
oguzkocer Nov 5, 2024
4f58cd9
Improve user list pagination integration tests
oguzkocer Nov 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
1 change: 1 addition & 0 deletions wp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ] }
Expand Down
29 changes: 29 additions & 0 deletions wp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -86,10 +87,38 @@ impl WpApiParamOrder {
}
}

impl FromStr for WpApiParamOrder {
type Err = EnumFromStrParsingError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
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<Option<Self>, 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) => {
Expand Down
58 changes: 49 additions & 9 deletions wp_api/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use url::Url;

use crate::{
api_error::{ParsedRequestError, RequestExecutionError, WpError},
url_query::{FromUrlQueryPairs, UrlQueryPairsMap},
WpApiError, WpAuthentication,
};

Expand All @@ -21,10 +22,29 @@ const HEADER_KEY_WP_TOTAL_PAGES: &str = "X-WP-TotalPages";

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ParsedResponse<T> {
pub data: T,
pub struct ParsedResponse<DataType, ParamsType> {
pub data: DataType,
#[serde(skip)]
pub header_map: Arc<WpNetworkHeaderMap>,
#[serde(skip)]
pub next_page_params: Option<ParamsType>,
#[serde(skip)]
pub prev_page_params: Option<ParamsType>,
}

#[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)]
Expand Down Expand Up @@ -322,15 +342,29 @@ impl WpNetworkResponse {
.collect()
}

pub fn get_pagination_header<P: FromUrlQueryPairs>(
&self,
header_key: PaginationHeaderKey,
) -> Option<P> {
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<T, D, E>(self) -> Result<T, E>
pub fn parse<ResponseType, DataType, ParamsType, E>(self) -> Result<ResponseType, E>
where
T: DeserializeOwned,
T: From<ParsedResponse<D>>,
ParsedResponse<D>: From<T>,
ResponseType: DeserializeOwned,
ResponseType: From<ParsedResponse<DataType, ParamsType>>,
ParsedResponse<DataType, ParamsType>: From<ResponseType>,
ParamsType: FromUrlQueryPairs,
E: ParsedRequestError,
{
if let Some(err) = E::try_parse(&self.body, self.status_code) {
Expand All @@ -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::<D>::from(x);
p.header_map = self.header_map;
T::from(p)
let mut parsed_response = ParsedResponse::<DataType, ParamsType>::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)
})
}

Expand Down
2 changes: 1 addition & 1 deletion wp_api/src/request/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion wp_api/src/request/endpoint/users_endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use super::{AsNamespace, DerivedRequest, WpNamespace};

#[derive(WpDerivedRequest)]
enum UsersRequest {
#[contextual_get(url = "/users", params = &UserListParams, output = Vec<crate::SparseUser>, filter_by = crate::SparseUserField)]
#[contextual_paged(url = "/users", params = &UserListParams, output = Vec<crate::SparseUser>, filter_by = crate::SparseUserField)]
List,
#[post(url = "/users", params = &UserCreateParams, output = UserWithEditContext)]
Create,
Expand Down
17 changes: 16 additions & 1 deletion wp_api/src/unit_test_common.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::url_query::AppendUrlQueryPairs;
use crate::url_query::{AppendUrlQueryPairs, FromUrlQueryPairs, UrlQueryPairsMap};
use url::Url;

#[cfg(test)]
Expand All @@ -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<P>(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);
}
95 changes: 86 additions & 9 deletions wp_api/src/url_query.rs
Original file line number Diff line number Diff line change
@@ -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>>;

Expand All @@ -9,27 +10,39 @@ pub(crate) trait AppendUrlQueryPairs {
}

pub(crate) trait QueryPairsExtension {
fn append_query_value_pair<T>(&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<T>(&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<T>(&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<T>(&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<T>(&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,
{
Expand All @@ -39,7 +52,11 @@ impl QueryPairsExtension for QueryPairs<'_> {
self
}

fn append_vec_query_value_pair<T>(&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,
{
Expand All @@ -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
}
Expand All @@ -76,6 +93,66 @@ impl AsQueryValue for String {
}
}

#[derive(Debug)]
pub struct UrlQueryPairsMap<'a> {
inner: HashMap<Cow<'a, str>, Cow<'a, str>>,
}

impl<'a> UrlQueryPairsMap<'a> {
pub(crate) fn new(query_pairs: HashMap<Cow<'a, str>, Cow<'a, str>>) -> Self {
Self { inner: query_pairs }
}

pub(crate) fn get<'b, T: FromStr>(&self, key: impl Into<&'b str>) -> Option<T> {
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<T> {
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<T> {
self.inner
.get(key.into())
.and_then(|v| {
v.split(',')
.map(|s| T::from_str(s).ok())
.collect::<Option<Vec<_>>>()
})
.unwrap_or_default()
}
}

pub trait FromUrlQueryPairs
where
Self: Sized,
{
fn from_url_query_pairs(query_pairs: UrlQueryPairsMap) -> Option<Self>;

// 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<T, P>`.
fn supports_pagination() -> bool;
}

impl FromUrlQueryPairs for () {
fn from_url_query_pairs(query_pairs: UrlQueryPairsMap) -> Option<Self> {
None
}

fn supports_pagination() -> bool {
false
}
}

mod macro_helper {
#[macro_export]
macro_rules! impl_as_query_value_from_as_str {
Expand Down
Loading