links/stats/
internals.rs

1//! Types that make up a links [`Statistic`]
2
3use std::{
4	fmt::{Display, Formatter, Result as FmtResult},
5	str::FromStr,
6};
7
8use serde::{Deserialize, Serialize};
9use strum::{Display as EnumDisplay, EnumString};
10use time::{
11	format_description::well_known::{
12		iso8601::{Config as TimeFormatConfig, EncodedConfig, TimePrecision},
13		Iso8601,
14	},
15	macros::datetime,
16	Duration, OffsetDateTime,
17};
18
19#[cfg(doc)]
20use crate::stats::Statistic;
21
22/// The data for a statistic
23///
24/// This struct holds the data associated with a statistic, that along with the
25/// statistic's type and link comprises one full [`Statistic`]
26#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(transparent)]
28pub struct StatisticData {
29	data: String,
30}
31
32impl From<&str> for StatisticData {
33	fn from(s: &str) -> Self {
34		Self { data: s.into() }
35	}
36}
37
38impl From<String> for StatisticData {
39	fn from(s: String) -> Self {
40		Self { data: s }
41	}
42}
43
44impl Display for StatisticData {
45	fn fmt(&self, fmt: &mut Formatter<'_>) -> FmtResult {
46		fmt.write_str(&self.data)
47	}
48}
49
50/// The timestamp for a statistic
51///
52/// This timestamp is generally represented as the start of the period it
53/// represents as an RFC3339/ISO8601 string (with the date, time with second
54/// precision, and time zone `Z` for UTC), e.g. `2022-10-01T16:30:00Z`
55///
56/// Internally, this stores the number of 15 minute periods since the beginning
57/// of the year 2000 UTC (e.g. on 2000-01-01 the period between 00:00:00.000 and
58/// 00:14:59.999 UTC is 0 and 15:30:00.000 to 15:44:59.999 UTC is 62)
59// Caused by the `datetime!` macro in `EPOCH`, related https://github.com/rust-lang/rust-clippy/issues/10349
60#[expect(
61	clippy::unsafe_derive_deserialize,
62	reason = "false positive in the EPOCH constant"
63)]
64#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(try_from = "&str", into = "String")]
66pub struct StatisticTime {
67	intervals: u32,
68}
69
70impl StatisticTime {
71	/// The datetime representing the beginning of `0` in [`StatisticTime`]
72	pub const EPOCH: OffsetDateTime = datetime!(2000-01-01 00:00:00 UTC);
73	/// The resolution of a [`StatisticTime`] (15 minutes) in seconds
74	pub const RESOLUTION_SECS: i64 = 15 * 60;
75
76	/// Get the [`StatisticTime`] for now (the current time)
77	#[must_use]
78	pub fn now() -> Self {
79		let intervals =
80			(OffsetDateTime::now_utc() - Self::EPOCH).whole_seconds() / Self::RESOLUTION_SECS;
81
82		Self {
83			intervals: intervals.try_into().unwrap_or(u32::MAX),
84		}
85	}
86}
87
88impl From<OffsetDateTime> for StatisticTime {
89	fn from(dt: OffsetDateTime) -> Self {
90		let intervals = (dt - Self::EPOCH).whole_seconds() / Self::RESOLUTION_SECS;
91
92		Self {
93			intervals: intervals.try_into().unwrap_or(u32::MAX),
94		}
95	}
96}
97
98impl From<StatisticTime> for OffsetDateTime {
99	fn from(st: StatisticTime) -> OffsetDateTime {
100		let seconds = i64::from(st.intervals) * StatisticTime::RESOLUTION_SECS;
101
102		StatisticTime::EPOCH + Duration::seconds(seconds)
103	}
104}
105
106impl TryFrom<&str> for StatisticTime {
107	type Error = time::Error;
108
109	fn try_from(s: &str) -> Result<Self, Self::Error> {
110		const TIME_FORMAT_CONFIG: EncodedConfig = TimeFormatConfig::DEFAULT
111			.set_time_precision(TimePrecision::Second {
112				decimal_digits: None,
113			})
114			.encode();
115
116		let dt = OffsetDateTime::parse(s, &Iso8601::<TIME_FORMAT_CONFIG>)?;
117
118		Ok(dt.into())
119	}
120}
121
122impl FromStr for StatisticTime {
123	type Err = time::Error;
124
125	fn from_str(s: &str) -> Result<Self, Self::Err> {
126		Self::try_from(s)
127	}
128}
129
130impl From<StatisticTime> for String {
131	fn from(st: StatisticTime) -> Self {
132		st.to_string()
133	}
134}
135
136impl Display for StatisticTime {
137	fn fmt(&self, fmt: &mut Formatter<'_>) -> FmtResult {
138		// This doesn't use `time`'s datetime formatting, because that can fail
139		let dt = OffsetDateTime::from(*self);
140
141		let (year, month, day) = dt.to_calendar_date();
142		let month = month as u8;
143
144		let (hour, minute, second) = dt.to_hms();
145
146		fmt.write_fmt(format_args!(
147			"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
148		))
149	}
150}
151
152/// The type of a links statistic
153///
154/// Each of the variants of this enum is one type of statistic, that along with
155/// the statistic's data and link comprises one full [`Statistic`]
156#[derive(
157	Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumString, EnumDisplay,
158)]
159#[serde(rename_all = "snake_case")]
160#[strum(serialize_all = "snake_case")]
161#[non_exhaustive]
162pub enum StatisticType {
163	/// Total number of requests
164	///
165	/// # Data
166	/// This statistic type does not have any additional data
167	Request,
168	/// Number of requests to the specified host/domain
169	///
170	/// # Data
171	/// The value of the [HTTP `Host` request header][host] or the [`:authority`
172	/// pseudo-header field][authority], e.g. `example.com` or `10.0.0.25:8000`
173	///
174	/// [host]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host
175	/// [authority]: https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.3
176	HostRequest,
177	/// Number of requests with the specified [SNI]
178	///
179	/// # Data
180	/// The value of the TLS [SNI], e.g. `example.com` or `www.links.example`
181	///
182	/// [SNI]: https://www.cloudflare.com/learning/ssl/what-is-sni/
183	SniRequest,
184	/// Number of requests that resulted in the given HTTP status code being
185	/// returned
186	///
187	/// # Data
188	/// The HTTP status code number, e.g. `404` or `308`
189	StatusCode,
190	/// Number of requests that used the given HTTP version
191	///
192	/// # Data
193	/// The HTTP protocol version used for the request, e.g. `HTTP/1.0` or
194	/// `HTTP/2`
195	HttpVersion,
196	/// Number of requests that used the given TLS version
197	///
198	/// # Data
199	/// The TLS version (see [`ProtocolVersion`]) used for the request, e.g.
200	/// `TLSv1.3` or `TLSv1.2`
201	///
202	/// [`ProtocolVersion`]: https://docs.rs/rustls/latest/rustls/enum.ProtocolVersion.html
203	TlsVersion,
204	/// Number of requests that used the provided TLS cipher suite
205	///
206	/// # Data
207	/// The TLS cipher suite (see [`CipherSuite`]) used for the request, e.g.
208	/// `TLS13_AES_256_GCM_SHA384` or `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`
209	///
210	/// [`CipherSuite`]: https://docs.rs/rustls/latest/rustls/enum.CipherSuite.html
211	TlsCipherSuite,
212	/// Number of requests by the provided user agent/browser
213	///
214	/// # Data
215	/// The content of the [`Sec-CH-UA` HTTP header][sec-ch-ua], or in case that
216	/// header is not available, the [`User-Agent` header][user-agent]
217	///
218	/// As recommended by the appropriate [standard], the data for this
219	/// statistic is the entire value of the header. The header is not parsed
220	/// into its individual components, instead it is simply copied verbatim.
221	///
222	/// [sec-ch-ua]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
223	/// [user-agent]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent
224	/// [standard]: https://wicg.github.io/ua-client-hints/#marketshare-analytics-use-case
225	UserAgent,
226	/// Number of requests by a user agent based on preference for a "mobile
227	/// experience"
228	///
229	/// # Data
230	/// The content of the [`Sec-CH-UA-Mobile` HTTP header][header], e.g. `?0`
231	/// (for false/no) or `?1` (for true/yes)
232	///
233	/// [header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA-Mobile
234	UserAgentMobile,
235	/// Number of requests by a user agent on the specified platform/operating
236	/// system
237	///
238	/// # Data
239	/// The content of the [`Sec-CH-UA-Platform` HTTP header][header], e.g.
240	/// `Android` or `Windows`
241	///
242	/// [header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA-Platform
243	UserAgentPlatform,
244}
245
246#[cfg(test)]
247mod tests {
248	use super::*;
249
250	#[test]
251	fn statistic_data() {
252		assert_eq!(
253			r#""Some Arbitrary Text Data""#,
254			serde_json::to_string_pretty(&StatisticData::from("Some Arbitrary Text Data")).unwrap()
255		);
256
257		assert_eq!(
258			serde_json::from_str::<StatisticData>(r#""!@#$%^&*()\" ᓚᘏᗢ""#).unwrap(),
259			StatisticData::from(r#"!@#$%^&*()" ᓚᘏᗢ"#)
260		);
261
262		assert_ne!(
263			r#""slightly different""#,
264			serde_json::to_string_pretty(&StatisticData::from("hardly different")).unwrap()
265		);
266
267		assert_eq!(
268			"Some Arbitrary Text Data",
269			StatisticData::from("Some Arbitrary Text Data").to_string()
270		);
271	}
272
273	#[test]
274	fn statistic_time() {
275		assert_ne!(StatisticTime::now(), StatisticTime::EPOCH.into());
276
277		assert_eq!(
278			StatisticTime::try_from(StatisticTime::now().to_string().as_str()).unwrap(),
279			StatisticTime::now()
280		);
281
282		assert_eq!(
283			StatisticTime::try_from("2022-10-08T16:30:00Z").unwrap(),
284			StatisticTime::try_from("2022-10-08T16:34:25.159Z").unwrap()
285		);
286
287		assert_ne!(
288			OffsetDateTime::from(StatisticTime::try_from("2022-10-08T16:34:25.159Z").unwrap()),
289			datetime!(2022-10-08 16:34:25.159 UTC)
290		);
291
292		assert_eq!(
293			OffsetDateTime::from(StatisticTime::try_from("2022-10-08T16:34:25.159Z").unwrap()),
294			datetime!(2022-10-08 16:30:00.000 UTC)
295		);
296
297		assert!(dbg!(StatisticTime::now().to_string()).ends_with(":00Z"));
298		assert_eq!(dbg!(StatisticTime::now().to_string()).len(), 20);
299
300		let stat_time = StatisticTime::from(datetime!(2022-09-30 15:24:38 +2));
301
302		assert_eq!(dbg!(stat_time.to_string()), "2022-09-30T13:15:00Z");
303		assert_eq!(
304			stat_time,
305			StatisticTime::try_from(stat_time.to_string().as_str()).unwrap()
306		);
307		assert_eq!(
308			stat_time,
309			serde_json::from_str(&serde_json::to_string(&stat_time).unwrap()).unwrap()
310		);
311
312		let stat_time = StatisticTime::now();
313
314		assert_eq!(
315			stat_time,
316			StatisticTime::try_from(stat_time.to_string().as_str()).unwrap()
317		);
318		assert_eq!(
319			stat_time,
320			serde_json::from_str(&serde_json::to_string(&stat_time).unwrap()).unwrap()
321		);
322	}
323
324	#[test]
325	fn statistic_type() {
326		assert_eq!(
327			StatisticType::HostRequest,
328			serde_json::from_str(
329				&serde_json::to_string_pretty(&StatisticType::HostRequest).unwrap()
330			)
331			.unwrap()
332		);
333
334		assert_eq!(
335			r#""user_agent_platform""#,
336			serde_json::to_string_pretty(&StatisticType::UserAgentPlatform).unwrap()
337		);
338
339		assert_eq!(
340			StatisticType::HttpVersion,
341			serde_json::from_str(r#""http_version""#).unwrap()
342		);
343
344		assert!(serde_json::from_str::<StatisticType>(r#""an_invalid_type""#).is_err());
345	}
346}