Skip to main content

tango/resources/
agencies.rs

1//! `GET /api/agencies/` and `GET /api/agencies/{code}/contracts/{which}/`.
2
3use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt, push_opt_u32};
6use crate::models::AgencyRecord;
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::Record;
9use bon::Builder;
10use std::sync::Arc;
11
12/// Options for [`Client::list_agencies`].
13#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct ListAgenciesOptions {
16    /// 1-based page number.
17    #[builder(into)]
18    pub page: Option<u32>,
19    /// Page size (server caps at 100).
20    #[builder(into)]
21    pub limit: Option<u32>,
22    /// Free-text search filter.
23    #[builder(into)]
24    pub search: Option<String>,
25}
26
27impl ListAgenciesOptions {
28    fn to_query(&self) -> Vec<(String, String)> {
29        let mut q = Vec::new();
30        push_opt_u32(&mut q, "page", self.page);
31        if let Some(limit) = self.limit.filter(|n| *n > 0) {
32            q.push(("limit".into(), limit.min(100).to_string()));
33        }
34        push_opt(&mut q, "search", self.search.as_deref());
35        q
36    }
37}
38
39/// Options for the agency contract sub-resources
40/// (`list_agency_awarding_contracts` / `list_agency_funding_contracts`).
41///
42/// Mirrors `AgencyContractsOptions` in the Node SDK.
43#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct AgencyContractsOptions {
46    /// 1-based page number.
47    #[builder(into)]
48    pub page: Option<u32>,
49    /// Page size (server caps at 100).
50    #[builder(into)]
51    pub limit: Option<u32>,
52    /// Keyset cursor (mutually exclusive with page).
53    #[builder(into)]
54    pub cursor: Option<String>,
55    /// Comma-separated field selector.
56    #[builder(into)]
57    pub shape: Option<String>,
58    /// Collapse nested objects into dot-separated keys.
59    #[builder(default)]
60    pub flat: bool,
61    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
62    #[builder(default)]
63    pub flat_lists: bool,
64    /// Joiner used between flattened keys when `flat=true`. Defaults to `.` server-side.
65    #[builder(into)]
66    pub joiner: Option<String>,
67    /// Server-side sort spec (endpoint-specific allowlist; prefix with `-` for descending).
68    #[builder(into)]
69    pub ordering: Option<String>,
70    /// Free-text search filter.
71    #[builder(into)]
72    pub search: Option<String>,
73}
74
75impl AgencyContractsOptions {
76    fn to_query(&self) -> Vec<(String, String)> {
77        let mut q = Vec::new();
78        apply_pagination(
79            &mut q,
80            self.page,
81            self.limit,
82            self.cursor.as_deref(),
83            self.shape.as_deref(),
84            self.flat,
85            self.flat_lists,
86        );
87        if self.flat {
88            if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
89                q.push(("joiner".into(), j.into()));
90            }
91        }
92        push_opt(&mut q, "ordering", self.ordering.as_deref());
93        push_opt(&mut q, "search", self.search.as_deref());
94        q
95    }
96}
97
98/// Alias matching the plan's `R-01` naming — same as [`AgencyContractsOptions`].
99pub type ListAgencyAwardingContractsOptions = AgencyContractsOptions;
100/// Alias matching the plan's `R-01` naming — same as [`AgencyContractsOptions`].
101pub type ListAgencyFundingContractsOptions = AgencyContractsOptions;
102
103/// Per-agency get options.
104#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct GetAgencyOptions {
107    /// Shape selector.
108    #[builder(into)]
109    pub shape: Option<String>,
110    /// Flatten nested objects into dot-separated keys.
111    #[builder(default)]
112    pub flat: bool,
113    /// When `flat=true`, also flatten list-valued nested fields.
114    #[builder(default)]
115    pub flat_lists: bool,
116}
117
118impl GetAgencyOptions {
119    fn to_query(&self) -> Vec<(String, String)> {
120        let mut q = Vec::new();
121        push_opt(&mut q, "shape", self.shape.as_deref());
122        if self.flat {
123            q.push(("flat".into(), "true".into()));
124        }
125        if self.flat_lists {
126            q.push(("flat_lists".into(), "true".into()));
127        }
128        q
129    }
130}
131
132impl Client {
133    /// `GET /api/agencies/` — list federal agencies.
134    pub async fn list_agencies(&self, opts: ListAgenciesOptions) -> Result<Page<Record>> {
135        let q = opts.to_query();
136        let bytes = self.get_bytes("/api/agencies/", &q).await?;
137        Page::decode(&bytes)
138    }
139
140    /// `GET /api/agencies/{code}/` — fetch a single agency.
141    ///
142    /// `code` is typically a CGAC code (e.g. `"9700"` for the Department of
143    /// Defense). Returns a typed [`AgencyRecord`]; forward-compatible server
144    /// fields land in [`AgencyRecord::extra`].
145    pub async fn get_agency(
146        &self,
147        code: &str,
148        opts: Option<GetAgencyOptions>,
149    ) -> Result<AgencyRecord> {
150        if code.is_empty() {
151            return Err(Error::Validation {
152                message: "agency code is required".into(),
153                response: None,
154            });
155        }
156        let q = opts.unwrap_or_default().to_query();
157        let path = format!("/api/agencies/{}/", urlencoding(code));
158        self.get_json::<AgencyRecord>(&path, &q).await
159    }
160
161    /// `GET /api/agencies/{code}/contracts/awarding/` — contracts where this
162    /// agency is the **awarding** agency.
163    pub async fn list_agency_awarding_contracts(
164        &self,
165        code: &str,
166        opts: AgencyContractsOptions,
167    ) -> Result<Page<Record>> {
168        list_agency_contracts(self, code, "awarding", opts).await
169    }
170
171    /// `GET /api/agencies/{code}/contracts/funding/` — contracts where this
172    /// agency is the **funding** agency.
173    pub async fn list_agency_funding_contracts(
174        &self,
175        code: &str,
176        opts: AgencyContractsOptions,
177    ) -> Result<Page<Record>> {
178        list_agency_contracts(self, code, "funding", opts).await
179    }
180
181    /// Stream every contract where `code` is the awarding agency.
182    pub fn iterate_agency_awarding_contracts(
183        &self,
184        code: &str,
185        opts: AgencyContractsOptions,
186    ) -> PageStream<Record> {
187        iterate_agency_contracts(self, code.to_string(), "awarding", opts)
188    }
189
190    /// Stream every contract where `code` is the funding agency.
191    pub fn iterate_agency_funding_contracts(
192        &self,
193        code: &str,
194        opts: AgencyContractsOptions,
195    ) -> PageStream<Record> {
196        iterate_agency_contracts(self, code.to_string(), "funding", opts)
197    }
198}
199
200async fn list_agency_contracts(
201    client: &Client,
202    code: &str,
203    which: &str,
204    opts: AgencyContractsOptions,
205) -> Result<Page<Record>> {
206    if code.is_empty() {
207        return Err(Error::Validation {
208            message: "agency code is required".into(),
209            response: None,
210        });
211    }
212    let q = opts.to_query();
213    let path = format!("/api/agencies/{}/contracts/{which}/", urlencoding(code));
214    let bytes = client.get_bytes(&path, &q).await?;
215    Page::decode(&bytes)
216}
217
218fn iterate_agency_contracts(
219    client: &Client,
220    code: String,
221    which: &'static str,
222    opts: AgencyContractsOptions,
223) -> PageStream<Record> {
224    let opts = Arc::new(opts);
225    let code = Arc::new(code);
226    let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
227        let opts = (*opts).clone();
228        let mut next = opts;
229        next.page = page;
230        next.cursor = cursor;
231        let code = code.clone();
232        Box::pin(async move { list_agency_contracts(&client, &code, which, next).await })
233    });
234    PageStream::new(client.clone(), fetch)
235}
236
237/// Minimal path-segment escaping. The Tango API rejects `+` as a space
238/// substitute, so we percent-encode like `url::PathEscape` (using `%20`).
239pub(crate) fn urlencoding(s: &str) -> String {
240    use std::fmt::Write;
241    let mut out = String::with_capacity(s.len());
242    for byte in s.bytes() {
243        match byte {
244            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
245                out.push(byte as char);
246            }
247            _ => write!(&mut out, "%{byte:02X}").expect("write to String never fails"),
248        }
249    }
250    out
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn list_agencies_caps_limit_at_100() {
259        let opts = ListAgenciesOptions::builder().limit(500u32).build();
260        let q = opts.to_query();
261        assert_eq!(
262            q.iter()
263                .find(|(k, _)| k == "limit")
264                .map(|(_, v)| v.as_str()),
265            Some("100")
266        );
267    }
268
269    #[test]
270    fn list_agencies_emits_search() {
271        let opts = ListAgenciesOptions::builder().search("defense").build();
272        let q = opts.to_query();
273        assert!(q.contains(&("search".into(), "defense".into())));
274    }
275
276    #[test]
277    fn agency_contracts_emits_joiner_only_when_flat() {
278        let opts = AgencyContractsOptions::builder()
279            .joiner("__".to_string())
280            .build();
281        let q = opts.to_query();
282        assert!(!q.iter().any(|(k, _)| k == "joiner"));
283
284        let opts = AgencyContractsOptions::builder()
285            .flat(true)
286            .joiner("__".to_string())
287            .build();
288        let q = opts.to_query();
289        assert!(q.contains(&("joiner".into(), "__".into())));
290    }
291
292    #[test]
293    fn urlencoding_handles_special_chars() {
294        assert_eq!(urlencoding("9700"), "9700");
295        assert_eq!(urlencoding("AB CD"), "AB%20CD");
296        assert_eq!(urlencoding("a/b"), "a%2Fb");
297        assert_eq!(urlencoding("foo+bar"), "foo%2Bbar");
298    }
299}