cadmus_core/
geolocation.rs1use anyhow::Error;
2use chrono_tz::Tz;
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::time::Duration;
6
7use crate::http::Client as HttpClient;
8
9#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Coordinates(f64, f64);
15
16impl Coordinates {
17 pub fn new(lat: f64, lon: f64) -> Result<Self, anyhow::Error> {
22 anyhow::ensure!(
23 !lat.is_nan()
24 && !lon.is_nan()
25 && (-90.0..=90.0).contains(&lat)
26 && (-180.0..=180.0).contains(&lon),
27 "The given coordinates are invalid"
28 );
29
30 Ok(Self(lat, lon))
31 }
32
33 pub fn latitude(self) -> f64 {
35 self.0
36 }
37
38 pub fn longitude(self) -> f64 {
40 self.1
41 }
42}
43
44impl From<Coordinates> for sunrise::Coordinates {
45 fn from(value: Coordinates) -> Self {
46 Self::new(value.0, value.1).unwrap_or_else(|| {
47 panic!(
48 "Given coordinates are invalid, the Coordinates struct should not be holding invalid coordinates: {value}"
49 )
50 })
51 }
52}
53
54impl fmt::Display for Coordinates {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(f, "({},{})", self.latitude(), self.longitude())
57 }
58}
59
60#[derive(Copy, Clone)]
62pub struct GeoLocation {
63 pub coordinates: Coordinates,
65 pub timezone: Tz,
67}
68
69impl fmt::Display for GeoLocation {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 write!(f, "{} {}", self.coordinates, self.timezone)
72 }
73}
74
75impl From<GeoLocation> for sunrise::Coordinates {
76 fn from(value: GeoLocation) -> Self {
77 value.coordinates.into()
78 }
79}
80
81#[derive(Debug, Clone, Deserialize)]
82struct IpApiResponse {
83 latitude: f64,
84 longitude: f64,
85 timezone: String,
86}
87
88pub fn fetch_geolocation(client: &HttpClient) -> Result<GeoLocation, Error> {
94 let resp: IpApiResponse = client
95 .get("https://ipapi.co/json/")
96 .timeout(Duration::from_secs(10))
97 .send()?
98 .json()?;
99
100 let timezone = resp
101 .timezone
102 .parse::<Tz>()
103 .map_err(|e| anyhow::anyhow!("invalid timezone from ipapi: {e}"))?;
104
105 let coordinates = Coordinates::new(resp.latitude, resp.longitude)?;
106
107 Ok(GeoLocation {
108 coordinates,
109 timezone,
110 })
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn parse_ipapi_response() {
119 let json = serde_json::json!({
120 "latitude": 51.5074,
121 "longitude": -0.1278,
122 "timezone": "Europe/London"
123 });
124 let resp: IpApiResponse = serde_json::from_value(json).unwrap();
125 assert!((resp.latitude - 51.5074).abs() < 0.001);
126 assert!((resp.longitude - (-0.1278)).abs() < 0.001);
127 assert_eq!(resp.timezone, "Europe/London");
128 }
129
130 #[test]
131 fn coordinates_validate_bounds() {
132 assert!(Coordinates::new(51.5074, -0.1278).is_ok());
133 assert!(Coordinates::new(-90.0, -180.0).is_ok());
134 assert!(Coordinates::new(90.0, 180.0).is_ok());
135 assert!(Coordinates::new(f64::NAN, 0.0).is_err());
136 assert!(Coordinates::new(0.0, f64::NAN).is_err());
137 assert!(Coordinates::new(90.1, 0.0).is_err());
138 assert!(Coordinates::new(0.0, 180.1).is_err());
139 }
140}