plausible_cli/client/
mod.rs

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/// High-level Plausible API client.
13#[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    /// Create a new client targeting the public Plausible API endpoint.
24    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    /// Create a client with a custom base URL (useful for self-hosted Plausible).
30    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    /// Returns the effective base URL.
52    pub fn base_url(&self) -> &Url {
53        &self.base_url
54    }
55
56    /// Returns the configured user agent string.
57    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    /// Fetch the list of sites accessible to the API key.
68    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    /// Create a Plausible site.
81    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    /// Update mutable properties for a Plausible site.
101    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    /// Reset statistics for a site.
122    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    /// Delete a Plausible site.
152    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    /// Query aggregate statistics for a site.
177    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    /// Query timeseries statistics for a site.
223    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    /// Query breakdown statistics for a site.
269    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    /// Fetch realtime visitor counts.
319    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    /// Send a custom event to Plausible.
379    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/// Query parameters for the stats aggregate endpoint.
435#[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/// Response envelope for aggregate metrics.
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
452pub struct AggregateResponse {
453    pub results: Map<String, Value>,
454}
455
456impl AggregateResponse {
457    /// Convenience helper to access numeric metrics as f64 values.
458    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/// Query parameters for the stats timeseries endpoint.
464#[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/// Response envelope for timeseries metrics.
480#[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/// Query parameters for the stats breakdown endpoint.
489#[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/// Response envelope for breakdown metrics.
506#[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/// Minimal representation of a Plausible site entry.
1058#[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/// Request payload to create a new site.
1072#[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/// Request payload to update an existing site.
1082#[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/// Request payload for resetting site statistics.
1093#[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/// Response payload for realtime visitors.
1100#[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}