1use regex::escape;
2use reqwest::{Client as HttpClient, Response, StatusCode};
3use serde::{de::DeserializeOwned, ser::SerializeSeq, Deserialize, Serialize, Serializer};
4use serde_json::{Map, Number, Value};
5use std::time::Duration;
6use time::{format_description::well_known::Rfc3339, Duration as TimeDuration, OffsetDateTime};
7use url::Url;
8
9const DEFAULT_BASE_URL: &str = "https://plausible.io/";
10const DEFAULT_TIMEOUT_SECS: u64 = 15;
11
12#[derive(Debug, Clone)]
14pub struct PlausibleClient {
15 http: HttpClient,
16 base_url: Url,
17 api_key: String,
18 user_agent: String,
19}
20
21impl PlausibleClient {
22 const REALTIME_WINDOW_MINUTES: i64 = 5;
23 pub fn new(api_key: impl Into<String>) -> Result<Self, ClientError> {
25 let base = Url::parse(DEFAULT_BASE_URL).map_err(ClientError::InvalidBaseUrl)?;
26 Self::with_base_url(api_key, base)
27 }
28
29 pub fn with_base_url(api_key: impl Into<String>, base_url: Url) -> Result<Self, ClientError> {
31 let api_key = api_key.into();
32 if api_key.trim().is_empty() {
33 return Err(ClientError::Validation("api_key cannot be empty".into()));
34 }
35 let base_url = normalize_base_url(base_url);
36 let user_agent = format!("plausible-cli/{}", env!("CARGO_PKG_VERSION"));
37 let http = HttpClient::builder()
38 .user_agent(&user_agent)
39 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
40 .build()
41 .map_err(ClientError::HttpClient)?;
42
43 Ok(Self {
44 http,
45 base_url,
46 api_key,
47 user_agent,
48 })
49 }
50
51 pub fn base_url(&self) -> &Url {
53 &self.base_url
54 }
55
56 pub fn user_agent(&self) -> &str {
58 &self.user_agent
59 }
60
61 fn endpoint(&self, fragment: &str) -> Result<Url, ClientError> {
62 self.base_url
63 .join(fragment)
64 .map_err(ClientError::InvalidEndpoint)
65 }
66
67 pub async fn list_sites(&self) -> Result<Vec<SiteSummary>, ClientError> {
69 let url = self.endpoint("api/v1/sites")?;
70 let response = self
71 .http
72 .get(url)
73 .bearer_auth(&self.api_key)
74 .send()
75 .await
76 .map_err(ClientError::Http)?;
77 self.handle_response(response).await
78 }
79
80 pub async fn create_site(
82 &self,
83 request: &CreateSiteRequest,
84 ) -> Result<SiteSummary, ClientError> {
85 if request.domain.trim().is_empty() {
86 return Err(ClientError::Validation("domain cannot be empty".into()));
87 }
88 let url = self.endpoint("api/v1/sites")?;
89 let response = self
90 .http
91 .post(url)
92 .bearer_auth(&self.api_key)
93 .json(request)
94 .send()
95 .await
96 .map_err(ClientError::Http)?;
97 self.handle_response(response).await
98 }
99
100 pub async fn update_site(
102 &self,
103 site_id: &str,
104 request: &UpdateSiteRequest,
105 ) -> Result<SiteSummary, ClientError> {
106 if site_id.trim().is_empty() {
107 return Err(ClientError::Validation("site_id cannot be empty".into()));
108 }
109 let url = self.endpoint(&format!("api/v1/sites/{site_id}"))?;
110 let response = self
111 .http
112 .patch(url)
113 .bearer_auth(&self.api_key)
114 .json(request)
115 .send()
116 .await
117 .map_err(ClientError::Http)?;
118 self.handle_response(response).await
119 }
120
121 pub async fn reset_site_stats(
123 &self,
124 site_id: &str,
125 request: &ResetSiteStatsRequest,
126 ) -> Result<(), ClientError> {
127 if site_id.trim().is_empty() {
128 return Err(ClientError::Validation("site_id cannot be empty".into()));
129 }
130 let url = self.endpoint(&format!("api/v1/sites/{site_id}/reset-stats"))?;
131 let response = self
132 .http
133 .post(url)
134 .bearer_auth(&self.api_key)
135 .json(request)
136 .send()
137 .await
138 .map_err(ClientError::Http)?;
139 if response.status().is_success() {
140 Ok(())
141 } else {
142 let status = response.status();
143 let message = response
144 .text()
145 .await
146 .unwrap_or_else(|_| String::from("unable to read error body"));
147 Err(ClientError::Api { status, message })
148 }
149 }
150
151 pub async fn delete_site(&self, site_id: &str) -> Result<(), ClientError> {
153 if site_id.trim().is_empty() {
154 return Err(ClientError::Validation("site_id cannot be empty".into()));
155 }
156 let url = self.endpoint(&format!("api/v1/sites/{site_id}"))?;
157 let response = self
158 .http
159 .delete(url)
160 .bearer_auth(&self.api_key)
161 .send()
162 .await
163 .map_err(ClientError::Http)?;
164 if response.status().is_success() || response.status() == StatusCode::NO_CONTENT {
165 Ok(())
166 } else {
167 let status = response.status();
168 let message = response
169 .text()
170 .await
171 .unwrap_or_else(|_| String::from("unable to read error body"));
172 Err(ClientError::Api { status, message })
173 }
174 }
175
176 pub async fn stats_aggregate(
178 &self,
179 query: &AggregateQuery,
180 ) -> Result<AggregateResponse, ClientError> {
181 if query.site_id.trim().is_empty() {
182 return Err(ClientError::Validation("site_id cannot be empty".into()));
183 }
184 if query.compare.is_some() {
185 return Err(ClientError::Validation(
186 "stats aggregate does not support --compare with the Stats API v2".into(),
187 ));
188 }
189 if !query.properties.is_empty() {
190 return Err(ClientError::Validation(
191 "stats aggregate does not support --properties; use stats breakdown instead".into(),
192 ));
193 }
194 let metrics = ensure_metrics(&query.metrics);
195 let date_range = Some(resolve_date_range(
196 query.period.as_ref(),
197 query.date.as_ref(),
198 )?);
199 let filters = parse_filters(&query.filters)?;
200 let order_by = parse_sort(query.sort.as_ref())?;
201 let (limit, offset) = resolve_pagination(query.limit, query.page)?;
202 if query.interval.is_some() {
203 return Err(ClientError::Validation(
204 "stats aggregate does not support --interval".into(),
205 ));
206 }
207 let payload = StatsQueryPayload {
208 site_id: query.site_id.clone(),
209 metrics: metrics.clone(),
210 date_range,
211 dimensions: Vec::new(),
212 filters,
213 order_by,
214 limit,
215 offset,
216 include: None,
217 };
218 let response = self.post_stats_query(&payload).await?;
219 Ok(convert_aggregate_response(&metrics, response))
220 }
221
222 pub async fn stats_timeseries(
224 &self,
225 query: &TimeseriesQuery,
226 ) -> Result<TimeseriesResponse, ClientError> {
227 if query.site_id.trim().is_empty() {
228 return Err(ClientError::Validation("site_id cannot be empty".into()));
229 }
230 if query.compare.is_some() {
231 return Err(ClientError::Validation(
232 "stats timeseries does not support --compare with the Stats API v2".into(),
233 ));
234 }
235 let metrics = ensure_metrics(&query.metrics);
236 let date_range = Some(resolve_date_range(
237 query.period.as_ref(),
238 query.date.as_ref(),
239 )?);
240 let filters = parse_filters(&query.filters)?;
241 let mut dimensions = Vec::new();
242 dimensions.push(resolve_time_dimension(query.interval.as_deref())?);
243 if !query.properties.is_empty() {
244 dimensions.extend(query.properties.clone());
245 }
246 let order_by = parse_sort(query.sort.as_ref())?;
247 let (limit, offset) = resolve_pagination(query.limit, query.page)?;
248 let include = StatsInclude {
249 time_labels: Some(true),
250 ..StatsInclude::default()
251 }
252 .into_option();
253 let payload = StatsQueryPayload {
254 site_id: query.site_id.clone(),
255 metrics: metrics.clone(),
256 date_range,
257 dimensions: dimensions.clone(),
258 filters,
259 order_by,
260 limit,
261 offset,
262 include,
263 };
264 let response = self.post_stats_query(&payload).await?;
265 Ok(convert_timeseries_response(&metrics, &dimensions, response))
266 }
267
268 pub async fn stats_breakdown(
270 &self,
271 query: &BreakdownQuery,
272 ) -> Result<BreakdownResponse, ClientError> {
273 if query.site_id.trim().is_empty() {
274 return Err(ClientError::Validation("site_id cannot be empty".into()));
275 }
276 if query.property.trim().is_empty() {
277 return Err(ClientError::Validation("property cannot be empty".into()));
278 }
279 if query.compare.is_some() {
280 return Err(ClientError::Validation(
281 "stats breakdown does not support --compare with the Stats API v2".into(),
282 ));
283 }
284 let metrics = ensure_metrics(&query.metrics);
285 let date_range = Some(resolve_date_range(
286 query.period.as_ref(),
287 query.date.as_ref(),
288 )?);
289 let filters = parse_filters(&query.filters)?;
290 let mut dimensions = vec![query.property.clone()];
291 if !query.properties.is_empty() {
292 dimensions.extend(query.properties.clone());
293 }
294 let order_by = parse_sort(query.sort.as_ref())?;
295 let (limit, offset) = resolve_pagination(query.limit, query.page)?;
296 let include = parse_include(query.include.as_ref(), limit.is_some())?;
297 let payload = StatsQueryPayload {
298 site_id: query.site_id.clone(),
299 metrics: metrics.clone(),
300 date_range,
301 dimensions: dimensions.clone(),
302 filters,
303 order_by,
304 limit,
305 offset,
306 include,
307 };
308 let response = self.post_stats_query(&payload).await?;
309 Ok(convert_breakdown_response(
310 &metrics,
311 &dimensions,
312 query.limit,
313 query.page,
314 response,
315 ))
316 }
317
318 pub async fn stats_realtime_visitors(
320 &self,
321 site_id: &str,
322 ) -> Result<RealtimeVisitorsResponse, ClientError> {
323 if site_id.trim().is_empty() {
324 return Err(ClientError::Validation("site_id cannot be empty".into()));
325 }
326 let metrics = vec![
327 "visitors".to_string(),
328 "pageviews".to_string(),
329 "bounce_rate".to_string(),
330 "visit_duration".to_string(),
331 ];
332 let now = OffsetDateTime::now_utc();
333 let window = TimeDuration::minutes(Self::REALTIME_WINDOW_MINUTES);
334 let start = now.checked_sub(window).ok_or_else(|| {
335 ClientError::Validation(
336 "failed to compute realtime window; clock may be out of range".into(),
337 )
338 })?;
339 let date_range = Some(StatsDateRange::Absolute {
340 start: start.format(&Rfc3339).map_err(|err| {
341 ClientError::Validation(format!("failed to format realtime start: {err}"))
342 })?,
343 end: now.format(&Rfc3339).map_err(|err| {
344 ClientError::Validation(format!("failed to format realtime end: {err}"))
345 })?,
346 });
347 let payload = StatsQueryPayload {
348 site_id: site_id.to_string(),
349 metrics: metrics.clone(),
350 date_range,
351 dimensions: Vec::new(),
352 filters: None,
353 order_by: Vec::new(),
354 limit: None,
355 offset: None,
356 include: None,
357 };
358 let response = self.post_stats_query(&payload).await?;
359 Ok(convert_realtime_response(&metrics, response))
360 }
361
362 async fn post_stats_query(
363 &self,
364 payload: &StatsQueryPayload,
365 ) -> Result<StatsQueryResponse, ClientError> {
366 let url = self.endpoint("api/v2/query")?;
367 let response = self
368 .http
369 .post(url)
370 .bearer_auth(&self.api_key)
371 .json(payload)
372 .send()
373 .await
374 .map_err(ClientError::Http)?;
375 self.handle_response(response).await
376 }
377
378 pub async fn send_event(&self, event: &Value) -> Result<(), ClientError> {
380 if !event.is_object() {
381 return Err(ClientError::Validation(
382 "event payload must be a JSON object".into(),
383 ));
384 }
385 let url = self.endpoint("api/v1/events")?;
386 let response = self
387 .http
388 .post(url)
389 .bearer_auth(&self.api_key)
390 .json(event)
391 .send()
392 .await
393 .map_err(ClientError::Http)?;
394 let status = response.status();
395 if status.is_success() {
396 Ok(())
397 } else {
398 let message = response
399 .text()
400 .await
401 .unwrap_or_else(|_| String::from("unable to read error body"));
402 Err(ClientError::Api { status, message })
403 }
404 }
405
406 async fn handle_response<T: DeserializeOwned>(
407 &self,
408 response: Response,
409 ) -> Result<T, ClientError> {
410 let status = response.status();
411 if status.is_success() {
412 response.json::<T>().await.map_err(ClientError::Http)
413 } else {
414 let message = response
415 .text()
416 .await
417 .unwrap_or_else(|_| String::from("unable to read error body"));
418 Err(ClientError::Api { status, message })
419 }
420 }
421}
422
423fn normalize_base_url(mut url: Url) -> Url {
424 if url.path().is_empty() {
425 url.set_path("/");
426 } else if !url.path().ends_with('/') {
427 let mut path = url.path().trim_end_matches('/').to_string();
428 path.push('/');
429 url.set_path(&path);
430 }
431 url
432}
433
434#[derive(Debug, Clone, Default)]
436pub struct AggregateQuery {
437 pub site_id: String,
438 pub metrics: Vec<String>,
439 pub period: Option<String>,
440 pub date: Option<String>,
441 pub filters: Vec<String>,
442 pub properties: Vec<String>,
443 pub compare: Option<String>,
444 pub interval: Option<String>,
445 pub sort: Option<String>,
446 pub limit: Option<u32>,
447 pub page: Option<u32>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
452pub struct AggregateResponse {
453 pub results: Map<String, Value>,
454}
455
456impl AggregateResponse {
457 pub fn metric_as_f64(&self, metric: &str) -> Option<f64> {
459 self.results.get(metric).and_then(|value| value.as_f64())
460 }
461}
462
463#[derive(Debug, Clone, Default)]
465pub struct TimeseriesQuery {
466 pub site_id: String,
467 pub metrics: Vec<String>,
468 pub period: Option<String>,
469 pub date: Option<String>,
470 pub interval: Option<String>,
471 pub filters: Vec<String>,
472 pub properties: Vec<String>,
473 pub compare: Option<String>,
474 pub sort: Option<String>,
475 pub limit: Option<u32>,
476 pub page: Option<u32>,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481pub struct TimeseriesResponse {
482 #[serde(default)]
483 pub results: Vec<Map<String, Value>>,
484 #[serde(default)]
485 pub totals: Map<String, Value>,
486}
487
488#[derive(Debug, Clone, Default)]
490pub struct BreakdownQuery {
491 pub site_id: String,
492 pub property: String,
493 pub metrics: Vec<String>,
494 pub period: Option<String>,
495 pub date: Option<String>,
496 pub filters: Vec<String>,
497 pub properties: Vec<String>,
498 pub compare: Option<String>,
499 pub sort: Option<String>,
500 pub limit: Option<u32>,
501 pub page: Option<u32>,
502 pub include: Option<String>,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
507pub struct BreakdownResponse {
508 #[serde(default)]
509 pub results: Vec<Map<String, Value>>,
510 #[serde(default)]
511 pub page: Option<u32>,
512 #[serde(default)]
513 pub total_pages: Option<u32>,
514 #[serde(default)]
515 pub totals: Option<Map<String, Value>>,
516}
517
518#[derive(Debug, Clone, Serialize)]
519struct StatsQueryPayload {
520 site_id: String,
521 metrics: Vec<String>,
522 #[serde(skip_serializing_if = "Option::is_none")]
523 date_range: Option<StatsDateRange>,
524 #[serde(skip_serializing_if = "Vec::is_empty")]
525 dimensions: Vec<String>,
526 #[serde(skip_serializing_if = "Option::is_none")]
527 filters: Option<Vec<StatsFilterClause>>,
528 #[serde(skip_serializing_if = "Vec::is_empty")]
529 order_by: Vec<StatsOrderClause>,
530 #[serde(skip_serializing_if = "Option::is_none")]
531 limit: Option<u32>,
532 #[serde(skip_serializing_if = "Option::is_none")]
533 offset: Option<u32>,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 include: Option<StatsInclude>,
536}
537
538#[derive(Debug, Clone, Serialize)]
539#[serde(untagged)]
540enum StatsDateRange {
541 Preset(String),
542 Absolute { start: String, end: String },
543}
544
545#[derive(Debug, Clone)]
546struct StatsFilterClause {
547 field: String,
548 operation: String,
549 values: Vec<String>,
550}
551
552impl serde::Serialize for StatsFilterClause {
553 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
554 where
555 S: Serializer,
556 {
557 let mut seq = serializer.serialize_seq(Some(3))?;
558 seq.serialize_element(&self.field)?;
559 seq.serialize_element(&self.operation)?;
560 if self.values.len() == 1 {
561 seq.serialize_element(&self.values[0])?;
562 } else {
563 seq.serialize_element(&self.values)?;
564 }
565 seq.end()
566 }
567}
568
569#[derive(Debug, Clone)]
570struct StatsOrderClause {
571 field: String,
572 direction: String,
573}
574
575impl serde::Serialize for StatsOrderClause {
576 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
577 where
578 S: Serializer,
579 {
580 let mut seq = serializer.serialize_seq(Some(2))?;
581 seq.serialize_element(&self.field)?;
582 seq.serialize_element(&self.direction)?;
583 seq.end()
584 }
585}
586
587#[derive(Debug, Clone, Default, Serialize)]
588struct StatsInclude {
589 #[serde(skip_serializing_if = "Option::is_none")]
590 imports: Option<bool>,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 time_labels: Option<bool>,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 total_rows: Option<bool>,
595}
596
597impl StatsInclude {
598 fn into_option(self) -> Option<Self> {
599 let wants_imports = self.imports.unwrap_or(false);
600 let wants_time = self.time_labels.unwrap_or(false);
601 let wants_total = self.total_rows.unwrap_or(false);
602 if wants_imports || wants_time || wants_total {
603 Some(self)
604 } else {
605 None
606 }
607 }
608}
609
610#[derive(Debug, Clone, Deserialize, Default)]
611struct StatsQueryResponse {
612 #[serde(default)]
613 results: Vec<StatsQueryRow>,
614 #[serde(default)]
615 meta: StatsQueryMeta,
616}
617
618#[derive(Debug, Clone, Deserialize, Default)]
619struct StatsQueryRow {
620 #[serde(default)]
621 dimensions: Vec<Value>,
622 #[serde(default)]
623 metrics: Vec<Value>,
624}
625
626#[derive(Debug, Clone, Deserialize, Default)]
627struct StatsQueryMeta {
628 #[serde(default)]
629 total_rows: Option<u64>,
630 #[serde(default)]
631 time_labels: Option<Vec<Value>>,
632 #[serde(default)]
633 metric_totals: Option<Map<String, Value>>,
634 #[serde(flatten)]
635 _extra: Map<String, Value>,
636}
637
638fn ensure_metrics(metrics: &[String]) -> Vec<String> {
639 if metrics.is_empty() {
640 vec!["visitors".to_string()]
641 } else {
642 metrics.to_vec()
643 }
644}
645
646fn resolve_date_range(
647 period: Option<&String>,
648 date: Option<&String>,
649) -> Result<StatsDateRange, ClientError> {
650 if let Some(date) = date {
651 let mut parts = date.split(',').map(|p| p.trim()).filter(|p| !p.is_empty());
652 let start = parts
653 .next()
654 .ok_or_else(|| ClientError::Validation("invalid --date range".into()))?;
655 let end = parts
656 .next()
657 .ok_or_else(|| ClientError::Validation("invalid --date range".into()))?;
658 if parts.next().is_some() {
659 return Err(ClientError::Validation(
660 "invalid --date range; expected start,end".into(),
661 ));
662 }
663 return Ok(StatsDateRange::Absolute {
664 start: start.to_string(),
665 end: end.to_string(),
666 });
667 }
668 let period = period
669 .map(|value| value.trim())
670 .filter(|value| !value.is_empty())
671 .map(|value| value.to_string())
672 .unwrap_or_else(|| "30d".to_string());
673 Ok(StatsDateRange::Preset(period))
674}
675
676fn parse_filters(filters: &[String]) -> Result<Option<Vec<StatsFilterClause>>, ClientError> {
677 if filters.is_empty() {
678 return Ok(None);
679 }
680 let mut clauses = Vec::new();
681 for entry in filters {
682 for fragment in entry.split(';') {
683 let trimmed = fragment.trim();
684 if trimmed.is_empty() {
685 continue;
686 }
687 clauses.push(parse_filter_clause(trimmed)?);
688 }
689 }
690 if clauses.is_empty() {
691 Ok(None)
692 } else {
693 Ok(Some(clauses))
694 }
695}
696
697fn parse_filter_clause(raw: &str) -> Result<StatsFilterClause, ClientError> {
698 const OPERATORS: [&str; 10] = ["!@", "=@", "!~", "=~", "!^", "=^", "!$", "$=", "!=", "=="];
699 let mut chosen = None;
700 for op in OPERATORS {
701 if let Some(index) = raw.find(op) {
702 chosen = Some((op, index));
703 break;
704 }
705 }
706 let (operator, position) = chosen.ok_or_else(|| {
707 ClientError::Validation("unsupported filter expression for Stats API v2".into())
708 })?;
709 let field = raw[..position].trim();
710 let value_part = raw[position + operator.len()..].trim();
711 if field.is_empty() || value_part.is_empty() {
712 return Err(ClientError::Validation(
713 "filters must use <dimension><operator><value>".into(),
714 ));
715 }
716 let (operation, values) = match operator {
717 "==" => ("is".to_string(), split_filter_values(value_part)),
718 "!=" => ("is_not".to_string(), split_filter_values(value_part)),
719 "=@" => ("contains".to_string(), split_filter_values(value_part)),
720 "!@" => ("contains_not".to_string(), split_filter_values(value_part)),
721 "=~" => ("matches".to_string(), vec![value_part.to_string()]),
722 "!~" => ("matches_not".to_string(), vec![value_part.to_string()]),
723 "=^" => (
724 "matches".to_string(),
725 vec![format!("^{}", escape(value_part))],
726 ),
727 "!^" => (
728 "matches_not".to_string(),
729 vec![format!("^{}", escape(value_part))],
730 ),
731 "$=" => (
732 "matches".to_string(),
733 vec![format!("{}$", escape(value_part))],
734 ),
735 "!$" => (
736 "matches_not".to_string(),
737 vec![format!("{}$", escape(value_part))],
738 ),
739 _ => {
740 return Err(ClientError::Validation(
741 "unsupported filter operator for Stats API v2".into(),
742 ))
743 }
744 };
745 if values.is_empty() {
746 return Err(ClientError::Validation(
747 "filters require at least one value".into(),
748 ));
749 }
750 Ok(StatsFilterClause {
751 field: field.to_string(),
752 operation,
753 values,
754 })
755}
756
757fn split_filter_values(raw: &str) -> Vec<String> {
758 raw.split(',')
759 .map(|value| value.trim())
760 .filter(|value| !value.is_empty())
761 .map(|value| value.to_string())
762 .collect()
763}
764
765fn parse_sort(sort: Option<&String>) -> Result<Vec<StatsOrderClause>, ClientError> {
766 let Some(sort) = sort else {
767 return Ok(Vec::new());
768 };
769 let mut clauses = Vec::new();
770 for token in sort.split([',', ';']) {
771 let trimmed = token.trim();
772 if trimmed.is_empty() {
773 continue;
774 }
775 let mut parts = trimmed.splitn(2, ':');
776 let field = parts
777 .next()
778 .map(str::trim)
779 .filter(|s| !s.is_empty())
780 .ok_or_else(|| {
781 ClientError::Validation("invalid --sort value; expected field[:direction]".into())
782 })?;
783 let direction = parts.next().map(str::trim).unwrap_or("desc");
784 let direction = match direction.to_ascii_lowercase().as_str() {
785 "asc" => "asc",
786 "desc" => "desc",
787 other => {
788 return Err(ClientError::Validation(format!(
789 "unsupported sort direction '{other}' for Stats API v2"
790 )))
791 }
792 };
793 clauses.push(StatsOrderClause {
794 field: field.to_string(),
795 direction: direction.to_string(),
796 });
797 }
798 Ok(clauses)
799}
800
801fn resolve_pagination(
802 limit: Option<u32>,
803 page: Option<u32>,
804) -> Result<(Option<u32>, Option<u32>), ClientError> {
805 match (limit, page) {
806 (Some(limit), Some(page)) => {
807 if page == 0 {
808 return Err(ClientError::Validation(
809 "page must be at least 1 when using the Stats API v2".into(),
810 ));
811 }
812 let offset = (page as u64 - 1) * limit as u64;
813 if offset > u32::MAX as u64 {
814 return Err(ClientError::Validation(
815 "requested page is too large for Stats API pagination".into(),
816 ));
817 }
818 Ok((Some(limit), Some(offset as u32)))
819 }
820 (Some(limit), None) => Ok((Some(limit), None)),
821 (None, Some(_)) => Err(ClientError::Validation(
822 "stats queries require --limit when --page is provided".into(),
823 )),
824 (None, None) => Ok((None, None)),
825 }
826}
827
828fn resolve_time_dimension(interval: Option<&str>) -> Result<String, ClientError> {
829 match interval.map(|s| s.to_ascii_lowercase()) {
830 None => Ok("time:day".into()),
831 Some(value) => match value.as_str() {
832 "date" | "day" => Ok("time:day".into()),
833 "hour" | "hours" => Ok("time:hour".into()),
834 "week" | "weekly" => Ok("time:week".into()),
835 "month" | "monthly" => Ok("time:month".into()),
836 "year" | "yearly" => Ok("time:year".into()),
837 "minute" | "minutes" => Ok("time:minute".into()),
838 other => Err(ClientError::Validation(format!(
839 "unsupported --interval '{other}' for Stats API v2"
840 ))),
841 },
842 }
843}
844
845fn canonical_dimension_key(dimension: &str, index: usize) -> String {
846 if dimension == "time" || dimension.starts_with("time:") {
847 "time".into()
848 } else if let Some((_, suffix)) = dimension.rsplit_once(':') {
849 suffix.to_string()
850 } else {
851 format!("dimension_{index}")
852 }
853}
854
855fn convert_aggregate_response(
856 metrics: &[String],
857 response: StatsQueryResponse,
858) -> AggregateResponse {
859 let mut results = Map::new();
860 if let Some(row) = response.results.first() {
861 for (index, metric) in metrics.iter().enumerate() {
862 let value = row.metrics.get(index).cloned().unwrap_or(Value::Null);
863 results.insert(metric.clone(), value);
864 }
865 } else {
866 for metric in metrics {
867 results.insert(metric.clone(), Value::Number(serde_json::Number::from(0)));
868 }
869 }
870 AggregateResponse { results }
871}
872
873fn convert_timeseries_response(
874 metrics: &[String],
875 dimensions: &[String],
876 mut response: StatsQueryResponse,
877) -> TimeseriesResponse {
878 if let Some(labels) = response.meta.time_labels.take() {
879 if !labels.is_empty() {
880 response.results =
881 fill_time_labels(dimensions, metrics.len(), response.results, labels);
882 }
883 }
884 let mut rows = Vec::with_capacity(response.results.len());
885 for entry in response.results {
886 let mut map = Map::new();
887 for (index, value) in entry.dimensions.into_iter().enumerate() {
888 let key = if index == 0 {
889 "time".to_string()
890 } else {
891 canonical_dimension_key(
892 dimensions
893 .get(index)
894 .map(String::as_str)
895 .unwrap_or("dimension"),
896 index,
897 )
898 };
899 map.insert(key, value);
900 }
901 for (index, metric) in metrics.iter().enumerate() {
902 let value = entry.metrics.get(index).cloned().unwrap_or(Value::Null);
903 map.insert(metric.clone(), value);
904 }
905 rows.push(map);
906 }
907 let totals = response.meta.metric_totals.unwrap_or_default();
908 TimeseriesResponse {
909 results: rows,
910 totals,
911 }
912}
913
914fn fill_time_labels(
915 dimensions: &[String],
916 metrics_len: usize,
917 existing: Vec<StatsQueryRow>,
918 labels: Vec<Value>,
919) -> Vec<StatsQueryRow> {
920 if dimensions.is_empty() {
921 return existing;
922 }
923 let mut filled = Vec::with_capacity(labels.len());
924 let mut iter = existing.into_iter().peekable();
925 for label in labels {
926 if let Some(next) = iter.peek() {
927 if next.dimensions.first() == Some(&label) {
928 filled.push(iter.next().unwrap());
929 continue;
930 }
931 }
932 let metrics = if metrics_len > 0 {
933 (0..metrics_len)
934 .map(|_| Value::Number(Number::from(0)))
935 .collect()
936 } else {
937 Vec::new()
938 };
939 let row = StatsQueryRow {
940 dimensions: vec![label],
941 metrics,
942 };
943 filled.push(row);
944 }
945 filled
946}
947
948fn convert_breakdown_response(
949 metrics: &[String],
950 dimensions: &[String],
951 limit: Option<u32>,
952 page: Option<u32>,
953 response: StatsQueryResponse,
954) -> BreakdownResponse {
955 let mut rows = Vec::with_capacity(response.results.len());
956 for entry in response.results {
957 let mut map = Map::new();
958 for (index, value) in entry.dimensions.into_iter().enumerate() {
959 if index == 0 {
960 map.insert("value".into(), value);
961 } else {
962 let key = dimensions
963 .get(index)
964 .cloned()
965 .unwrap_or_else(|| format!("dimension_{index}"));
966 map.insert(key, value);
967 }
968 }
969 for (index, metric) in metrics.iter().enumerate() {
970 let value = entry.metrics.get(index).cloned().unwrap_or(Value::Null);
971 map.insert(metric.clone(), value);
972 }
973 rows.push(map);
974 }
975 let totals = response.meta.metric_totals.filter(|map| !map.is_empty());
976 let total_pages = response
977 .meta
978 .total_rows
979 .and_then(|rows| limit.map(|limit| rows.div_ceil(limit as u64) as u32));
980 BreakdownResponse {
981 results: rows,
982 page,
983 total_pages,
984 totals,
985 }
986}
987
988fn convert_realtime_response(
989 metrics: &[String],
990 response: StatsQueryResponse,
991) -> RealtimeVisitorsResponse {
992 let mut realtime = RealtimeVisitorsResponse::default();
993 if let Some(row) = response.results.first() {
994 for (index, metric) in metrics.iter().enumerate() {
995 let value = row.metrics.get(index).unwrap_or(&Value::Null);
996 match metric.as_str() {
997 "visitors" => {
998 realtime.visitors = value.as_i64().unwrap_or_else(|| {
999 value
1000 .as_f64()
1001 .map(|v| v as i64)
1002 .unwrap_or_else(|| realtime.visitors)
1003 });
1004 }
1005 "pageviews" => {
1006 realtime.pageviews =
1007 value.as_i64().or_else(|| value.as_f64().map(|v| v as i64));
1008 }
1009 "bounce_rate" => {
1010 if let Some(rate) = value.as_f64() {
1011 realtime.bounce_rate = Some(if rate > 1.0 { rate / 100.0 } else { rate });
1012 }
1013 }
1014 "visit_duration" => {
1015 if let Some(duration) = value.as_f64() {
1016 realtime.visit_duration = Some(duration);
1017 } else if let Some(duration) = value.as_i64() {
1018 realtime.visit_duration = Some(duration as f64);
1019 }
1020 }
1021 _ => {}
1022 }
1023 }
1024 }
1025 realtime
1026}
1027
1028fn parse_include(
1029 include: Option<&String>,
1030 needs_total_rows: bool,
1031) -> Result<Option<StatsInclude>, ClientError> {
1032 let mut options = StatsInclude::default();
1033 if let Some(include) = include {
1034 for token in include.split([',', ';']) {
1035 let trimmed = token.trim().to_ascii_lowercase();
1036 if trimmed.is_empty() {
1037 continue;
1038 }
1039 match trimmed.as_str() {
1040 "imports" | "imported" => options.imports = Some(true),
1041 "time_labels" | "time-labels" | "labels" => options.time_labels = Some(true),
1042 "total_rows" | "total-rows" | "totals" => options.total_rows = Some(true),
1043 other => {
1044 return Err(ClientError::Validation(format!(
1045 "unsupported --include option '{other}' for Stats API v2"
1046 )));
1047 }
1048 }
1049 }
1050 }
1051 if needs_total_rows {
1052 options.total_rows = Some(true);
1053 }
1054 Ok(options.into_option())
1055}
1056
1057#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1059pub struct SiteSummary {
1060 pub domain: String,
1061 #[serde(default)]
1062 pub timezone: Option<String>,
1063 #[serde(default)]
1064 pub is_main_site: Option<bool>,
1065 #[serde(default)]
1066 pub public: Option<bool>,
1067 #[serde(default)]
1068 pub verified: Option<bool>,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1073pub struct CreateSiteRequest {
1074 pub domain: String,
1075 #[serde(skip_serializing_if = "Option::is_none")]
1076 pub timezone: Option<String>,
1077 #[serde(skip_serializing_if = "Option::is_none")]
1078 pub public: Option<bool>,
1079}
1080
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1083pub struct UpdateSiteRequest {
1084 #[serde(skip_serializing_if = "Option::is_none")]
1085 pub timezone: Option<String>,
1086 #[serde(skip_serializing_if = "Option::is_none")]
1087 pub public: Option<bool>,
1088 #[serde(rename = "is_main_site", skip_serializing_if = "Option::is_none")]
1089 pub main_site: Option<bool>,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1094pub struct ResetSiteStatsRequest {
1095 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub date: Option<String>,
1097}
1098
1099#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1101pub struct RealtimeVisitorsResponse {
1102 pub visitors: i64,
1103 #[serde(default)]
1104 pub pageviews: Option<i64>,
1105 #[serde(default)]
1106 pub bounce_rate: Option<f64>,
1107 #[serde(default)]
1108 pub visit_duration: Option<f64>,
1109}
1110
1111#[derive(thiserror::Error, Debug)]
1112pub enum ClientError {
1113 #[error("invalid base URL: {0}")]
1114 InvalidBaseUrl(#[source] url::ParseError),
1115 #[error("invalid endpoint: {0}")]
1116 InvalidEndpoint(#[source] url::ParseError),
1117 #[error("HTTP client build error: {0}")]
1118 HttpClient(#[source] reqwest::Error),
1119 #[error(transparent)]
1120 Http(#[from] reqwest::Error),
1121 #[error("request validation failed: {0}")]
1122 Validation(String),
1123 #[error("API request failed with status {status}: {message}")]
1124 Api { status: StatusCode, message: String },
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130 use httpmock::{prelude::*, Method::PATCH};
1131 use serde_json::json;
1132
1133 fn test_client(api_key: &str, server: &MockServer) -> PlausibleClient {
1134 let url = Url::parse(&format!("{}/", server.base_url())).expect("url parse");
1135 PlausibleClient::with_base_url(api_key, url).expect("client")
1136 }
1137
1138 #[tokio::test]
1139 async fn list_sites_fetches_with_auth_header() {
1140 let server = MockServer::start_async().await;
1141
1142 server
1143 .mock_async(|when, then| {
1144 when.method(GET)
1145 .path("/api/v1/sites")
1146 .header("authorization", "Bearer secret");
1147 then.status(200)
1148 .header("content-type", "application/json")
1149 .json_body(json!([
1150 {
1151 "domain": "example.com",
1152 "timezone": "UTC",
1153 "is_main_site": false,
1154 "public": false,
1155 "verified": true
1156 }
1157 ]));
1158 })
1159 .await;
1160
1161 let client = test_client("secret", &server);
1162 let sites = client.list_sites().await.expect("list sites");
1163 assert_eq!(sites.len(), 1);
1164 assert_eq!(sites[0].domain, "example.com");
1165 assert_eq!(sites[0].timezone.as_deref(), Some("UTC"));
1166 }
1167
1168 #[tokio::test]
1169 async fn stats_aggregate_builds_expected_query() {
1170 let server = MockServer::start_async().await;
1171
1172 let expected_body = json!({
1173 "site_id": "example.com",
1174 "metrics": ["visitors", "pageviews"],
1175 "date_range": "30d",
1176 "filters": [
1177 ["event:page", "is", "/docs"]
1178 ]
1179 });
1180
1181 server
1182 .mock_async(move |when, then| {
1183 when.method(POST)
1184 .path("/api/v2/query")
1185 .header("authorization", "Bearer test-key")
1186 .json_body(expected_body.clone());
1187 then.status(200)
1188 .header("content-type", "application/json")
1189 .json_body(json!({
1190 "results": [
1191 {
1192 "dimensions": [],
1193 "metrics": [120, 350]
1194 }
1195 ],
1196 "meta": {
1197 "metric_totals": {
1198 "visitors": 120,
1199 "pageviews": 350
1200 }
1201 }
1202 }));
1203 })
1204 .await;
1205
1206 let client = test_client("test-key", &server);
1207 let query = AggregateQuery {
1208 site_id: "example.com".into(),
1209 metrics: vec!["visitors".into(), "pageviews".into()],
1210 filters: vec!["event:page==/docs".into()],
1211 ..AggregateQuery::default()
1212 };
1213
1214 let response = client.stats_aggregate(&query).await.expect("aggregate");
1215 assert_eq!(response.metric_as_f64("visitors"), Some(120.0));
1216 assert_eq!(response.metric_as_f64("pageviews"), Some(350.0));
1217 }
1218
1219 #[tokio::test]
1220 async fn stats_aggregate_requires_site_id() {
1221 let server = MockServer::start_async().await;
1222 let client = test_client("secret", &server);
1223 let query = AggregateQuery::default();
1224 let err = client
1225 .stats_aggregate(&query)
1226 .await
1227 .expect_err("validation");
1228 assert!(matches!(err, ClientError::Validation(msg) if msg.contains("site_id")));
1229 }
1230
1231 #[tokio::test]
1232 async fn non_success_status_returns_api_error() {
1233 let server = MockServer::start_async().await;
1234
1235 server
1236 .mock_async(|when, then| {
1237 when.method(GET).path("/api/v1/sites");
1238 then.status(401)
1239 .header("content-type", "text/plain")
1240 .body("unauthorized");
1241 })
1242 .await;
1243
1244 let client = test_client("secret", &server);
1245 let err = client.list_sites().await.expect_err("api error");
1246 assert!(
1247 matches!(err, ClientError::Api { status, .. } if status == StatusCode::UNAUTHORIZED)
1248 );
1249 }
1250
1251 #[tokio::test]
1252 async fn stats_timeseries_hits_endpoint_with_query() {
1253 let server = MockServer::start_async().await;
1254
1255 let expected_body = json!({
1256 "site_id": "example.com",
1257 "metrics": ["visitors"],
1258 "date_range": "30d",
1259 "dimensions": ["time:day"],
1260 "include": { "time_labels": true }
1261 });
1262
1263 server
1264 .mock_async(move |when, then| {
1265 when.method(POST)
1266 .path("/api/v2/query")
1267 .header("authorization", "Bearer key-123")
1268 .json_body(expected_body.clone());
1269 then.status(200)
1270 .header("content-type", "application/json")
1271 .json_body(json!({
1272 "results": [
1273 { "dimensions": ["2024-01-01"], "metrics": [10] },
1274 { "dimensions": ["2024-01-02"], "metrics": [12] }
1275 ],
1276 "meta": {
1277 "metric_totals": { "visitors": 22 }
1278 }
1279 }));
1280 })
1281 .await;
1282
1283 let client = test_client("key-123", &server);
1284 let query = TimeseriesQuery {
1285 site_id: "example.com".into(),
1286 metrics: vec!["visitors".into()],
1287 interval: Some("date".into()),
1288 ..TimeseriesQuery::default()
1289 };
1290
1291 let response = client
1292 .stats_timeseries(&query)
1293 .await
1294 .expect("timeseries response");
1295 assert_eq!(response.results.len(), 2);
1296 assert_eq!(response.results[0].get("time"), Some(&json!("2024-01-01")));
1297 assert_eq!(response.results[0].get("visitors"), Some(&json!(10)));
1298 assert_eq!(
1299 response.totals.get("visitors").and_then(|v| v.as_i64()),
1300 Some(22)
1301 );
1302 }
1303
1304 #[tokio::test]
1305 async fn stats_breakdown_hits_endpoint_with_query() {
1306 let server = MockServer::start_async().await;
1307
1308 let expected_body = json!({
1309 "site_id": "example.com",
1310 "metrics": ["visitors"],
1311 "date_range": "30d",
1312 "dimensions": ["event:page"],
1313 "limit": 50,
1314 "offset": 0,
1315 "include": { "total_rows": true }
1316 });
1317
1318 server
1319 .mock_async(move |when, then| {
1320 when.method(POST)
1321 .path("/api/v2/query")
1322 .header("authorization", "Bearer breakdown-key")
1323 .json_body(expected_body.clone());
1324 then.status(200)
1325 .header("content-type", "application/json")
1326 .json_body(json!({
1327 "results": [
1328 { "dimensions": ["/docs"], "metrics": [50] },
1329 { "dimensions": ["/blog"], "metrics": [30] }
1330 ],
1331 "meta": {
1332 "total_rows": 2,
1333 "metric_totals": { "visitors": 80 }
1334 }
1335 }));
1336 })
1337 .await;
1338
1339 let client = test_client("breakdown-key", &server);
1340 let query = BreakdownQuery {
1341 site_id: "example.com".into(),
1342 property: "event:page".into(),
1343 metrics: vec!["visitors".into()],
1344 limit: Some(50),
1345 page: Some(1),
1346 ..BreakdownQuery::default()
1347 };
1348
1349 let response = client
1350 .stats_breakdown(&query)
1351 .await
1352 .expect("breakdown response");
1353 assert_eq!(response.results.len(), 2);
1354 assert_eq!(response.results[0].get("value"), Some(&json!("/docs")));
1355 assert_eq!(response.page, Some(1));
1356 assert_eq!(response.total_pages, Some(1));
1357 }
1358
1359 #[tokio::test]
1360 async fn send_event_posts_payload() {
1361 let server = MockServer::start_async().await;
1362
1363 server
1364 .mock_async(|when, then| {
1365 when.method(POST)
1366 .path("/api/v1/events")
1367 .header("authorization", "Bearer event-key")
1368 .json_body(json!({
1369 "name": "Signup",
1370 "domain": "example.com"
1371 }));
1372 then.status(202);
1373 })
1374 .await;
1375
1376 let client = test_client("event-key", &server);
1377 let event = json!({
1378 "name": "Signup",
1379 "domain": "example.com"
1380 });
1381 client.send_event(&event).await.expect("send event");
1382 }
1383
1384 #[tokio::test]
1385 async fn send_event_rejects_non_object_payload() {
1386 let server = MockServer::start_async().await;
1387 let client = test_client("key", &server);
1388 let event = serde_json::Value::String("not-object".into());
1389 let err = client.send_event(&event).await.expect_err("validation");
1390 assert!(matches!(err, ClientError::Validation(msg) if msg.contains("JSON object")));
1391 }
1392
1393 #[tokio::test]
1394 async fn create_site_posts_payload() {
1395 let server = MockServer::start_async().await;
1396
1397 server
1398 .mock_async(|when, then| {
1399 when.method(POST)
1400 .path("/api/v1/sites")
1401 .header("authorization", "Bearer site-key")
1402 .json_body(json!({
1403 "domain": "example.com",
1404 "timezone": "UTC",
1405 "public": true
1406 }));
1407 then.status(201)
1408 .header("content-type", "application/json")
1409 .json_body(json!({
1410 "domain": "example.com",
1411 "timezone": "UTC",
1412 "public": true,
1413 "verified": false
1414 }));
1415 })
1416 .await;
1417
1418 let client = test_client("site-key", &server);
1419 let site = client
1420 .create_site(&CreateSiteRequest {
1421 domain: "example.com".into(),
1422 timezone: Some("UTC".into()),
1423 public: Some(true),
1424 })
1425 .await
1426 .expect("create site");
1427 assert_eq!(site.domain, "example.com");
1428 assert_eq!(site.public, Some(true));
1429 }
1430
1431 #[tokio::test]
1432 async fn update_site_sends_patch_body() {
1433 let server = MockServer::start_async().await;
1434
1435 server
1436 .mock_async(|when, then| {
1437 when.method(PATCH)
1438 .path("/api/v1/sites/example.com")
1439 .header("authorization", "Bearer update-key")
1440 .json_body(json!({ "timezone": "Europe/Berlin" }));
1441 then.status(200)
1442 .header("content-type", "application/json")
1443 .json_body(json!({
1444 "domain": "example.com",
1445 "timezone": "Europe/Berlin"
1446 }));
1447 })
1448 .await;
1449
1450 let client = test_client("update-key", &server);
1451 let site = client
1452 .update_site(
1453 "example.com",
1454 &UpdateSiteRequest {
1455 timezone: Some("Europe/Berlin".into()),
1456 public: None,
1457 main_site: None,
1458 },
1459 )
1460 .await
1461 .expect("update site");
1462 assert_eq!(site.timezone.as_deref(), Some("Europe/Berlin"));
1463 }
1464
1465 #[tokio::test]
1466 async fn reset_site_stats_posts_date_range() {
1467 let server = MockServer::start_async().await;
1468
1469 server
1470 .mock_async(|when, then| {
1471 when.method(POST)
1472 .path("/api/v1/sites/example.com/reset-stats")
1473 .header("authorization", "Bearer reset-key")
1474 .json_body(json!({ "date": "2024-01-01" }));
1475 then.status(202);
1476 })
1477 .await;
1478
1479 let client = test_client("reset-key", &server);
1480 client
1481 .reset_site_stats(
1482 "example.com",
1483 &ResetSiteStatsRequest {
1484 date: Some("2024-01-01".into()),
1485 },
1486 )
1487 .await
1488 .expect("reset stats");
1489 }
1490
1491 #[tokio::test]
1492 async fn delete_site_issues_delete() {
1493 let server = MockServer::start_async().await;
1494
1495 server
1496 .mock_async(|when, then| {
1497 when.method(DELETE)
1498 .path("/api/v1/sites/example.com")
1499 .header("authorization", "Bearer delete-key");
1500 then.status(204);
1501 })
1502 .await;
1503
1504 let client = test_client("delete-key", &server);
1505 client
1506 .delete_site("example.com")
1507 .await
1508 .expect("delete site");
1509 }
1510
1511 #[tokio::test]
1512 async fn realtime_visitors_fetches_metrics() {
1513 let server = MockServer::start_async().await;
1514
1515 server
1516 .mock_async(|when, then| {
1517 when.method(POST)
1518 .path("/api/v2/query")
1519 .header("authorization", "Bearer realtime-key")
1520 .body_contains("\"site_id\":\"example.com\"")
1521 .body_contains("\"metrics\":[\"visitors\",\"pageviews\",\"bounce_rate\",\"visit_duration\"]");
1522 then.status(200)
1523 .header("content-type", "application/json")
1524 .json_body(json!({
1525 "results": [
1526 {
1527 "dimensions": [],
1528 "metrics": [5, 7, 45.0, 120.0]
1529 }
1530 ]
1531 }));
1532 })
1533 .await;
1534
1535 let client = test_client("realtime-key", &server);
1536 let realtime = client
1537 .stats_realtime_visitors("example.com")
1538 .await
1539 .expect("realtime stats");
1540 assert_eq!(realtime.visitors, 5);
1541 assert_eq!(realtime.pageviews, Some(7));
1542 assert_eq!(realtime.bounce_rate, Some(0.45));
1543 assert_eq!(realtime.visit_duration, Some(120.0));
1544 }
1545}