Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions wp_api/src/wp_com/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use super::endpoint::{
stats_tags_endpoint::{StatsTagsRequestBuilder, StatsTagsRequestExecutor},
stats_top_authors_endpoint::{StatsTopAuthorsRequestBuilder, StatsTopAuthorsRequestExecutor},
stats_top_posts_endpoint::{StatsTopPostsRequestBuilder, StatsTopPostsRequestExecutor},
stats_utm_endpoint::{StatsUtmRequestBuilder, StatsUtmRequestExecutor},
stats_video_plays_endpoint::{StatsVideoPlaysRequestBuilder, StatsVideoPlaysRequestExecutor},
stats_visits_endpoint::{StatsVisitsRequestBuilder, StatsVisitsRequestExecutor},
subscribers_endpoint::{SubscribersRequestBuilder, SubscribersRequestExecutor},
Expand Down Expand Up @@ -85,6 +86,7 @@ pub struct WpComApiRequestBuilder {
stats_tags: Arc<StatsTagsRequestBuilder>,
stats_top_authors: Arc<StatsTopAuthorsRequestBuilder>,
stats_top_posts: Arc<StatsTopPostsRequestBuilder>,
stats_utm: Arc<StatsUtmRequestBuilder>,
stats_video_plays: Arc<StatsVideoPlaysRequestBuilder>,
stats_visits: Arc<StatsVisitsRequestBuilder>,
subscribers: Arc<SubscribersRequestBuilder>,
Expand Down Expand Up @@ -123,6 +125,7 @@ impl WpComApiRequestBuilder {
stats_tags,
stats_top_authors,
stats_top_posts,
stats_utm,
stats_video_plays,
stats_visits,
subscribers,
Expand Down Expand Up @@ -172,6 +175,7 @@ pub struct WpComApiClient {
stats_tags: Arc<StatsTagsRequestExecutor>,
stats_top_authors: Arc<StatsTopAuthorsRequestExecutor>,
stats_top_posts: Arc<StatsTopPostsRequestExecutor>,
stats_utm: Arc<StatsUtmRequestExecutor>,
stats_video_plays: Arc<StatsVideoPlaysRequestExecutor>,
stats_visits: Arc<StatsVisitsRequestExecutor>,
subscribers: Arc<SubscribersRequestExecutor>,
Expand Down Expand Up @@ -211,6 +215,7 @@ impl WpComApiClient {
stats_tags,
stats_top_authors,
stats_top_posts,
stats_utm,
stats_video_plays,
stats_visits,
subscribers,
Expand Down Expand Up @@ -243,6 +248,7 @@ api_client_generate_endpoint_impl!(WpComApi, stats_summary);
api_client_generate_endpoint_impl!(WpComApi, stats_tags);
api_client_generate_endpoint_impl!(WpComApi, stats_top_authors);
api_client_generate_endpoint_impl!(WpComApi, stats_top_posts);
api_client_generate_endpoint_impl!(WpComApi, stats_utm);
api_client_generate_endpoint_impl!(WpComApi, stats_video_plays);
api_client_generate_endpoint_impl!(WpComApi, stats_visits);
api_client_generate_endpoint_impl!(WpComApi, subscribers);
Expand Down
1 change: 1 addition & 0 deletions wp_api/src/wp_com/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod stats_summary_endpoint;
pub mod stats_tags_endpoint;
pub mod stats_top_authors_endpoint;
pub mod stats_top_posts_endpoint;
pub mod stats_utm_endpoint;
pub mod stats_video_plays_endpoint;
pub mod stats_visits_endpoint;
pub mod subscribers_endpoint;
Expand Down
20 changes: 20 additions & 0 deletions wp_api/src/wp_com/endpoint/stats_utm_endpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use crate::{
request::endpoint::{AsNamespace, DerivedRequest},
wp_com::{
WpComNamespace, WpComSiteId,
stats_utm::{StatsUtmKeys, StatsUtmParams, StatsUtmResponse},
},
};
use wp_derive_request_builder::WpDerivedRequest;

#[derive(WpDerivedRequest)]
enum StatsUtmRequest {
#[get(url = "/sites/<wp_com_site_id>/stats/utm/<stats_utm_keys>", params = &StatsUtmParams, output = StatsUtmResponse)]
GetStatsUtm,
}

impl DerivedRequest for StatsUtmRequest {
fn namespace() -> impl AsNamespace {
WpComNamespace::RestV1_1
}
}
1 change: 1 addition & 0 deletions wp_api/src/wp_com/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod stats_summary;
pub mod stats_tags;
pub mod stats_top_authors;
pub mod stats_top_posts;
pub mod stats_utm;
pub mod stats_video_plays;
pub mod stats_visits;
pub mod subscribers;
Expand Down
300 changes: 300 additions & 0 deletions wp_api/src/wp_com/stats_utm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
use crate::url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wp_serde_helper::deserialize_empty_array_or_hashmap;

/// A single UTM key that can be used in the stats UTM endpoint path.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize,
uniffi::Enum,
strum_macros::EnumString,
strum_macros::Display,
)]
pub enum StatsUtmKey {
#[strum(serialize = "utm_source")]
#[serde(rename = "utm_source")]
UtmSource,
#[strum(serialize = "utm_medium")]
#[serde(rename = "utm_medium")]
UtmMedium,
#[strum(serialize = "utm_campaign")]
#[serde(rename = "utm_campaign")]
UtmCampaign,
}

uniffi::custom_newtype!(StatsUtmKeys, String);
/// Comma-separated UTM keys used as a URL path segment.
///
/// For example, `"utm_source,utm_medium"` or `"utm_campaign"`.
/// Construct from a list of `StatsUtmKey` using `StatsUtmKeys::new`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatsUtmKeys(pub String);

impl StatsUtmKeys {
pub fn new(keys: &[StatsUtmKey]) -> Self {
assert!(!keys.is_empty(), "At least one UTM key must be provided");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this was added to address @jkmassel's feedback here.

Keys being empty is a programmer mistake, which IMHO should be an assertion instead of an error.

But the assertion still makes it a runtime error. So, I don't agree with the change. In my opinion, the assertions shouldn't be part of the public facing API. If this was WPiOS or WPAndroid, I can understand, although that's still questionable for a network call, because there is no need to crash the app. I suggest going back to 2cdcba9 and return a Result.

If we really want to avoid it, we can use a builder-like pattern where the client adds the keys one by one.


There is this approach as well mentioned by Claude:

pub fn new(first: StatsUtmKey, rest: &[StatsUtmKey]) -> Self

I think it's a very awkward API and Claude agreed. Still including it for completeness.

Self(
keys.iter()
.map(|k| k.to_string())
.collect::<Vec<_>>()
.join(","),
)
}
}

impl std::fmt::Display for StatsUtmKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

/// Parameters for the stats UTM endpoint.
#[derive(Debug, PartialEq, Eq, uniffi::Record)]
pub struct StatsUtmParams {
/// The maximum number of results to return. Use 0 for unlimited.
#[uniffi(default = None)]
pub max: Option<u32>,
/// The date to query stats for (format: YYYY-MM-DD).
#[uniffi(default = None)]
pub date: Option<String>,
/// The number of days to include in the query.
#[uniffi(default = None)]
pub days: Option<u32>,
/// The start date to query stats for (format: YYYY-MM-DD).
#[uniffi(default = None)]
pub start_date: Option<String>,
/// Whether to include top posts data in the response.
#[uniffi(default = true)]
pub query_top_posts: bool,
}

impl Default for StatsUtmParams {
fn default() -> Self {
Self {
max: None,
date: None,
days: None,
start_date: None,
query_top_posts: true,
}
}
}

impl AppendUrlQueryPairs for StatsUtmParams {
fn append_query_pairs(&self, query_pairs_mut: &mut QueryPairs) {
query_pairs_mut
.append_option_query_value_pair("max", self.max.as_ref())
.append_option_query_value_pair("date", self.date.as_ref())
.append_option_query_value_pair("days", self.days.as_ref())
.append_option_query_value_pair("start_date", self.start_date.as_ref())
.append_query_value_pair("query_top_posts", &(self.query_top_posts as u32));
}
}

/// Response from the stats UTM endpoint.
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
pub struct StatsUtmResponse {
/// Top UTM values with their view counts.
/// Keys are UTM value strings (single values like `"impact"` or JSON arrays
/// like `["impact","affiliate"]` when multiple UTM keys are queried).
#[serde(deserialize_with = "deserialize_empty_array_or_hashmap")]
pub top_utm_values: HashMap<String, u64>,
/// Top posts grouped by UTM value.
/// Keys match the keys in `top_utm_values`.
#[serde(default, deserialize_with = "deserialize_empty_array_or_hashmap")]
pub top_posts: HashMap<String, Vec<StatsUtmPost>>,
}

/// A post entry in the stats UTM response.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct StatsUtmPost {
/// The post ID. 0 indicates the home page or archives.
pub id: u64,
/// The URL of the post.
pub href: String,
/// The title of the post.
pub title: String,
/// The number of views from this UTM source.
pub views: u64,
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::*;

#[test]
fn test_stats_utm_keys_single() {
let keys = StatsUtmKeys::new(&[StatsUtmKey::UtmSource]);
assert_eq!(keys.to_string(), "utm_source");
}

#[test]
fn test_stats_utm_keys_multiple() {
let keys = StatsUtmKeys::new(&[StatsUtmKey::UtmSource, StatsUtmKey::UtmMedium]);
assert_eq!(keys.to_string(), "utm_source,utm_medium");
}

#[test]
fn test_stats_utm_keys_all() {
let keys = StatsUtmKeys::new(&[
StatsUtmKey::UtmCampaign,
StatsUtmKey::UtmSource,
StatsUtmKey::UtmMedium,
]);
assert_eq!(keys.to_string(), "utm_campaign,utm_source,utm_medium");
}

#[test]
#[should_panic(expected = "At least one UTM key must be provided")]
fn test_stats_utm_keys_empty() {
StatsUtmKeys::new(&[]);
}

#[test]
fn test_stats_utm_params_serialization() {
let mut url = url::Url::parse(
"https://public-api.wordpress.com/rest/v1.1/sites/1234/stats/utm/utm_source",
)
.expect("Failed to parse url");

let params = StatsUtmParams {
max: Some(0),
date: Some("2026-03-24".to_string()),
days: Some(365),
start_date: Some("2026-03-24".to_string()),
query_top_posts: true,
};

let mut query_pairs = url.query_pairs_mut();
params.append_query_pairs(&mut query_pairs);

assert_eq!(
query_pairs.finish().as_str(),
"https://public-api.wordpress.com/rest/v1.1/sites/1234/stats/utm/utm_source?max=0&date=2026-03-24&days=365&start_date=2026-03-24&query_top_posts=1"
);
}

#[test]
fn test_stats_utm_params_serialization_minimal() {
let mut url = url::Url::parse(
"https://public-api.wordpress.com/rest/v1.1/sites/1234/stats/utm/utm_source",
)
.expect("Failed to parse url");

let params = StatsUtmParams {
date: Some("2026-03-24".to_string()),
days: Some(1),
..Default::default()
};

let mut query_pairs = url.query_pairs_mut();
params.append_query_pairs(&mut query_pairs);

assert_eq!(
query_pairs.finish().as_str(),
"https://public-api.wordpress.com/rest/v1.1/sites/1234/stats/utm/utm_source?date=2026-03-24&days=1&query_top_posts=1"
);
}

#[test]
fn test_stats_utm_params_with_false_query_top_posts() {
let mut url = url::Url::parse(
"https://public-api.wordpress.com/rest/v1.1/sites/1234/stats/utm/utm_source",
)
.expect("Failed to parse url");

let params = StatsUtmParams {
query_top_posts: false,
..Default::default()
};

let mut query_pairs = url.query_pairs_mut();
params.append_query_pairs(&mut query_pairs);

assert_eq!(
query_pairs.finish().as_str(),
"https://public-api.wordpress.com/rest/v1.1/sites/1234/stats/utm/utm_source?query_top_posts=0"
);
}

#[rstest]
#[case("tests/wpcom/stats_utm/single-key.json")]
#[case("tests/wpcom/stats_utm/multiple-keys.json")]
#[case("tests/wpcom/stats_utm/triple-keys.json")]
fn test_stats_utm_response_deserialization(#[case] json_file_path: &str) {
let file = std::fs::File::open(json_file_path).expect("Failed to open file");
let response: StatsUtmResponse =
serde_json::from_reader(file).expect("Unable to parse JSON");

assert!(!response.top_utm_values.is_empty());
assert!(!response.top_posts.is_empty());

// Keys in top_utm_values and top_posts should match
for key in response.top_utm_values.keys() {
assert!(
response.top_posts.contains_key(key),
"top_posts should contain key: {key}"
);
}
}

#[test]
fn test_stats_utm_response_deserialization_single_key() {
let json_file_path = "tests/wpcom/stats_utm/single-key.json";
let file = std::fs::File::open(json_file_path).expect("Failed to open file");
let response: StatsUtmResponse =
serde_json::from_reader(file).expect("Unable to parse JSON");

assert_eq!(response.top_utm_values.len(), 3);
assert_eq!(response.top_utm_values["impact"], 5);
assert_eq!(response.top_utm_values["trustpilot"], 1);
assert_eq!(response.top_utm_values["hovercard"], 1);

let impact_posts = &response.top_posts["impact"];
assert_eq!(impact_posts.len(), 2);
assert_eq!(impact_posts[0].id, 146836);
assert_eq!(impact_posts[0].views, 3);
}

#[test]
fn test_stats_utm_response_deserialization_empty() {
let json_file_path = "tests/wpcom/stats_utm/empty-response.json";
let file = std::fs::File::open(json_file_path).expect("Failed to open file");
let response: StatsUtmResponse =
serde_json::from_reader(file).expect("Unable to parse JSON");

assert!(response.top_utm_values.is_empty());
assert!(response.top_posts.is_empty());
}

#[test]
fn test_stats_utm_response_deserialization_empty_arrays() {
let json_file_path = "tests/wpcom/stats_utm/empty-arrays-response.json";
let file = std::fs::File::open(json_file_path).expect("Failed to open file");
let response: StatsUtmResponse =
serde_json::from_reader(file).expect("Unable to parse JSON");

assert!(response.top_utm_values.is_empty());
assert!(response.top_posts.is_empty());
}

#[test]
fn test_stats_utm_response_deserialization_missing_top_posts() {
let json_file_path = "tests/wpcom/stats_utm/missing-top-posts.json";
let file = std::fs::File::open(json_file_path).expect("Failed to open file");
let response: StatsUtmResponse =
serde_json::from_reader(file).expect("Unable to parse JSON");

assert!(response.top_utm_values.is_empty());
assert!(response.top_posts.is_empty());
}
}
4 changes: 4 additions & 0 deletions wp_api/tests/wpcom/stats_utm/empty-arrays-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"top_utm_values": [],
"top_posts": []
}
4 changes: 4 additions & 0 deletions wp_api/tests/wpcom/stats_utm/empty-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"top_utm_values": {},
"top_posts": {}
}
1 change: 1 addition & 0 deletions wp_api/tests/wpcom/stats_utm/missing-top-posts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"top_utm_values":[]}
Loading