Skip to main content

aerocontext_awc/
client.rs

1//! HTTP client and [`WeatherBriefingProvider`] implementation for the AWC
2//! Data API.
3
4use std::sync::Arc;
5
6use aerocontext_core::{
7    Area, AreaBriefingRequest, Briefing, ContextProvider, GeoPoint, NavDataSnapshot, Product,
8    ProductKind, ProviderError, RouteBriefingRequest, WeatherBriefingProvider,
9};
10use async_trait::async_trait;
11use chrono::Utc;
12use reqwest::StatusCode;
13use tracing::{debug, warn};
14use url::Url;
15
16use crate::decode;
17
18/// Production base URL of the Data API.
19///
20/// Note the path inversion: the documentation lives at `/data/api/`, the
21/// endpoints live under `/api/data/`.
22pub const DEFAULT_BASE_URL: &str = "https://aviationweather.gov/api/data/";
23
24/// Configuration for [`AwcClient`].
25#[derive(Debug, Clone)]
26pub struct AwcConfig {
27    /// Total per-request deadline. A hung upstream must never stall a
28    /// gather indefinitely.
29    pub timeout: std::time::Duration,
30    /// Base URL the per-product paths are joined onto. Point this at a
31    /// mock server in tests.
32    pub base_url: String,
33    /// `User-Agent` to send. AWC filters traffic with default agents, so
34    /// identify your application here.
35    pub user_agent: String,
36}
37
38impl Default for AwcConfig {
39    fn default() -> Self {
40        Self {
41            timeout: std::time::Duration::from_secs(30),
42            base_url: DEFAULT_BASE_URL.to_owned(),
43            user_agent: concat!("aerocontext-awc/", env!("CARGO_PKG_VERSION")).to_owned(),
44        }
45    }
46}
47
48/// Failure constructing an [`AwcClient`].
49#[derive(Debug, thiserror::Error)]
50pub enum ConfigError {
51    /// The configured base URL did not parse or cannot carry paths.
52    #[error("invalid AWC base URL {url:?}")]
53    InvalidBaseUrl {
54        /// The offending URL string.
55        url: String,
56        /// Parse failure, when the URL did not parse at all.
57        #[source]
58        cause: Option<url::ParseError>,
59    },
60    /// The HTTP client could not be constructed.
61    #[error("could not build HTTP client")]
62    HttpClient(#[source] reqwest::Error),
63}
64
65/// aviationweather.gov Data API client.
66#[derive(Debug, Clone)]
67pub struct AwcClient {
68    http: reqwest::Client,
69    base_url: Url,
70    nav_data: Option<Arc<NavDataSnapshot>>,
71}
72
73impl AwcClient {
74    /// Build a client from `config`.
75    pub fn new(config: AwcConfig) -> Result<Self, ConfigError> {
76        let base_url =
77            Url::parse(&config.base_url).map_err(|cause| ConfigError::InvalidBaseUrl {
78                url: config.base_url.clone(),
79                cause: Some(cause),
80            })?;
81        if base_url.cannot_be_a_base() {
82            return Err(ConfigError::InvalidBaseUrl {
83                url: config.base_url,
84                cause: None,
85            });
86        }
87        let http = reqwest::Client::builder()
88            .user_agent(config.user_agent)
89            .timeout(config.timeout)
90            .connect_timeout(std::time::Duration::from_secs(10))
91            .build()
92            .map_err(ConfigError::HttpClient)?;
93        Ok(Self {
94            http,
95            base_url,
96            nav_data: None,
97        })
98    }
99
100    /// Attach a cycle-tagged navigation-data snapshot for resolving
101    /// identifier-based areas before calling AWC.
102    #[must_use]
103    pub fn with_nav_data(mut self, nav_data: NavDataSnapshot) -> Self {
104        self.nav_data = Some(Arc::new(nav_data));
105        self
106    }
107
108    fn endpoint(&self, path: &str) -> Result<Url, ProviderError> {
109        let mut url = self.base_url.clone();
110        url.path_segments_mut()
111            .map_err(|()| ProviderError::Unsupported {
112                reason: format!("base URL {} cannot carry endpoint paths", self.base_url),
113            })?
114            .pop_if_empty()
115            .push(path);
116        Ok(url)
117    }
118
119    /// GET one product endpoint and return the body, or `None` when the
120    /// API answers `204 No Content` (a valid query that matched no data —
121    /// AWC signals "empty" with 204, not with an empty JSON array).
122    async fn fetch(
123        &self,
124        path: &str,
125        query: &[(&str, String)],
126    ) -> Result<Option<String>, ProviderError> {
127        let url = self.endpoint(path)?;
128        let context = || format!("fetching {path}");
129        debug!(%url, "requesting AWC product");
130        let response = self
131            .http
132            .get(url)
133            .query(query)
134            .send()
135            .await
136            .map_err(|cause| ProviderError::Transport {
137                context: context(),
138                cause: Box::new(cause),
139            })?;
140        let status = response.status();
141        if status == StatusCode::NO_CONTENT {
142            return Ok(None);
143        }
144        let body = response
145            .text()
146            .await
147            .map_err(|cause| ProviderError::Transport {
148                context: context(),
149                cause: Box::new(cause),
150            })?;
151        if !status.is_success() {
152            return Err(ProviderError::Provider {
153                context: context(),
154                message: body.chars().take(200).collect(),
155                status: Some(status.as_u16()),
156            });
157        }
158        Ok(Some(body))
159    }
160
161    async fn fetch_products(
162        &self,
163        kind: &ProductKind,
164        bbox: &str,
165        clip: (GeoPoint, GeoPoint),
166        lookback_hours: Option<u32>,
167    ) -> Result<Vec<Product>, ProviderError> {
168        let format = ("format", "json".to_owned());
169        let area = ("bbox", bbox.to_owned());
170        match kind {
171            ProductKind::Metar => {
172                let mut query = vec![area, format];
173                if let Some(hours) = lookback_hours {
174                    query.push(("hours", hours.to_string()));
175                }
176                self.decode(self.fetch("metar", &query).await?, "metar", decode::metars)
177            }
178            ProductKind::Taf => self.decode(
179                self.fetch("taf", &[area, format]).await?,
180                "taf",
181                decode::tafs,
182            ),
183            ProductKind::Pirep => {
184                // PIREP names its lookback parameter `age`, not `hours`.
185                let mut query = vec![area, format];
186                if let Some(hours) = lookback_hours {
187                    query.push(("age", hours.to_string()));
188                }
189                self.decode(self.fetch("pirep", &query).await?, "pirep", decode::pireps)
190            }
191            ProductKind::Sigmet => {
192                // `airsigmet` has no bbox parameter: it returns the whole
193                // CONUS set, which must be clipped client-side.
194                let body = self.fetch("airsigmet", &[format]).await?;
195                let all = self.decode(body, "airsigmet", decode::air_sigmets)?;
196                Ok(decode::clip_to_bbox(all, clip))
197            }
198            other => Err(ProviderError::Unsupported {
199                reason: format!("AWC adapter does not serve {other:?} products"),
200            }),
201        }
202    }
203
204    fn decode<T>(
205        &self,
206        body: Option<String>,
207        what: &str,
208        decoder: impl Fn(&str) -> Result<T, serde_json::Error>,
209    ) -> Result<T, ProviderError>
210    where
211        T: Default,
212    {
213        match body {
214            None => Ok(T::default()),
215            Some(body) => decoder(&body).map_err(|cause| ProviderError::Decode {
216                context: format!("decoding {what} JSON"),
217                cause: Box::new(cause),
218            }),
219        }
220    }
221
222    fn enclosing_bbox(&self, area: &Area) -> Result<(GeoPoint, GeoPoint), ProviderError> {
223        if let Some(bbox) = area.enclosing_bbox() {
224            return Ok(bbox);
225        }
226        let Area::LocationRadius { ident, radius_nm } = area else {
227            return Err(ProviderError::Unsupported {
228                reason: format!("AWC needs a coordinate area; {area:?} has no equivalent"),
229            });
230        };
231        let nav_data = self
232            .nav_data
233            .as_ref()
234            .ok_or_else(|| ProviderError::Unsupported {
235                reason: "AWC needs coordinates for identifier areas; attach a \
236                         cycle-tagged navigation-data snapshot"
237                    .to_owned(),
238            })?;
239        let point = nav_data
240            .resolve(ident)
241            .ok_or_else(|| ProviderError::Unsupported {
242                reason: format!(
243                    "AWC needs coordinates for {ident}; navigation-data snapshot effective {} \
244                     does not contain it",
245                    nav_data.cycle.effective_on
246                ),
247            })?;
248        Area::PointRadius {
249            center: point.position,
250            radius_nm: *radius_nm,
251        }
252        .enclosing_bbox()
253        .ok_or_else(|| ProviderError::Unsupported {
254            reason: format!("AWC could not convert {ident} radius {radius_nm} NM to a bbox"),
255        })
256    }
257}
258
259/// Corridor segment length per AWC bbox request: large enough to keep the
260/// request count low, small enough that each box stays a sane query.
261const ROUTE_SEGMENT_NM: f64 = 250.0;
262
263/// Products fetched when [`AreaBriefingRequest::products`] is empty.
264const DEFAULT_PRODUCTS: [ProductKind; 4] = [
265    ProductKind::Metar,
266    ProductKind::Taf,
267    ProductKind::Pirep,
268    ProductKind::Sigmet,
269];
270
271impl ContextProvider for AwcClient {
272    fn name(&self) -> &str {
273        "awc"
274    }
275}
276
277#[async_trait(?Send)]
278impl WeatherBriefingProvider for AwcClient {
279    async fn area_briefing(
280        &self,
281        request: &AreaBriefingRequest,
282    ) -> Result<Briefing, ProviderError> {
283        if request.departure_at.is_some() {
284            // AWC serves current observations and active forecasts; the
285            // ETD anchors navdata selection upstream but cannot shift
286            // these products in time.
287            warn!("departure_at has no AWC equivalent; products reflect current conditions");
288        }
289        let (south_west, north_east) = self.enclosing_bbox(&request.area)?;
290        // AWC's bbox parameter is latitude-first: lat0,lon0,lat1,lon1.
291        let bbox = format!(
292            "{},{},{},{}",
293            south_west.lat, south_west.lon, north_east.lat, north_east.lon
294        );
295        let kinds: &[ProductKind] = if request.products.is_empty() {
296            &DEFAULT_PRODUCTS
297        } else {
298            &request.products
299        };
300        let mut products = Vec::new();
301        // Sequential on purpose: AWC asks for at most one in-flight
302        // request per thread.
303        for kind in kinds {
304            let batch = self
305                .fetch_products(
306                    kind,
307                    &bbox,
308                    (south_west, north_east),
309                    request.lookback_hours,
310                )
311                .await?;
312            debug!(kind = ?kind, count = batch.len(), "decoded AWC products");
313            products.extend(batch);
314        }
315        Ok(Briefing::new(self.name())
316            .with_generated_at(Some(Utc::now()))
317            .with_products(products))
318    }
319
320    async fn route_briefing(
321        &self,
322        request: &RouteBriefingRequest,
323    ) -> Result<Briefing, ProviderError> {
324        // A corridor needs a leg; refuse a degenerate route loudly rather
325        // than return an empty brief — parity with the Leidos provider.
326        if request.waypoints.len() < 2 {
327            return Err(ProviderError::Unsupported {
328                reason: "a route briefing needs at least two waypoints".to_owned(),
329            });
330        }
331        // AWC has no route product; the proper composition is one bbox
332        // request per corridor segment, merged. Boxes are capped in span
333        // so a long route does not become one continent-sized query.
334        let kinds: &[ProductKind] = if request.products.is_empty() {
335            &DEFAULT_PRODUCTS
336        } else {
337            &request.products
338        };
339        let mut products: Vec<Product> = Vec::new();
340        for area in request.segment_bboxes(ROUTE_SEGMENT_NM) {
341            let (south_west, north_east) = self.enclosing_bbox(&area)?;
342            let bbox = format!(
343                "{},{},{},{}",
344                south_west.lat, south_west.lon, north_east.lat, north_east.lon
345            );
346            for kind in kinds {
347                let batch = self
348                    .fetch_products(kind, &bbox, (south_west, north_east), None)
349                    .await?;
350                for product in batch {
351                    // Adjacent boxes overlap; the same station must not
352                    // appear twice.
353                    if !products.iter().any(|existing| {
354                        existing.kind == product.kind && existing.raw_text == product.raw_text
355                    }) {
356                        products.push(product);
357                    }
358                }
359            }
360        }
361        Ok(Briefing::new(self.name())
362            .with_generated_at(Some(Utc::now()))
363            .with_products(products))
364    }
365}