mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
wip: dev-1126-v2
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Types for the [Aditude API](https://aditude.io).
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user