Skip to main content

ferric_fred/
client.rs

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