links/stats/
misc.rs

1//! Miscellaneous statistics-related types
2
3use std::{
4	convert::Infallible,
5	fmt::{Display, Formatter, Result as FmtResult},
6	str::FromStr,
7	sync::Arc,
8};
9
10use hyper::Version;
11use links_id::Id;
12use links_normalized::Normalized;
13use serde::{Deserialize, Serialize};
14use tokio_rustls::rustls::{ProtocolVersion, SupportedCipherSuite};
15
16use super::StatisticType;
17
18/// Extra statistics-related information passed to the links HTTP redirector for
19/// collection
20#[derive(Debug, Clone, Default)]
21pub struct ExtraStatisticInfo {
22	/// The server name indication from TLS, if available
23	pub tls_sni: Option<Arc<str>>,
24	/// The version of TLS used, if any
25	pub tls_version: Option<ProtocolVersion>,
26	/// The negotiated TLS cipher suite, if any
27	pub tls_cipher_suite: Option<SupportedCipherSuite>,
28}
29
30/// A links ID or vanity path
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(untagged)]
33pub enum IdOrVanity {
34	/// A links [`Id`]
35	Id(Id),
36	/// A links vanity path as a [`Normalized`]
37	Vanity(Normalized),
38}
39
40impl Display for IdOrVanity {
41	fn fmt(&self, fmt: &mut Formatter<'_>) -> FmtResult {
42		fmt.write_str(&match self {
43			Self::Id(id) => id.to_string(),
44			Self::Vanity(vanity) => vanity.to_string(),
45		})
46	}
47}
48
49impl From<&str> for IdOrVanity {
50	fn from(s: &str) -> Self {
51		Id::from_str(s).map_or_else(|_| Self::Vanity(Normalized::new(s)), Self::Id)
52	}
53}
54
55impl From<String> for IdOrVanity {
56	fn from(s: String) -> Self {
57		Id::from_str(s.as_str()).map_or_else(|_| Self::Vanity(Normalized::from(s)), Self::Id)
58	}
59}
60
61impl From<Id> for IdOrVanity {
62	fn from(id: Id) -> Self {
63		Self::Id(id)
64	}
65}
66
67impl From<Normalized> for IdOrVanity {
68	fn from(vanity: Normalized) -> Self {
69		Self::Vanity(vanity)
70	}
71}
72
73impl From<&IdOrVanity> for IdOrVanity {
74	fn from(iov: &Self) -> Self {
75		iov.clone()
76	}
77}
78
79/// Which categories of statistics are to be collected
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(from = "Vec<&str>", into = "Vec<&'static str>")]
82#[non_exhaustive]
83#[expect(
84	clippy::struct_excessive_bools,
85	reason = "using bitflags to save 3 bytes is a bit excessive for this"
86)]
87pub struct StatisticCategories {
88	/// Collect [`StatisticType::Request`]
89	pub redirect: bool,
90	/// Collect [`StatisticType::HostRequest`], [`StatisticType::SniRequest`],
91	/// and [`StatisticType::StatusCode`]
92	pub basic: bool,
93	/// Collect [`StatisticType::HttpVersion`], [`StatisticType::TlsVersion`],
94	/// and [`StatisticType::TlsCipherSuite`]
95	pub protocol: bool,
96	/// Collect [`StatisticType::UserAgent`],
97	/// [`StatisticType::UserAgentMobile`],
98	/// and [`StatisticType::UserAgentPlatform`]
99	pub user_agent: bool,
100}
101
102impl StatisticCategories {
103	/// All categories enabled
104	pub const ALL: Self = Self {
105		redirect: true,
106		basic: true,
107		protocol: true,
108		user_agent: true,
109	};
110	/// No categories enabled
111	pub const NONE: Self = Self {
112		redirect: false,
113		basic: false,
114		protocol: false,
115		user_agent: false,
116	};
117
118	/// Whether this [`StatisticCategories`] struct specifies that a statistic
119	/// with the provided [`StatisticType`] should be collected
120	#[must_use]
121	pub const fn specifies(self, stat_type: StatisticType) -> bool {
122		#[allow(clippy::enum_glob_use, reason = "scoped to this 6-line function")]
123		use StatisticType::*;
124
125		match stat_type {
126			Request => self.redirect,
127			HostRequest | SniRequest | StatusCode => self.basic,
128			HttpVersion | TlsVersion | TlsCipherSuite => self.protocol,
129			UserAgent | UserAgentMobile | UserAgentPlatform => self.user_agent,
130		}
131	}
132
133	/// Convert this [`StatisticCategories`] into a `Vec` of the names of its
134	/// enabled categories
135	///
136	/// # Example
137	/// ```rust
138	/// # use links::stats::StatisticCategories;
139	/// let mut categories = StatisticCategories::NONE;
140	///
141	/// categories.redirect = true;
142	/// categories.basic = false;
143	/// categories.protocol = true;
144	/// categories.user_agent = false;
145	///
146	/// assert_eq!(categories.to_names(), vec!["redirect", "protocol"]);
147	/// ```
148	#[must_use]
149	pub fn to_names(self) -> Vec<&'static str> {
150		let mut names = Vec::with_capacity(4);
151
152		if self.redirect {
153			names.push("redirect");
154		}
155
156		if self.basic {
157			names.push("basic");
158		}
159
160		if self.protocol {
161			names.push("protocol");
162		}
163
164		if self.user_agent {
165			names.push("user-agent");
166		}
167
168		names
169	}
170
171	/// Convert a list of category names into a [`StatisticCategories`].
172	/// Unrecognized category names are ignored.
173	///
174	/// # Example
175	/// ```rust
176	/// # use links::stats::StatisticCategories;
177	/// let list = ["redirect", "protocol", "invalid"];
178	/// let mut categories = StatisticCategories::from_names(list);
179	///
180	/// assert!(categories.redirect);
181	/// assert!(!categories.basic);
182	/// assert!(categories.protocol);
183	/// assert!(!categories.user_agent);
184	/// ```
185	#[must_use]
186	pub fn from_names<L, T>(categories: L) -> Self
187	where
188		L: AsRef<[T]>,
189		T: AsRef<str>,
190	{
191		let mut cats = Self::NONE;
192
193		for cat in categories.as_ref() {
194			match cat.as_ref() {
195				"redirect" => cats.redirect = true,
196				"basic" => cats.basic = true,
197				"protocol" => cats.protocol = true,
198				"user-agent" => cats.user_agent = true,
199				_ => (),
200			}
201		}
202
203		cats
204	}
205}
206
207impl Default for StatisticCategories {
208	fn default() -> Self {
209		Self {
210			redirect: true,
211			basic: true,
212			protocol: true,
213			user_agent: false,
214		}
215	}
216}
217
218impl From<Vec<&str>> for StatisticCategories {
219	fn from(names: Vec<&str>) -> Self {
220		Self::from_names(names)
221	}
222}
223
224impl From<StatisticCategories> for Vec<&'static str> {
225	fn from(cats: StatisticCategories) -> Self {
226		cats.to_names()
227	}
228}
229
230/// An HTTP protocol version
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
232#[serde(from = "&str")]
233#[non_exhaustive]
234pub enum HttpVersion {
235	/// HTTP version 0.9
236	V09,
237	/// HTTP version 1.0
238	V10,
239	/// HTTP version 1.1
240	V11,
241	/// HTTP version 2
242	V2,
243	/// HTTP version 3
244	V3,
245	/// Unknown HTTP version
246	Unknown,
247}
248
249impl HttpVersion {
250	/// Get a string representing the HTTP version
251	#[must_use]
252	pub const fn as_str(self) -> &'static str {
253		match self {
254			HttpVersion::V09 => "HTTP/0.9",
255			HttpVersion::V10 => "HTTP/1.0",
256			HttpVersion::V11 => "HTTP/1.1",
257			HttpVersion::V2 => "HTTP/2",
258			HttpVersion::V3 => "HTTP/3",
259			HttpVersion::Unknown => "HTTP/???",
260		}
261	}
262}
263
264impl FromStr for HttpVersion {
265	type Err = Infallible;
266
267	fn from_str(s: &str) -> Result<Self, Self::Err> {
268		Ok(s.into())
269	}
270}
271
272impl From<&str> for HttpVersion {
273	fn from(s: &str) -> Self {
274		match s.trim_start_matches("HTTP/").trim_start_matches("http/") {
275			"0.9" => Self::V09,
276			"1.0" => Self::V10,
277			"1.1" => Self::V11,
278			"2" => Self::V2,
279			"3" => Self::V3,
280			_ => Self::Unknown,
281		}
282	}
283}
284
285impl Serialize for HttpVersion {
286	fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
287	where
288		S: serde::Serializer,
289	{
290		ser.serialize_str(self.as_str())
291	}
292}
293
294impl From<Version> for HttpVersion {
295	fn from(v: Version) -> Self {
296		match v {
297			Version::HTTP_09 => Self::V09,
298			Version::HTTP_10 => Self::V10,
299			Version::HTTP_11 => Self::V11,
300			Version::HTTP_2 => Self::V2,
301			Version::HTTP_3 => Self::V3,
302			_ => Self::Unknown,
303		}
304	}
305}
306
307impl TryFrom<HttpVersion> for Version {
308	type Error = UnknownHttpVersionError;
309
310	fn try_from(v: HttpVersion) -> Result<Self, Self::Error> {
311		match v {
312			HttpVersion::V09 => Ok(Self::HTTP_09),
313			HttpVersion::V10 => Ok(Self::HTTP_10),
314			HttpVersion::V11 => Ok(Self::HTTP_11),
315			HttpVersion::V2 => Ok(Self::HTTP_2),
316			HttpVersion::V3 => Ok(Self::HTTP_3),
317			HttpVersion::Unknown => Err(UnknownHttpVersionError),
318		}
319	}
320}
321
322/// The error returned when attempting to convert an [`HttpVersion::Unknown`] to
323/// another type that can't represent that value
324#[derive(Debug, Clone, Copy, thiserror::Error)]
325#[error("the HTTP version is unknown")]
326pub struct UnknownHttpVersionError;
327
328#[cfg(test)]
329mod tests {
330	use super::*;
331
332	#[test]
333	#[expect(
334		clippy::unnecessary_fallible_conversions,
335		reason = "that's what this test is for"
336	)]
337	fn id_or_vanity() {
338		assert_eq!(
339			IdOrVanity::Id([0x11, 0x33, 0x55, 0x77, 0x99].into()),
340			IdOrVanity::try_from("0fXMgWQz").unwrap()
341		);
342
343		assert_eq!(
344			IdOrVanity::Vanity("example-test".into()),
345			IdOrVanity::try_from("example-test").unwrap()
346		);
347
348		assert_eq!(
349			IdOrVanity::Id([0x11, 0x33, 0x55, 0x77, 0x99].into()),
350			IdOrVanity::from("0fXMgWQz")
351		);
352
353		assert_eq!(
354			IdOrVanity::Vanity("example-test".into()),
355			IdOrVanity::from("example-test")
356		);
357	}
358
359	#[test]
360	fn statistic_categories() {
361		assert_eq!(Vec::<&str>::new(), StatisticCategories::NONE.to_names());
362
363		assert_eq!(
364			StatisticCategories::from_names(Vec::<&str>::new()),
365			StatisticCategories::NONE
366		);
367
368		assert_eq!(
369			vec!["redirect", "basic", "protocol"],
370			StatisticCategories::default().to_names()
371		);
372
373		let names = vec!["protocol", "user-agent"];
374		assert_eq!(names, StatisticCategories::from_names(&names).to_names());
375
376		let names = vec!["protocol", "user-agent"];
377		assert_eq!(
378			names,
379			Vec::<&str>::from(StatisticCategories::from(names.clone()))
380		);
381
382		let categories = StatisticCategories::default();
383		assert!(categories.specifies(StatisticType::Request));
384		assert!(categories.specifies(StatisticType::HostRequest));
385		assert!(categories.specifies(StatisticType::SniRequest));
386		assert!(categories.specifies(StatisticType::HttpVersion));
387		assert!(!categories.specifies(StatisticType::UserAgent));
388		assert!(!categories.specifies(StatisticType::UserAgentPlatform));
389
390		assert_eq!(
391			serde_json::from_str::<StatisticCategories>(r#"["redirect", "basic", "protocol"]"#)
392				.unwrap(),
393			StatisticCategories::default()
394		);
395
396		assert_eq!(
397			serde_json::from_str::<StatisticCategories>(
398				&serde_json::to_string(&StatisticCategories::ALL).unwrap()
399			)
400			.unwrap(),
401			StatisticCategories::ALL
402		);
403	}
404
405	#[test]
406	#[expect(
407		clippy::unnecessary_fallible_conversions,
408		reason = "that's what this test is for"
409	)]
410	fn http_version() {
411		assert_eq!(
412			HttpVersion::from_str("HTTP/1.1").unwrap().as_str(),
413			"HTTP/1.1"
414		);
415
416		assert_eq!(
417			HttpVersion::try_from(HttpVersion::V2.as_str()).unwrap(),
418			HttpVersion::V2
419		);
420
421		assert_eq!(HttpVersion::from(HttpVersion::V2.as_str()), HttpVersion::V2);
422
423		assert!(Version::try_from(HttpVersion::Unknown).is_err());
424
425		assert_eq!(
426			Version::try_from(HttpVersion::V10).unwrap(),
427			Version::HTTP_10
428		);
429	}
430}