Skip to main content

tango/resources/
federal_register.rs

1//! `GET /api/federal_register/` — Federal Register documents: rules, proposed rules, notices and presidential documents published since 1994 (Tango API 5.3.0).
2//!
3//! The detail route takes a document's `uuid`, not its `document_number`: the Federal Register reused some document numbers before 2016, so a number is not unique on its own.
4//! Look a document up by number with [`ListFederalRegisterDocumentsOptions::document_number`].
5//!
6//! `full_text` can run to several MB. It is served on [`Client::get_federal_register_document`] only when named in `shape`, and neither default shape includes it.
7
8use crate::client::Client;
9use crate::error::{Error, Result};
10use crate::internal::{apply_pagination, push_opt, push_opt_bool};
11use crate::pagination::{FetchFn, Page, PageStream};
12use crate::resources::agencies::urlencoding;
13use crate::Record;
14use bon::Builder;
15use std::collections::BTreeMap;
16use std::sync::Arc;
17
18/// Options for [`Client::list_federal_register_documents`] and [`Client::iterate_federal_register_documents`].
19///
20/// All seventeen of the API's filters are named fields, plus `ordering`.
21#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct ListFederalRegisterDocumentsOptions {
24    /// 1-based page number.
25    #[builder(into)]
26    pub page: Option<u32>,
27    /// Page size (server caps at 100).
28    #[builder(into)]
29    pub limit: Option<u32>,
30    /// Keyset cursor.
31    #[builder(into)]
32    pub cursor: Option<String>,
33    /// Comma-separated field selector. Use [`SHAPE_FEDERAL_REGISTER_MINIMAL`](crate::SHAPE_FEDERAL_REGISTER_MINIMAL) or roll your own. `full_text` is not available on the list.
34    #[builder(into)]
35    pub shape: Option<String>,
36    /// Collapse nested objects into dot-separated keys.
37    #[builder(default)]
38    pub flat: bool,
39    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
40    #[builder(default)]
41    pub flat_lists: bool,
42
43    /// Ranked full-text search over the title, abstract and action. Wrap in double quotes for a phrase.
44    #[builder(into)]
45    pub search: Option<String>,
46    /// Exact FR document number, e.g. `2016-31922`. OR several with `|`. A number the Federal Register reused before 2016 matches every document that carries it.
47    #[builder(into)]
48    pub document_number: Option<String>,
49    /// Document type: `Notice`, `Rule`, `Proposed Rule`, `Presidential Document`, `Correction`, `Sunshine Act Document` or `Uncategorized Document` (case-insensitive). OR several with `|`. Documents published before 2008 are mostly `Uncategorized Document`.
50    #[builder(into)]
51    pub r#type: Option<String>,
52    /// A Tango agency name, abbreviation, code or organization key, e.g. `EPA`. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies. OR several with `|`.
53    #[builder(into)]
54    pub agency: Option<String>,
55    /// The Federal Register's own agency slug, e.g. `environmental-protection-agency`. OR several with `|`.
56    #[builder(into)]
57    pub fr_agency: Option<String>,
58    /// Published on or after (`YYYY-MM-DD`).
59    #[builder(into)]
60    pub publication_date_after: Option<String>,
61    /// Published on or before (`YYYY-MM-DD`).
62    #[builder(into)]
63    pub publication_date_before: Option<String>,
64    /// Effective on or after (`YYYY-MM-DD`).
65    #[builder(into)]
66    pub effective_on_after: Option<String>,
67    /// Effective on or before (`YYYY-MM-DD`).
68    #[builder(into)]
69    pub effective_on_before: Option<String>,
70    /// Comment period closes on or after (`YYYY-MM-DD`).
71    #[builder(into)]
72    pub comments_close_on_after: Option<String>,
73    /// Comment period closes on or before (`YYYY-MM-DD`).
74    #[builder(into)]
75    pub comments_close_on_before: Option<String>,
76    /// `Some(true)` for documents whose comment period closes today or later, `Some(false)` for those already closed. Documents with no comment deadline match neither.
77    pub comments_open: Option<bool>,
78    /// A CFR title number, e.g. `40`.
79    #[builder(into)]
80    pub cfr_title: Option<String>,
81    /// A CFR part number, e.g. `52`. Requires [`cfr_title`](Self::cfr_title), and both must match the same CFR reference.
82    #[builder(into)]
83    pub cfr_part: Option<String>,
84    /// Significant under Executive Order 12866.
85    pub significant: Option<bool>,
86    /// A Regulation Identifier Number, e.g. `2060-AV16`. OR several with `|`.
87    #[builder(into)]
88    pub rin: Option<String>,
89    /// Exact executive order number.
90    #[builder(into)]
91    pub executive_order_number: Option<String>,
92
93    /// Sort key: `publication_date` (the server default, as `-publication_date`), `effective_on`, `comments_close_on`, `document_number` or `rank`, prefixed with `-` for descending. `rank` requires a non-empty [`search`](Self::search).
94    #[builder(into)]
95    pub ordering: Option<String>,
96
97    /// Escape hatch for filter keys not yet first-classed on this struct.
98    #[builder(default)]
99    pub extra: BTreeMap<String, String>,
100}
101
102impl ListFederalRegisterDocumentsOptions {
103    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
104        let mut q = Vec::new();
105        apply_pagination(
106            &mut q,
107            self.page,
108            self.limit,
109            self.cursor.as_deref(),
110            self.shape.as_deref(),
111            self.flat,
112            self.flat_lists,
113        );
114        push_opt(&mut q, "search", self.search.as_deref());
115        push_opt(&mut q, "document_number", self.document_number.as_deref());
116        push_opt(&mut q, "type", self.r#type.as_deref());
117        push_opt(&mut q, "agency", self.agency.as_deref());
118        push_opt(&mut q, "fr_agency", self.fr_agency.as_deref());
119        for (key, value) in [
120            ("publication_date_after", &self.publication_date_after),
121            ("publication_date_before", &self.publication_date_before),
122            ("effective_on_after", &self.effective_on_after),
123            ("effective_on_before", &self.effective_on_before),
124            ("comments_close_on_after", &self.comments_close_on_after),
125            ("comments_close_on_before", &self.comments_close_on_before),
126        ] {
127            push_opt(&mut q, key, value.as_deref());
128        }
129        push_opt_bool(&mut q, "comments_open", self.comments_open);
130        push_opt(&mut q, "cfr_title", self.cfr_title.as_deref());
131        push_opt(&mut q, "cfr_part", self.cfr_part.as_deref());
132        push_opt_bool(&mut q, "significant", self.significant);
133        push_opt(&mut q, "rin", self.rin.as_deref());
134        push_opt(
135            &mut q,
136            "executive_order_number",
137            self.executive_order_number.as_deref(),
138        );
139        push_opt(&mut q, "ordering", self.ordering.as_deref());
140        for (k, v) in &self.extra {
141            if !v.is_empty() {
142                q.push((k.clone(), v.clone()));
143            }
144        }
145        q
146    }
147}
148
149/// Options for [`Client::get_federal_register_document`].
150#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct GetFederalRegisterDocumentOptions {
153    /// Shape selector. When empty, the server returns its detail default, which [`SHAPE_FEDERAL_REGISTER_COMPREHENSIVE`](crate::SHAPE_FEDERAL_REGISTER_COMPREHENSIVE) mirrors. Name `full_text` to get the document's plain text, which can run to several MB.
154    #[builder(into)]
155    pub shape: Option<String>,
156    /// Flatten nested objects into dot-separated keys.
157    #[builder(default)]
158    pub flat: bool,
159    /// When `flat=true`, also flatten list-valued nested fields.
160    #[builder(default)]
161    pub flat_lists: bool,
162}
163
164impl GetFederalRegisterDocumentOptions {
165    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
166        let mut q = Vec::new();
167        push_opt(&mut q, "shape", self.shape.as_deref());
168        if self.flat {
169            q.push(("flat".into(), "true".into()));
170        }
171        if self.flat_lists {
172            q.push(("flat_lists".into(), "true".into()));
173        }
174        q
175    }
176}
177
178impl Client {
179    /// `GET /api/federal_register/` — one page of Federal Register documents, newest publication first by default.
180    pub async fn list_federal_register_documents(
181        &self,
182        opts: ListFederalRegisterDocumentsOptions,
183    ) -> Result<Page<Record>> {
184        let q = opts.to_query();
185        self.get_page("/api/federal_register/", &q).await
186    }
187
188    /// `GET /api/federal_register/{uuid}/` — one Federal Register document by its `uuid`.
189    ///
190    /// The route takes the `uuid`, not the `document_number`, which is not unique on its own; find a document by number with [`list_federal_register_documents`](Self::list_federal_register_documents) and [`ListFederalRegisterDocumentsOptions::document_number`].
191    /// `full_text` is returned only when named in `shape`.
192    pub async fn get_federal_register_document(
193        &self,
194        document_uuid: &str,
195        opts: Option<GetFederalRegisterDocumentOptions>,
196    ) -> Result<Record> {
197        if document_uuid.is_empty() {
198            return Err(Error::Validation {
199                message: "get_federal_register_document: document_uuid is required".into(),
200                response: None,
201            });
202        }
203        let q = opts.unwrap_or_default().to_query();
204        let path = format!("/api/federal_register/{}/", urlencoding(document_uuid));
205        self.get_json::<Record>(&path, &q).await
206    }
207
208    /// Stream every Federal Register document matching `opts`.
209    pub fn iterate_federal_register_documents(
210        &self,
211        opts: ListFederalRegisterDocumentsOptions,
212    ) -> PageStream<Record> {
213        let opts = Arc::new(opts);
214        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
215            let mut next = (*opts).clone();
216            next.page = page;
217            next.cursor = cursor;
218            Box::pin(async move { client.list_federal_register_documents(next).await })
219        });
220        PageStream::new(self.clone(), fetch)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
229        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
230    }
231
232    #[test]
233    fn list_federal_register_documents_all_filters_emit() {
234        let opts = ListFederalRegisterDocumentsOptions::builder()
235            .search("\"air quality\"")
236            .document_number("2016-31922")
237            .r#type("Rule|Proposed Rule")
238            .agency("EPA")
239            .fr_agency("environmental-protection-agency")
240            .publication_date_after("2024-01-01")
241            .publication_date_before("2024-12-31")
242            .effective_on_after("2024-02-01")
243            .effective_on_before("2024-11-30")
244            .comments_close_on_after("2024-03-01")
245            .comments_close_on_before("2024-10-31")
246            .comments_open(false)
247            .cfr_title("40")
248            .cfr_part("52")
249            .significant(true)
250            .rin("2060-AV16")
251            .executive_order_number("14028")
252            .ordering("rank")
253            .build();
254        let q = opts.to_query();
255        for (k, v) in [
256            ("search", "\"air quality\""),
257            ("document_number", "2016-31922"),
258            ("type", "Rule|Proposed Rule"),
259            ("agency", "EPA"),
260            ("fr_agency", "environmental-protection-agency"),
261            ("publication_date_after", "2024-01-01"),
262            ("publication_date_before", "2024-12-31"),
263            ("effective_on_after", "2024-02-01"),
264            ("effective_on_before", "2024-11-30"),
265            ("comments_close_on_after", "2024-03-01"),
266            ("comments_close_on_before", "2024-10-31"),
267            ("comments_open", "false"),
268            ("cfr_title", "40"),
269            ("cfr_part", "52"),
270            ("significant", "true"),
271            ("rin", "2060-AV16"),
272            ("executive_order_number", "14028"),
273            ("ordering", "rank"),
274        ] {
275            assert_eq!(get_q(&q, k).as_deref(), Some(v), "{k}");
276        }
277        assert_eq!(q.len(), 18);
278    }
279
280    #[test]
281    fn list_federal_register_documents_zero_value_omitted() {
282        assert!(ListFederalRegisterDocumentsOptions::builder()
283            .build()
284            .to_query()
285            .is_empty());
286    }
287
288    #[test]
289    fn default_shapes_never_name_full_text() {
290        for shape in [
291            crate::SHAPE_FEDERAL_REGISTER_MINIMAL,
292            crate::SHAPE_FEDERAL_REGISTER_COMPREHENSIVE,
293        ] {
294            assert!(!shape.split(',').any(|f| f == "full_text"), "{shape}");
295        }
296    }
297
298    #[tokio::test]
299    async fn empty_uuid_is_rejected_before_any_request() {
300        let client = Client::builder().api_key("x").build().expect("client");
301        let err = client
302            .get_federal_register_document("", None)
303            .await
304            .unwrap_err();
305        assert!(matches!(err, Error::Validation { .. }));
306    }
307}