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        self.get_page("/api/agencies/", &q).await
137    }
138
139    /// `GET /api/agencies/{code}/` — fetch a single agency.
140    ///
141    /// `code` is typically a CGAC code (e.g. `"9700"` for the Department of
142    /// Defense). Returns a typed [`AgencyRecord`]; forward-compatible server
143    /// fields land in [`AgencyRecord::extra`].
144    pub async fn get_agency(
145        &self,
146        code: &str,
147        opts: Option<GetAgencyOptions>,
148    ) -> Result<AgencyRecord> {
149        if code.is_empty() {
150            return Err(Error::Validation {
151                message: "agency code is required".into(),
152                response: None,
153            });
154        }
155        let q = opts.unwrap_or_default().to_query();
156        let path = format!("/api/agencies/{}/", urlencoding(code));
157        self.get_json::<AgencyRecord>(&path, &q).await
158    }
159
160    /// `GET /api/agencies/{code}/contracts/awarding/` — contracts where this
161    /// agency is the **awarding** agency.
162    pub async fn list_agency_awarding_contracts(
163        &self,
164        code: &str,
165        opts: AgencyContractsOptions,
166    ) -> Result<Page<Record>> {
167        list_agency_contracts(self, code, "awarding", opts).await
168    }
169
170    /// `GET /api/agencies/{code}/contracts/funding/` — contracts where this
171    /// agency is the **funding** agency.
172    pub async fn list_agency_funding_contracts(
173        &self,
174        code: &str,
175        opts: AgencyContractsOptions,
176    ) -> Result<Page<Record>> {
177        list_agency_contracts(self, code, "funding", opts).await
178    }
179
180    /// Stream every contract where `code` is the awarding agency.
181    pub fn iterate_agency_awarding_contracts(
182        &self,
183        code: &str,
184        opts: AgencyContractsOptions,
185    ) -> PageStream<Record> {
186        iterate_agency_contracts(self, code.to_string(), "awarding", opts)
187    }
188
189    /// Stream every contract where `code` is the funding agency.
190    pub fn iterate_agency_funding_contracts(
191        &self,
192        code: &str,
193        opts: AgencyContractsOptions,
194    ) -> PageStream<Record> {
195        iterate_agency_contracts(self, code.to_string(), "funding", opts)
196    }
197}
198
199async fn list_agency_contracts(
200    client: &Client,
201    code: &str,
202    which: &str,
203    opts: AgencyContractsOptions,
204) -> Result<Page<Record>> {
205    if code.is_empty() {
206        return Err(Error::Validation {
207            message: "agency code is required".into(),
208            response: None,
209        });
210    }
211    let q = opts.to_query();
212    let path = format!("/api/agencies/{}/contracts/{which}/", urlencoding(code));
213    client.get_page(&path, &q).await
214}
215
216fn iterate_agency_contracts(
217    client: &Client,
218    code: String,
219    which: &'static str,
220    opts: AgencyContractsOptions,
221) -> PageStream<Record> {
222    let opts = Arc::new(opts);
223    let code = Arc::new(code);
224    let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
225        let opts = (*opts).clone();
226        let mut next = opts;
227        next.page = page;
228        next.cursor = cursor;
229        let code = code.clone();
230        Box::pin(async move { list_agency_contracts(&client, &code, which, next).await })
231    });
232    PageStream::new(client.clone(), fetch)
233}
234
235/// Minimal path-segment escaping. The Tango API rejects `+` as a space
236/// substitute, so we percent-encode like `url::PathEscape` (using `%20`).
237pub(crate) fn urlencoding(s: &str) -> String {
238    use std::fmt::Write;
239    let mut out = String::with_capacity(s.len());
240    for byte in s.bytes() {
241        match byte {
242            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
243                out.push(byte as char);
244            }
245            _ => write!(&mut out, "%{byte:02X}").expect("write to String never fails"),
246        }
247    }
248    out
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn list_agencies_caps_limit_at_100() {
257        let opts = ListAgenciesOptions::builder().limit(500u32).build();
258        let q = opts.to_query();
259        assert_eq!(
260            q.iter()
261                .find(|(k, _)| k == "limit")
262                .map(|(_, v)| v.as_str()),
263            Some("100")
264        );
265    }
266
267    #[test]
268    fn list_agencies_emits_search() {
269        let opts = ListAgenciesOptions::builder().search("defense").build();
270        let q = opts.to_query();
271        assert!(q.contains(&("search".into(), "defense".into())));
272    }
273
274    #[test]
275    fn agency_contracts_emits_joiner_only_when_flat() {
276        let opts = AgencyContractsOptions::builder()
277            .joiner("__".to_string())
278            .build();
279        let q = opts.to_query();
280        assert!(!q.iter().any(|(k, _)| k == "joiner"));
281
282        let opts = AgencyContractsOptions::builder()
283            .flat(true)
284            .joiner("__".to_string())
285            .build();
286        let q = opts.to_query();
287        assert!(q.contains(&("joiner".into(), "__".into())));
288    }
289
290    #[test]
291    fn urlencoding_handles_special_chars() {
292        assert_eq!(urlencoding("9700"), "9700");
293        assert_eq!(urlencoding("AB CD"), "AB%20CD");
294        assert_eq!(urlencoding("a/b"), "a%2Fb");
295        assert_eq!(urlencoding("foo+bar"), "foo%2Bbar");
296    }
297}