use std::collections::HashMap;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::Deserialize;
use crate::client::Client;
use crate::error::Error;
use crate::http::{RequestSpec, content_disposition_filename, encode_segment};
pub struct Stats {
pub(crate) client: Client,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Dimension {
Time,
Browser,
Os,
Device,
Country,
City,
Referrer,
ShortCode,
UtmSource,
UtmMedium,
UtmCampaign,
}
impl Dimension {
fn as_str(self) -> &'static str {
match self {
Dimension::Time => "time",
Dimension::Browser => "browser",
Dimension::Os => "os",
Dimension::Device => "device",
Dimension::Country => "country",
Dimension::City => "city",
Dimension::Referrer => "referrer",
Dimension::ShortCode => "short_code",
Dimension::UtmSource => "utm_source",
Dimension::UtmMedium => "utm_medium",
Dimension::UtmCampaign => "utm_campaign",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Metric {
Clicks,
UniqueClicks,
}
impl Metric {
fn as_str(self) -> &'static str {
match self {
Metric::Clicks => "clicks",
Metric::UniqueClicks => "unique_clicks",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FilterDimension {
Browser,
Os,
Device,
Country,
City,
Referrer,
UtmSource,
UtmMedium,
UtmCampaign,
}
impl FilterDimension {
fn as_str(self) -> &'static str {
match self {
FilterDimension::Browser => "browser",
FilterDimension::Os => "os",
FilterDimension::Device => "device",
FilterDimension::Country => "country",
FilterDimension::City => "city",
FilterDimension::Referrer => "referrer",
FilterDimension::UtmSource => "utm_source",
FilterDimension::UtmMedium => "utm_medium",
FilterDimension::UtmCampaign => "utm_campaign",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub enum Scope {
#[serde(rename = "all")]
All,
#[serde(rename = "anon")]
Anon,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Summary {
pub total_clicks: u64,
pub unique_clicks: u64,
#[serde(default)]
pub first_click: Option<DateTime<Utc>>,
#[serde(default)]
pub last_click: Option<DateTime<Utc>>,
#[serde(default)]
pub avg_redirection_time: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct TimeRange {
#[serde(default)]
pub start_date: Option<DateTime<Utc>>,
#[serde(default)]
pub end_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct TimeBucketInfo {
pub strategy: String,
pub mongo_format: String,
pub display_format: String,
pub timezone: String,
#[serde(default)]
pub interval_minutes: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ComputedMetrics {
pub unique_click_rate: f64,
pub repeat_click_rate: f64,
pub average_clicks_per_visitor: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct StatsReport {
pub scope: Scope,
#[serde(default)]
pub filters: HashMap<String, Vec<String>>,
#[serde(default)]
pub group_by: Vec<String>,
pub timezone: String,
pub time_range: TimeRange,
pub summary: Summary,
#[serde(default)]
pub metrics: HashMap<String, Vec<serde_json::Map<String, serde_json::Value>>>,
#[serde(default)]
pub generated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub short_code: Option<String>,
#[serde(default)]
pub time_bucket_info: Option<TimeBucketInfo>,
#[serde(default)]
pub computed_metrics: Option<ComputedMetrics>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct LinkStatsReport {
#[serde(flatten)]
pub stats: StatsReport,
pub url_id: String,
pub alias: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ExportFormat {
Json,
Csv,
Xlsx,
Xml,
}
impl ExportFormat {
fn as_str(self) -> &'static str {
match self {
ExportFormat::Json => "json",
ExportFormat::Csv => "csv",
ExportFormat::Xlsx => "xlsx",
ExportFormat::Xml => "xml",
}
}
fn extension(self) -> &'static str {
match self {
ExportFormat::Csv => "zip",
other => other.as_str(),
}
}
}
pub struct Export {
pub filename: String,
pub content_type: String,
response: reqwest::Response,
}
impl std::fmt::Debug for Export {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Export")
.field("filename", &self.filename)
.field("content_type", &self.content_type)
.finish_non_exhaustive()
}
}
impl Export {
pub async fn bytes(self) -> Result<Vec<u8>, Error> {
Ok(self
.response
.bytes()
.await
.map_err(Error::Transport)?
.to_vec())
}
#[cfg(feature = "stream")]
pub fn bytes_stream(
self,
) -> impl futures_core::Stream<Item = Result<bytes::Bytes, reqwest::Error>> {
self.response.bytes_stream()
}
}
impl Stats {
pub fn account(&self) -> AccountStatsBuilder {
AccountStatsBuilder {
client: self.client.clone(),
core: QueryCore::default(),
short_codes: Vec::new(),
url_ids: Vec::new(),
}
}
pub fn for_link(&self, url_id: impl Into<String>) -> LinkStatsBuilder {
LinkStatsBuilder {
client: self.client.clone(),
url_id: url_id.into(),
core: QueryCore::default(),
}
}
pub fn export(&self) -> ExportBuilder {
ExportBuilder {
client: self.client.clone(),
path: "/api/v1/export".to_owned(),
format: None,
core: QueryCore::default(),
}
}
pub fn export_link(&self, url_id: impl Into<String>) -> ExportBuilder {
ExportBuilder {
client: self.client.clone(),
path: format!("/api/v1/export/links/{}", encode_segment(&url_id.into())),
format: None,
core: QueryCore::default(),
}
}
}
#[derive(Default, Clone)]
struct QueryCore {
start_date: Option<String>,
end_date: Option<String>,
group_by: Vec<Dimension>,
metrics: Vec<Metric>,
timezone: Option<String>,
filters: std::collections::BTreeMap<&'static str, Vec<String>>,
}
impl QueryCore {
fn apply(self, mut spec: RequestSpec) -> Result<RequestSpec, Error> {
spec = spec
.query("start_date", self.start_date)
.query("end_date", self.end_date)
.query(
"group_by",
(!self.group_by.is_empty()).then(|| {
self.group_by
.iter()
.map(|d| d.as_str())
.collect::<Vec<_>>()
.join(",")
}),
)
.query(
"metrics",
(!self.metrics.is_empty()).then(|| {
self.metrics
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(",")
}),
)
.query("timezone", self.timezone);
if !self.filters.is_empty() {
let filters = serde_json::to_string(&self.filters).map_err(Error::Decode)?;
spec = spec.query("filters", Some(filters));
}
Ok(spec)
}
}
macro_rules! stats_query_methods {
() => {
pub fn start_date(mut self, when: DateTime<Utc>) -> Self {
self.core.start_date = Some(when.to_rfc3339());
self
}
pub fn end_date(mut self, when: DateTime<Utc>) -> Self {
self.core.end_date = Some(when.to_rfc3339());
self
}
pub fn group_by(mut self, dimension: Dimension) -> Self {
self.core.group_by.push(dimension);
self
}
pub fn metric(mut self, metric: Metric) -> Self {
self.core.metrics.push(metric);
self
}
pub fn timezone(mut self, tz: impl Into<String>) -> Self {
self.core.timezone = Some(tz.into());
self
}
pub fn filter<I, S>(mut self, dimension: FilterDimension, values: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.core
.filters
.entry(dimension.as_str())
.or_default()
.extend(values.into_iter().map(Into::into));
self
}
};
}
#[must_use = "builders do nothing until .send() is awaited"]
pub struct AccountStatsBuilder {
client: Client,
core: QueryCore,
short_codes: Vec<String>,
url_ids: Vec<String>,
}
impl AccountStatsBuilder {
stats_query_methods!();
pub fn short_codes<I, S>(mut self, codes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.short_codes.extend(codes.into_iter().map(Into::into));
self
}
pub fn url_ids<I, S>(mut self, ids: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.url_ids.extend(ids.into_iter().map(Into::into));
self
}
pub async fn send(mut self) -> Result<StatsReport, Error> {
if !self.short_codes.is_empty() {
self.core.filters.insert("short_code", self.short_codes);
}
if !self.url_ids.is_empty() {
self.core.filters.insert("url_id", self.url_ids);
}
let spec = self
.core
.apply(RequestSpec::new(Method::GET, "/api/v1/stats"))?;
self.client.transport.execute(spec).await
}
}
#[must_use = "builders do nothing until .send() is awaited"]
pub struct LinkStatsBuilder {
client: Client,
url_id: String,
core: QueryCore,
}
impl LinkStatsBuilder {
stats_query_methods!();
pub async fn send(self) -> Result<LinkStatsReport, Error> {
let spec = self.core.apply(RequestSpec::new(
Method::GET,
format!("/api/v1/stats/links/{}", encode_segment(&self.url_id)),
))?;
self.client.transport.execute(spec).await
}
}
#[must_use = "builders do nothing until .send() is awaited"]
pub struct ExportBuilder {
client: Client,
path: String,
format: Option<ExportFormat>,
core: QueryCore,
}
impl ExportBuilder {
stats_query_methods!();
pub fn format(mut self, format: ExportFormat) -> Self {
self.format = Some(format);
self
}
pub async fn send(self) -> Result<Export, Error> {
let fallback = format!(
"spoo-export.{}",
self.format.map(ExportFormat::extension).unwrap_or("json")
);
let spec = self.core.apply(
RequestSpec::new(Method::GET, self.path)
.query("format", self.format.map(|f| f.as_str().to_owned())),
)?;
let response = self.client.transport.send(spec).await?;
let filename = content_disposition_filename(
response
.headers()
.get("content-disposition")
.and_then(|v| v.to_str().ok()),
&fallback,
);
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
Ok(Export {
filename,
content_type,
response,
})
}
}