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.
892    pub async fn regional_data(
893        &self,
894        series_group: &SeriesGroupId,
895        region_type: RegionType,
896        date: NaiveDate,
897        units: impl Into<String>,
898        frequency: Frequency,
899        season: SeasonalAdjustment,
900    ) -> Result<RegionalData> {
901        self.get_geofred(
902            "/regional/data",
903            &[
904                ("series_group", series_group.as_str().to_owned()),
905                ("region_type", region_type.query_code().to_owned()),
906                ("date", date.to_string()),
907                ("units", units.into()),
908                ("frequency", frequency.query_code().to_owned()),
909                ("season", season.query_code().to_owned()),
910            ],
911        )
912        .await
913    }
914
915    /// Begin a request for one regional series' values across regions (the
916    /// GeoFRED `geofred/series/data` endpoint).
917    ///
918    /// Returns a builder; set an optional [`date`](SeriesDataRequest::date) or
919    /// [`start_date`](SeriesDataRequest::start_date) and call
920    /// [`send`](SeriesDataRequest::send). With neither set, FRED returns the most
921    /// recent date.
922    ///
923    /// ```no_run
924    /// # async fn run(client: &ferric_fred::Client) -> ferric_fred::Result<()> {
925    /// use ferric_fred::SeriesId;
926    /// let data = client
927    ///     .series_data(&SeriesId::new("SMU56000000500000001"))
928    ///     .send()
929    ///     .await?;
930    /// println!("{} dates", data.meta.data.len());
931    /// # Ok(())
932    /// # }
933    /// ```
934    pub fn series_data(&self, series_id: &SeriesId) -> SeriesDataRequest<'_> {
935        SeriesDataRequest::new(self, series_id.clone())
936    }
937
938    /// Run a GeoFRED series/data request (invoked by
939    /// [`SeriesDataRequest::send`]).
940    pub(crate) async fn execute_series_data(
941        &self,
942        request: &SeriesDataRequest<'_>,
943    ) -> Result<RegionalData> {
944        self.get_geofred("/series/data", &request.query_params())
945            .await
946    }
947
948    /// Fetch the series-group metadata for a regional series (the GeoFRED
949    /// `geofred/series/group` endpoint) — pass a regional `series_id` and get
950    /// back the group it belongs to (title, region type, date span).
951    ///
952    /// # Errors
953    ///
954    /// Returns an error if the request fails to send, FRED returns a non-success
955    /// status, or the response body cannot be deserialized.
956    pub async fn series_group(&self, series_id: &SeriesId) -> Result<SeriesGroup> {
957        let response: SeriesGroupResponse = self
958            .get_geofred(
959                "/series/group",
960                &[("series_id", series_id.as_str().to_owned())],
961            )
962            .await?;
963        Ok(response.series_group)
964    }
965
966    /// Fetch the region boundary polygons for a shape type (the GeoFRED
967    /// `geofred/shapes/file` endpoint) — a GeoJSON [`ShapeFile`] this crate
968    /// transports without interpreting (ADR-0025).
969    ///
970    /// # Errors
971    ///
972    /// Returns an error if the request fails to send, FRED returns a non-success
973    /// status, or the response body cannot be deserialized.
974    pub async fn shape_file(&self, shape: ShapeType) -> Result<ShapeFile> {
975        self.get_geofred("/shapes/file", &[("shape", shape.query_code().to_owned())])
976            .await
977    }
978
979    /// GET a core-FRED `path` with `params`; see [`get_from`](Self::get_from).
980    async fn get<T: DeserializeOwned>(
981        &self,
982        path: &str,
983        params: &[(&'static str, String)],
984    ) -> Result<T> {
985        self.get_from(&self.base_url, path, params).await
986    }
987
988    /// GET a GeoFRED / Maps `path` with `params` (the `/geofred` base);
989    /// see [`get_from`](Self::get_from).
990    async fn get_geofred<T: DeserializeOwned>(
991        &self,
992        path: &str,
993        params: &[(&'static str, String)],
994    ) -> Result<T> {
995        self.get_from(&self.geofred_base_url, path, params).await
996    }
997
998    /// GET `base_url` + `path` with `params` plus `api_key`/`file_type`, then
999    /// deserialize the JSON body as `T`. A non-success status becomes
1000    /// [`Error::Api`] (or [`Error::RateLimited`]); a body that doesn't match `T`
1001    /// becomes [`Error::Deserialize`].
1002    async fn get_from<T: DeserializeOwned>(
1003        &self,
1004        base_url: &str,
1005        path: &str,
1006        params: &[(&'static str, String)],
1007    ) -> Result<T> {
1008        let mut query: Vec<(&str, String)> = Vec::with_capacity(params.len() + 2);
1009        query.push(("api_key", self.api_key.clone()));
1010        query.push(("file_type", "json".to_owned()));
1011        query.extend(params.iter().cloned());
1012
1013        let response = self
1014            .http
1015            .get(format!("{base_url}{path}"))
1016            .query(&query)
1017            .send()
1018            .await?;
1019
1020        let status = response.status();
1021        // Read `Retry-After` before consuming the response into its body.
1022        let retry_after = parse_retry_after(response.headers());
1023        let body = response.bytes().await?;
1024
1025        if !status.is_success() {
1026            return Err(api_error(status, retry_after, &body));
1027        }
1028
1029        serde_json::from_slice(&body).map_err(Error::from)
1030    }
1031}
1032
1033/// Join tag names with `;`, FRED's multi-value separator for the `tag_names`
1034/// parameter (shared by every endpoint that takes a seed tag set).
1035fn join_tag_names<I, S>(tag_names: I) -> String
1036where
1037    I: IntoIterator<Item = S>,
1038    S: AsRef<str>,
1039{
1040    tag_names
1041        .into_iter()
1042        .map(|name| name.as_ref().to_owned())
1043        .collect::<Vec<_>>()
1044        .join(";")
1045}
1046
1047/// Build an [`Error`] from a non-success FRED response, decoding FRED's error
1048/// body (`{"error_code": N, "error_message": "..."}`) when present. `retry_after`
1049/// is the parsed `Retry-After` header, carried through on a `429`.
1050fn api_error(status: reqwest::StatusCode, retry_after: Option<Duration>, body: &[u8]) -> Error {
1051    let fred: Option<FredErrorBody> = serde_json::from_slice(body).ok();
1052    let code = fred.as_ref().and_then(|e| e.error_code);
1053    let message = fred.and_then(|e| e.error_message).unwrap_or_else(|| {
1054        status
1055            .canonical_reason()
1056            .unwrap_or("unknown error")
1057            .to_owned()
1058    });
1059
1060    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1061        return Error::RateLimited { retry_after };
1062    }
1063
1064    Error::Api {
1065        status: status.as_u16(),
1066        code,
1067        message,
1068    }
1069}
1070
1071/// Parse a `Retry-After` header, if present, as a whole number of seconds
1072/// (FRED's form). The HTTP-date form is not used by FRED and is treated as
1073/// absent.
1074fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
1075    let seconds: u64 = headers
1076        .get(reqwest::header::RETRY_AFTER)?
1077        .to_str()
1078        .ok()?
1079        .trim()
1080        .parse()
1081        .ok()?;
1082    Some(Duration::from_secs(seconds))
1083}
1084
1085/// The `series/observations` response envelope. Metadata fields (realtime range,
1086/// units, paging) are ignored for this slice; serde drops unknown fields.
1087#[derive(Deserialize)]
1088struct ObservationsResponse {
1089    observations: Vec<Observation>,
1090}
1091
1092/// The `series` response envelope. FRED pluralizes the array key as `seriess`
1093/// (sic); other metadata fields are ignored for this slice.
1094#[derive(Deserialize)]
1095struct SeriesResponse {
1096    seriess: Vec<Series>,
1097}
1098
1099/// The `category` / `category/children` response envelope.
1100#[derive(Deserialize)]
1101struct CategoriesResponse {
1102    categories: Vec<Category>,
1103}
1104
1105/// The single-`release` response envelope. The `releases` list endpoint
1106/// deserializes into [`ReleasesResults`] directly (it carries pagination);
1107/// `fred/release` returns only the array.
1108#[derive(Deserialize)]
1109struct ReleaseResponse {
1110    releases: Vec<Release>,
1111}
1112
1113/// The `source` / `release/sources` response envelope: a bare `sources` array
1114/// (the paginated `sources` list endpoint deserializes into [`SourcesResults`]
1115/// directly; `release/sources` is unpaginated, so it uses this).
1116#[derive(Deserialize)]
1117struct SourceResponse {
1118    sources: Vec<Source>,
1119}
1120
1121/// The GeoFRED `series/group` response envelope: `{ "series_group": { … } }`.
1122#[derive(Deserialize)]
1123struct SeriesGroupResponse {
1124    series_group: SeriesGroup,
1125}
1126
1127/// FRED's error response body.
1128#[derive(Deserialize)]
1129struct FredErrorBody {
1130    error_code: Option<u32>,
1131    error_message: Option<String>,
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::Client;
1137    use std::time::Duration;
1138
1139    use crate::{
1140        CategoryId, Error, Frequency, OrderBy, Paginate, RegionType, ReleaseElementId, ReleaseId,
1141        SeasonalAdjustment, SeriesGroupId, SeriesId, ShapeType, SortOrder, SourceId, Units,
1142        UpdatesFilter,
1143    };
1144    use wiremock::matchers::{method, path, query_param};
1145    use wiremock::{Mock, MockServer, ResponseTemplate};
1146
1147    /// A representative `seriess[0]` object, reused across the response bodies.
1148    const SERIES_OBJECT: &str = r#"{
1149        "id": "GNPCA",
1150        "title": "Real Gross National Product",
1151        "observation_start": "1929-01-01",
1152        "observation_end": "2023-01-01",
1153        "frequency": "Annual",
1154        "units": "Billions of Chained 2017 Dollars",
1155        "seasonal_adjustment": "Not Seasonally Adjusted",
1156        "last_updated": "2024-03-28 07:56:03-05",
1157        "popularity": 76,
1158        "notes": "BEA Account Code: A001RX"
1159    }"#;
1160
1161    fn client_for(server: &MockServer) -> Client {
1162        Client::with_base_url("test-key", server.uri()).expect("client builds")
1163    }
1164
1165    #[tokio::test]
1166    async fn series_parses_metadata() {
1167        let server = MockServer::start().await;
1168        Mock::given(method("GET"))
1169            .and(path("/series"))
1170            .respond_with(
1171                ResponseTemplate::new(200)
1172                    .set_body_string(format!("{{\"seriess\":[{SERIES_OBJECT}]}}")),
1173            )
1174            .mount(&server)
1175            .await;
1176
1177        let series = client_for(&server)
1178            .series(&SeriesId::new("GNPCA"))
1179            .await
1180            .expect("series parses");
1181        assert_eq!(series.id, SeriesId::new("GNPCA"));
1182        assert_eq!(series.frequency, Frequency::Annual);
1183        assert_eq!(
1184            series.seasonal_adjustment,
1185            SeasonalAdjustment::NotSeasonallyAdjusted
1186        );
1187        assert_eq!(series.popularity, 76);
1188    }
1189
1190    #[tokio::test]
1191    async fn observations_parse_missing_and_present_values() {
1192        let server = MockServer::start().await;
1193        let body = r#"{"observations":[
1194            {"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1930-01-01","value":"."},
1195            {"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1929-01-01","value":"1065.9"}
1196        ]}"#;
1197        Mock::given(method("GET"))
1198            .and(path("/series/observations"))
1199            .respond_with(ResponseTemplate::new(200).set_body_string(body))
1200            .mount(&server)
1201            .await;
1202
1203        let observations = client_for(&server)
1204            .observations(&SeriesId::new("GNPCA"))
1205            .send()
1206            .await
1207            .expect("observations parse");
1208        assert_eq!(observations.len(), 2);
1209        assert_eq!(observations[0].value, None); // the "." sentinel
1210        assert_eq!(observations[1].value, Some(1065.9));
1211    }
1212
1213    #[tokio::test]
1214    async fn observations_point_in_time_sends_realtime_and_parses_period() {
1215        let server = MockServer::start().await;
1216        // A point-in-time query: realtime_start == realtime_end must reach the
1217        // wire, and each row's archived real-time period must deserialize.
1218        Mock::given(method("GET"))
1219            .and(path("/series/observations"))
1220            .and(query_param("realtime_start", "2020-01-01"))
1221            .and(query_param("realtime_end", "2020-01-01"))
1222            .respond_with(ResponseTemplate::new(200).set_body_string(
1223                r#"{"observations":[
1224                    {"realtime_start":"2020-01-01","realtime_end":"2020-01-01","date":"2017-01-01","value":"18344.563"}
1225                ]}"#,
1226            ))
1227            .mount(&server)
1228            .await;
1229
1230        let as_of = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
1231        let observations = client_for(&server)
1232            .observations(&SeriesId::new("GNPCA"))
1233            .realtime(as_of, as_of)
1234            .send()
1235            .await
1236            .expect("point-in-time observations parse");
1237        assert_eq!(observations[0].realtime_start, as_of);
1238        assert_eq!(observations[0].realtime_end, as_of);
1239        assert_eq!(observations[0].value, Some(18344.563));
1240    }
1241
1242    #[tokio::test]
1243    async fn search_parses_results_with_pagination() {
1244        let server = MockServer::start().await;
1245        let body =
1246            format!("{{\"count\":1,\"offset\":0,\"limit\":1000,\"seriess\":[{SERIES_OBJECT}]}}");
1247        Mock::given(method("GET"))
1248            .and(path("/series/search"))
1249            .respond_with(ResponseTemplate::new(200).set_body_string(body))
1250            .mount(&server)
1251            .await;
1252
1253        let results = client_for(&server)
1254            .search("real gnp")
1255            .send()
1256            .await
1257            .expect("search parses");
1258        assert_eq!(results.count, 1);
1259        assert_eq!(results.series.len(), 1);
1260        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1261    }
1262
1263    #[tokio::test]
1264    async fn error_status_with_body_maps_to_api_error() {
1265        let server = MockServer::start().await;
1266        let body = r#"{"error_code":400,"error_message":"Bad Request. Invalid value for variable series_id."}"#;
1267        Mock::given(method("GET"))
1268            .and(path("/series"))
1269            .respond_with(ResponseTemplate::new(400).set_body_string(body))
1270            .mount(&server)
1271            .await;
1272
1273        let error = client_for(&server)
1274            .series(&SeriesId::new("BAD"))
1275            .await
1276            .expect_err("a 400 should be an API error");
1277        match error {
1278            Error::Api {
1279                status,
1280                code,
1281                message,
1282            } => {
1283                assert_eq!(status, 400);
1284                assert_eq!(code, Some(400));
1285                assert!(message.contains("Invalid value"), "message was {message:?}");
1286            }
1287            other => panic!("expected Error::Api, got {other:?}"),
1288        }
1289    }
1290
1291    #[tokio::test]
1292    async fn too_many_requests_maps_to_rate_limited() {
1293        let server = MockServer::start().await;
1294        Mock::given(method("GET"))
1295            .and(path("/series"))
1296            .respond_with(ResponseTemplate::new(429))
1297            .mount(&server)
1298            .await;
1299
1300        let error = client_for(&server)
1301            .series(&SeriesId::new("GNPCA"))
1302            .await
1303            .expect_err("a 429 should be rate-limited");
1304        assert!(matches!(error, Error::RateLimited { .. }), "got {error:?}");
1305    }
1306
1307    #[tokio::test]
1308    async fn rate_limited_carries_retry_after_seconds() {
1309        let server = MockServer::start().await;
1310        Mock::given(method("GET"))
1311            .and(path("/series"))
1312            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "120"))
1313            .mount(&server)
1314            .await;
1315
1316        let error = client_for(&server)
1317            .series(&SeriesId::new("GNPCA"))
1318            .await
1319            .expect_err("a 429 should be rate-limited");
1320        match error {
1321            Error::RateLimited { retry_after } => {
1322                assert_eq!(retry_after, Some(Duration::from_secs(120)));
1323            }
1324            other => panic!("expected Error::RateLimited, got {other:?}"),
1325        }
1326    }
1327
1328    #[tokio::test]
1329    async fn send_all_walks_every_page() {
1330        let server = MockServer::start().await;
1331        // First page (offset 0) reports a total of 3 and returns 2 sources; the
1332        // second page (offset 2) returns the last one. `send_all` should stitch
1333        // the two into one Vec and stop once it has walked past `count`.
1334        Mock::given(method("GET"))
1335            .and(path("/sources"))
1336            .and(query_param("offset", "0"))
1337            .respond_with(ResponseTemplate::new(200).set_body_string(
1338                r#"{"count":3,"offset":0,"limit":1000,"sources":[
1339                    {"id":1,"name":"Source One"},
1340                    {"id":2,"name":"Source Two"}
1341                ]}"#,
1342            ))
1343            .mount(&server)
1344            .await;
1345        Mock::given(method("GET"))
1346            .and(path("/sources"))
1347            .and(query_param("offset", "2"))
1348            .respond_with(ResponseTemplate::new(200).set_body_string(
1349                r#"{"count":3,"offset":2,"limit":1000,"sources":[
1350                    {"id":3,"name":"Source Three"}
1351                ]}"#,
1352            ))
1353            .mount(&server)
1354            .await;
1355
1356        let sources = client_for(&server)
1357            .sources()
1358            .send_all()
1359            .await
1360            .expect("send_all walks both pages");
1361        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1362        assert_eq!(
1363            ids,
1364            vec![SourceId::new(1), SourceId::new(2), SourceId::new(3)]
1365        );
1366    }
1367
1368    #[tokio::test]
1369    async fn send_all_treats_limit_as_a_ceiling() {
1370        let server = MockServer::start().await;
1371        // Only an offset-0 page is mocked, and only for a limit of 2. `count` is
1372        // 5, but a `.limit(2)` ceiling must stop `send_all` after one request of
1373        // exactly two — a second page request would 404 and fail the test.
1374        Mock::given(method("GET"))
1375            .and(path("/sources"))
1376            .and(query_param("offset", "0"))
1377            .and(query_param("limit", "2"))
1378            .respond_with(ResponseTemplate::new(200).set_body_string(
1379                r#"{"count":5,"offset":0,"limit":2,"sources":[
1380                    {"id":1,"name":"Source One"},
1381                    {"id":2,"name":"Source Two"}
1382                ]}"#,
1383            ))
1384            .mount(&server)
1385            .await;
1386
1387        let sources = client_for(&server)
1388            .sources()
1389            .limit(2)
1390            .send_all()
1391            .await
1392            .expect("send_all stops at the ceiling");
1393        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1394        assert_eq!(ids, vec![SourceId::new(1), SourceId::new(2)]);
1395    }
1396
1397    #[tokio::test]
1398    async fn send_all_retries_after_a_429() {
1399        let server = MockServer::start().await;
1400        // The first request is rate-limited with `Retry-After: 0` (so the retry
1401        // sleeps for no real time); the retry then succeeds. Priority + a
1402        // one-shot cap make the 429 fire first, then fall through to the 200.
1403        Mock::given(method("GET"))
1404            .and(path("/sources"))
1405            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
1406            .up_to_n_times(1)
1407            .with_priority(1)
1408            .mount(&server)
1409            .await;
1410        Mock::given(method("GET"))
1411            .and(path("/sources"))
1412            .respond_with(ResponseTemplate::new(200).set_body_string(
1413                r#"{"count":1,"offset":0,"limit":1000,"sources":[
1414                    {"id":1,"name":"Source One"}
1415                ]}"#,
1416            ))
1417            .with_priority(2)
1418            .mount(&server)
1419            .await;
1420
1421        let sources = client_for(&server)
1422            .sources()
1423            .send_all()
1424            .await
1425            .expect("send_all retries the 429 and then succeeds");
1426        assert_eq!(sources.len(), 1);
1427        assert_eq!(sources[0].id, SourceId::new(1));
1428    }
1429
1430    #[tokio::test]
1431    async fn stream_walks_every_page() {
1432        use futures_util::TryStreamExt;
1433
1434        let server = MockServer::start().await;
1435        Mock::given(method("GET"))
1436            .and(path("/sources"))
1437            .and(query_param("offset", "0"))
1438            .respond_with(ResponseTemplate::new(200).set_body_string(
1439                r#"{"count":3,"offset":0,"limit":1000,"sources":[
1440                    {"id":1,"name":"Source One"},
1441                    {"id":2,"name":"Source Two"}
1442                ]}"#,
1443            ))
1444            .mount(&server)
1445            .await;
1446        Mock::given(method("GET"))
1447            .and(path("/sources"))
1448            .and(query_param("offset", "2"))
1449            .respond_with(ResponseTemplate::new(200).set_body_string(
1450                r#"{"count":3,"offset":2,"limit":1000,"sources":[
1451                    {"id":3,"name":"Source Three"}
1452                ]}"#,
1453            ))
1454            .mount(&server)
1455            .await;
1456
1457        let sources: Vec<_> = client_for(&server)
1458            .sources()
1459            .stream()
1460            .try_collect()
1461            .await
1462            .expect("stream walks both pages");
1463        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1464        assert_eq!(
1465            ids,
1466            vec![SourceId::new(1), SourceId::new(2), SourceId::new(3)]
1467        );
1468    }
1469
1470    #[tokio::test]
1471    async fn stream_treats_limit_as_a_ceiling() {
1472        use futures_util::TryStreamExt;
1473
1474        let server = MockServer::start().await;
1475        // Only an offset-0, limit-2 page is mocked; a `.limit(2)` ceiling must
1476        // stop the stream after it, without ever requesting a second page.
1477        Mock::given(method("GET"))
1478            .and(path("/sources"))
1479            .and(query_param("offset", "0"))
1480            .and(query_param("limit", "2"))
1481            .respond_with(ResponseTemplate::new(200).set_body_string(
1482                r#"{"count":5,"offset":0,"limit":2,"sources":[
1483                    {"id":1,"name":"Source One"},
1484                    {"id":2,"name":"Source Two"}
1485                ]}"#,
1486            ))
1487            .mount(&server)
1488            .await;
1489
1490        let sources: Vec<_> = client_for(&server)
1491            .sources()
1492            .limit(2)
1493            .stream()
1494            .try_collect()
1495            .await
1496            .expect("stream stops at the ceiling");
1497        let ids: Vec<_> = sources.iter().map(|s| s.id).collect();
1498        assert_eq!(ids, vec![SourceId::new(1), SourceId::new(2)]);
1499    }
1500
1501    #[tokio::test]
1502    async fn stream_surfaces_a_mid_stream_error() {
1503        use futures_util::StreamExt;
1504
1505        let server = MockServer::start().await;
1506        // Page one succeeds; page two (offset 2) fails. The items from page one
1507        // arrive as `Ok`, then the error arrives as a final `Err` item.
1508        Mock::given(method("GET"))
1509            .and(path("/sources"))
1510            .and(query_param("offset", "0"))
1511            .respond_with(ResponseTemplate::new(200).set_body_string(
1512                r#"{"count":4,"offset":0,"limit":1000,"sources":[
1513                    {"id":1,"name":"Source One"},
1514                    {"id":2,"name":"Source Two"}
1515                ]}"#,
1516            ))
1517            .mount(&server)
1518            .await;
1519        Mock::given(method("GET"))
1520            .and(path("/sources"))
1521            .and(query_param("offset", "2"))
1522            .respond_with(ResponseTemplate::new(500))
1523            .mount(&server)
1524            .await;
1525
1526        let results: Vec<_> = client_for(&server).sources().stream().collect().await;
1527        assert_eq!(results.len(), 3);
1528        assert_eq!(
1529            results[0].as_ref().expect("first item is Ok").id,
1530            SourceId::new(1)
1531        );
1532        assert_eq!(
1533            results[1].as_ref().expect("second item is Ok").id,
1534            SourceId::new(2)
1535        );
1536        assert!(
1537            matches!(results[2], Err(Error::Api { status: 500, .. })),
1538            "third item should be the page-two error, got {:?}",
1539            results[2]
1540        );
1541    }
1542
1543    #[tokio::test]
1544    async fn malformed_body_maps_to_deserialize_error() {
1545        let server = MockServer::start().await;
1546        Mock::given(method("GET"))
1547            .and(path("/series"))
1548            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"unexpected":true}"#))
1549            .mount(&server)
1550            .await;
1551
1552        let error = client_for(&server)
1553            .series(&SeriesId::new("GNPCA"))
1554            .await
1555            .expect_err("an unexpected body should fail to deserialize");
1556        assert!(matches!(error, Error::Deserialize(_)), "got {error:?}");
1557    }
1558
1559    #[tokio::test]
1560    async fn request_carries_api_key_file_type_and_params() {
1561        let server = MockServer::start().await;
1562        // This mock only matches when every expected query parameter is present;
1563        // an unmatched request 404s and the call fails. So a *successful* call
1564        // proves the client sent api_key, file_type, and the builder's params.
1565        Mock::given(method("GET"))
1566            .and(path("/series/observations"))
1567            .and(query_param("api_key", "test-key"))
1568            .and(query_param("file_type", "json"))
1569            .and(query_param("units", "pch"))
1570            .and(query_param("limit", "5"))
1571            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"observations":[]}"#))
1572            .mount(&server)
1573            .await;
1574
1575        let observations = client_for(&server)
1576            .observations(&SeriesId::new("GNPCA"))
1577            .units(Units::PercentChange)
1578            .limit(5)
1579            .send()
1580            .await
1581            .expect("request with the expected params should match the mock");
1582        assert!(observations.is_empty());
1583    }
1584
1585    #[tokio::test]
1586    async fn category_parses() {
1587        let server = MockServer::start().await;
1588        Mock::given(method("GET"))
1589            .and(path("/category"))
1590            .respond_with(ResponseTemplate::new(200).set_body_string(
1591                r#"{"categories":[{"id":125,"name":"Trade Balance","parent_id":13}]}"#,
1592            ))
1593            .mount(&server)
1594            .await;
1595
1596        let category = client_for(&server)
1597            .category(CategoryId::new(125))
1598            .await
1599            .expect("category parses");
1600        assert_eq!(category.id, CategoryId::new(125));
1601        assert_eq!(category.name, "Trade Balance");
1602        assert_eq!(category.parent_id, CategoryId::new(13));
1603    }
1604
1605    #[tokio::test]
1606    async fn category_children_parse() {
1607        let server = MockServer::start().await;
1608        Mock::given(method("GET"))
1609            .and(path("/category/children"))
1610            .respond_with(ResponseTemplate::new(200).set_body_string(
1611                r#"{"categories":[
1612                    {"id":16,"name":"Exports","parent_id":13},
1613                    {"id":17,"name":"Imports","parent_id":13}
1614                ]}"#,
1615            ))
1616            .mount(&server)
1617            .await;
1618
1619        let children = client_for(&server)
1620            .category_children(CategoryId::new(13))
1621            .await
1622            .expect("children parse");
1623        assert_eq!(children.len(), 2);
1624        assert_eq!(children[0].name, "Exports");
1625        assert_eq!(children[1].id, CategoryId::new(17));
1626    }
1627
1628    #[tokio::test]
1629    async fn category_related_parse() {
1630        let server = MockServer::start().await;
1631        Mock::given(method("GET"))
1632            .and(path("/category/related"))
1633            .and(query_param("category_id", "32073"))
1634            .respond_with(ResponseTemplate::new(200).set_body_string(
1635                r#"{"categories":[
1636                    {"id":149,"name":"Arkansas","parent_id":27281},
1637                    {"id":150,"name":"Illinois","parent_id":27281}
1638                ]}"#,
1639            ))
1640            .mount(&server)
1641            .await;
1642
1643        let related = client_for(&server)
1644            .category_related(CategoryId::new(32073))
1645            .await
1646            .expect("category/related parse");
1647        assert_eq!(related.len(), 2);
1648        assert_eq!(related[0].name, "Arkansas");
1649        assert_eq!(related[1].id, CategoryId::new(150));
1650    }
1651
1652    #[tokio::test]
1653    async fn category_series_sends_params_and_parses() {
1654        let server = MockServer::start().await;
1655        // Matches only when the builder's params reach the wire.
1656        Mock::given(method("GET"))
1657            .and(path("/category/series"))
1658            .and(query_param("category_id", "125"))
1659            .and(query_param("order_by", "popularity"))
1660            .and(query_param("limit", "2"))
1661            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
1662                "{{\"count\":1,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
1663            )))
1664            .mount(&server)
1665            .await;
1666
1667        let results = client_for(&server)
1668            .category_series(CategoryId::new(125))
1669            .order_by(OrderBy::Popularity)
1670            .limit(2)
1671            .send()
1672            .await
1673            .expect("category series parse");
1674        assert_eq!(results.count, 1);
1675        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1676    }
1677
1678    #[tokio::test]
1679    async fn releases_parse_with_pagination() {
1680        let server = MockServer::start().await;
1681        Mock::given(method("GET"))
1682            .and(path("/releases"))
1683            .respond_with(ResponseTemplate::new(200).set_body_string(
1684                r#"{"count":2,"offset":0,"limit":1000,"releases":[
1685                    {"id":9,"name":"Advance Monthly Sales","press_release":false},
1686                    {"id":53,"name":"Gross Domestic Product","press_release":true,"link":"http://bea.gov"}
1687                ]}"#,
1688            ))
1689            .mount(&server)
1690            .await;
1691
1692        let results = client_for(&server)
1693            .releases()
1694            .send()
1695            .await
1696            .expect("releases parse");
1697        assert_eq!(results.count, 2);
1698        assert_eq!(results.releases[1].id, ReleaseId::new(53));
1699        assert_eq!(results.releases[1].link.as_deref(), Some("http://bea.gov"));
1700    }
1701
1702    #[tokio::test]
1703    async fn release_parses() {
1704        let server = MockServer::start().await;
1705        Mock::given(method("GET"))
1706            .and(path("/release"))
1707            .and(query_param("release_id", "53"))
1708            .respond_with(ResponseTemplate::new(200).set_body_string(
1709                r#"{"releases":[{"id":53,"name":"Gross Domestic Product","press_release":true}]}"#,
1710            ))
1711            .mount(&server)
1712            .await;
1713
1714        let release = client_for(&server)
1715            .release(ReleaseId::new(53))
1716            .await
1717            .expect("release parses");
1718        assert_eq!(release.id, ReleaseId::new(53));
1719        assert_eq!(release.name, "Gross Domestic Product");
1720        assert!(release.press_release);
1721    }
1722
1723    #[tokio::test]
1724    async fn release_series_sends_params_and_parses() {
1725        let server = MockServer::start().await;
1726        Mock::given(method("GET"))
1727            .and(path("/release/series"))
1728            .and(query_param("release_id", "53"))
1729            .and(query_param("limit", "2"))
1730            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
1731                "{{\"count\":1,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
1732            )))
1733            .mount(&server)
1734            .await;
1735
1736        let results = client_for(&server)
1737            .release_series(ReleaseId::new(53))
1738            .limit(2)
1739            .send()
1740            .await
1741            .expect("release series parse");
1742        assert_eq!(results.count, 1);
1743        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1744    }
1745
1746    #[tokio::test]
1747    async fn tags_search_sends_text_and_parses() {
1748        let server = MockServer::start().await;
1749        Mock::given(method("GET"))
1750            .and(path("/tags"))
1751            .and(query_param("search_text", "gdp"))
1752            .respond_with(ResponseTemplate::new(200).set_body_string(
1753                r#"{"count":1,"offset":0,"limit":1000,"tags":[
1754                    {"name":"gdp","group_id":"gen","popularity":80,"series_count":12345}
1755                ]}"#,
1756            ))
1757            .mount(&server)
1758            .await;
1759
1760        let results = client_for(&server)
1761            .tags()
1762            .search_text("gdp")
1763            .send()
1764            .await
1765            .expect("tags parse");
1766        assert_eq!(results.count, 1);
1767        assert_eq!(results.tags[0].name, "gdp");
1768        assert_eq!(results.tags[0].series_count, 12345);
1769    }
1770
1771    #[tokio::test]
1772    async fn related_tags_send_seed_names_and_parses() {
1773        let server = MockServer::start().await;
1774        // The seed tags reach `/related_tags` joined by `;`.
1775        Mock::given(method("GET"))
1776            .and(path("/related_tags"))
1777            .and(query_param("tag_names", "gdp;quarterly"))
1778            .respond_with(ResponseTemplate::new(200).set_body_string(
1779                r#"{"count":1,"offset":0,"limit":1000,"tags":[
1780                    {"name":"nsa","group_id":"seas","popularity":90,"series_count":42}
1781                ]}"#,
1782            ))
1783            .mount(&server)
1784            .await;
1785
1786        let results = client_for(&server)
1787            .related_tags(["gdp", "quarterly"])
1788            .send()
1789            .await
1790            .expect("related_tags parse");
1791        assert_eq!(results.count, 1);
1792        assert_eq!(results.tags[0].name, "nsa");
1793    }
1794
1795    /// A minimal single-tag `tags` response body, reused by the scoped-tag tests.
1796    const ONE_TAG_BODY: &str = r#"{"count":1,"offset":0,"limit":1000,"tags":[
1797        {"name":"gdp","group_id":"gen","popularity":80,"series_count":42}
1798    ]}"#;
1799
1800    #[tokio::test]
1801    async fn category_tags_send_scope_and_parse() {
1802        let server = MockServer::start().await;
1803        Mock::given(method("GET"))
1804            .and(path("/category/tags"))
1805            .and(query_param("category_id", "125"))
1806            .and(query_param("search_text", "gdp"))
1807            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1808            .mount(&server)
1809            .await;
1810
1811        let results = client_for(&server)
1812            .category_tags(CategoryId::new(125))
1813            .search_text("gdp")
1814            .send()
1815            .await
1816            .expect("category/tags parse");
1817        assert_eq!(results.count, 1);
1818        assert_eq!(results.tags[0].name, "gdp");
1819    }
1820
1821    #[tokio::test]
1822    async fn category_related_tags_send_scope_and_seed_and_parse() {
1823        let server = MockServer::start().await;
1824        Mock::given(method("GET"))
1825            .and(path("/category/related_tags"))
1826            .and(query_param("category_id", "125"))
1827            .and(query_param("tag_names", "gdp;quarterly"))
1828            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1829            .mount(&server)
1830            .await;
1831
1832        let results = client_for(&server)
1833            .category_related_tags(CategoryId::new(125), ["gdp", "quarterly"])
1834            .send()
1835            .await
1836            .expect("category/related_tags parse");
1837        assert_eq!(results.count, 1);
1838    }
1839
1840    #[tokio::test]
1841    async fn release_tags_send_scope_and_parse() {
1842        let server = MockServer::start().await;
1843        Mock::given(method("GET"))
1844            .and(path("/release/tags"))
1845            .and(query_param("release_id", "53"))
1846            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1847            .mount(&server)
1848            .await;
1849
1850        let results = client_for(&server)
1851            .release_tags(ReleaseId::new(53))
1852            .send()
1853            .await
1854            .expect("release/tags parse");
1855        assert_eq!(results.count, 1);
1856    }
1857
1858    #[tokio::test]
1859    async fn release_related_tags_send_scope_and_seed_and_parse() {
1860        let server = MockServer::start().await;
1861        Mock::given(method("GET"))
1862            .and(path("/release/related_tags"))
1863            .and(query_param("release_id", "53"))
1864            .and(query_param("tag_names", "gdp"))
1865            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1866            .mount(&server)
1867            .await;
1868
1869        let results = client_for(&server)
1870            .release_related_tags(ReleaseId::new(53), ["gdp"])
1871            .send()
1872            .await
1873            .expect("release/related_tags parse");
1874        assert_eq!(results.count, 1);
1875    }
1876
1877    #[tokio::test]
1878    async fn series_search_tags_send_scope_and_tag_search_text() {
1879        let server = MockServer::start().await;
1880        // series/search/* sends the tag filter under `tag_search_text`, not
1881        // `search_text`; the mock only matches if that key is used.
1882        Mock::given(method("GET"))
1883            .and(path("/series/search/tags"))
1884            .and(query_param("series_search_text", "unemployment"))
1885            .and(query_param("tag_search_text", "rate"))
1886            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1887            .mount(&server)
1888            .await;
1889
1890        let results = client_for(&server)
1891            .series_search_tags("unemployment")
1892            .search_text("rate")
1893            .send()
1894            .await
1895            .expect("series/search/tags parse");
1896        assert_eq!(results.count, 1);
1897    }
1898
1899    #[tokio::test]
1900    async fn series_search_related_tags_send_scope_and_seed() {
1901        let server = MockServer::start().await;
1902        Mock::given(method("GET"))
1903            .and(path("/series/search/related_tags"))
1904            .and(query_param("series_search_text", "unemployment"))
1905            .and(query_param("tag_names", "monthly"))
1906            .respond_with(ResponseTemplate::new(200).set_body_string(ONE_TAG_BODY))
1907            .mount(&server)
1908            .await;
1909
1910        let results = client_for(&server)
1911            .series_search_related_tags("unemployment", ["monthly"])
1912            .send()
1913            .await
1914            .expect("series/search/related_tags parse");
1915        assert_eq!(results.count, 1);
1916    }
1917
1918    #[tokio::test]
1919    async fn tags_series_joins_names_and_parses() {
1920        let server = MockServer::start().await;
1921        // The two tag names must reach the wire joined by `;`.
1922        Mock::given(method("GET"))
1923            .and(path("/tags/series"))
1924            .and(query_param("tag_names", "gdp;quarterly"))
1925            .and(query_param("limit", "2"))
1926            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
1927                "{{\"count\":1,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
1928            )))
1929            .mount(&server)
1930            .await;
1931
1932        let results = client_for(&server)
1933            .tags_series(["gdp", "quarterly"])
1934            .limit(2)
1935            .send()
1936            .await
1937            .expect("tags/series parse");
1938        assert_eq!(results.count, 1);
1939        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
1940    }
1941
1942    #[tokio::test]
1943    async fn series_tags_parses() {
1944        let server = MockServer::start().await;
1945        Mock::given(method("GET"))
1946            .and(path("/series/tags"))
1947            .and(query_param("series_id", "GNPCA"))
1948            .respond_with(ResponseTemplate::new(200).set_body_string(
1949                r#"{"count":2,"offset":0,"limit":1000,"tags":[
1950                    {"name":"gnp","group_id":"gen","popularity":50,"series_count":10},
1951                    {"name":"usa","group_id":"geo","notes":null,"popularity":100,"series_count":500}
1952                ]}"#,
1953            ))
1954            .mount(&server)
1955            .await;
1956
1957        let results = client_for(&server)
1958            .series_tags(&SeriesId::new("GNPCA"))
1959            .await
1960            .expect("series/tags parse");
1961        assert_eq!(results.count, 2);
1962        assert_eq!(results.tags[0].name, "gnp");
1963        assert!(results.tags[1].notes.is_none());
1964    }
1965
1966    #[tokio::test]
1967    async fn sources_parse_with_pagination() {
1968        let server = MockServer::start().await;
1969        Mock::given(method("GET"))
1970            .and(path("/sources"))
1971            .respond_with(ResponseTemplate::new(200).set_body_string(
1972                r#"{"count":2,"offset":0,"limit":1000,"sources":[
1973                    {"id":1,"name":"Board of Governors of the Federal Reserve System (US)"},
1974                    {"id":18,"name":"U.S. Bureau of Economic Analysis","link":"http://bea.gov"}
1975                ]}"#,
1976            ))
1977            .mount(&server)
1978            .await;
1979
1980        let results = client_for(&server)
1981            .sources()
1982            .send()
1983            .await
1984            .expect("sources parse");
1985        assert_eq!(results.count, 2);
1986        assert_eq!(results.sources[1].id, SourceId::new(18));
1987        assert_eq!(results.sources[1].link.as_deref(), Some("http://bea.gov"));
1988    }
1989
1990    #[tokio::test]
1991    async fn source_parses() {
1992        let server = MockServer::start().await;
1993        Mock::given(method("GET"))
1994            .and(path("/source"))
1995            .and(query_param("source_id", "18"))
1996            .respond_with(ResponseTemplate::new(200).set_body_string(
1997                r#"{"sources":[{"id":18,"name":"U.S. Bureau of Economic Analysis","link":"http://bea.gov"}]}"#,
1998            ))
1999            .mount(&server)
2000            .await;
2001
2002        let source = client_for(&server)
2003            .source(SourceId::new(18))
2004            .await
2005            .expect("source parses");
2006        assert_eq!(source.id, SourceId::new(18));
2007        assert_eq!(source.name, "U.S. Bureau of Economic Analysis");
2008    }
2009
2010    #[tokio::test]
2011    async fn source_releases_send_source_id_and_parse() {
2012        let server = MockServer::start().await;
2013        // The source_id reaches `/source/releases`, which returns releases.
2014        Mock::given(method("GET"))
2015            .and(path("/source/releases"))
2016            .and(query_param("source_id", "18"))
2017            .and(query_param("limit", "2"))
2018            .respond_with(ResponseTemplate::new(200).set_body_string(
2019                r#"{"count":1,"offset":0,"limit":2,"releases":[
2020                    {"id":53,"name":"Gross Domestic Product","press_release":true}
2021                ]}"#,
2022            ))
2023            .mount(&server)
2024            .await;
2025
2026        let results = client_for(&server)
2027            .source_releases(SourceId::new(18))
2028            .limit(2)
2029            .send()
2030            .await
2031            .expect("source/releases parse");
2032        assert_eq!(results.count, 1);
2033        assert_eq!(results.releases[0].id, ReleaseId::new(53));
2034    }
2035
2036    #[tokio::test]
2037    async fn release_sources_send_release_id_and_parse() {
2038        let server = MockServer::start().await;
2039        // The release_id reaches `/release/sources`, which returns a bare
2040        // (unpaginated) `sources` array wrapped alongside realtime fields.
2041        Mock::given(method("GET"))
2042            .and(path("/release/sources"))
2043            .and(query_param("release_id", "51"))
2044            .respond_with(ResponseTemplate::new(200).set_body_string(
2045                r#"{"realtime_start":"2013-08-14","realtime_end":"2013-08-14","sources":[
2046                    {"id":18,"name":"U.S. Bureau of Economic Analysis","link":"http://www.bea.gov/"},
2047                    {"id":19,"name":"U.S. Census Bureau"}
2048                ]}"#,
2049            ))
2050            .mount(&server)
2051            .await;
2052
2053        let sources = client_for(&server)
2054            .release_sources(ReleaseId::new(51))
2055            .await
2056            .expect("release/sources parse");
2057        assert_eq!(sources.len(), 2);
2058        assert_eq!(sources[0].id, SourceId::new(18));
2059        assert_eq!(sources[0].link.as_deref(), Some("http://www.bea.gov/"));
2060        assert!(sources[1].link.is_none());
2061    }
2062
2063    #[tokio::test]
2064    async fn releases_dates_send_params_and_parse() {
2065        let server = MockServer::start().await;
2066        // The `/releases/dates` calendar carries a release_name per entry.
2067        Mock::given(method("GET"))
2068            .and(path("/releases/dates"))
2069            .and(query_param("sort_order", "desc"))
2070            .and(query_param("limit", "2"))
2071            .respond_with(ResponseTemplate::new(200).set_body_string(
2072                r#"{"count":2,"offset":0,"limit":2,"release_dates":[
2073                    {"release_id":9,"release_name":"Advance Monthly Sales","date":"2013-08-13"},
2074                    {"release_id":10,"release_name":"Consumer Price Index","date":"2013-08-15"}
2075                ]}"#,
2076            ))
2077            .mount(&server)
2078            .await;
2079
2080        let results = client_for(&server)
2081            .releases_dates()
2082            .sort_order(SortOrder::Descending)
2083            .limit(2)
2084            .send()
2085            .await
2086            .expect("releases/dates parse");
2087        assert_eq!(results.count, 2);
2088        assert_eq!(results.release_dates[0].release_id, ReleaseId::new(9));
2089        assert_eq!(
2090            results.release_dates[0].release_name.as_deref(),
2091            Some("Advance Monthly Sales")
2092        );
2093    }
2094
2095    #[tokio::test]
2096    async fn release_dates_send_release_id_and_include_flag_and_parse() {
2097        let server = MockServer::start().await;
2098        // `/release/dates` fixes the release, so entries omit release_name; the
2099        // request must carry release_id and the include-no-data toggle.
2100        Mock::given(method("GET"))
2101            .and(path("/release/dates"))
2102            .and(query_param("release_id", "82"))
2103            .and(query_param("include_release_dates_with_no_data", "true"))
2104            .respond_with(ResponseTemplate::new(200).set_body_string(
2105                r#"{"count":2,"offset":0,"limit":10000,"release_dates":[
2106                    {"release_id":82,"date":"1997-02-10"},
2107                    {"release_id":82,"date":"1998-02-10"}
2108                ]}"#,
2109            ))
2110            .mount(&server)
2111            .await;
2112
2113        let results = client_for(&server)
2114            .release_dates(ReleaseId::new(82))
2115            .include_dates_with_no_data(true)
2116            .send()
2117            .await
2118            .expect("release/dates parse");
2119        assert_eq!(results.count, 2);
2120        assert_eq!(results.release_dates[0].release_id, ReleaseId::new(82));
2121        assert!(results.release_dates[0].release_name.is_none());
2122    }
2123
2124    #[tokio::test]
2125    async fn release_tables_send_element_and_parse_tree() {
2126        let server = MockServer::start().await;
2127        // The element_id (subtree scope) must reach the wire, and the nested
2128        // tree — a section containing a series row — must deserialize.
2129        Mock::given(method("GET"))
2130            .and(path("/release/tables"))
2131            .and(query_param("release_id", "10"))
2132            .and(query_param("element_id", "34483"))
2133            .respond_with(ResponseTemplate::new(200).set_body_string(
2134                r#"{"name":"Monthly, SA","element_id":34483,"release_id":"10","elements":{
2135                    "34484":{"element_id":34484,"release_id":10,"parent_id":34483,
2136                        "series_id":"","type":"series","name":"All items","line":"1","level":"0",
2137                        "children":[
2138                            {"element_id":34485,"release_id":10,"parent_id":34484,
2139                             "series_id":"CPIFABSL","type":"series","name":"Food",
2140                             "line":"2","level":"1","children":[]}
2141                        ]}
2142                }}"#,
2143            ))
2144            .mount(&server)
2145            .await;
2146
2147        let table = client_for(&server)
2148            .release_tables(ReleaseId::new(10))
2149            .element(ReleaseElementId::new(34483))
2150            .send()
2151            .await
2152            .expect("release/tables parse");
2153        assert_eq!(table.name.as_deref(), Some("Monthly, SA"));
2154        assert_eq!(table.roots.len(), 1);
2155        let leaf = &table.roots[0].children[0];
2156        assert_eq!(
2157            leaf.series_id.as_ref().map(|s| s.as_str()),
2158            Some("CPIFABSL")
2159        );
2160    }
2161
2162    #[tokio::test]
2163    async fn release_tables_observation_values_reach_wire_and_parse() {
2164        let server = MockServer::start().await;
2165        // `.observation_date(..)` must send both `observation_date` (ISO) and
2166        // `include_observation_values=true`, and the per-element value/date
2167        // fields must deserialize onto the returned series row.
2168        Mock::given(method("GET"))
2169            .and(path("/release/tables"))
2170            .and(query_param("release_id", "10"))
2171            .and(query_param("include_observation_values", "true"))
2172            .and(query_param("observation_date", "2023-06-01"))
2173            .respond_with(ResponseTemplate::new(200).set_body_string(
2174                r#"{"release_id":"10","elements":{
2175                    "36715":{"element_id":36715,"release_id":10,"parent_id":36714,
2176                        "series_id":"CUSR0000SA0L5","type":"series","name":"All items",
2177                        "level":"1","observation_value":"292.260","observation_date":"Jun 2023",
2178                        "children":[]}
2179                }}"#,
2180            ))
2181            .mount(&server)
2182            .await;
2183
2184        let table = client_for(&server)
2185            .release_tables(ReleaseId::new(10))
2186            .observation_date(chrono::NaiveDate::from_ymd_opt(2023, 6, 1).unwrap())
2187            .send()
2188            .await
2189            .expect("release/tables with values parse");
2190        let row = &table.roots[0];
2191        assert_eq!(row.observation_value, Some(292.260));
2192        assert_eq!(row.observation_date.as_deref(), Some("Jun 2023"));
2193    }
2194
2195    #[tokio::test]
2196    async fn series_categories_parse() {
2197        let server = MockServer::start().await;
2198        Mock::given(method("GET"))
2199            .and(path("/series/categories"))
2200            .and(query_param("series_id", "GNPCA"))
2201            .respond_with(ResponseTemplate::new(200).set_body_string(
2202                r#"{"categories":[
2203                    {"id":106,"name":"Gross National Product","parent_id":18},
2204                    {"id":18,"name":"National Income & Product Accounts","parent_id":13}
2205                ]}"#,
2206            ))
2207            .mount(&server)
2208            .await;
2209
2210        let categories = client_for(&server)
2211            .series_categories(&SeriesId::new("GNPCA"))
2212            .await
2213            .expect("series/categories parse");
2214        assert_eq!(categories.len(), 2);
2215        assert_eq!(categories[0].id, CategoryId::new(106));
2216    }
2217
2218    #[tokio::test]
2219    async fn series_release_parses() {
2220        let server = MockServer::start().await;
2221        Mock::given(method("GET"))
2222            .and(path("/series/release"))
2223            .and(query_param("series_id", "GNPCA"))
2224            .respond_with(ResponseTemplate::new(200).set_body_string(
2225                r#"{"releases":[{"id":53,"name":"Gross Domestic Product","press_release":true}]}"#,
2226            ))
2227            .mount(&server)
2228            .await;
2229
2230        let release = client_for(&server)
2231            .series_release(&SeriesId::new("GNPCA"))
2232            .await
2233            .expect("series/release parse");
2234        assert_eq!(release.id, ReleaseId::new(53));
2235        assert_eq!(release.name, "Gross Domestic Product");
2236    }
2237
2238    #[tokio::test]
2239    async fn series_updates_sends_filter_and_parses() {
2240        let server = MockServer::start().await;
2241        Mock::given(method("GET"))
2242            .and(path("/series/updates"))
2243            .and(query_param("filter_value", "macro"))
2244            .and(query_param("limit", "2"))
2245            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
2246                "{{\"count\":5,\"offset\":0,\"limit\":2,\"seriess\":[{SERIES_OBJECT}]}}"
2247            )))
2248            .mount(&server)
2249            .await;
2250
2251        let results = client_for(&server)
2252            .series_updates()
2253            .filter(UpdatesFilter::Macro)
2254            .limit(2)
2255            .send()
2256            .await
2257            .expect("series/updates parse");
2258        assert_eq!(results.count, 5);
2259        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
2260    }
2261
2262    #[tokio::test]
2263    async fn series_updates_sends_time_window_as_yyyymmddhhmm() {
2264        use chrono::NaiveDate;
2265        let server = MockServer::start().await;
2266        Mock::given(method("GET"))
2267            .and(path("/series/updates"))
2268            .and(query_param("start_time", "201803021420"))
2269            .and(query_param("end_time", "201803030905"))
2270            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
2271                "{{\"count\":1,\"offset\":0,\"limit\":1,\"seriess\":[{SERIES_OBJECT}]}}"
2272            )))
2273            .mount(&server)
2274            .await;
2275
2276        let start = NaiveDate::from_ymd_opt(2018, 3, 2)
2277            .unwrap()
2278            .and_hms_opt(14, 20, 0)
2279            .unwrap();
2280        let end = NaiveDate::from_ymd_opt(2018, 3, 3)
2281            .unwrap()
2282            .and_hms_opt(9, 5, 0)
2283            .unwrap();
2284        let results = client_for(&server)
2285            .series_updates()
2286            .time_window(start, end)
2287            .send()
2288            .await
2289            .expect("series/updates time-window parse");
2290        assert_eq!(results.count, 1);
2291    }
2292
2293    #[tokio::test]
2294    async fn series_vintagedates_send_id_and_parse() {
2295        let server = MockServer::start().await;
2296        Mock::given(method("GET"))
2297            .and(path("/series/vintagedates"))
2298            .and(query_param("series_id", "GNPCA"))
2299            .and(query_param("limit", "2"))
2300            .respond_with(ResponseTemplate::new(200).set_body_string(
2301                r#"{"count":3,"offset":0,"limit":2,"vintage_dates":["1958-12-21","1959-02-19"]}"#,
2302            ))
2303            .mount(&server)
2304            .await;
2305
2306        let dates = client_for(&server)
2307            .series_vintagedates(&SeriesId::new("GNPCA"))
2308            .limit(2)
2309            .send()
2310            .await
2311            .expect("series/vintagedates parse");
2312        assert_eq!(dates.count, 3);
2313        assert_eq!(dates.vintage_dates.len(), 2);
2314        assert_eq!(
2315            dates.vintage_dates[0],
2316            chrono::NaiveDate::from_ymd_opt(1958, 12, 21).unwrap()
2317        );
2318    }
2319
2320    // --- GeoFRED / Maps (ADR-0025) -------------------------------------------
2321
2322    #[tokio::test]
2323    async fn geofred_regional_data_sends_all_params_and_parses() {
2324        let server = MockServer::start().await;
2325        // The mock matches only when every required param — including the enum
2326        // query codes (region_type=state, frequency=a, season=NSA) — reaches the
2327        // wire on the `/geofred` base.
2328        Mock::given(method("GET"))
2329            .and(path("/regional/data"))
2330            .and(query_param("series_group", "882"))
2331            .and(query_param("region_type", "state"))
2332            .and(query_param("date", "2013-01-01"))
2333            .and(query_param("units", "Dollars"))
2334            .and(query_param("frequency", "a"))
2335            .and(query_param("season", "NSA"))
2336            .respond_with(ResponseTemplate::new(200).set_body_string(
2337                r#"{"meta":{"title":"t","region":"state","seasonality":"Not Seasonally Adjusted",
2338                    "units":"Dollars","frequency":"Annual","data":{"2013-01-01":[
2339                        {"region":"Alabama","code":"01","value":35706,"series_id":"ALPCPI"}
2340                    ]}}}"#,
2341            ))
2342            .mount(&server)
2343            .await;
2344
2345        let data = client_for(&server)
2346            .regional_data(
2347                &SeriesGroupId::new("882"),
2348                RegionType::State,
2349                chrono::NaiveDate::from_ymd_opt(2013, 1, 1).unwrap(),
2350                "Dollars",
2351                Frequency::Annual,
2352                SeasonalAdjustment::NotSeasonallyAdjusted,
2353            )
2354            .await
2355            .expect("regional data parses");
2356        let day = &data.meta.data["2013-01-01"];
2357        assert_eq!(day[0].region, "Alabama");
2358        assert_eq!(day[0].value, Some(35706.0));
2359        assert_eq!(day[0].series_id, SeriesId::new("ALPCPI"));
2360    }
2361
2362    #[tokio::test]
2363    async fn geofred_series_data_sends_optional_date_and_parses() {
2364        let server = MockServer::start().await;
2365        Mock::given(method("GET"))
2366            .and(path("/series/data"))
2367            .and(query_param("series_id", "SMU56000000500000001"))
2368            .and(query_param("date", "2013-01-01"))
2369            .respond_with(ResponseTemplate::new(200).set_body_string(
2370                r#"{"meta":{"title":"t","region":"state","seasonality":"Not Seasonally Adjusted",
2371                    "units":"Thousands of Persons","frequency":"Monthly","data":{"2013-01-01":[
2372                        {"region":"Alabama","code":"01","value":1506.5,"series_id":"SMU01000000500000001"}
2373                    ]}}}"#,
2374            ))
2375            .mount(&server)
2376            .await;
2377
2378        let data = client_for(&server)
2379            .series_data(&SeriesId::new("SMU56000000500000001"))
2380            .date(chrono::NaiveDate::from_ymd_opt(2013, 1, 1).unwrap())
2381            .send()
2382            .await
2383            .expect("series data parses");
2384        assert_eq!(data.meta.data["2013-01-01"][0].value, Some(1506.5));
2385    }
2386
2387    #[tokio::test]
2388    async fn geofred_series_group_unwraps_envelope_and_parses() {
2389        let server = MockServer::start().await;
2390        Mock::given(method("GET"))
2391            .and(path("/series/group"))
2392            .and(query_param("series_id", "SMU56000000500000001"))
2393            .respond_with(ResponseTemplate::new(200).set_body_string(
2394                r#"{"series_group":{"title":"All Employees: Total Private","region_type":"state",
2395                    "series_group":"1223","season":"NSA","units":"Thousands of Persons",
2396                    "frequency":"Monthly","min_date":"1990-01-01","max_date":"2026-05-01"}}"#,
2397            ))
2398            .mount(&server)
2399            .await;
2400
2401        let group = client_for(&server)
2402            .series_group(&SeriesId::new("SMU56000000500000001"))
2403            .await
2404            .expect("series group parses");
2405        assert_eq!(group.id, SeriesGroupId::new("1223"));
2406        assert_eq!(group.region_type, "state");
2407    }
2408
2409    #[tokio::test]
2410    async fn geofred_shape_file_sends_shape_and_parses_geojson() {
2411        let server = MockServer::start().await;
2412        Mock::given(method("GET"))
2413            .and(path("/shapes/file"))
2414            .and(query_param("shape", "bea"))
2415            .respond_with(ResponseTemplate::new(200).set_body_string(
2416                r#"{"type":"FeatureCollection","name":"state_bea_region","features":[
2417                    {"type":"Feature","properties":{"bea_region":8},
2418                     "geometry":{"type":"MultiPolygon","coordinates":[[[[1485,2651]]]]}}
2419                ]}"#,
2420            ))
2421            .mount(&server)
2422            .await;
2423
2424        let shapes = client_for(&server)
2425            .shape_file(ShapeType::Bea)
2426            .await
2427            .expect("shape file parses");
2428        assert_eq!(shapes.kind, "FeatureCollection");
2429        assert_eq!(shapes.features[0].geometry.kind, "MultiPolygon");
2430    }
2431}