1use 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
18pub const DEFAULT_BASE_URL: &str = "https://aviationweather.gov/api/data/";
23
24#[derive(Debug, Clone)]
26pub struct AwcConfig {
27 pub timeout: std::time::Duration,
30 pub base_url: String,
33 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#[derive(Debug, thiserror::Error)]
50pub enum ConfigError {
51 #[error("invalid AWC base URL {url:?}")]
53 InvalidBaseUrl {
54 url: String,
56 #[source]
58 cause: Option<url::ParseError>,
59 },
60 #[error("could not build HTTP client")]
62 HttpClient(#[source] reqwest::Error),
63}
64
65#[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 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 #[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 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 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 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
259const ROUTE_SEGMENT_NM: f64 = 250.0;
262
263const 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 warn!("departure_at has no AWC equivalent; products reflect current conditions");
288 }
289 let (south_west, north_east) = self.enclosing_bbox(&request.area)?;
290 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 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 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 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 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}