1use reqwest::{Client as HttpClient, Response, StatusCode};
2use serde::{de::DeserializeOwned, Deserialize, Serialize};
3use serde_json::{Map, Value};
4use std::time::Duration;
5use url::Url;
6
7const DEFAULT_BASE_URL: &str = "https://plausible.io/";
8const DEFAULT_TIMEOUT_SECS: u64 = 15;
9
10#[derive(Debug, Clone)]
12pub struct PlausibleClient {
13 http: HttpClient,
14 base_url: Url,
15 api_key: String,
16 user_agent: String,
17}
18
19impl PlausibleClient {
20 pub fn new(api_key: impl Into<String>) -> Result<Self, ClientError> {
22 let base = Url::parse(DEFAULT_BASE_URL).map_err(ClientError::InvalidBaseUrl)?;
23 Self::with_base_url(api_key, base)
24 }
25
26 pub fn with_base_url(api_key: impl Into<String>, base_url: Url) -> Result<Self, ClientError> {
28 let api_key = api_key.into();
29 if api_key.trim().is_empty() {
30 return Err(ClientError::Validation("api_key cannot be empty"));
31 }
32 let base_url = normalize_base_url(base_url);
33 let user_agent = format!("plausible-cli/{}", env!("CARGO_PKG_VERSION"));
34 let http = HttpClient::builder()
35 .user_agent(&user_agent)
36 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
37 .build()
38 .map_err(ClientError::HttpClient)?;
39
40 Ok(Self {
41 http,
42 base_url,
43 api_key,
44 user_agent,
45 })
46 }
47
48 pub fn base_url(&self) -> &Url {
50 &self.base_url
51 }
52
53 pub fn user_agent(&self) -> &str {
55 &self.user_agent
56 }
57
58 fn endpoint(&self, fragment: &str) -> Result<Url, ClientError> {
59 self.base_url
60 .join(fragment)
61 .map_err(ClientError::InvalidEndpoint)
62 }
63
64 pub async fn list_sites(&self) -> Result<Vec<SiteSummary>, ClientError> {
66 let url = self.endpoint("api/v1/sites")?;
67 let response = self
68 .http
69 .get(url)
70 .bearer_auth(&self.api_key)
71 .send()
72 .await
73 .map_err(ClientError::Http)?;
74 self.handle_response(response).await
75 }
76
77 pub async fn create_site(
79 &self,
80 request: &CreateSiteRequest,
81 ) -> Result<SiteSummary, ClientError> {
82 if request.domain.trim().is_empty() {
83 return Err(ClientError::Validation("domain cannot be empty"));
84 }
85 let url = self.endpoint("api/v1/sites")?;
86 let response = self
87 .http
88 .post(url)
89 .bearer_auth(&self.api_key)
90 .json(request)
91 .send()
92 .await
93 .map_err(ClientError::Http)?;
94 self.handle_response(response).await
95 }
96
97 pub async fn update_site(
99 &self,
100 site_id: &str,
101 request: &UpdateSiteRequest,
102 ) -> Result<SiteSummary, ClientError> {
103 if site_id.trim().is_empty() {
104 return Err(ClientError::Validation("site_id cannot be empty"));
105 }
106 let url = self.endpoint(&format!("api/v1/sites/{site_id}"))?;
107 let response = self
108 .http
109 .patch(url)
110 .bearer_auth(&self.api_key)
111 .json(request)
112 .send()
113 .await
114 .map_err(ClientError::Http)?;
115 self.handle_response(response).await
116 }
117
118 pub async fn reset_site_stats(
120 &self,
121 site_id: &str,
122 request: &ResetSiteStatsRequest,
123 ) -> Result<(), ClientError> {
124 if site_id.trim().is_empty() {
125 return Err(ClientError::Validation("site_id cannot be empty"));
126 }
127 let url = self.endpoint(&format!("api/v1/sites/{site_id}/reset-stats"))?;
128 let response = self
129 .http
130 .post(url)
131 .bearer_auth(&self.api_key)
132 .json(request)
133 .send()
134 .await
135 .map_err(ClientError::Http)?;
136 if response.status().is_success() {
137 Ok(())
138 } else {
139 let status = response.status();
140 let message = response
141 .text()
142 .await
143 .unwrap_or_else(|_| String::from("unable to read error body"));
144 Err(ClientError::Api { status, message })
145 }
146 }
147
148 pub async fn delete_site(&self, site_id: &str) -> Result<(), ClientError> {
150 if site_id.trim().is_empty() {
151 return Err(ClientError::Validation("site_id cannot be empty"));
152 }
153 let url = self.endpoint(&format!("api/v1/sites/{site_id}"))?;
154 let response = self
155 .http
156 .delete(url)
157 .bearer_auth(&self.api_key)
158 .send()
159 .await
160 .map_err(ClientError::Http)?;
161 if response.status().is_success() || response.status() == StatusCode::NO_CONTENT {
162 Ok(())
163 } else {
164 let status = response.status();
165 let message = response
166 .text()
167 .await
168 .unwrap_or_else(|_| String::from("unable to read error body"));
169 Err(ClientError::Api { status, message })
170 }
171 }
172
173 pub async fn stats_aggregate(
175 &self,
176 query: &AggregateQuery,
177 ) -> Result<AggregateResponse, ClientError> {
178 if query.site_id.trim().is_empty() {
179 return Err(ClientError::Validation("site_id cannot be empty"));
180 }
181 let url = self.endpoint("api/v1/stats/aggregate")?;
182 let params = build_stats_query(
183 &query.site_id,
184 Some(&query.metrics),
185 &StatsQueryOptions {
186 period: query.period.as_ref(),
187 date: query.date.as_ref(),
188 filters: &query.filters,
189 properties: &query.properties,
190 compare: query.compare.as_ref(),
191 interval: query.interval.as_ref(),
192 sort: query.sort.as_ref(),
193 limit: query.limit,
194 page: query.page,
195 include: None,
196 property: None,
197 },
198 );
199
200 let response = self
201 .http
202 .get(url)
203 .bearer_auth(&self.api_key)
204 .query(¶ms)
205 .send()
206 .await
207 .map_err(ClientError::Http)?;
208 self.handle_response(response).await
209 }
210
211 pub async fn stats_timeseries(
213 &self,
214 query: &TimeseriesQuery,
215 ) -> Result<TimeseriesResponse, ClientError> {
216 if query.site_id.trim().is_empty() {
217 return Err(ClientError::Validation("site_id cannot be empty"));
218 }
219 let url = self.endpoint("api/v1/stats/timeseries")?;
220 let params = build_stats_query(
221 &query.site_id,
222 Some(&query.metrics),
223 &StatsQueryOptions {
224 period: query.period.as_ref(),
225 date: query.date.as_ref(),
226 filters: &query.filters,
227 properties: &query.properties,
228 compare: query.compare.as_ref(),
229 interval: query.interval.as_ref(),
230 sort: query.sort.as_ref(),
231 limit: query.limit,
232 page: query.page,
233 include: None,
234 property: None,
235 },
236 );
237 let response = self
238 .http
239 .get(url)
240 .bearer_auth(&self.api_key)
241 .query(¶ms)
242 .send()
243 .await
244 .map_err(ClientError::Http)?;
245 self.handle_response(response).await
246 }
247
248 pub async fn stats_breakdown(
250 &self,
251 query: &BreakdownQuery,
252 ) -> Result<BreakdownResponse, ClientError> {
253 if query.site_id.trim().is_empty() {
254 return Err(ClientError::Validation("site_id cannot be empty"));
255 }
256 if query.property.trim().is_empty() {
257 return Err(ClientError::Validation("property cannot be empty"));
258 }
259 let url = self.endpoint("api/v1/stats/breakdown")?;
260 let params = build_stats_query(
261 &query.site_id,
262 Some(&query.metrics),
263 &StatsQueryOptions {
264 period: query.period.as_ref(),
265 date: query.date.as_ref(),
266 filters: &query.filters,
267 properties: &query.properties,
268 compare: query.compare.as_ref(),
269 interval: None,
270 sort: query.sort.as_ref(),
271 limit: query.limit,
272 page: query.page,
273 include: query.include.as_ref(),
274 property: Some(&query.property),
275 },
276 );
277 let response = self
278 .http
279 .get(url)
280 .bearer_auth(&self.api_key)
281 .query(¶ms)
282 .send()
283 .await
284 .map_err(ClientError::Http)?;
285 self.handle_response(response).await
286 }
287
288 pub async fn stats_realtime_visitors(
290 &self,
291 site_id: &str,
292 ) -> Result<RealtimeVisitorsResponse, ClientError> {
293 if site_id.trim().is_empty() {
294 return Err(ClientError::Validation("site_id cannot be empty"));
295 }
296 let url = self.endpoint("api/v1/stats/realtime/visitors")?;
297 let response = self
298 .http
299 .get(url)
300 .bearer_auth(&self.api_key)
301 .query(&[("site_id", site_id)])
302 .send()
303 .await
304 .map_err(ClientError::Http)?;
305 self.handle_response(response).await
306 }
307
308 pub async fn send_event(&self, event: &Value) -> Result<(), ClientError> {
310 if !event.is_object() {
311 return Err(ClientError::Validation(
312 "event payload must be a JSON object",
313 ));
314 }
315 let url = self.endpoint("api/v1/events")?;
316 let response = self
317 .http
318 .post(url)
319 .bearer_auth(&self.api_key)
320 .json(event)
321 .send()
322 .await
323 .map_err(ClientError::Http)?;
324 let status = response.status();
325 if status.is_success() {
326 Ok(())
327 } else {
328 let message = response
329 .text()
330 .await
331 .unwrap_or_else(|_| String::from("unable to read error body"));
332 Err(ClientError::Api { status, message })
333 }
334 }
335
336 async fn handle_response<T: DeserializeOwned>(
337 &self,
338 response: Response,
339 ) -> Result<T, ClientError> {
340 let status = response.status();
341 if status.is_success() {
342 response.json::<T>().await.map_err(ClientError::Http)
343 } else {
344 let message = response
345 .text()
346 .await
347 .unwrap_or_else(|_| String::from("unable to read error body"));
348 Err(ClientError::Api { status, message })
349 }
350 }
351}
352
353fn normalize_base_url(mut url: Url) -> Url {
354 if url.path().is_empty() {
355 url.set_path("/");
356 } else if !url.path().ends_with('/') {
357 let mut path = url.path().trim_end_matches('/').to_string();
358 path.push('/');
359 url.set_path(&path);
360 }
361 url
362}
363
364struct StatsQueryOptions<'a> {
365 period: Option<&'a String>,
366 date: Option<&'a String>,
367 filters: &'a [String],
368 properties: &'a [String],
369 compare: Option<&'a String>,
370 interval: Option<&'a String>,
371 sort: Option<&'a String>,
372 limit: Option<u32>,
373 page: Option<u32>,
374 include: Option<&'a String>,
375 property: Option<&'a String>,
376}
377
378fn build_stats_query(
379 site_id: &str,
380 metrics: Option<&[String]>,
381 options: &StatsQueryOptions<'_>,
382) -> Vec<(String, String)> {
383 let mut params: Vec<(String, String)> = Vec::new();
384 params.push(("site_id".into(), site_id.to_string()));
385 let metrics = if let Some(metrics) = metrics {
386 if metrics.is_empty() {
387 vec!["visitors".to_string()]
388 } else {
389 metrics.to_vec()
390 }
391 } else {
392 vec!["visitors".to_string()]
393 };
394 params.push(("metrics".into(), metrics.join(",")));
395
396 if let Some(period) = options.period {
397 params.push(("period".into(), period.clone()));
398 }
399 if let Some(date) = options.date {
400 params.push(("date".into(), date.clone()));
401 }
402 if !options.filters.is_empty() {
403 params.push(("filters".into(), options.filters.join(";")));
404 }
405 if !options.properties.is_empty() {
406 params.push(("properties".into(), options.properties.join(";")));
407 }
408 if let Some(compare) = options.compare {
409 params.push(("compare".into(), compare.clone()));
410 }
411 if let Some(interval) = options.interval {
412 params.push(("interval".into(), interval.clone()));
413 }
414 if let Some(sort) = options.sort {
415 params.push(("sort".into(), sort.clone()));
416 }
417 if let Some(limit) = options.limit {
418 params.push(("limit".into(), limit.to_string()));
419 }
420 if let Some(page) = options.page {
421 params.push(("page".into(), page.to_string()));
422 }
423 if let Some(include) = options.include {
424 params.push(("include".into(), include.clone()));
425 }
426 if let Some(property) = options.property {
427 params.push(("property".into(), property.clone()));
428 }
429 params
430}
431
432#[derive(Debug, Clone, Default)]
434pub struct AggregateQuery {
435 pub site_id: String,
436 pub metrics: Vec<String>,
437 pub period: Option<String>,
438 pub date: Option<String>,
439 pub filters: Vec<String>,
440 pub properties: Vec<String>,
441 pub compare: Option<String>,
442 pub interval: Option<String>,
443 pub sort: Option<String>,
444 pub limit: Option<u32>,
445 pub page: Option<u32>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
450pub struct AggregateResponse {
451 pub results: Map<String, Value>,
452}
453
454impl AggregateResponse {
455 pub fn metric_as_f64(&self, metric: &str) -> Option<f64> {
457 self.results.get(metric).and_then(|value| value.as_f64())
458 }
459}
460
461#[derive(Debug, Clone, Default)]
463pub struct TimeseriesQuery {
464 pub site_id: String,
465 pub metrics: Vec<String>,
466 pub period: Option<String>,
467 pub date: Option<String>,
468 pub interval: Option<String>,
469 pub filters: Vec<String>,
470 pub properties: Vec<String>,
471 pub compare: Option<String>,
472 pub sort: Option<String>,
473 pub limit: Option<u32>,
474 pub page: Option<u32>,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
479pub struct TimeseriesResponse {
480 #[serde(default)]
481 pub results: Vec<Map<String, Value>>,
482 #[serde(default)]
483 pub totals: Map<String, Value>,
484}
485
486#[derive(Debug, Clone, Default)]
488pub struct BreakdownQuery {
489 pub site_id: String,
490 pub property: String,
491 pub metrics: Vec<String>,
492 pub period: Option<String>,
493 pub date: Option<String>,
494 pub filters: Vec<String>,
495 pub properties: Vec<String>,
496 pub compare: Option<String>,
497 pub sort: Option<String>,
498 pub limit: Option<u32>,
499 pub page: Option<u32>,
500 pub include: Option<String>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
505pub struct BreakdownResponse {
506 #[serde(default)]
507 pub results: Vec<Map<String, Value>>,
508 #[serde(default)]
509 pub page: Option<u32>,
510 #[serde(default)]
511 pub total_pages: Option<u32>,
512 #[serde(default)]
513 pub totals: Option<Map<String, Value>>,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518pub struct SiteSummary {
519 pub domain: String,
520 #[serde(default)]
521 pub timezone: Option<String>,
522 #[serde(default)]
523 pub is_main_site: Option<bool>,
524 #[serde(default)]
525 pub public: Option<bool>,
526 #[serde(default)]
527 pub verified: Option<bool>,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532pub struct CreateSiteRequest {
533 pub domain: String,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 pub timezone: Option<String>,
536 #[serde(skip_serializing_if = "Option::is_none")]
537 pub public: Option<bool>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
542pub struct UpdateSiteRequest {
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub timezone: Option<String>,
545 #[serde(skip_serializing_if = "Option::is_none")]
546 pub public: Option<bool>,
547 #[serde(rename = "is_main_site", skip_serializing_if = "Option::is_none")]
548 pub main_site: Option<bool>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
553pub struct ResetSiteStatsRequest {
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub date: Option<String>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
560pub struct RealtimeVisitorsResponse {
561 pub visitors: i64,
562 #[serde(default)]
563 pub pageviews: Option<i64>,
564 #[serde(default)]
565 pub bounce_rate: Option<f64>,
566 #[serde(default)]
567 pub visit_duration: Option<f64>,
568}
569
570#[derive(thiserror::Error, Debug)]
571pub enum ClientError {
572 #[error("invalid base URL: {0}")]
573 InvalidBaseUrl(#[source] url::ParseError),
574 #[error("invalid endpoint: {0}")]
575 InvalidEndpoint(#[source] url::ParseError),
576 #[error("HTTP client build error: {0}")]
577 HttpClient(#[source] reqwest::Error),
578 #[error(transparent)]
579 Http(#[from] reqwest::Error),
580 #[error("request validation failed: {0}")]
581 Validation(&'static str),
582 #[error("API request failed with status {status}: {message}")]
583 Api { status: StatusCode, message: String },
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use httpmock::{prelude::*, Method::PATCH};
590 use serde_json::json;
591
592 fn test_client(api_key: &str, server: &MockServer) -> PlausibleClient {
593 let url = Url::parse(&format!("{}/", server.base_url())).expect("url parse");
594 PlausibleClient::with_base_url(api_key, url).expect("client")
595 }
596
597 #[tokio::test]
598 async fn list_sites_fetches_with_auth_header() {
599 let server = MockServer::start_async().await;
600
601 server
602 .mock_async(|when, then| {
603 when.method(GET)
604 .path("/api/v1/sites")
605 .header("authorization", "Bearer secret");
606 then.status(200)
607 .header("content-type", "application/json")
608 .json_body(json!([
609 {
610 "domain": "example.com",
611 "timezone": "UTC",
612 "is_main_site": false,
613 "public": false,
614 "verified": true
615 }
616 ]));
617 })
618 .await;
619
620 let client = test_client("secret", &server);
621 let sites = client.list_sites().await.expect("list sites");
622 assert_eq!(sites.len(), 1);
623 assert_eq!(sites[0].domain, "example.com");
624 assert_eq!(sites[0].timezone.as_deref(), Some("UTC"));
625 }
626
627 #[tokio::test]
628 async fn stats_aggregate_builds_expected_query() {
629 let server = MockServer::start_async().await;
630
631 server
632 .mock_async(|when, then| {
633 when.method(GET)
634 .path("/api/v1/stats/aggregate")
635 .header("authorization", "Bearer test-key")
636 .query_param("site_id", "example.com")
637 .query_param("metrics", "visitors,pageviews")
638 .query_param("filters", "event:page==/docs");
639 then.status(200)
640 .header("content-type", "application/json")
641 .json_body(json!({
642 "results": {
643 "visitors": 120,
644 "pageviews": 350
645 }
646 }));
647 })
648 .await;
649
650 let client = test_client("test-key", &server);
651 let query = AggregateQuery {
652 site_id: "example.com".into(),
653 metrics: vec!["visitors".into(), "pageviews".into()],
654 filters: vec!["event:page==/docs".into()],
655 ..AggregateQuery::default()
656 };
657
658 let response = client.stats_aggregate(&query).await.expect("aggregate");
659 assert_eq!(response.metric_as_f64("visitors"), Some(120.0));
660 assert_eq!(response.metric_as_f64("pageviews"), Some(350.0));
661 }
662
663 #[tokio::test]
664 async fn stats_aggregate_requires_site_id() {
665 let server = MockServer::start_async().await;
666 let client = test_client("secret", &server);
667 let query = AggregateQuery::default();
668 let err = client
669 .stats_aggregate(&query)
670 .await
671 .expect_err("validation");
672 assert!(matches!(err, ClientError::Validation(msg) if msg.contains("site_id")));
673 }
674
675 #[tokio::test]
676 async fn non_success_status_returns_api_error() {
677 let server = MockServer::start_async().await;
678
679 server
680 .mock_async(|when, then| {
681 when.method(GET).path("/api/v1/sites");
682 then.status(401)
683 .header("content-type", "text/plain")
684 .body("unauthorized");
685 })
686 .await;
687
688 let client = test_client("secret", &server);
689 let err = client.list_sites().await.expect_err("api error");
690 assert!(
691 matches!(err, ClientError::Api { status, .. } if status == StatusCode::UNAUTHORIZED)
692 );
693 }
694
695 #[tokio::test]
696 async fn stats_timeseries_hits_endpoint_with_query() {
697 let server = MockServer::start_async().await;
698
699 server
700 .mock_async(|when, then| {
701 when.method(GET)
702 .path("/api/v1/stats/timeseries")
703 .header("authorization", "Bearer key-123")
704 .query_param("site_id", "example.com")
705 .query_param("metrics", "visitors")
706 .query_param("interval", "date");
707 then.status(200)
708 .header("content-type", "application/json")
709 .json_body(json!({
710 "results": [
711 { "date": "2024-01-01", "visitors": 10 },
712 { "date": "2024-01-02", "visitors": 12 }
713 ],
714 "totals": { "visitors": 22 }
715 }));
716 })
717 .await;
718
719 let client = test_client("key-123", &server);
720 let query = TimeseriesQuery {
721 site_id: "example.com".into(),
722 metrics: vec!["visitors".into()],
723 interval: Some("date".into()),
724 ..TimeseriesQuery::default()
725 };
726
727 let response = client
728 .stats_timeseries(&query)
729 .await
730 .expect("timeseries response");
731 assert_eq!(response.results.len(), 2);
732 assert_eq!(response.results[0].get("visitors"), Some(&json!(10)));
733 assert_eq!(
734 response.totals.get("visitors").and_then(|v| v.as_i64()),
735 Some(22)
736 );
737 }
738
739 #[tokio::test]
740 async fn stats_breakdown_hits_endpoint_with_query() {
741 let server = MockServer::start_async().await;
742
743 server
744 .mock_async(|when, then| {
745 when.method(GET)
746 .path("/api/v1/stats/breakdown")
747 .header("authorization", "Bearer breakdown-key")
748 .query_param("site_id", "example.com")
749 .query_param("property", "event:page")
750 .query_param("metrics", "visitors");
751 then.status(200)
752 .header("content-type", "application/json")
753 .json_body(json!({
754 "results": [
755 { "value": "/docs", "visitors": 50 },
756 { "value": "/blog", "visitors": 30 }
757 ],
758 "page": 1,
759 "total_pages": 1
760 }));
761 })
762 .await;
763
764 let client = test_client("breakdown-key", &server);
765 let query = BreakdownQuery {
766 site_id: "example.com".into(),
767 property: "event:page".into(),
768 metrics: vec!["visitors".into()],
769 ..BreakdownQuery::default()
770 };
771
772 let response = client
773 .stats_breakdown(&query)
774 .await
775 .expect("breakdown response");
776 assert_eq!(response.results.len(), 2);
777 assert_eq!(response.results[0].get("value"), Some(&json!("/docs")));
778 assert_eq!(response.total_pages, Some(1));
779 }
780
781 #[tokio::test]
782 async fn send_event_posts_payload() {
783 let server = MockServer::start_async().await;
784
785 server
786 .mock_async(|when, then| {
787 when.method(POST)
788 .path("/api/v1/events")
789 .header("authorization", "Bearer event-key")
790 .json_body(json!({
791 "name": "Signup",
792 "domain": "example.com"
793 }));
794 then.status(202);
795 })
796 .await;
797
798 let client = test_client("event-key", &server);
799 let event = json!({
800 "name": "Signup",
801 "domain": "example.com"
802 });
803 client.send_event(&event).await.expect("send event");
804 }
805
806 #[tokio::test]
807 async fn send_event_rejects_non_object_payload() {
808 let server = MockServer::start_async().await;
809 let client = test_client("key", &server);
810 let event = serde_json::Value::String("not-object".into());
811 let err = client.send_event(&event).await.expect_err("validation");
812 assert!(matches!(err, ClientError::Validation(msg) if msg.contains("JSON object")));
813 }
814
815 #[tokio::test]
816 async fn create_site_posts_payload() {
817 let server = MockServer::start_async().await;
818
819 server
820 .mock_async(|when, then| {
821 when.method(POST)
822 .path("/api/v1/sites")
823 .header("authorization", "Bearer site-key")
824 .json_body(json!({
825 "domain": "example.com",
826 "timezone": "UTC",
827 "public": true
828 }));
829 then.status(201)
830 .header("content-type", "application/json")
831 .json_body(json!({
832 "domain": "example.com",
833 "timezone": "UTC",
834 "public": true,
835 "verified": false
836 }));
837 })
838 .await;
839
840 let client = test_client("site-key", &server);
841 let site = client
842 .create_site(&CreateSiteRequest {
843 domain: "example.com".into(),
844 timezone: Some("UTC".into()),
845 public: Some(true),
846 })
847 .await
848 .expect("create site");
849 assert_eq!(site.domain, "example.com");
850 assert_eq!(site.public, Some(true));
851 }
852
853 #[tokio::test]
854 async fn update_site_sends_patch_body() {
855 let server = MockServer::start_async().await;
856
857 server
858 .mock_async(|when, then| {
859 when.method(PATCH)
860 .path("/api/v1/sites/example.com")
861 .header("authorization", "Bearer update-key")
862 .json_body(json!({ "timezone": "Europe/Berlin" }));
863 then.status(200)
864 .header("content-type", "application/json")
865 .json_body(json!({
866 "domain": "example.com",
867 "timezone": "Europe/Berlin"
868 }));
869 })
870 .await;
871
872 let client = test_client("update-key", &server);
873 let site = client
874 .update_site(
875 "example.com",
876 &UpdateSiteRequest {
877 timezone: Some("Europe/Berlin".into()),
878 public: None,
879 main_site: None,
880 },
881 )
882 .await
883 .expect("update site");
884 assert_eq!(site.timezone.as_deref(), Some("Europe/Berlin"));
885 }
886
887 #[tokio::test]
888 async fn reset_site_stats_posts_date_range() {
889 let server = MockServer::start_async().await;
890
891 server
892 .mock_async(|when, then| {
893 when.method(POST)
894 .path("/api/v1/sites/example.com/reset-stats")
895 .header("authorization", "Bearer reset-key")
896 .json_body(json!({ "date": "2024-01-01" }));
897 then.status(202);
898 })
899 .await;
900
901 let client = test_client("reset-key", &server);
902 client
903 .reset_site_stats(
904 "example.com",
905 &ResetSiteStatsRequest {
906 date: Some("2024-01-01".into()),
907 },
908 )
909 .await
910 .expect("reset stats");
911 }
912
913 #[tokio::test]
914 async fn delete_site_issues_delete() {
915 let server = MockServer::start_async().await;
916
917 server
918 .mock_async(|when, then| {
919 when.method(DELETE)
920 .path("/api/v1/sites/example.com")
921 .header("authorization", "Bearer delete-key");
922 then.status(204);
923 })
924 .await;
925
926 let client = test_client("delete-key", &server);
927 client
928 .delete_site("example.com")
929 .await
930 .expect("delete site");
931 }
932
933 #[tokio::test]
934 async fn realtime_visitors_fetches_metrics() {
935 let server = MockServer::start_async().await;
936
937 server
938 .mock_async(|when, then| {
939 when.method(GET)
940 .path("/api/v1/stats/realtime/visitors")
941 .header("authorization", "Bearer realtime-key")
942 .query_param("site_id", "example.com");
943 then.status(200)
944 .header("content-type", "application/json")
945 .json_body(json!({
946 "visitors": 5,
947 "pageviews": 7
948 }));
949 })
950 .await;
951
952 let client = test_client("realtime-key", &server);
953 let realtime = client
954 .stats_realtime_visitors("example.com")
955 .await
956 .expect("realtime stats");
957 assert_eq!(realtime.visitors, 5);
958 assert_eq!(realtime.pageviews, Some(7));
959 }
960}