Skip to main content

ferric_fred/
client.rs

1use std::time::Duration;
2
3use chrono::NaiveDate;
4use serde::de::DeserializeOwned;
5use serde::Deserialize;
6
7use crate::{
8    Category, CategoryId, Error, Frequency, Observation, ObservationsRequest, RegionType,
9    RegionalData, Release, ReleaseDatesRequest, ReleaseDatesResults, ReleaseId, ReleaseTable,
10    ReleaseTablesRequest, ReleasesRequest, ReleasesResults, Result, SeasonalAdjustment, Series,
11    SeriesDataRequest, SeriesGroup, SeriesGroupId, SeriesId, SeriesListRequest,
12    SeriesSearchRequest, SeriesSearchResults, SeriesUpdatesRequest, ShapeFile, ShapeType, Source,
13    SourceId, SourcesRequest, SourcesResults, TagsRequest, TagsResults, VintageDates,
14    VintageDatesRequest,
15};
16
17/// Base URL for the FRED REST API.
18const FRED_BASE_URL: &str = "https://api.stlouisfed.org/fred";
19
20/// Base URL for the GeoFRED / Maps API — a separate surface on the same host,
21/// under `/geofred` instead of `/fred` (ADR-0025).
22const GEOFRED_BASE_URL: &str = "https://api.stlouisfed.org/geofred";
23
24/// An async client for the FRED API.
25///
26/// Cheap to clone — the underlying `reqwest::Client` holds a connection pool
27/// behind an `Arc`, so clones share it.
28#[derive(Debug, Clone)]
29pub struct Client {
30    http: reqwest::Client,
31    api_key: String,
32    base_url: String,
33    geofred_base_url: String,
34}
35
36impl Client {
37    /// Build a client with the given FRED API key.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if the underlying HTTP client cannot be built.
42    pub fn new(api_key: impl Into<String>) -> Result<Self> {
43        let http = reqwest::Client::builder().build()?;
44        Ok(Self {
45            http,
46            api_key: api_key.into(),
47            base_url: FRED_BASE_URL.to_owned(),
48            geofred_base_url: GEOFRED_BASE_URL.to_owned(),
49        })
50    }
51
52    /// Build a client pointed at a custom base URL. A test seam for aiming the
53    /// client at a local mock HTTP server (ADR-0011); deliberately not public.
54    #[cfg(test)]
55    pub(crate) fn with_base_url(
56        api_key: impl Into<String>,
57        base_url: impl Into<String>,
58    ) -> Result<Self> {
59        // Point both the core and GeoFRED bases at the same mock so path-only
60        // matching works for either surface (ADR-0025).
61        let base_url = base_url.into();
62        Ok(Self {
63            http: reqwest::Client::builder().build()?,
64            api_key: api_key.into(),
65            geofred_base_url: base_url.clone(),
66            base_url,
67        })
68    }
69
70    /// Build a client, reading the API key from the `FRED_API_KEY` environment
71    /// variable.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`Error::InvalidInput`] if `FRED_API_KEY` is unset, or an error if
76    /// the underlying HTTP client cannot be built.
77    pub fn from_env() -> Result<Self> {
78        let api_key = std::env::var("FRED_API_KEY").map_err(|_| {
79            Error::InvalidInput("FRED_API_KEY environment variable is not set".to_owned())
80        })?;
81        Self::new(api_key)
82    }
83
84    /// Begin an observations request for a series.
85    ///
86    /// Returns a builder; set optional parameters (date range, units transform,
87    /// frequency aggregation, sort order, paging) and call
88    /// [`ObservationsRequest::send`] to run it. With nothing set, FRED's
89    /// defaults apply (full history, levels, ascending by date).
90    ///
91    /// ```no_run
92    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
93    /// use ferric_fred::{SeriesId, Units};
94    /// let obs = client
95    ///     .observations(&SeriesId::new("GNPCA"))
96    ///     .units(Units::PercentChange)
97    ///     .limit(10)
98    ///     .send()
99    ///     .await?;
100    /// # Ok(())
101    /// # }
102    /// ```
103    pub fn observations(&self, series_id: &SeriesId) -> ObservationsRequest<'_> {
104        ObservationsRequest::new(self, series_id.clone())
105    }
106
107    /// Run an observations request (invoked by [`ObservationsRequest::send`]).
108    pub(crate) async fn execute_observations(
109        &self,
110        request: &ObservationsRequest<'_>,
111    ) -> Result<Vec<Observation>> {
112        let response: ObservationsResponse = self
113            .get("/series/observations", &request.query_params())
114            .await?;
115        Ok(response.observations)
116    }
117
118    /// Fetch metadata for a series (the `fred/series` endpoint).
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the request fails to send, FRED returns a non-success
123    /// status, or the response body cannot be deserialized.
124    pub async fn series(&self, series_id: &SeriesId) -> Result<Series> {
125        let response: SeriesResponse = self
126            .get("/series", &[("series_id", series_id.as_str().to_owned())])
127            .await?;
128        response
129            .seriess
130            .into_iter()
131            .next()
132            .ok_or_else(|| Error::Api {
133                status: 200,
134                code: None,
135                message: format!("FRED returned no series for id `{series_id}`"),
136            })
137    }
138
139    /// Begin a search over series (the `fred/series/search` endpoint).
140    ///
141    /// Returns a builder; set optional parameters (search type, ordering, sort,
142    /// paging) and call [`SeriesSearchRequest::send`] to run it.
143    ///
144    /// ```no_run
145    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
146    /// use ferric_fred::OrderBy;
147    /// let results = client
148    ///     .search("industrial production")
149    ///     .order_by(OrderBy::Popularity)
150    ///     .limit(5)
151    ///     .send()
152    ///     .await?;
153    /// println!("{} matches", results.count);
154    /// # Ok(())
155    /// # }
156    /// ```
157    pub fn search(&self, search_text: impl Into<String>) -> SeriesSearchRequest<'_> {
158        SeriesSearchRequest::new(self, search_text.into())
159    }
160
161    /// Run a search request (invoked by [`SeriesSearchRequest::send`]).
162    pub(crate) async fn execute_search(
163        &self,
164        request: &SeriesSearchRequest<'_>,
165    ) -> Result<SeriesSearchResults> {
166        self.get("/series/search", &request.query_params()).await
167    }
168
169    /// Begin a request for the tags on the series matching a search (the
170    /// `fred/series/search/tags` endpoint) — the tag facets of a full-text
171    /// search, for narrowing it down.
172    ///
173    /// Returns a [`TagsRequest`] builder; set optional tag-filter text (sent as
174    /// FRED's `tag_search_text`), sort, and paging, then call
175    /// [`send`](TagsRequest::send) to run it.
176    ///
177    /// ```no_run
178    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
179    /// let results = client.series_search_tags("unemployment").limit(10).send().await?;
180    /// println!("{} tags", results.count);
181    /// # Ok(())
182    /// # }
183    /// ```
184    pub fn series_search_tags(&self, search_text: impl Into<String>) -> TagsRequest<'_> {
185        TagsRequest::scoped(
186            self,
187            "/series/search/tags",
188            ("series_search_text", search_text.into()),
189            None,
190            "tag_search_text",
191        )
192    }
193
194    /// Begin a request for the tags that co-occur, among the series matching a
195    /// search, with a seed set of tags (the `fred/series/search/related_tags`
196    /// endpoint).
197    ///
198    /// Accepts any iterable of seed tag names (joined with `;` for FRED).
199    /// Returns a [`TagsRequest`] builder; set optional tag-filter text (sent as
200    /// `tag_search_text`), sort, and paging, then call
201    /// [`send`](TagsRequest::send) to run it.
202    ///
203    /// ```no_run
204    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
205    /// let results = client
206    ///     .series_search_related_tags("unemployment", ["monthly"])
207    ///     .send()
208    ///     .await?;
209    /// println!("{} related tags", results.count);
210    /// # Ok(())
211    /// # }
212    /// ```
213    pub fn series_search_related_tags<I, S>(
214        &self,
215        search_text: impl Into<String>,
216        tag_names: I,
217    ) -> TagsRequest<'_>
218    where
219        I: IntoIterator<Item = S>,
220        S: AsRef<str>,
221    {
222        TagsRequest::scoped(
223            self,
224            "/series/search/related_tags",
225            ("series_search_text", search_text.into()),
226            Some(join_tag_names(tag_names)),
227            "tag_search_text",
228        )
229    }
230
231    /// Fetch a single category by id (the `fred/category` endpoint). Use
232    /// [`CategoryId::ROOT`] for the top of the tree.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the request fails to send, FRED returns a non-success
237    /// status, or the response body cannot be deserialized.
238    pub async fn category(&self, category_id: CategoryId) -> Result<Category> {
239        let response: CategoriesResponse = self
240            .get(
241                "/category",
242                &[("category_id", category_id.get().to_string())],
243            )
244            .await?;
245        response
246            .categories
247            .into_iter()
248            .next()
249            .ok_or_else(|| Error::Api {
250                status: 200,
251                code: None,
252                message: format!("FRED returned no category for id `{category_id}`"),
253            })
254    }
255
256    /// Fetch the child categories of a category (the `fred/category/children`
257    /// endpoint) — the primary way to walk the category tree downward.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the request fails to send, FRED returns a non-success
262    /// status, or the response body cannot be deserialized.
263    pub async fn category_children(&self, category_id: CategoryId) -> Result<Vec<Category>> {
264        let response: CategoriesResponse = self
265            .get(
266                "/category/children",
267                &[("category_id", category_id.get().to_string())],
268            )
269            .await?;
270        Ok(response.categories)
271    }
272
273    /// Fetch the categories related to a category (the `fred/category/related`
274    /// endpoint) — cross-links to sibling topics elsewhere in the tree, distinct
275    /// from the parent/child hierarchy. FRED returns the full list unpaginated,
276    /// so this yields a plain `Vec<Category>` (often empty).
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the request fails to send, FRED returns a non-success
281    /// status, or the response body cannot be deserialized.
282    pub async fn category_related(&self, category_id: CategoryId) -> Result<Vec<Category>> {
283        let response: CategoriesResponse = self
284            .get(
285                "/category/related",
286                &[("category_id", category_id.get().to_string())],
287            )
288            .await?;
289        Ok(response.categories)
290    }
291
292    /// Begin a request for the series in a category (the `fred/category/series`
293    /// endpoint).
294    ///
295    /// Returns a [`SeriesListRequest`] builder; set optional ordering/paging and
296    /// call [`send`](SeriesListRequest::send) to run it.
297    ///
298    /// ```no_run
299    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
300    /// use ferric_fred::CategoryId;
301    /// let results = client
302    ///     .category_series(CategoryId::new(125))
303    ///     .limit(5)
304    ///     .send()
305    ///     .await?;
306    /// println!("{} series", results.count);
307    /// # Ok(())
308    /// # }
309    /// ```
310    pub fn category_series(&self, category_id: CategoryId) -> SeriesListRequest<'_> {
311        SeriesListRequest::new(
312            self,
313            "/category/series",
314            "category_id",
315            category_id.get().to_string(),
316        )
317    }
318
319    /// Begin a request for the tags used by the series in a category (the
320    /// `fred/category/tags` endpoint) — the tag facets available when browsing
321    /// a category.
322    ///
323    /// Returns a [`TagsRequest`] builder; set optional tag-filter text/sort/
324    /// paging and call [`send`](TagsRequest::send) to run it.
325    ///
326    /// ```no_run
327    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
328    /// use ferric_fred::CategoryId;
329    /// let results = client.category_tags(CategoryId::new(125)).limit(10).send().await?;
330    /// println!("{} tags", results.count);
331    /// # Ok(())
332    /// # }
333    /// ```
334    pub fn category_tags(&self, category_id: CategoryId) -> TagsRequest<'_> {
335        TagsRequest::scoped(
336            self,
337            "/category/tags",
338            ("category_id", category_id.get().to_string()),
339            None,
340            "search_text",
341        )
342    }
343
344    /// Begin a request for the tags that co-occur, within a category, with a
345    /// seed set of tags (the `fred/category/related_tags` endpoint) — refine a
346    /// category browse by discovering adjacent tags.
347    ///
348    /// Accepts any iterable of seed tag names (joined with `;` for FRED).
349    /// Returns a [`TagsRequest`] builder; set optional tag-filter text/sort/
350    /// paging and call [`send`](TagsRequest::send) to run it.
351    ///
352    /// ```no_run
353    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
354    /// use ferric_fred::CategoryId;
355    /// let results = client.category_related_tags(CategoryId::new(125), ["gdp"]).send().await?;
356    /// println!("{} related tags", results.count);
357    /// # Ok(())
358    /// # }
359    /// ```
360    pub fn category_related_tags<I, S>(
361        &self,
362        category_id: CategoryId,
363        tag_names: I,
364    ) -> TagsRequest<'_>
365    where
366        I: IntoIterator<Item = S>,
367        S: AsRef<str>,
368    {
369        TagsRequest::scoped(
370            self,
371            "/category/related_tags",
372            ("category_id", category_id.get().to_string()),
373            Some(join_tag_names(tag_names)),
374            "search_text",
375        )
376    }
377
378    /// Begin a request listing all FRED data releases (the `fred/releases`
379    /// endpoint) — a browse axis parallel to categories.
380    ///
381    /// Returns a builder; set optional sort/paging and call
382    /// [`ReleasesRequest::send`] to run it.
383    ///
384    /// ```no_run
385    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
386    /// let results = client.releases().limit(20).send().await?;
387    /// println!("{} releases", results.count);
388    /// # Ok(())
389    /// # }
390    /// ```
391    pub fn releases(&self) -> ReleasesRequest<'_> {
392        ReleasesRequest::new(self, "/releases")
393    }
394
395    /// Run a releases request — `releases` or `source/releases` (invoked by
396    /// [`ReleasesRequest::send`]).
397    pub(crate) async fn execute_releases(
398        &self,
399        request: &ReleasesRequest<'_>,
400    ) -> Result<ReleasesResults> {
401        self.get(request.path(), &request.query_params()).await
402    }
403
404    /// Begin a request for the publication dates of *all* releases (the
405    /// `fred/releases/dates` endpoint) — a release calendar across FRED,
406    /// newest first by default.
407    ///
408    /// Returns a builder; set optional sort/paging (and
409    /// [`include_dates_with_no_data`](ReleaseDatesRequest::include_dates_with_no_data))
410    /// and call [`ReleaseDatesRequest::send`] to run it.
411    ///
412    /// ```no_run
413    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
414    /// let calendar = client.releases_dates().limit(20).send().await?;
415    /// println!("{} release dates", calendar.count);
416    /// # Ok(())
417    /// # }
418    /// ```
419    pub fn releases_dates(&self) -> ReleaseDatesRequest<'_> {
420        ReleaseDatesRequest::new(self, "/releases/dates")
421    }
422
423    /// Run a release-dates request — `releases/dates` or `release/dates`
424    /// (invoked by [`ReleaseDatesRequest::send`]).
425    pub(crate) async fn execute_release_dates(
426        &self,
427        request: &ReleaseDatesRequest<'_>,
428    ) -> Result<ReleaseDatesResults> {
429        self.get(request.path(), &request.query_params()).await
430    }
431
432    /// Fetch a single release by id (the `fred/release` endpoint).
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if the request fails to send, FRED returns a non-success
437    /// status, or the response body cannot be deserialized.
438    pub async fn release(&self, release_id: ReleaseId) -> Result<Release> {
439        let response: ReleaseResponse = self
440            .get("/release", &[("release_id", release_id.get().to_string())])
441            .await?;
442        response
443            .releases
444            .into_iter()
445            .next()
446            .ok_or_else(|| Error::Api {
447                status: 200,
448                code: None,
449                message: format!("FRED returned no release for id `{release_id}`"),
450            })
451    }
452
453    /// Begin a request for the series in a release (the `fred/release/series`
454    /// endpoint).
455    ///
456    /// Returns a [`SeriesListRequest`] builder; set optional ordering/paging and
457    /// call [`send`](SeriesListRequest::send) to run it.
458    ///
459    /// ```no_run
460    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
461    /// use ferric_fred::ReleaseId;
462    /// let results = client
463    ///     .release_series(ReleaseId::new(53))
464    ///     .limit(5)
465    ///     .send()
466    ///     .await?;
467    /// println!("{} series", results.count);
468    /// # Ok(())
469    /// # }
470    /// ```
471    pub fn release_series(&self, release_id: ReleaseId) -> SeriesListRequest<'_> {
472        SeriesListRequest::new(
473            self,
474            "/release/series",
475            "release_id",
476            release_id.get().to_string(),
477        )
478    }
479
480    /// Fetch the sources for a release (the `fred/release/sources` endpoint) —
481    /// the reverse of [`source_releases`](Client::source_releases). FRED returns
482    /// the full list unpaginated, so this yields a plain `Vec<Source>`.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if the request fails to send, FRED returns a non-success
487    /// status, or the response body cannot be deserialized.
488    pub async fn release_sources(&self, release_id: ReleaseId) -> Result<Vec<Source>> {
489        let response: SourceResponse = self
490            .get(
491                "/release/sources",
492                &[("release_id", release_id.get().to_string())],
493            )
494            .await?;
495        Ok(response.sources)
496    }
497
498    /// Begin a request for the publication dates of *one* release (the
499    /// `fred/release/dates` endpoint) — that release's calendar, oldest first
500    /// by default.
501    ///
502    /// Returns a [`ReleaseDatesRequest`] builder; set optional sort/paging (and
503    /// [`include_dates_with_no_data`](ReleaseDatesRequest::include_dates_with_no_data))
504    /// and call [`send`](ReleaseDatesRequest::send) to run it.
505    ///
506    /// ```no_run
507    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
508    /// use ferric_fred::ReleaseId;
509    /// let dates = client.release_dates(ReleaseId::new(82)).limit(10).send().await?;
510    /// println!("{} release dates", dates.count);
511    /// # Ok(())
512    /// # }
513    /// ```
514    pub fn release_dates(&self, release_id: ReleaseId) -> ReleaseDatesRequest<'_> {
515        ReleaseDatesRequest::with_release(self, "/release/dates", release_id.get().to_string())
516    }
517
518    /// Begin a request for a release's table tree (the `fred/release/tables`
519    /// endpoint) — the nested layout (sections, tables, and series rows) a
520    /// release uses to present its series.
521    ///
522    /// Returns a builder; optionally scope to a subtree with
523    /// [`element`](ReleaseTablesRequest::element), then call
524    /// [`ReleaseTablesRequest::send`] to run it.
525    ///
526    /// ```no_run
527    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
528    /// use ferric_fred::ReleaseId;
529    /// let table = client.release_tables(ReleaseId::new(10)).send().await?;
530    /// println!("{} root elements", table.roots.len());
531    /// # Ok(())
532    /// # }
533    /// ```
534    pub fn release_tables(&self, release_id: ReleaseId) -> ReleaseTablesRequest<'_> {
535        ReleaseTablesRequest::new(self, release_id.get())
536    }
537
538    /// Run a release/tables request (invoked by [`ReleaseTablesRequest::send`]).
539    pub(crate) async fn execute_release_tables(
540        &self,
541        request: &ReleaseTablesRequest<'_>,
542    ) -> Result<ReleaseTable> {
543        self.get("/release/tables", &request.query_params()).await
544    }
545
546    /// Begin a request for the tags used by the series in a release (the
547    /// `fred/release/tags` endpoint) — the tag facets available when browsing a
548    /// release.
549    ///
550    /// Returns a [`TagsRequest`] builder; set optional tag-filter text/sort/
551    /// paging and call [`send`](TagsRequest::send) to run it.
552    ///
553    /// ```no_run
554    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
555    /// use ferric_fred::ReleaseId;
556    /// let results = client.release_tags(ReleaseId::new(53)).limit(10).send().await?;
557    /// println!("{} tags", results.count);
558    /// # Ok(())
559    /// # }
560    /// ```
561    pub fn release_tags(&self, release_id: ReleaseId) -> TagsRequest<'_> {
562        TagsRequest::scoped(
563            self,
564            "/release/tags",
565            ("release_id", release_id.get().to_string()),
566            None,
567            "search_text",
568        )
569    }
570
571    /// Begin a request for the tags that co-occur, within a release, with a
572    /// seed set of tags (the `fred/release/related_tags` endpoint).
573    ///
574    /// Accepts any iterable of seed tag names (joined with `;` for FRED).
575    /// Returns a [`TagsRequest`] builder; set optional tag-filter text/sort/
576    /// paging and call [`send`](TagsRequest::send) to run it.
577    ///
578    /// ```no_run
579    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
580    /// use ferric_fred::ReleaseId;
581    /// let results = client.release_related_tags(ReleaseId::new(53), ["gdp"]).send().await?;
582    /// println!("{} related tags", results.count);
583    /// # Ok(())
584    /// # }
585    /// ```
586    pub fn release_related_tags<I, S>(&self, release_id: ReleaseId, tag_names: I) -> TagsRequest<'_>
587    where
588        I: IntoIterator<Item = S>,
589        S: AsRef<str>,
590    {
591        TagsRequest::scoped(
592            self,
593            "/release/related_tags",
594            ("release_id", release_id.get().to_string()),
595            Some(join_tag_names(tag_names)),
596            "search_text",
597        )
598    }
599
600    /// Begin a request to browse or search FRED's tag vocabulary (the
601    /// `fred/tags` endpoint).
602    ///
603    /// Returns a builder; set optional search text/sort/paging and call
604    /// [`TagsRequest::send`] to run it.
605    ///
606    /// ```no_run
607    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
608    /// let results = client.tags().search_text("gdp").limit(10).send().await?;
609    /// println!("{} tags", results.count);
610    /// # Ok(())
611    /// # }
612    /// ```
613    pub fn tags(&self) -> TagsRequest<'_> {
614        TagsRequest::new(self, "/tags")
615    }
616
617    /// Begin a request for the tags that co-occur with a seed set of tags (the
618    /// `fred/related_tags` endpoint) — refine a faceted search by discovering
619    /// adjacent tags.
620    ///
621    /// Accepts any iterable of tag names (they are joined with `;` for FRED).
622    /// Returns a [`TagsRequest`] builder; set optional search text/sort/paging
623    /// and call [`send`](TagsRequest::send) to run it.
624    ///
625    /// ```no_run
626    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
627    /// let results = client.related_tags(["gdp"]).limit(10).send().await?;
628    /// println!("{} tags related to gdp", results.count);
629    /// # Ok(())
630    /// # }
631    /// ```
632    pub fn related_tags<I, S>(&self, tag_names: I) -> TagsRequest<'_>
633    where
634        I: IntoIterator<Item = S>,
635        S: AsRef<str>,
636    {
637        TagsRequest::with_tag_names(self, "/related_tags", join_tag_names(tag_names))
638    }
639
640    /// Run a tags request — `tags` or `related_tags` (invoked by
641    /// [`TagsRequest::send`]).
642    pub(crate) async fn execute_tags(&self, request: &TagsRequest<'_>) -> Result<TagsResults> {
643        self.get(request.path(), &request.query_params()).await
644    }
645
646    /// Begin a request for the series carrying *all* of the given tags (the
647    /// `fred/tags/series` endpoint) — faceted discovery.
648    ///
649    /// Accepts any iterable of tag names (they are joined with `;` for FRED).
650    /// Returns a [`SeriesListRequest`] builder; set optional ordering/paging and
651    /// call [`send`](SeriesListRequest::send) to run it.
652    ///
653    /// ```no_run
654    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
655    /// let results = client.tags_series(["gdp", "quarterly"]).limit(5).send().await?;
656    /// println!("{} series", results.count);
657    /// # Ok(())
658    /// # }
659    /// ```
660    pub fn tags_series<I, S>(&self, tag_names: I) -> SeriesListRequest<'_>
661    where
662        I: IntoIterator<Item = S>,
663        S: AsRef<str>,
664    {
665        SeriesListRequest::new(self, "/tags/series", "tag_names", join_tag_names(tag_names))
666    }
667
668    /// Run a series-list request — `category/series`, `release/series`, or
669    /// `tags/series` (invoked by [`SeriesListRequest::send`]).
670    pub(crate) async fn execute_series_list(
671        &self,
672        request: &SeriesListRequest<'_>,
673    ) -> Result<SeriesSearchResults> {
674        self.get(request.path(), &request.query_params()).await
675    }
676
677    /// Fetch the tags attached to a series (the `fred/series/tags` endpoint) —
678    /// the reverse of [`tags_series`](Client::tags_series).
679    ///
680    /// # Errors
681    ///
682    /// Returns an error if the request fails to send, FRED returns a non-success
683    /// status, or the response body cannot be deserialized.
684    pub async fn series_tags(&self, series_id: &SeriesId) -> Result<TagsResults> {
685        self.get(
686            "/series/tags",
687            &[("series_id", series_id.as_str().to_owned())],
688        )
689        .await
690    }
691
692    /// Begin a request for the most recently updated series (the
693    /// `fred/series/updates` endpoint) — a "what changed" feed, ordered by
694    /// last-updated time.
695    ///
696    /// Returns a builder; set an optional class filter/paging and call
697    /// [`SeriesUpdatesRequest::send`] to run it.
698    ///
699    /// ```no_run
700    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
701    /// let results = client.series_updates().limit(20).send().await?;
702    /// println!("{} recently updated", results.count);
703    /// # Ok(())
704    /// # }
705    /// ```
706    pub fn series_updates(&self) -> SeriesUpdatesRequest<'_> {
707        SeriesUpdatesRequest::new(self)
708    }
709
710    /// Run a series/updates request (invoked by [`SeriesUpdatesRequest::send`]).
711    pub(crate) async fn execute_series_updates(
712        &self,
713        request: &SeriesUpdatesRequest<'_>,
714    ) -> Result<SeriesSearchResults> {
715        self.get("/series/updates", &request.query_params()).await
716    }
717
718    /// Begin a request for a series' vintage dates (the
719    /// `fred/series/vintagedates` endpoint) — the dates on which the series was
720    /// revised or newly released.
721    ///
722    /// Returns a builder; set optional sort/paging and call
723    /// [`VintageDatesRequest::send`] to run it.
724    ///
725    /// ```no_run
726    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
727    /// use ferric_fred::SeriesId;
728    /// let dates = client
729    ///     .series_vintagedates(&SeriesId::new("GNPCA"))
730    ///     .limit(10)
731    ///     .send()
732    ///     .await?;
733    /// println!("{} vintage dates", dates.count);
734    /// # Ok(())
735    /// # }
736    /// ```
737    pub fn series_vintagedates(&self, series_id: &SeriesId) -> VintageDatesRequest<'_> {
738        VintageDatesRequest::new(self, series_id.clone())
739    }
740
741    /// Run a series/vintagedates request (invoked by
742    /// [`VintageDatesRequest::send`]).
743    pub(crate) async fn execute_vintage_dates(
744        &self,
745        request: &VintageDatesRequest<'_>,
746    ) -> Result<VintageDates> {
747        self.get("/series/vintagedates", &request.query_params())
748            .await
749    }
750
751    /// Fetch the categories a series belongs to (the `fred/series/categories`
752    /// endpoint) — the reverse of [`category_series`](Client::category_series).
753    ///
754    /// # Errors
755    ///
756    /// Returns an error if the request fails to send, FRED returns a non-success
757    /// status, or the response body cannot be deserialized.
758    pub async fn series_categories(&self, series_id: &SeriesId) -> Result<Vec<Category>> {
759        let response: CategoriesResponse = self
760            .get(
761                "/series/categories",
762                &[("series_id", series_id.as_str().to_owned())],
763            )
764            .await?;
765        Ok(response.categories)
766    }
767
768    /// Fetch the release a series belongs to (the `fred/series/release`
769    /// endpoint) — the reverse of [`release_series`](Client::release_series).
770    ///
771    /// # Errors
772    ///
773    /// Returns an error if the request fails to send, FRED returns a non-success
774    /// status, or the response body cannot be deserialized.
775    pub async fn series_release(&self, series_id: &SeriesId) -> Result<Release> {
776        let response: ReleaseResponse = self
777            .get(
778                "/series/release",
779                &[("series_id", series_id.as_str().to_owned())],
780            )
781            .await?;
782        response
783            .releases
784            .into_iter()
785            .next()
786            .ok_or_else(|| Error::Api {
787                status: 200,
788                code: None,
789                message: format!("FRED returned no release for series `{series_id}`"),
790            })
791    }
792
793    /// Begin a request listing all FRED data sources (the `fred/sources`
794    /// endpoint) — the organizations that produce releases.
795    ///
796    /// Returns a builder; set optional sort/paging and call
797    /// [`SourcesRequest::send`] to run it.
798    ///
799    /// ```no_run
800    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
801    /// let results = client.sources().limit(20).send().await?;
802    /// println!("{} sources", results.count);
803    /// # Ok(())
804    /// # }
805    /// ```
806    pub fn sources(&self) -> SourcesRequest<'_> {
807        SourcesRequest::new(self)
808    }
809
810    /// Run a sources request (invoked by [`SourcesRequest::send`]).
811    pub(crate) async fn execute_sources(
812        &self,
813        request: &SourcesRequest<'_>,
814    ) -> Result<SourcesResults> {
815        self.get("/sources", &request.query_params()).await
816    }
817
818    /// Fetch a single source by id (the `fred/source` endpoint).
819    ///
820    /// # Errors
821    ///
822    /// Returns an error if the request fails to send, FRED returns a non-success
823    /// status, or the response body cannot be deserialized.
824    pub async fn source(&self, source_id: SourceId) -> Result<Source> {
825        let response: SourceResponse = self
826            .get("/source", &[("source_id", source_id.get().to_string())])
827            .await?;
828        response
829            .sources
830            .into_iter()
831            .next()
832            .ok_or_else(|| Error::Api {
833                status: 200,
834                code: None,
835                message: format!("FRED returned no source for id `{source_id}`"),
836            })
837    }
838
839    /// Begin a request for the releases produced by a source (the
840    /// `fred/source/releases` endpoint).
841    ///
842    /// Returns a [`ReleasesRequest`] builder; set optional sort/paging and call
843    /// [`send`](ReleasesRequest::send) to run it.
844    ///
845    /// ```no_run
846    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
847    /// use ferric_fred::SourceId;
848    /// let results = client.source_releases(SourceId::new(18)).limit(5).send().await?;
849    /// println!("{} releases", results.count);
850    /// # Ok(())
851    /// # }
852    /// ```
853    pub fn source_releases(&self, source_id: SourceId) -> ReleasesRequest<'_> {
854        ReleasesRequest::with_source(self, "/source/releases", source_id.get().to_string())
855    }
856
857    // --- GeoFRED / Maps API (ADR-0025) ---------------------------------------
858
859    /// Fetch a region cross-section for a series group (the GeoFRED
860    /// `geofred/regional/data` endpoint) — the value in every region of
861    /// `region_type` on `date`, for the given `units` label,
862    /// `frequency`, and `season`.
863    ///
864    /// FRED requires **all** of these parameters (a live probe rejects any
865    /// omission — ADR-0025), so this is a direct call rather than a builder.
866    /// `units` is a free-form measurement label FRED echoes into the result
867    /// title (e.g. `"Dollars"`), not a transformation code.
868    ///
869    /// ```no_run
870    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
871    /// use ferric_fred::{Frequency, RegionType, SeasonalAdjustment, SeriesGroupId};
872    /// let date = chrono::NaiveDate::from_ymd_opt(2013, 1, 1).unwrap();
873    /// let data = client
874    ///     .regional_data(
875    ///         &SeriesGroupId::new("882"),
876    ///         RegionType::State,
877    ///         date,
878    ///         "Dollars",
879    ///         Frequency::Annual,
880    ///         SeasonalAdjustment::NotSeasonallyAdjusted,
881    ///     )
882    ///     .await?;
883    /// println!("{}", data.meta.title);
884    /// # Ok(())
885    /// # }
886    /// ```
887    ///
888    /// # Errors
889    ///
890    /// Returns an error if the request fails to send, FRED returns a non-success
891    /// status, or the response body cannot be deserialized. An unknown or
892    /// non-regional `series_group` surfaces as a clear [`Error::Api`] naming the
893    /// id — FRED answers that case with a bare HTTP 500, which the client rewrites
894    /// into an actionable message.
895    pub async fn regional_data(
896        &self,
897        series_group: &SeriesGroupId,
898        region_type: RegionType,
899        date: NaiveDate,
900        units: impl Into<String>,
901        frequency: Frequency,
902        season: SeasonalAdjustment,
903    ) -> Result<RegionalData> {
904        self.get_geofred(
905            "/regional/data",
906            &[
907                ("series_group", series_group.as_str().to_owned()),
908                ("region_type", region_type.query_code().to_owned()),
909                ("date", date.to_string()),
910                ("units", units.into()),
911                ("frequency", frequency.query_code().to_owned()),
912                ("season", season.query_code().to_owned()),
913            ],
914        )
915        .await
916    }
917
918    /// Begin a request for one regional series' values across regions (the
919    /// GeoFRED `geofred/series/data` endpoint).
920    ///
921    /// Returns a builder; set an optional [`date`](SeriesDataRequest::date) or
922    /// [`start_date`](SeriesDataRequest::start_date) and call
923    /// [`send`](SeriesDataRequest::send). With neither set, FRED returns the most
924    /// recent date.
925    ///
926    /// ```no_run
927    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
928    /// use ferric_fred::SeriesId;
929    /// let data = client
930    ///     .series_data(&SeriesId::new("SMU56000000500000001"))
931    ///     .send()
932    ///     .await?;
933    /// println!("{} dates", data.meta.data.len());
934    /// # Ok(())
935    /// # }
936    /// ```
937    pub fn series_data(&self, series_id: &SeriesId) -> SeriesDataRequest<'_> {
938        SeriesDataRequest::new(self, series_id.clone())
939    }
940
941    /// Run a GeoFRED series/data request (invoked by
942    /// [`SeriesDataRequest::send`]).
943    pub(crate) async fn execute_series_data(
944        &self,
945        request: &SeriesDataRequest<'_>,
946    ) -> Result<RegionalData> {
947        self.get_geofred("/series/data", &request.query_params())
948            .await
949    }
950
951    /// Fetch the series-group metadata for a regional series (the GeoFRED
952    /// `geofred/series/group` endpoint) — pass a regional `series_id` and get
953    /// back the group it belongs to (title, region type, date span).
954    ///
955    /// # Errors
956    ///
957    /// Returns an error if the request fails to send, FRED returns a non-success
958    /// status, or the response body cannot be deserialized. An unknown or
959    /// non-regional `series_id` surfaces as a clear [`Error::Api`] naming the id —
960    /// FRED answers that case with a bare HTTP 500, which the client rewrites into
961    /// an actionable message.
962    pub async fn series_group(&self, series_id: &SeriesId) -> Result<SeriesGroup> {
963        let response: SeriesGroupResponse = self
964            .get_geofred(
965                "/series/group",
966                &[("series_id", series_id.as_str().to_owned())],
967            )
968            .await?;
969        Ok(response.series_group)
970    }
971
972    /// Fetch the region boundary polygons for a shape type (the GeoFRED
973    /// `geofred/shapes/file` endpoint) — a GeoJSON [`ShapeFile`] this crate
974    /// transports without interpreting (ADR-0025).
975    ///
976    /// # Errors
977    ///
978    /// Returns an error if the request fails to send, FRED returns a non-success
979    /// status, or the response body cannot be deserialized.
980    pub async fn shape_file(&self, shape: ShapeType) -> Result<ShapeFile> {
981        self.get_geofred("/shapes/file", &[("shape", shape.query_code().to_owned())])
982            .await
983    }
984
985    /// GET a core-FRED `path` with `params`; see [`get_from`](Self::get_from).
986    async fn get<T: DeserializeOwned>(
987        &self,
988        path: &str,
989        params: &[(&'static str, String)],
990    ) -> Result<T> {
991        self.get_from(&self.base_url, path, params).await
992    }
993
994    /// GET a GeoFRED / Maps `path` with `params` (the `/geofred` base);
995    /// see [`get_from`](Self::get_from). A GeoFRED "bad id" 500 is rewritten into
996    /// an actionable message by [`geofred_error`].
997    async fn get_geofred<T: DeserializeOwned>(
998        &self,
999        path: &str,
1000        params: &[(&'static str, String)],
1001    ) -> Result<T> {
1002        self.get_from(&self.geofred_base_url, path, params)
1003            .await
1004            .map_err(|error| geofred_error(path, params, error))
1005    }
1006
1007    /// GET `base_url` + `path` with `params` plus `api_key`/`file_type`, then
1008    /// deserialize the JSON body as `T`. A non-success status becomes
1009    /// [`Error::Api`] (or [`Error::RateLimited`]); a body that doesn't match `T`
1010    /// becomes [`Error::Deserialize`].
1011    async fn get_from<T: DeserializeOwned>(
1012        &self,
1013        base_url: &str,
1014        path: &str,
1015        params: &[(&'static str, String)],
1016    ) -> Result<T> {
1017        let mut query: Vec<(&str, String)> = Vec::with_capacity(params.len() + 2);
1018        query.push(("api_key", self.api_key.clone()));
1019        query.push(("file_type", "json".to_owned()));
1020        query.extend(params.iter().cloned());
1021
1022        let response = self
1023            .http
1024            .get(format!("{base_url}{path}"))
1025            .query(&query)
1026            .send()
1027            .await?;
1028
1029        let status = response.status();
1030        // Read `Retry-After` before consuming the response into its body.
1031        let retry_after = parse_retry_after(response.headers());
1032        let body = response.bytes().await?;
1033
1034        if !status.is_success() {
1035            return Err(api_error(status, retry_after, &body));
1036        }
1037
1038        serde_json::from_slice(&body).map_err(Error::from)
1039    }
1040}
1041
1042/// Join tag names with `;`, FRED's multi-value separator for the `tag_names`
1043/// parameter (shared by every endpoint that takes a seed tag set).
1044fn join_tag_names<I, S>(tag_names: I) -> String
1045where
1046    I: IntoIterator<Item = S>,
1047    S: AsRef<str>,
1048{
1049    tag_names
1050        .into_iter()
1051        .map(|name| name.as_ref().to_owned())
1052        .collect::<Vec<_>>()
1053        .join(";")
1054}
1055
1056/// Build an [`Error`] from a non-success FRED response, decoding FRED's error
1057/// body (`{"error_code": N, "error_message": "..."}`) when present. `retry_after`
1058/// is the parsed `Retry-After` header, carried through on a `429`.
1059fn api_error(status: reqwest::StatusCode, retry_after: Option<Duration>, body: &[u8]) -> Error {
1060    let fred: Option<FredErrorBody> = serde_json::from_slice(body).ok();
1061    let code = fred.as_ref().and_then(|e| e.error_code);
1062    let message = fred.and_then(|e| e.error_message).unwrap_or_else(|| {
1063        status
1064            .canonical_reason()
1065            .unwrap_or("unknown error")
1066            .to_owned()
1067    });
1068
1069    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1070        return Error::RateLimited { retry_after };
1071    }
1072
1073    Error::Api {
1074        status: status.as_u16(),
1075        code,
1076        message,
1077    }
1078}
1079
1080/// Rewrite FRED's GeoFRED "bad id" `500` into an actionable error.
1081///
1082/// The GeoFRED (`/geofred`) endpoints answer an **unknown or non-regional**
1083/// `series_id` / `series_group` with an HTTP `500` carrying the generic body
1084/// `{"error_code":500,"error_message":"Internal Server Error"}` — a 400-shaped
1085/// "bad input" wearing a 500. Left verbatim it reads as "the server broke," so a
1086/// caller retries or backs off instead of fixing the id (the macro endpoints, by
1087/// contrast, return a clear `400 … does not exist`). When the failing request
1088/// carried an id parameter, we rewrite that specific case to name the offending
1089/// id and point at the likely cause, preserving the status and code. We cannot
1090/// fully distinguish it from a genuine server fault, so the message allows for
1091/// both. Every other error — a real `4xx` with its own message, a request with no
1092/// id (e.g. `shapes/file`), a transport failure, a rate-limit — passes through
1093/// untouched.
1094fn geofred_error(path: &str, params: &[(&'static str, String)], error: Error) -> Error {
1095    let id = params
1096        .iter()
1097        .find(|(key, _)| *key == "series_id" || *key == "series_group");
1098    match (error, id) {
1099        (
1100            Error::Api {
1101                status: 500,
1102                code,
1103                message,
1104            },
1105            Some((key, value)),
1106        ) if message == "Internal Server Error" => Error::Api {
1107            status: 500,
1108            code,
1109            message: format!(
1110                "GeoFRED {path} ({key}={value}) returned HTTP 500 — the id is likely \
1111                 invalid or not a regional (GeoFRED/Maps) series; FRED answers that case \
1112                 with a 500 rather than a 404. (If the id is definitely a regional series, \
1113                 FRED may be having a genuine internal error.)"
1114            ),
1115        },
1116        (other, _) => other,
1117    }
1118}
1119
1120/// Parse a `Retry-After` header, if present, as a whole number of seconds
1121/// (FRED's form). The HTTP-date form is not used by FRED and is treated as
1122/// absent.
1123fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1124    let seconds: u64 = headers
1125        .get(reqwest::header::RETRY_AFTER)?
1126        .to_str()
1127        .ok()?
1128        .trim()
1129        .parse()
1130        .ok()?;
1131    Some(Duration::from_secs(seconds))
1132}
1133
1134/// The `series/observations` response envelope. Metadata fields (realtime range,
1135/// units, paging) are ignored for this slice; serde drops unknown fields.
1136#[derive(Deserialize)]
1137struct ObservationsResponse {
1138    observations: Vec<Observation>,
1139}
1140
1141/// The `series` response envelope. FRED pluralizes the array key as `seriess`
1142/// (sic); other metadata fields are ignored for this slice.
1143#[derive(Deserialize)]
1144struct SeriesResponse {
1145    seriess: Vec<Series>,
1146}
1147
1148/// The `category` / `category/children` response envelope.
1149#[derive(Deserialize)]
1150struct CategoriesResponse {
1151    categories: Vec<Category>,
1152}
1153
1154/// The single-`release` response envelope. The `releases` list endpoint
1155/// deserializes into [`ReleasesResults`] directly (it carries pagination);
1156/// `fred/release` returns only the array.
1157#[derive(Deserialize)]
1158struct ReleaseResponse {
1159    releases: Vec<Release>,
1160}
1161
1162/// The `source` / `release/sources` response envelope: a bare `sources` array
1163/// (the paginated `sources` list endpoint deserializes into [`SourcesResults`]
1164/// directly; `release/sources` is unpaginated, so it uses this).
1165#[derive(Deserialize)]
1166struct SourceResponse {
1167    sources: Vec<Source>,
1168}
1169
1170/// The GeoFRED `series/group` response envelope: `{ "series_group": { … } }`.
1171#[derive(Deserialize)]
1172struct SeriesGroupResponse {
1173    series_group: SeriesGroup,
1174}
1175
1176/// FRED's error response body.
1177#[derive(Deserialize)]
1178struct FredErrorBody {
1179    error_code: Option<u32>,
1180    error_message: Option<String>,
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::Client;
1186    use std::time::Duration;
1187
1188    use crate::{
1189        CategoryId, Error, Frequency, OrderBy, Paginate, RegionType, ReleaseElementId, ReleaseId,
1190        SeasonalAdjustment, SeriesGroupId, SeriesId, ShapeType, SortOrder, SourceId, Units,
1191        UpdatesFilter,
1192    };
1193    use wiremock::matchers::{method, path, query_param};
1194    use wiremock::{Mock, MockServer, ResponseTemplate};
1195
1196    /// A representative `seriess[0]` object, reused across the response bodies.
1197    const SERIES_OBJECT: &str = r#"{
1198        "id": "GNPCA",
1199        "title": "Real Gross National Product",
1200        "observation_start": "1929-01-01",
1201        "observation_end": "2023-01-01",
1202        "frequency": "Annual",
1203        "units": "Billions of Chained 2017 Dollars",
1204        "seasonal_adjustment": "Not Seasonally Adjusted",
1205        "last_updated": "2024-03-28 07:56:03-05",
1206        "popularity": 76,
1207        "notes": "BEA Account Code: A001RX"
1208    }"#;
1209
1210    fn client_for(server: &MockServer) -> Client {
1211        Client::with_base_url("test-key", server.uri()).expect("client builds")
1212    }
1213
1214    #[tokio::test]
1215    async fn series_parses_metadata() {
1216        let server = MockServer::start().await;
1217        Mock::given(method("GET"))
1218            .and(path("/series"))
1219            .respond_with(
1220                ResponseTemplate::new(200)
1221                    .set_body_string(format!("{{\"seriess\":[{SERIES_OBJECT}]}}")),
1222            )
1223            .mount(&server)
1224            .await;
1225
1226        let series = client_for(&server)
1227            .series(&SeriesId::new("GNPCA"))
1228            .await
1229            .expect("series parses");
1230        assert_eq!(series.id, SeriesId::new("GNPCA"));
1231        assert_eq!(series.frequency, Frequency::Annual);
1232        assert_eq!(
1233            series.seasonal_adjustment,
1234            SeasonalAdjustment::NotSeasonallyAdjusted
1235        );
1236        assert_eq!(series.popularity, 76);
1237    }
1238
1239    #[tokio::test]
1240    async fn observations_parse_missing_and_present_values() {
1241        let server = MockServer::start().await;
1242        let body = r#"{"observations":[
1243            {"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1930-01-01","value":"."},
1244            {"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1929-01-01","value":"1065.9"}
1245        ]}"#;
1246        Mock::given(method("GET"))
1247            .and(path("/series/observations"))
1248            .respond_with(ResponseTemplate::new(200).set_body_string(body))
1249            .mount(&server)
1250            .await;
1251
1252        let observations = client_for(&server)
1253            .observations(&SeriesId::new("GNPCA"))
1254            .send()
1255            .await
1256            .expect("observations parse");
1257        assert_eq!(observations.len(), 2);
1258        assert_eq!(observations[0].value, None); // the "." sentinel
1259        assert_eq!(observations[1].value, Some(1065.9));
1260    }
1261
1262    #[tokio::test]
1263    async fn observations_point_in_time_sends_realtime_and_parses_period() {
1264        let server = MockServer::start().await;
1265        // A point-in-time query: realtime_start == realtime_end must reach the
1266        // wire, and each row's archived real-time period must deserialize.
1267        Mock::given(method("GET"))
1268            .and(path("/series/observations"))
1269            .and(query_param("realtime_start", "2020-01-01"))
1270            .and(query_param("realtime_end", "2020-01-01"))
1271            .respond_with(ResponseTemplate::new(200).set_body_string(
1272                r#"{"observations":[
1273                    {"realtime_start":"2020-01-01","realtime_end":"2020-01-01","date":"2017-01-01","value":"18344.563"}
1274                ]}"#,
1275            ))
1276            .mount(&server)
1277            .await;
1278
1279        let as_of = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
1280        let observations = client_for(&server)
1281            .observations(&SeriesId::new("GNPCA"))
1282            .realtime(as_of, as_of)
1283            .send()
1284            .await
1285            .expect("point-in-time observations parse");
1286        assert_eq!(observations[0].realtime_start, as_of);
1287        assert_eq!(observations[0].realtime_end, as_of);
1288        assert_eq!(observations[0].value, Some(18344.563));
1289    }
1290
1291    #[tokio::test]
1292    async fn search_parses_results_with_pagination() {
1293        let server = MockServer::start().await;
1294        let body =
1295            format!("{{\"count\":1,\"offset\":0,\"limit\":1000,\"seriess\":[{SERIES_OBJECT}]}}");
1296        Mock::given(method("GET"))
1297            .and(path("/series/search"))
1298            .respond_with(ResponseTemplate::new(200).set_body_string(body))
1299            .mount(&server)
1300            .await;
1301
1302        let results = client_for(&server)
1303            .search("real gnp")
1304            .send()
1305            .await
1306            .expect("search parses");
1307        assert_eq!(results.count, 1);
1308        assert_eq!(results.series.len(), 1);
1309        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1310    }
1311
1312    #[tokio::test]
1313    async fn error_status_with_body_maps_to_api_error() {
1314        let server = MockServer::start().await;
1315        let body = r#"{"error_code":400,"error_message":"Bad Request. Invalid value for variable series_id."}"#;
1316        Mock::given(method("GET"))
1317            .and(path("/series"))
1318            .respond_with(ResponseTemplate::new(400).set_body_string(body))
1319            .mount(&server)
1320            .await;
1321
1322        let error = client_for(&server)
1323            .series(&SeriesId::new("BAD"))
1324            .await
1325            .expect_err("a 400 should be an API error");
1326        match error {
1327            Error::Api {
1328                status,
1329                code,
1330                message,
1331            } => {
1332                assert_eq!(status, 400);
1333                assert_eq!(code, Some(400));
1334                assert!(message.contains("Invalid value"), "message was {message:?}");
1335            }
1336            other => panic!("expected Error::Api, got {other:?}"),
1337        }
1338    }
1339
1340    #[tokio::test]
1341    async fn too_many_requests_maps_to_rate_limited() {
1342        let server = MockServer::start().await;
1343        Mock::given(method("GET"))
1344            .and(path("/series"))
1345            .respond_with(ResponseTemplate::new(429))
1346            .mount(&server)
1347            .await;
1348
1349        let error = client_for(&server)
1350            .series(&SeriesId::new("GNPCA"))
1351            .await
1352            .expect_err("a 429 should be rate-limited");
1353        assert!(matches!(error, Error::RateLimited { .. }), "got {error:?}");
1354    }
1355
1356    #[tokio::test]
1357    async fn rate_limited_carries_retry_after_seconds() {
1358        let server = MockServer::start().await;
1359        Mock::given(method("GET"))
1360            .and(path("/series"))
1361            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "120"))
1362            .mount(&server)
1363            .await;
1364
1365        let error = client_for(&server)
1366            .series(&SeriesId::new("GNPCA"))
1367            .await
1368            .expect_err("a 429 should be rate-limited");
1369        match error {
1370            Error::RateLimited { retry_after } => {
1371                assert_eq!(retry_after, Some(Duration::from_secs(120)));
1372            }
1373            other => panic!("expected Error::RateLimited, got {other:?}"),
1374        }
1375    }
1376
1377    #[tokio::test]
1378    async fn send_all_walks_every_page() {
1379        let server = MockServer::start().await;
1380        // First page (offset 0) reports a total of 3 and returns 2 sources; the
1381        // second page (offset 2) returns the last one. `send_all` should stitch
1382        // the two into one Vec and stop once it has walked past `count`.
1383        Mock::given(method("GET"))
1384            .and(path("/sources"))
1385            .and(query_param("offset", "0"))
1386            .respond_with(ResponseTemplate::new(200).set_body_string(
1387                r#"{"count":3,"offset":0,"limit":1000,"sources":[
1388                    {"id":1,"name":"Source One"},
1389                    {"id":2,"name":"Source Two"}
1390                ]}"#,
1391            ))
1392            .mount(&server)
1393            .await;
1394        Mock::given(method("GET"))
1395            .and(path("/sources"))
1396            .and(query_param("offset", "2"))
1397            .respond_with(ResponseTemplate::new(200).set_body_string(
1398                r#"{"count":3,"offset":2,"limit":1000,"sources":[
1399                    {"id":3,"name":"Source Three"}
1400                ]}"#,
1401            ))
1402            .mount(&server)
1403            .await;
1404
1405        let sources = client_for(&server)
1406            .sources()
1407            .send_all()
1408            .await
1409            .expect("send_all walks both pages");
1410        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1411        assert_eq!(
1412            ids,
1413            vec![SourceId::new(1), SourceId::new(2), SourceId::new(3)]
1414        );
1415    }
1416
1417    #[tokio::test]
1418    async fn send_all_treats_limit_as_a_ceiling() {
1419        let server = MockServer::start().await;
1420        // Only an offset-0 page is mocked, and only for a limit of 2. `count` is
1421        // 5, but a `.limit(2)` ceiling must stop `send_all` after one request of
1422        // exactly two — a second page request would 404 and fail the test.
1423        Mock::given(method("GET"))
1424            .and(path("/sources"))
1425            .and(query_param("offset", "0"))
1426            .and(query_param("limit", "2"))
1427            .respond_with(ResponseTemplate::new(200).set_body_string(
1428                r#"{"count":5,"offset":0,"limit":2,"sources":[
1429                    {"id":1,"name":"Source One"},
1430                    {"id":2,"name":"Source Two"}
1431                ]}"#,
1432            ))
1433            .mount(&server)
1434            .await;
1435
1436        let sources = client_for(&server)
1437            .sources()
1438            .limit(2)
1439            .send_all()
1440            .await
1441            .expect("send_all stops at the ceiling");
1442        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1443        assert_eq!(ids, vec![SourceId::new(1), SourceId::new(2)]);
1444    }
1445
1446    #[tokio::test]
1447    async fn send_all_retries_after_a_429() {
1448        let server = MockServer::start().await;
1449        // The first request is rate-limited with `Retry-After: 0` (so the retry
1450        // sleeps for no real time); the retry then succeeds. Priority + a
1451        // one-shot cap make the 429 fire first, then fall through to the 200.
1452        Mock::given(method("GET"))
1453            .and(path("/sources"))
1454            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
1455            .up_to_n_times(1)
1456            .with_priority(1)
1457            .mount(&server)
1458            .await;
1459        Mock::given(method("GET"))
1460            .and(path("/sources"))
1461            .respond_with(ResponseTemplate::new(200).set_body_string(
1462                r#"{"count":1,"offset":0,"limit":1000,"sources":[
1463                    {"id":1,"name":"Source One"}
1464                ]}"#,
1465            ))
1466            .with_priority(2)
1467            .mount(&server)
1468            .await;
1469
1470        let sources = client_for(&server)
1471            .sources()
1472            .send_all()
1473            .await
1474            .expect("send_all retries the 429 and then succeeds");
1475        assert_eq!(sources.len(), 1);
1476        assert_eq!(sources[0].id, SourceId::new(1));
1477    }
1478
1479    #[tokio::test]
1480    async fn stream_walks_every_page() {
1481        use futures_util::TryStreamExt;
1482
1483        let server = MockServer::start().await;
1484        Mock::given(method("GET"))
1485            .and(path("/sources"))
1486            .and(query_param("offset", "0"))
1487            .respond_with(ResponseTemplate::new(200).set_body_string(
1488                r#"{"count":3,"offset":0,"limit":1000,"sources":[
1489                    {"id":1,"name":"Source One"},
1490                    {"id":2,"name":"Source Two"}
1491                ]}"#,
1492            ))
1493            .mount(&server)
1494            .await;
1495        Mock::given(method("GET"))
1496            .and(path("/sources"))
1497            .and(query_param("offset", "2"))
1498            .respond_with(ResponseTemplate::new(200).set_body_string(
1499                r#"{"count":3,"offset":2,"limit":1000,"sources":[
1500                    {"id":3,"name":"Source Three"}
1501                ]}"#,
1502            ))
1503            .mount(&server)
1504            .await;
1505
1506        let sources: Vec<_> = client_for(&server)
1507            .sources()
1508            .stream()
1509            .try_collect()
1510            .await
1511            .expect("stream walks both pages");
1512        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1513        assert_eq!(
1514            ids,
1515            vec![SourceId::new(1), SourceId::new(2), SourceId::new(3)]
1516        );
1517    }
1518
1519    #[tokio::test]
1520    async fn stream_treats_limit_as_a_ceiling() {
1521        use futures_util::TryStreamExt;
1522
1523        let server = MockServer::start().await;
1524        // Only an offset-0, limit-2 page is mocked; a `.limit(2)` ceiling must
1525        // stop the stream after it, without ever requesting a second page.
1526        Mock::given(method("GET"))
1527            .and(path("/sources"))
1528            .and(query_param("offset", "0"))
1529            .and(query_param("limit", "2"))
1530            .respond_with(ResponseTemplate::new(200).set_body_string(
1531                r#"{"count":5,"offset":0,"limit":2,"sources":[
1532                    {"id":1,"name":"Source One"},
1533                    {"id":2,"name":"Source Two"}
1534                ]}"#,
1535            ))
1536            .mount(&server)
1537            .await;
1538
1539        let sources: Vec<_> = client_for(&server)
1540            .sources()
1541            .limit(2)
1542            .stream()
1543            .try_collect()
1544            .await
1545            .expect("stream stops at the ceiling");
1546        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1547        assert_eq!(ids, vec![SourceId::new(1), SourceId::new(2)]);
1548    }
1549
1550    #[tokio::test]
1551    async fn stream_surfaces_a_mid_stream_error() {
1552        use futures_util::StreamExt;
1553
1554        let server = MockServer::start().await;
1555        // Page one succeeds; page two (offset 2) fails. The items from page one
1556        // arrive as `Ok`, then the error arrives as a final `Err` item.
1557        Mock::given(method("GET"))
1558            .and(path("/sources"))
1559            .and(query_param("offset", "0"))
1560            .respond_with(ResponseTemplate::new(200).set_body_string(
1561                r#"{"count":4,"offset":0,"limit":1000,"sources":[
1562                    {"id":1,"name":"Source One"},
1563                    {"id":2,"name":"Source Two"}
1564                ]}"#,
1565            ))
1566            .mount(&server)
1567            .await;
1568        Mock::given(method("GET"))
1569            .and(path("/sources"))
1570            .and(query_param("offset", "2"))
1571            .respond_with(ResponseTemplate::new(500))
1572            .mount(&server)
1573            .await;
1574
1575        let results: Vec<_> = client_for(&server).sources().stream().collect().await;
1576        assert_eq!(results.len(), 3);
1577        assert_eq!(
1578            results[0].as_ref().expect("first item is Ok").id,
1579            SourceId::new(1)
1580        );
1581        assert_eq!(
1582            results[1].as_ref().expect("second item is Ok").id,
1583            SourceId::new(2)
1584        );
1585        assert!(
1586            matches!(results[2], Err(Error::Api { status: 500, .. })),
1587            "third item should be the page-two error, got {:?}",
1588            results[2]
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn malformed_body_maps_to_deserialize_error() {
1594        let server = MockServer::start().await;
1595        Mock::given(method("GET"))
1596            .and(path("/series"))
1597            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"unexpected":true}"#))
1598            .mount(&server)
1599            .await;
1600
1601        let error = client_for(&server)
1602            .series(&SeriesId::new("GNPCA"))
1603            .await
1604            .expect_err("an unexpected body should fail to deserialize");
1605        assert!(matches!(error, Error::Deserialize(_)), "got {error:?}");
1606    }
1607
1608    #[tokio::test]
1609    async fn request_carries_api_key_file_type_and_params() {
1610        let server = MockServer::start().await;
1611        // This mock only matches when every expected query parameter is present;
1612        // an unmatched request 404s and the call fails. So a *successful* call
1613        // proves the client sent api_key, file_type, and the builder's params.
1614        Mock::given(method("GET"))
1615            .and(path("/series/observations"))
1616            .and(query_param("api_key", "test-key"))
1617            .and(query_param("file_type", "json"))
1618            .and(query_param("units", "pch"))
1619            .and(query_param("limit", "5"))
1620            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"observations":[]}"#))
1621            .mount(&server)
1622            .await;
1623
1624        let observations = client_for(&server)
1625            .observations(&SeriesId::new("GNPCA"))
1626            .units(Units::PercentChange)
1627            .limit(5)
1628            .send()
1629            .await
1630            .expect("request with the expected params should match the mock");
1631        assert!(observations.is_empty());
1632    }
1633
1634    #[tokio::test]
1635    async fn category_parses() {
1636        let server = MockServer::start().await;
1637        Mock::given(method("GET"))
1638            .and(path("/category"))
1639            .respond_with(ResponseTemplate::new(200).set_body_string(
1640                r#"{"categories":[{"id":125,"name":"Trade Balance","parent_id":13}]}"#,
1641            ))
1642            .mount(&server)
1643            .await;
1644
1645        let category = client_for(&server)
1646            .category(CategoryId::new(125))
1647            .await
1648            .expect("category parses");
1649        assert_eq!(category.id, CategoryId::new(125));
1650        assert_eq!(category.name, "Trade Balance");
1651        assert_eq!(category.parent_id, CategoryId::new(13));
1652    }
1653
1654    #[tokio::test]
1655    async fn category_children_parse() {
1656        let server = MockServer::start().await;
1657        Mock::given(method("GET"))
1658            .and(path("/category/children"))
1659            .respond_with(ResponseTemplate::new(200).set_body_string(
1660                r#"{"categories":[
1661                    {"id":16,"name":"Exports","parent_id":13},
1662                    {"id":17,"name":"Imports","parent_id":13}
1663                ]}"#,
1664            ))
1665            .mount(&server)
1666            .await;
1667
1668        let children = client_for(&server)
1669            .category_children(CategoryId::new(13))
1670            .await
1671            .expect("children parse");
1672        assert_eq!(children.len(), 2);
1673        assert_eq!(children[0].name, "Exports");
1674        assert_eq!(children[1].id, CategoryId::new(17));
1675    }
1676
1677    #[tokio::test]
1678    async fn category_related_parse() {
1679        let server = MockServer::start().await;
1680        Mock::given(method("GET"))
1681            .and(path("/category/related"))
1682            .and(query_param("category_id", "32073"))
1683            .respond_with(ResponseTemplate::new(200).set_body_string(
1684                r#"{"categories":[
1685                    {"id":149,"name":"Arkansas","parent_id":27281},
1686                    {"id":150,"name":"Illinois","parent_id":27281}
1687                ]}"#,
1688            ))
1689            .mount(&server)
1690            .await;
1691
1692        let related = client_for(&server)
1693            .category_related(CategoryId::new(32073))
1694            .await
1695            .expect("category/related parse");
1696        assert_eq!(related.len(), 2);
1697        assert_eq!(related[0].name, "Arkansas");
1698        assert_eq!(related[1].id, CategoryId::new(150));
1699    }
1700
1701    #[tokio::test]
1702    async fn category_series_sends_params_and_parses() {
1703        let server = MockServer::start().await;
1704        // Matches only when the builder's params reach the wire.
1705        Mock::given(method("GET"))
1706            .and(path("/category/series"))
1707            .and(query_param("category_id", "125"))
1708            .and(query_param("order_by", "popularity"))
1709            .and(query_param("limit", "2"))
1710            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
1711                "{{\"count\":1,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
1712            )))
1713            .mount(&server)
1714            .await;
1715
1716        let results = client_for(&server)
1717            .category_series(CategoryId::new(125))
1718            .order_by(OrderBy::Popularity)
1719            .limit(2)
1720            .send()
1721            .await
1722            .expect("category series parse");
1723        assert_eq!(results.count, 1);
1724        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1725    }
1726
1727    #[tokio::test]
1728    async fn releases_parse_with_pagination() {
1729        let server = MockServer::start().await;
1730        Mock::given(method("GET"))
1731            .and(path("/releases"))
1732            .respond_with(ResponseTemplate::new(200).set_body_string(
1733                r#"{"count":2,"offset":0,"limit":1000,"releases":[
1734                    {"id":9,"name":"Advance Monthly Sales","press_release":false},
1735                    {"id":53,"name":"Gross Domestic Product","press_release":true,"link":"http://bea.gov"}
1736                ]}"#,
1737            ))
1738            .mount(&server)
1739            .await;
1740
1741        let results = client_for(&server)
1742            .releases()
1743            .send()
1744            .await
1745            .expect("releases parse");
1746        assert_eq!(results.count, 2);
1747        assert_eq!(results.releases[1].id, ReleaseId::new(53));
1748        assert_eq!(results.releases[1].link.as_deref(), Some("http://bea.gov"));
1749    }
1750
1751    #[tokio::test]
1752    async fn release_parses() {
1753        let server = MockServer::start().await;
1754        Mock::given(method("GET"))
1755            .and(path("/release"))
1756            .and(query_param("release_id", "53"))
1757            .respond_with(ResponseTemplate::new(200).set_body_string(
1758                r#"{"releases":[{"id":53,"name":"Gross Domestic Product","press_release":true}]}"#,
1759            ))
1760            .mount(&server)
1761            .await;
1762
1763        let release = client_for(&server)
1764            .release(ReleaseId::new(53))
1765            .await
1766            .expect("release parses");
1767        assert_eq!(release.id, ReleaseId::new(53));
1768        assert_eq!(release.name, "Gross Domestic Product");
1769        assert!(release.press_release);
1770    }
1771
1772    #[tokio::test]
1773    async fn release_series_sends_params_and_parses() {
1774        let server = MockServer::start().await;
1775        Mock::given(method("GET"))
1776            .and(path("/release/series"))
1777            .and(query_param("release_id", "53"))
1778            .and(query_param("limit", "2"))
1779            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
1780                "{{\"count\":1,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
1781            )))
1782            .mount(&server)
1783            .await;
1784
1785        let results = client_for(&server)
1786            .release_series(ReleaseId::new(53))
1787            .limit(2)
1788            .send()
1789            .await
1790            .expect("release series parse");
1791        assert_eq!(results.count, 1);
1792        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1793    }
1794
1795    #[tokio::test]
1796    async fn tags_search_sends_text_and_parses() {
1797        let server = MockServer::start().await;
1798        Mock::given(method("GET"))
1799            .and(path("/tags"))
1800            .and(query_param("search_text", "gdp"))
1801            .respond_with(ResponseTemplate::new(200).set_body_string(
1802                r#"{"count":1,"offset":0,"limit":1000,"tags":[
1803                    {"name":"gdp","group_id":"gen","popularity":80,"series_count":12345}
1804                ]}"#,
1805            ))
1806            .mount(&server)
1807            .await;
1808
1809        let results = client_for(&server)
1810            .tags()
1811            .search_text("gdp")
1812            .send()
1813            .await
1814            .expect("tags parse");
1815        assert_eq!(results.count, 1);
1816        assert_eq!(results.tags[0].name, "gdp");
1817        assert_eq!(results.tags[0].series_count, 12345);
1818    }
1819
1820    #[tokio::test]
1821    async fn related_tags_send_seed_names_and_parses() {
1822        let server = MockServer::start().await;
1823        // The seed tags reach `/related_tags` joined by `;`.
1824        Mock::given(method("GET"))
1825            .and(path("/related_tags"))
1826            .and(query_param("tag_names", "gdp;quarterly"))
1827            .respond_with(ResponseTemplate::new(200).set_body_string(
1828                r#"{"count":1,"offset":0,"limit":1000,"tags":[
1829                    {"name":"nsa","group_id":"seas","popularity":90,"series_count":42}
1830                ]}"#,
1831            ))
1832            .mount(&server)
1833            .await;
1834
1835        let results = client_for(&server)
1836            .related_tags(["gdp", "quarterly"])
1837            .send()
1838            .await
1839            .expect("related_tags parse");
1840        assert_eq!(results.count, 1);
1841        assert_eq!(results.tags[0].name, "nsa");
1842    }
1843
1844    /// A minimal single-tag `tags` response body, reused by the scoped-tag tests.
1845    const ONE_TAG_BODY: &str = r#"{"count":1,"offset":0,"limit":1000,"tags":[
1846        {"name":"gdp","group_id":"gen","popularity":80,"series_count":42}
1847    ]}"#;
1848
1849    #[tokio::test]
1850    async fn category_tags_send_scope_and_parse() {
1851        let server = MockServer::start().await;
1852        Mock::given(method("GET"))
1853            .and(path("/category/tags"))
1854            .and(query_param("category_id", "125"))
1855            .and(query_param("search_text", "gdp"))
1856            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1857            .mount(&server)
1858            .await;
1859
1860        let results = client_for(&server)
1861            .category_tags(CategoryId::new(125))
1862            .search_text("gdp")
1863            .send()
1864            .await
1865            .expect("category/tags parse");
1866        assert_eq!(results.count, 1);
1867        assert_eq!(results.tags[0].name, "gdp");
1868    }
1869
1870    #[tokio::test]
1871    async fn category_related_tags_send_scope_and_seed_and_parse() {
1872        let server = MockServer::start().await;
1873        Mock::given(method("GET"))
1874            .and(path("/category/related_tags"))
1875            .and(query_param("category_id", "125"))
1876            .and(query_param("tag_names", "gdp;quarterly"))
1877            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1878            .mount(&server)
1879            .await;
1880
1881        let results = client_for(&server)
1882            .category_related_tags(CategoryId::new(125), ["gdp", "quarterly"])
1883            .send()
1884            .await
1885            .expect("category/related_tags parse");
1886        assert_eq!(results.count, 1);
1887    }
1888
1889    #[tokio::test]
1890    async fn release_tags_send_scope_and_parse() {
1891        let server = MockServer::start().await;
1892        Mock::given(method("GET"))
1893            .and(path("/release/tags"))
1894            .and(query_param("release_id", "53"))
1895            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1896            .mount(&server)
1897            .await;
1898
1899        let results = client_for(&server)
1900            .release_tags(ReleaseId::new(53))
1901            .send()
1902            .await
1903            .expect("release/tags parse");
1904        assert_eq!(results.count, 1);
1905    }
1906
1907    #[tokio::test]
1908    async fn release_related_tags_send_scope_and_seed_and_parse() {
1909        let server = MockServer::start().await;
1910        Mock::given(method("GET"))
1911            .and(path("/release/related_tags"))
1912            .and(query_param("release_id", "53"))
1913            .and(query_param("tag_names", "gdp"))
1914            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1915            .mount(&server)
1916            .await;
1917
1918        let results = client_for(&server)
1919            .release_related_tags(ReleaseId::new(53), ["gdp"])
1920            .send()
1921            .await
1922            .expect("release/related_tags parse");
1923        assert_eq!(results.count, 1);
1924    }
1925
1926    #[tokio::test]
1927    async fn series_search_tags_send_scope_and_tag_search_text() {
1928        let server = MockServer::start().await;
1929        // series/search/* sends the tag filter under `tag_search_text`, not
1930        // `search_text`; the mock only matches if that key is used.
1931        Mock::given(method("GET"))
1932            .and(path("/series/search/tags"))
1933            .and(query_param("series_search_text", "unemployment"))
1934            .and(query_param("tag_search_text", "rate"))
1935            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1936            .mount(&server)
1937            .await;
1938
1939        let results = client_for(&server)
1940            .series_search_tags("unemployment")
1941            .search_text("rate")
1942            .send()
1943            .await
1944            .expect("series/search/tags parse");
1945        assert_eq!(results.count, 1);
1946    }
1947
1948    #[tokio::test]
1949    async fn series_search_related_tags_send_scope_and_seed() {
1950        let server = MockServer::start().await;
1951        Mock::given(method("GET"))
1952            .and(path("/series/search/related_tags"))
1953            .and(query_param("series_search_text", "unemployment"))
1954            .and(query_param("tag_names", "monthly"))
1955            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1956            .mount(&server)
1957            .await;
1958
1959        let results = client_for(&server)
1960            .series_search_related_tags("unemployment", ["monthly"])
1961            .send()
1962            .await
1963            .expect("series/search/related_tags parse");
1964        assert_eq!(results.count, 1);
1965    }
1966
1967    #[tokio::test]
1968    async fn tags_series_joins_names_and_parses() {
1969        let server = MockServer::start().await;
1970        // The two tag names must reach the wire joined by `;`.
1971        Mock::given(method("GET"))
1972            .and(path("/tags/series"))
1973            .and(query_param("tag_names", "gdp;quarterly"))
1974            .and(query_param("limit", "2"))
1975            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
1976                "{{\"count\":1,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
1977            )))
1978            .mount(&server)
1979            .await;
1980
1981        let results = client_for(&server)
1982            .tags_series(["gdp", "quarterly"])
1983            .limit(2)
1984            .send()
1985            .await
1986            .expect("tags/series parse");
1987        assert_eq!(results.count, 1);
1988        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1989    }
1990
1991    #[tokio::test]
1992    async fn series_tags_parses() {
1993        let server = MockServer::start().await;
1994        Mock::given(method("GET"))
1995            .and(path("/series/tags"))
1996            .and(query_param("series_id", "GNPCA"))
1997            .respond_with(ResponseTemplate::new(200).set_body_string(
1998                r#"{"count":2,"offset":0,"limit":1000,"tags":[
1999                    {"name":"gnp","group_id":"gen","popularity":50,"series_count":10},
2000                    {"name":"usa","group_id":"geo","notes":null,"popularity":100,"series_count":500}
2001                ]}"#,
2002            ))
2003            .mount(&server)
2004            .await;
2005
2006        let results = client_for(&server)
2007            .series_tags(&SeriesId::new("GNPCA"))
2008            .await
2009            .expect("series/tags parse");
2010        assert_eq!(results.count, 2);
2011        assert_eq!(results.tags[0].name, "gnp");
2012        assert!(results.tags[1].notes.is_none());
2013    }
2014
2015    #[tokio::test]
2016    async fn sources_parse_with_pagination() {
2017        let server = MockServer::start().await;
2018        Mock::given(method("GET"))
2019            .and(path("/sources"))
2020            .respond_with(ResponseTemplate::new(200).set_body_string(
2021                r#"{"count":2,"offset":0,"limit":1000,"sources":[
2022                    {"id":1,"name":"Board of Governors of the Federal Reserve System (US)"},
2023                    {"id":18,"name":"U.S. Bureau of Economic Analysis","link":"http://bea.gov"}
2024                ]}"#,
2025            ))
2026            .mount(&server)
2027            .await;
2028
2029        let results = client_for(&server)
2030            .sources()
2031            .send()
2032            .await
2033            .expect("sources parse");
2034        assert_eq!(results.count, 2);
2035        assert_eq!(results.sources[1].id, SourceId::new(18));
2036        assert_eq!(results.sources[1].link.as_deref(), Some("http://bea.gov"));
2037    }
2038
2039    #[tokio::test]
2040    async fn source_parses() {
2041        let server = MockServer::start().await;
2042        Mock::given(method("GET"))
2043            .and(path("/source"))
2044            .and(query_param("source_id", "18"))
2045            .respond_with(ResponseTemplate::new(200).set_body_string(
2046                r#"{"sources":[{"id":18,"name":"U.S. Bureau of Economic Analysis","link":"http://bea.gov"}]}"#,
2047            ))
2048            .mount(&server)
2049            .await;
2050
2051        let source = client_for(&server)
2052            .source(SourceId::new(18))
2053            .await
2054            .expect("source parses");
2055        assert_eq!(source.id, SourceId::new(18));
2056        assert_eq!(source.name, "U.S. Bureau of Economic Analysis");
2057    }
2058
2059    #[tokio::test]
2060    async fn source_releases_send_source_id_and_parse() {
2061        let server = MockServer::start().await;
2062        // The source_id reaches `/source/releases`, which returns releases.
2063        Mock::given(method("GET"))
2064            .and(path("/source/releases"))
2065            .and(query_param("source_id", "18"))
2066            .and(query_param("limit", "2"))
2067            .respond_with(ResponseTemplate::new(200).set_body_string(
2068                r#"{"count":1,"offset":0,"limit":2,"releases":[
2069                    {"id":53,"name":"Gross Domestic Product","press_release":true}
2070                ]}"#,
2071            ))
2072            .mount(&server)
2073            .await;
2074
2075        let results = client_for(&server)
2076            .source_releases(SourceId::new(18))
2077            .limit(2)
2078            .send()
2079            .await
2080            .expect("source/releases parse");
2081        assert_eq!(results.count, 1);
2082        assert_eq!(results.releases[0].id, ReleaseId::new(53));
2083    }
2084
2085    #[tokio::test]
2086    async fn release_sources_send_release_id_and_parse() {
2087        let server = MockServer::start().await;
2088        // The release_id reaches `/release/sources`, which returns a bare
2089        // (unpaginated) `sources` array wrapped alongside realtime fields.
2090        Mock::given(method("GET"))
2091            .and(path("/release/sources"))
2092            .and(query_param("release_id", "51"))
2093            .respond_with(ResponseTemplate::new(200).set_body_string(
2094                r#"{"realtime_start":"2013-08-14","realtime_end":"2013-08-14","sources":[
2095                    {"id":18,"name":"U.S. Bureau of Economic Analysis","link":"http://www.bea.gov/"},
2096                    {"id":19,"name":"U.S. Census Bureau"}
2097                ]}"#,
2098            ))
2099            .mount(&server)
2100            .await;
2101
2102        let sources = client_for(&server)
2103            .release_sources(ReleaseId::new(51))
2104            .await
2105            .expect("release/sources parse");
2106        assert_eq!(sources.len(), 2);
2107        assert_eq!(sources[0].id, SourceId::new(18));
2108        assert_eq!(sources[0].link.as_deref(), Some("http://www.bea.gov/"));
2109        assert!(sources[1].link.is_none());
2110    }
2111
2112    #[tokio::test]
2113    async fn releases_dates_send_params_and_parse() {
2114        let server = MockServer::start().await;
2115        // The `/releases/dates` calendar carries a release_name per entry.
2116        Mock::given(method("GET"))
2117            .and(path("/releases/dates"))
2118            .and(query_param("sort_order", "desc"))
2119            .and(query_param("limit", "2"))
2120            .respond_with(ResponseTemplate::new(200).set_body_string(
2121                r#"{"count":2,"offset":0,"limit":2,"release_dates":[
2122                    {"release_id":9,"release_name":"Advance Monthly Sales","date":"2013-08-13"},
2123                    {"release_id":10,"release_name":"Consumer Price Index","date":"2013-08-15"}
2124                ]}"#,
2125            ))
2126            .mount(&server)
2127            .await;
2128
2129        let results = client_for(&server)
2130            .releases_dates()
2131            .sort_order(SortOrder::Descending)
2132            .limit(2)
2133            .send()
2134            .await
2135            .expect("releases/dates parse");
2136        assert_eq!(results.count, 2);
2137        assert_eq!(results.release_dates[0].release_id, ReleaseId::new(9));
2138        assert_eq!(
2139            results.release_dates[0].release_name.as_deref(),
2140            Some("Advance Monthly Sales")
2141        );
2142    }
2143
2144    #[tokio::test]
2145    async fn release_dates_send_release_id_and_include_flag_and_parse() {
2146        let server = MockServer::start().await;
2147        // `/release/dates` fixes the release, so entries omit release_name; the
2148        // request must carry release_id and the include-no-data toggle.
2149        Mock::given(method("GET"))
2150            .and(path("/release/dates"))
2151            .and(query_param("release_id", "82"))
2152            .and(query_param("include_release_dates_with_no_data", "true"))
2153            .respond_with(ResponseTemplate::new(200).set_body_string(
2154                r#"{"count":2,"offset":0,"limit":10000,"release_dates":[
2155                    {"release_id":82,"date":"1997-02-10"},
2156                    {"release_id":82,"date":"1998-02-10"}
2157                ]}"#,
2158            ))
2159            .mount(&server)
2160            .await;
2161
2162        let results = client_for(&server)
2163            .release_dates(ReleaseId::new(82))
2164            .include_dates_with_no_data(true)
2165            .send()
2166            .await
2167            .expect("release/dates parse");
2168        assert_eq!(results.count, 2);
2169        assert_eq!(results.release_dates[0].release_id, ReleaseId::new(82));
2170        assert!(results.release_dates[0].release_name.is_none());
2171    }
2172
2173    #[tokio::test]
2174    async fn release_tables_send_element_and_parse_tree() {
2175        let server = MockServer::start().await;
2176        // The element_id (subtree scope) must reach the wire, and the nested
2177        // tree — a section containing a series row — must deserialize.
2178        Mock::given(method("GET"))
2179            .and(path("/release/tables"))
2180            .and(query_param("release_id", "10"))
2181            .and(query_param("element_id", "34483"))
2182            .respond_with(ResponseTemplate::new(200).set_body_string(
2183                r#"{"name":"Monthly, SA","element_id":34483,"release_id":"10","elements":{
2184                    "34484":{"element_id":34484,"release_id":10,"parent_id":34483,
2185                        "series_id":"","type":"series","name":"All items","line":"1","level":"0",
2186                        "children":[
2187                            {"element_id":34485,"release_id":10,"parent_id":34484,
2188                             "series_id":"CPIFABSL","type":"series","name":"Food",
2189                             "line":"2","level":"1","children":[]}
2190                        ]}
2191                }}"#,
2192            ))
2193            .mount(&server)
2194            .await;
2195
2196        let table = client_for(&server)
2197            .release_tables(ReleaseId::new(10))
2198            .element(ReleaseElementId::new(34483))
2199            .send()
2200            .await
2201            .expect("release/tables parse");
2202        assert_eq!(table.name.as_deref(), Some("Monthly, SA"));
2203        assert_eq!(table.roots.len(), 1);
2204        let leaf = &table.roots[0].children[0];
2205        assert_eq!(
2206            leaf.series_id.as_ref().map(|s| s.as_str()),
2207            Some("CPIFABSL")
2208        );
2209    }
2210
2211    #[tokio::test]
2212    async fn release_tables_observation_values_reach_wire_and_parse() {
2213        let server = MockServer::start().await;
2214        // `.observation_date(..)` must send both `observation_date` (ISO) and
2215        // `include_observation_values=true`, and the per-element value/date
2216        // fields must deserialize onto the returned series row.
2217        Mock::given(method("GET"))
2218            .and(path("/release/tables"))
2219            .and(query_param("release_id", "10"))
2220            .and(query_param("include_observation_values", "true"))
2221            .and(query_param("observation_date", "2023-06-01"))
2222            .respond_with(ResponseTemplate::new(200).set_body_string(
2223                r#"{"release_id":"10","elements":{
2224                    "36715":{"element_id":36715,"release_id":10,"parent_id":36714,
2225                        "series_id":"CUSR0000SA0L5","type":"series","name":"All items",
2226                        "level":"1","observation_value":"292.260","observation_date":"Jun 2023",
2227                        "children":[]}
2228                }}"#,
2229            ))
2230            .mount(&server)
2231            .await;
2232
2233        let table = client_for(&server)
2234            .release_tables(ReleaseId::new(10))
2235            .observation_date(chrono::NaiveDate::from_ymd_opt(2023, 6, 1).unwrap())
2236            .send()
2237            .await
2238            .expect("release/tables with values parse");
2239        let row = &table.roots[0];
2240        assert_eq!(row.observation_value, Some(292.260));
2241        assert_eq!(row.observation_date.as_deref(), Some("Jun 2023"));
2242    }
2243
2244    #[tokio::test]
2245    async fn series_categories_parse() {
2246        let server = MockServer::start().await;
2247        Mock::given(method("GET"))
2248            .and(path("/series/categories"))
2249            .and(query_param("series_id", "GNPCA"))
2250            .respond_with(ResponseTemplate::new(200).set_body_string(
2251                r#"{"categories":[
2252                    {"id":106,"name":"Gross National Product","parent_id":18},
2253                    {"id":18,"name":"National Income & Product Accounts","parent_id":13}
2254                ]}"#,
2255            ))
2256            .mount(&server)
2257            .await;
2258
2259        let categories = client_for(&server)
2260            .series_categories(&SeriesId::new("GNPCA"))
2261            .await
2262            .expect("series/categories parse");
2263        assert_eq!(categories.len(), 2);
2264        assert_eq!(categories[0].id, CategoryId::new(106));
2265    }
2266
2267    #[tokio::test]
2268    async fn series_release_parses() {
2269        let server = MockServer::start().await;
2270        Mock::given(method("GET"))
2271            .and(path("/series/release"))
2272            .and(query_param("series_id", "GNPCA"))
2273            .respond_with(ResponseTemplate::new(200).set_body_string(
2274                r#"{"releases":[{"id":53,"name":"Gross Domestic Product","press_release":true}]}"#,
2275            ))
2276            .mount(&server)
2277            .await;
2278
2279        let release = client_for(&server)
2280            .series_release(&SeriesId::new("GNPCA"))
2281            .await
2282            .expect("series/release parse");
2283        assert_eq!(release.id, ReleaseId::new(53));
2284        assert_eq!(release.name, "Gross Domestic Product");
2285    }
2286
2287    #[tokio::test]
2288    async fn series_updates_sends_filter_and_parses() {
2289        let server = MockServer::start().await;
2290        Mock::given(method("GET"))
2291            .and(path("/series/updates"))
2292            .and(query_param("filter_value", "macro"))
2293            .and(query_param("limit", "2"))
2294            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
2295                "{{\"count\":5,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
2296            )))
2297            .mount(&server)
2298            .await;
2299
2300        let results = client_for(&server)
2301            .series_updates()
2302            .filter(UpdatesFilter::Macro)
2303            .limit(2)
2304            .send()
2305            .await
2306            .expect("series/updates parse");
2307        assert_eq!(results.count, 5);
2308        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
2309    }
2310
2311    #[tokio::test]
2312    async fn series_updates_sends_time_window_as_yyyymmddhhmm() {
2313        use chrono::NaiveDate;
2314        let server = MockServer::start().await;
2315        Mock::given(method("GET"))
2316            .and(path("/series/updates"))
2317            .and(query_param("start_time", "201803021420"))
2318            .and(query_param("end_time", "201803030905"))
2319            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
2320                "{{\"count\":1,\"offset\":0,\"limit\":1,\"seriess\":[{SERIES_OBJECT}]}}"
2321            )))
2322            .mount(&server)
2323            .await;
2324
2325        let start = NaiveDate::from_ymd_opt(2018, 3, 2)
2326            .unwrap()
2327            .and_hms_opt(14, 20, 0)
2328            .unwrap();
2329        let end = NaiveDate::from_ymd_opt(2018, 3, 3)
2330            .unwrap()
2331            .and_hms_opt(9, 5, 0)
2332            .unwrap();
2333        let results = client_for(&server)
2334            .series_updates()
2335            .time_window(start, end)
2336            .send()
2337            .await
2338            .expect("series/updates time-window parse");
2339        assert_eq!(results.count, 1);
2340    }
2341
2342    #[tokio::test]
2343    async fn series_vintagedates_send_id_and_parse() {
2344        let server = MockServer::start().await;
2345        Mock::given(method("GET"))
2346            .and(path("/series/vintagedates"))
2347            .and(query_param("series_id", "GNPCA"))
2348            .and(query_param("limit", "2"))
2349            .respond_with(ResponseTemplate::new(200).set_body_string(
2350                r#"{"count":3,"offset":0,"limit":2,"vintage_dates":["1958-12-21","1959-02-19"]}"#,
2351            ))
2352            .mount(&server)
2353            .await;
2354
2355        let dates = client_for(&server)
2356            .series_vintagedates(&SeriesId::new("GNPCA"))
2357            .limit(2)
2358            .send()
2359            .await
2360            .expect("series/vintagedates parse");
2361        assert_eq!(dates.count, 3);
2362        assert_eq!(dates.vintage_dates.len(), 2);
2363        assert_eq!(
2364            dates.vintage_dates[0],
2365            chrono::NaiveDate::from_ymd_opt(1958, 12, 21).unwrap()
2366        );
2367    }
2368
2369    // --- GeoFRED / Maps (ADR-0025) -------------------------------------------
2370
2371    #[tokio::test]
2372    async fn geofred_regional_data_sends_all_params_and_parses() {
2373        let server = MockServer::start().await;
2374        // The mock matches only when every required param — including the enum
2375        // query codes (region_type=state, frequency=a, season=NSA) — reaches the
2376        // wire on the `/geofred` base.
2377        Mock::given(method("GET"))
2378            .and(path("/regional/data"))
2379            .and(query_param("series_group", "882"))
2380            .and(query_param("region_type", "state"))
2381            .and(query_param("date", "2013-01-01"))
2382            .and(query_param("units", "Dollars"))
2383            .and(query_param("frequency", "a"))
2384            .and(query_param("season", "NSA"))
2385            .respond_with(ResponseTemplate::new(200).set_body_string(
2386                r#"{"meta":{"title":"t","region":"state","seasonality":"Not Seasonally Adjusted",
2387                    "units":"Dollars","frequency":"Annual","data":{"2013-01-01":[
2388                        {"region":"Alabama","code":"01","value":35706,"series_id":"ALPCPI"}
2389                    ]}}}"#,
2390            ))
2391            .mount(&server)
2392            .await;
2393
2394        let data = client_for(&server)
2395            .regional_data(
2396                &SeriesGroupId::new("882"),
2397                RegionType::State,
2398                chrono::NaiveDate::from_ymd_opt(2013, 1, 1).unwrap(),
2399                "Dollars",
2400                Frequency::Annual,
2401                SeasonalAdjustment::NotSeasonallyAdjusted,
2402            )
2403            .await
2404            .expect("regional data parses");
2405        let day = &data.meta.data["2013-01-01"];
2406        assert_eq!(day[0].region, "Alabama");
2407        assert_eq!(day[0].value, Some(35706.0));
2408        assert_eq!(day[0].series_id, SeriesId::new("ALPCPI"));
2409    }
2410
2411    #[tokio::test]
2412    async fn geofred_series_data_sends_optional_date_and_parses() {
2413        let server = MockServer::start().await;
2414        Mock::given(method("GET"))
2415            .and(path("/series/data"))
2416            .and(query_param("series_id", "SMU56000000500000001"))
2417            .and(query_param("date", "2013-01-01"))
2418            .respond_with(ResponseTemplate::new(200).set_body_string(
2419                r#"{"meta":{"title":"t","region":"state","seasonality":"Not Seasonally Adjusted",
2420                    "units":"Thousands of Persons","frequency":"Monthly","data":{"2013-01-01":[
2421                        {"region":"Alabama","code":"01","value":1506.5,"series_id":"SMU01000000500000001"}
2422                    ]}}}"#,
2423            ))
2424            .mount(&server)
2425            .await;
2426
2427        let data = client_for(&server)
2428            .series_data(&SeriesId::new("SMU56000000500000001"))
2429            .date(chrono::NaiveDate::from_ymd_opt(2013, 1, 1).unwrap())
2430            .send()
2431            .await
2432            .expect("series data parses");
2433        assert_eq!(data.meta.data["2013-01-01"][0].value, Some(1506.5));
2434    }
2435
2436    #[tokio::test]
2437    async fn geofred_series_group_unwraps_envelope_and_parses() {
2438        let server = MockServer::start().await;
2439        Mock::given(method("GET"))
2440            .and(path("/series/group"))
2441            .and(query_param("series_id", "SMU56000000500000001"))
2442            .respond_with(ResponseTemplate::new(200).set_body_string(
2443                r#"{"series_group":{"title":"All Employees: Total Private","region_type":"state",
2444                    "series_group":"1223","season":"NSA","units":"Thousands of Persons",
2445                    "frequency":"Monthly","min_date":"1990-01-01","max_date":"2026-05-01"}}"#,
2446            ))
2447            .mount(&server)
2448            .await;
2449
2450        let group = client_for(&server)
2451            .series_group(&SeriesId::new("SMU56000000500000001"))
2452            .await
2453            .expect("series group parses");
2454        assert_eq!(group.id, SeriesGroupId::new("1223"));
2455        assert_eq!(group.region_type, "state");
2456    }
2457
2458    #[tokio::test]
2459    async fn geofred_bad_series_500_becomes_actionable_error() {
2460        // FRED's GeoFRED endpoints answer an unknown or non-regional series with
2461        // an HTTP 500 carrying a generic body (verified live) — a 400-shaped
2462        // condition wearing a 500. We rewrite it into an actionable message that
2463        // names the id, while preserving the 500 status/code (#56).
2464        let server = MockServer::start().await;
2465        Mock::given(method("GET"))
2466            .and(path("/series/group"))
2467            .respond_with(
2468                ResponseTemplate::new(500).set_body_string(
2469                    r#"{"error_code":500,"error_message":"Internal Server Error"}"#,
2470                ),
2471            )
2472            .mount(&server)
2473            .await;
2474
2475        let error = client_for(&server)
2476            .series_group(&SeriesId::new("GNPCA"))
2477            .await
2478            .expect_err("a non-regional series should error");
2479        match error {
2480            Error::Api {
2481                status,
2482                code,
2483                message,
2484            } => {
2485                assert_eq!(status, 500);
2486                assert_eq!(code, Some(500));
2487                assert_ne!(
2488                    message, "Internal Server Error",
2489                    "the generic message should be rewritten"
2490                );
2491                assert!(
2492                    message.contains("series_id=GNPCA"),
2493                    "message was {message:?}"
2494                );
2495                assert!(
2496                    message.contains("regional"),
2497                    "message should point at the non-regional cause: {message:?}"
2498                );
2499            }
2500            other => panic!("expected Error::Api, got {other:?}"),
2501        }
2502    }
2503
2504    #[tokio::test]
2505    async fn geofred_shape_file_sends_shape_and_parses_geojson() {
2506        let server = MockServer::start().await;
2507        Mock::given(method("GET"))
2508            .and(path("/shapes/file"))
2509            .and(query_param("shape", "bea"))
2510            .respond_with(ResponseTemplate::new(200).set_body_string(
2511                r#"{"type":"FeatureCollection","name":"state_bea_region","features":[
2512                    {"type":"Feature","properties":{"bea_region":8},
2513                     "geometry":{"type":"MultiPolygon","coordinates":[[[[1485,2651]]]]}}
2514                ]}"#,
2515            ))
2516            .mount(&server)
2517            .await;
2518
2519        let shapes = client_for(&server)
2520            .shape_file(ShapeType::Bea)
2521            .await
2522            .expect("shape file parses");
2523        assert_eq!(shapes.kind, "FeatureCollection");
2524        assert_eq!(shapes.features[0].geometry.kind, "MultiPolygon");
2525    }
2526}