wip: dev-1126-v2

This commit is contained in:
aecsocket
2026-08-17 07:28:50 +00:00
parent 248c7bb217
commit 3326c6d973
14 changed files with 617 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "aditude"
edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
arc-swap = { workspace = true, optional = true }
reqwest = { workspace = true, features = ["json"] }
rust_decimal = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true, features = ["derive"] }
chrono = { workspace = true, features = ["serde"] }
[dev-dependencies]
aditude = { path = ".", features = ["mock"] }
dotenvy = { workspace = true }
eyre = { workspace = true }
tokio = { workspace = true, features = ["full"] }
[features]
mock = ["dep:arc-swap"]
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
Types for the [Aditude API](https://aditude.io).
+38
View File
@@ -0,0 +1,38 @@
//! Example Aditude client.
#![expect(clippy::print_stdout, reason = "this is an example")]
use aditude::{Client, v1, v2};
use eyre::{Context, Result};
#[tokio::main]
async fn main() -> Result<()> {
_ = dotenvy::dotenv();
let aditude = Client::new(
dotenvy::var("ADITUDE_API_URL").wrap_err("no API URL")?,
dotenvy::var("ADITUDE_API_KEY").wrap_err("no API key")?,
);
let resp = aditude
.get_metrics_v1(v1::GetMetrics {
metrics: &[v1::MetricKind::Impressions, v1::MetricKind::Revenue],
range: v1::Range::Yesterday,
interval: v1::Interval::OneDay,
})
.await
.wrap_err("failed to get metrics")?;
println!("{resp:#?}");
println!("\n---\n");
let resp = aditude
.get_metrics_v2(v2::GetMetrics {
metrics: &[v2::MetricKind::Impressions, v2::MetricKind::Revenue],
range: v2::Range::Yesterday,
interval: v2::Interval::OneDay,
})
.await
.wrap_err("failed to get metrics")?;
println!("{resp:#?}");
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
#![doc = include_str!("../README.md")]
#![allow(missing_docs, reason = "these are Aditude types")]
#[cfg(feature = "mock")]
pub mod mock;
pub mod v1;
pub mod v2;
use std::borrow::Cow;
use chrono::{DateTime, Duration, Utc};
use secrecy::SecretString;
/// [Aditude](https://www.aditude.com/) client.
#[derive(Debug)]
pub struct Client {
http: reqwest::Client,
pub api_url: Cow<'static, str>,
pub api_key: SecretString,
#[cfg(feature = "mock")]
pub mock: arc_swap::ArcSwapOption<mock::AditudeMock>,
}
impl Client {
/// Creates a new Aditude client with a [`reqwest::Client`].
#[must_use]
pub fn from_client(
http: reqwest::Client,
api_url: impl Into<Cow<'static, str>>,
api_key: impl Into<SecretString>,
) -> Self {
Self {
http,
api_url: api_url.into(),
api_key: api_key.into(),
#[cfg(feature = "mock")]
mock: arc_swap::ArcSwapOption::empty(),
}
}
/// Creates a new Aditude client.
#[must_use]
pub fn new(
api_url: impl Into<Cow<'static, str>>,
api_key: impl Into<SecretString>,
) -> Self {
Self::from_client(reqwest::Client::new(), api_url, api_key)
}
/// Creates an Aditude client which mocks responses.
#[cfg(feature = "mock")]
#[must_use]
pub fn from_mock(mock: mock::AditudeMock) -> Self {
Self {
http: reqwest::Client::new(),
api_url: "".into(),
api_key: SecretString::from(String::new()),
mock: arc_swap::ArcSwapOption::from_pointee(mock),
}
}
/// Sets the mock responses that this client will output.
#[cfg(feature = "mock")]
pub fn set_mock(&self, mock: mock::AditudeMock) {
self.mock.store(Some(std::sync::Arc::new(mock)));
}
}
/// Returns the start and end of what Aditude considers the "Yesterday" time
/// range.
#[must_use]
pub fn yesterday(now: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
let start = DateTime::<Utc>::from_naive_utc_and_offset(
(now - Duration::days(1))
.date_naive()
.and_hms_nano_opt(0, 0, 0, 0)
.unwrap_or_default(),
Utc,
);
let end = start + Duration::days(1);
(start, end)
}
+12
View File
@@ -0,0 +1,12 @@
//! See [`AditudeMock`].
use crate::{v1, v2};
/// Mock data returned by [`crate::Aditude`].
#[derive(Debug, Clone, Default)]
pub struct AditudeMock {
/// Response for [`crate::Aditude::get_metrics_v1`].
pub metrics_v1_response: Option<Vec<v1::MetricsResponse>>,
/// Response for [`crate::Aditude::get_metrics_v2`].
pub metrics_v2_response: Option<v2::Metrics>,
}
+139
View File
@@ -0,0 +1,139 @@
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use crate::Client;
impl Client {
/// Fetches insights metrics from the API.
///
/// # Errors
///
/// Errors if the request could not be completed.
pub async fn get_metrics_v1(
&self,
req: GetMetrics<'_>,
) -> reqwest::Result<Vec<MetricsResponse>> {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Body<'a> {
pub metrics: &'a [MetricKind],
pub range: &'static str,
pub interval: Interval,
#[serde(with = "chrono::serde::ts_milliseconds_option")]
pub start_time: Option<DateTime<Utc>>,
#[serde(with = "chrono::serde::ts_milliseconds_option")]
pub end_time: Option<DateTime<Utc>>,
}
#[cfg(feature = "mock")]
if let Some(mock) = &*self.mock.load() {
return Ok(mock.get_metrics_v1());
}
let body = Body {
metrics: req.metrics,
range: match req.range {
Range::Yesterday => "Yesterday",
Range::Custom { .. } => "custom",
},
interval: req.interval,
start_time: if let Range::Custom { start, .. } = req.range {
Some(start)
} else {
None
},
end_time: if let Range::Custom { end, .. } = req.range {
Some(end)
} else {
None
},
};
self.http
.post(format!("{}/public/insights/metrics", self.api_url))
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await?
.error_for_status()?
.json::<Vec<MetricsResponse>>()
.await
}
}
#[derive(Debug, Clone)]
pub struct GetMetrics<'a> {
pub metrics: &'a [MetricKind],
pub range: Range,
pub interval: Interval,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Range {
#[serde(rename = "Yesterday")]
Yesterday,
#[serde(rename = "custom", rename_all = "camelCase")]
Custom {
#[serde(with = "chrono::serde::ts_milliseconds")]
start: DateTime<Utc>,
#[serde(with = "chrono::serde::ts_milliseconds")]
end: DateTime<Utc>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Interval {
#[serde(rename = "1d")]
OneDay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MetricKind {
#[serde(rename = "METRIC_IMPRESSIONS")]
Impressions,
#[serde(rename = "METRIC_REVENUE")]
Revenue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MetricsResponse {
pub points_list: Vec<Point>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Point {
pub metric: Metric,
pub time: Time,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Metric {
#[serde(with = "rust_decimal::serde::float_option", default)]
pub revenue: Option<Decimal>,
pub impressions: Option<u128>,
#[serde(with = "rust_decimal::serde::float_option", default)]
pub cpm: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Time {
pub seconds: u64,
pub nanos: u64,
}
#[cfg(feature = "mock")]
const _: () = {
use crate::{mock::AditudeMock, v1};
impl AditudeMock {
pub(crate) fn get_metrics_v1(&self) -> Vec<v1::MetricsResponse> {
self.metrics_v1_response
.as_ref()
.expect("missing mock `metrics_v1_response`")
.clone()
}
}
};
+114
View File
@@ -0,0 +1,114 @@
pub use crate::v1::{Interval, Range};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use crate::Client;
impl Client {
/// Fetches insights metrics from the API.
///
/// # Errors
///
/// Errors if the request could not be completed.
pub async fn get_metrics_v2(
&self,
req: GetMetrics<'_>,
) -> reqwest::Result<Metrics> {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Body<'a> {
pub metrics: &'a [MetricKind],
pub range: &'static str,
pub interval: Interval,
#[serde(with = "chrono::serde::ts_milliseconds_option")]
pub start_time: Option<DateTime<Utc>>,
#[serde(with = "chrono::serde::ts_milliseconds_option")]
pub end_time: Option<DateTime<Utc>>,
}
#[cfg(feature = "mock")]
if let Some(mock) = &*self.mock.load() {
return Ok(mock.get_metrics_v2());
}
let body = Body {
metrics: req.metrics,
range: match req.range {
Range::Yesterday => "Yesterday",
Range::Custom { .. } => "custom",
},
interval: req.interval,
start_time: if let Range::Custom { start, .. } = req.range {
Some(start)
} else {
None
},
end_time: if let Range::Custom { end, .. } = req.range {
Some(end)
} else {
None
},
};
self.http
.post(format!("{}/public/insights/metrics/v2", self.api_url))
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await?
.error_for_status()?
.json::<Metrics>()
.await
}
}
#[derive(Debug, Clone)]
pub struct GetMetrics<'a> {
pub metrics: &'a [MetricKind],
pub range: Range,
pub interval: Interval,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MetricKind {
#[serde(rename = "IMPRESSIONS")]
Impressions,
#[serde(rename = "REVENUE")]
Revenue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Metrics {
pub responses: Vec<Response>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Response {
pub rows: Vec<Row>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Row {
#[serde(rename = "IMPRESSIONS")]
pub impressions: Option<u128>,
#[serde(rename = "REVENUE")]
pub revenue: Option<Decimal>,
#[serde(rename = "_TIME", with = "chrono::serde::ts_milliseconds")]
pub time: DateTime<Utc>,
}
#[cfg(feature = "mock")]
const _: () = {
use crate::{mock::AditudeMock, v2};
impl AditudeMock {
pub(crate) fn get_metrics_v2(&self) -> v2::Metrics {
self.metrics_v2_response
.as_ref()
.expect("missing mock `metrics_v2_response`")
.clone()
}
}
};
+117
View File
@@ -0,0 +1,117 @@
#![expect(missing_docs, reason = "test crate")]
use aditude::{Client, mock::AditudeMock, v1, v2};
use chrono::{DateTime, Utc};
use rust_decimal::dec;
#[tokio::test]
async fn mock_metrics() {
let metrics_v1_response = vec![v1::MetricsResponse {
points_list: vec![v1::Point {
metric: v1::Metric {
revenue: Some(dec!(1.23)),
impressions: Some(456),
cpm: Some(dec!(7.89)),
},
time: v1::Time {
seconds: 123,
nanos: 0,
},
}],
}];
let epoch = DateTime::UNIX_EPOCH;
let metrics_v2_response = v2::Metrics {
responses: vec![
v2::Response {
rows: vec![v2::Row {
impressions: Some(123),
revenue: None,
time: epoch,
}],
},
v2::Response {
rows: vec![v2::Row {
impressions: None,
revenue: Some(dec!(45.6)),
time: epoch,
}],
},
],
};
let aditude = Client::from_mock(AditudeMock {
metrics_v1_response: Some(metrics_v1_response.clone()),
metrics_v2_response: Some(metrics_v2_response.clone()),
});
assert_eq!(
metrics_v1_response,
aditude
.get_metrics_v1(v1::GetMetrics {
metrics: &[],
range: v1::Range::Yesterday,
interval: v1::Interval::OneDay,
})
.await
.unwrap()
);
assert_eq!(
metrics_v2_response,
aditude
.get_metrics_v2(v2::GetMetrics {
metrics: &[],
range: v2::Range::Yesterday,
interval: v2::Interval::OneDay,
})
.await
.unwrap()
);
}
#[tokio::test]
async fn test_yesterday() {
_ = dotenvy::dotenv();
let (Ok(url), Ok(key)) = (
dotenvy::var("ADITUDE_API_URL"),
dotenvy::var("ADITUDE_API_KEY"),
) else {
return;
};
if url.trim().is_empty()
|| key.trim().is_empty()
|| url == "none"
|| key == "none"
{
return;
}
let aditude = Client::new(url, key);
let real_yesterday = aditude
.get_metrics_v2(v2::GetMetrics {
metrics: &[v2::MetricKind::Impressions, v2::MetricKind::Revenue],
range: v2::Range::Yesterday,
interval: v2::Interval::OneDay,
})
.await
.unwrap();
// Aditude defines the time range "Yesterday" according to this logic, I think.
// We need to make sure it stays defined like this.
let now = Utc::now();
let (start_of_yesterday, end_of_yesterday) = aditude::yesterday(now);
let our_yesterday = aditude
.get_metrics_v2(v2::GetMetrics {
metrics: &[v2::MetricKind::Impressions, v2::MetricKind::Revenue],
range: v2::Range::Custom {
start: start_of_yesterday,
end: end_of_yesterday,
},
interval: v2::Interval::OneDay,
})
.await
.unwrap();
assert_eq!(real_yesterday, our_yesterday);
}