Skip to main content

tango/resources/
entities.rs

1//! `GET /api/entities/` — list and stream federal vendor / recipient records.
2
3use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::resources::agencies::urlencoding;
8use crate::Record;
9use bon::Builder;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13/// Options for [`Client::list_entities`] and [`Client::iterate_entities`].
14///
15/// Mirrors the Go SDK's `ListEntitiesOptions`.
16#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct ListEntitiesOptions {
19    // ----- Pagination + shape -----
20    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
21    #[builder(into)]
22    pub page: Option<u32>,
23    /// Page size.
24    #[builder(into)]
25    pub limit: Option<u32>,
26    /// Keyset cursor.
27    #[builder(into)]
28    pub cursor: Option<String>,
29    /// Comma-separated field selector.
30    #[builder(into)]
31    pub shape: Option<String>,
32    /// Collapse nested objects into dot-separated keys.
33    #[builder(default)]
34    pub flat: bool,
35    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
36    #[builder(default)]
37    pub flat_lists: bool,
38
39    // ----- Resource filters -----
40    /// Free-text search filter.
41    #[builder(into)]
42    pub search: Option<String>,
43    /// CAGE code filter.
44    #[builder(into)]
45    pub cage_code: Option<String>,
46    /// Alias for [`cage_code`](Self::cage_code): both filter the same field.
47    /// The API rejects a request that sets both, so use one or the other.
48    #[builder(into)]
49    pub cage: Option<String>,
50    /// NAICS code filter.
51    #[builder(into)]
52    pub naics: Option<String>,
53    /// Entity name filter.
54    #[builder(into)]
55    pub name: Option<String>,
56    /// PSC code filter.
57    #[builder(into)]
58    pub psc: Option<String>,
59    /// SAM purpose-of-registration code.
60    #[builder(into)]
61    pub purpose_of_registration_code: Option<String>,
62    /// Socioeconomic indicator filter.
63    #[builder(into)]
64    pub socioeconomic: Option<String>,
65    /// US state filter (2-letter code).
66    #[builder(into)]
67    pub state: Option<String>,
68    /// Lower bound for total obligated awards (dollars).
69    #[builder(into)]
70    pub total_awards_obligated_gte: Option<String>,
71    /// Upper bound for total obligated awards (dollars).
72    #[builder(into)]
73    pub total_awards_obligated_lte: Option<String>,
74    /// UEI filter.
75    #[builder(into)]
76    pub uei: Option<String>,
77    /// ZIP code filter.
78    #[builder(into)]
79    pub zip_code: Option<String>,
80
81    /// Escape hatch for filter keys not yet first-classed on this struct.
82    #[builder(default)]
83    pub extra: BTreeMap<String, String>,
84}
85
86impl ListEntitiesOptions {
87    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
88        let mut q = Vec::new();
89        apply_pagination(
90            &mut q,
91            self.page,
92            self.limit,
93            self.cursor.as_deref(),
94            self.shape.as_deref(),
95            self.flat,
96            self.flat_lists,
97        );
98        push_opt(&mut q, "search", self.search.as_deref());
99        push_opt(&mut q, "cage_code", self.cage_code.as_deref());
100        push_opt(&mut q, "cage", self.cage.as_deref());
101        push_opt(&mut q, "naics", self.naics.as_deref());
102        push_opt(&mut q, "name", self.name.as_deref());
103        push_opt(&mut q, "psc", self.psc.as_deref());
104        push_opt(
105            &mut q,
106            "purpose_of_registration_code",
107            self.purpose_of_registration_code.as_deref(),
108        );
109        push_opt(&mut q, "socioeconomic", self.socioeconomic.as_deref());
110        push_opt(&mut q, "state", self.state.as_deref());
111        push_opt(
112            &mut q,
113            "total_awards_obligated_gte",
114            self.total_awards_obligated_gte.as_deref(),
115        );
116        push_opt(
117            &mut q,
118            "total_awards_obligated_lte",
119            self.total_awards_obligated_lte.as_deref(),
120        );
121        push_opt(&mut q, "uei", self.uei.as_deref());
122        push_opt(&mut q, "zip_code", self.zip_code.as_deref());
123
124        for (k, v) in &self.extra {
125            if !v.is_empty() {
126                q.push((k.clone(), v.clone()));
127            }
128        }
129        q
130    }
131}
132
133/// Options for [`Client::get_entity`].
134#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
135#[non_exhaustive]
136pub struct GetEntityOptions {
137    /// Shape selector. When empty, the server returns its comprehensive default.
138    #[builder(into)]
139    pub shape: Option<String>,
140    /// Flatten nested objects into dot-separated keys.
141    #[builder(default)]
142    pub flat: bool,
143    /// When `flat=true`, also flatten list-valued nested fields.
144    #[builder(default)]
145    pub flat_lists: bool,
146}
147
148impl GetEntityOptions {
149    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
150        let mut q = Vec::new();
151        push_opt(&mut q, "shape", self.shape.as_deref());
152        if self.flat {
153            q.push(("flat".into(), "true".into()));
154        }
155        if self.flat_lists {
156            q.push(("flat_lists".into(), "true".into()));
157        }
158        q
159    }
160}
161
162impl Client {
163    /// `GET /api/entities/` — one page of entity records.
164    pub async fn list_entities(&self, opts: ListEntitiesOptions) -> Result<Page<Record>> {
165        let q = opts.to_query();
166        self.get_page("/api/entities/", &q).await
167    }
168
169    /// `GET /api/entities/{uei}/` — fetch a single entity by UEI or CAGE code.
170    pub async fn get_entity(&self, uei: &str, opts: Option<GetEntityOptions>) -> Result<Record> {
171        if uei.is_empty() {
172            return Err(Error::Validation {
173                message: "get_entity: uei is required".into(),
174                response: None,
175            });
176        }
177        let q = opts.unwrap_or_default().to_query();
178        let path = format!("/api/entities/{}/", urlencoding(uei));
179        self.get_json::<Record>(&path, &q).await
180    }
181
182    /// Stream every entity record matching `opts`.
183    pub fn iterate_entities(&self, opts: ListEntitiesOptions) -> PageStream<Record> {
184        let opts = Arc::new(opts);
185        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
186            let mut next = (*opts).clone();
187            next.page = page;
188            next.cursor = cursor;
189            Box::pin(async move { client.list_entities(next).await })
190        });
191        PageStream::new(self.clone(), fetch)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
200        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
201    }
202
203    #[test]
204    fn list_entities_all_filters_emit() {
205        let opts = ListEntitiesOptions::builder()
206            .search("Acme")
207            .cage_code("1ABC5")
208            .cage("1ABC5")
209            .naics("541512")
210            .name("Acme Corp")
211            .psc("D302")
212            .purpose_of_registration_code("Z1")
213            .socioeconomic("A5")
214            .state("VA")
215            .total_awards_obligated_gte("100000")
216            .total_awards_obligated_lte("999999")
217            .uei("UEI123456789")
218            .zip_code("22201")
219            .build();
220        let q = opts.to_query();
221        assert_eq!(get_q(&q, "search").as_deref(), Some("Acme"));
222        assert_eq!(get_q(&q, "cage_code").as_deref(), Some("1ABC5"));
223        assert_eq!(get_q(&q, "cage").as_deref(), Some("1ABC5"));
224        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
225        assert_eq!(get_q(&q, "name").as_deref(), Some("Acme Corp"));
226        assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
227        assert_eq!(
228            get_q(&q, "purpose_of_registration_code").as_deref(),
229            Some("Z1")
230        );
231        assert_eq!(get_q(&q, "socioeconomic").as_deref(), Some("A5"));
232        assert_eq!(get_q(&q, "state").as_deref(), Some("VA"));
233        assert_eq!(
234            get_q(&q, "total_awards_obligated_gte").as_deref(),
235            Some("100000")
236        );
237        assert_eq!(
238            get_q(&q, "total_awards_obligated_lte").as_deref(),
239            Some("999999")
240        );
241        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI123456789"));
242        assert_eq!(get_q(&q, "zip_code").as_deref(), Some("22201"));
243    }
244
245    #[test]
246    fn list_entities_zero_value_omitted() {
247        let opts = ListEntitiesOptions::builder().build();
248        let q = opts.to_query();
249        assert!(q.is_empty(), "expected empty query, got {q:?}");
250    }
251
252    #[test]
253    fn list_entities_pagination_applied() {
254        let opts = ListEntitiesOptions::builder()
255            .page(2u32)
256            .limit(25u32)
257            .build();
258        let q = opts.to_query();
259        assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
260        assert_eq!(get_q(&q, "limit").as_deref(), Some("25"));
261    }
262
263    #[test]
264    fn get_entity_opts_emits_shape_and_flat() {
265        let opts = GetEntityOptions::builder()
266            .shape(crate::SHAPE_ENTITIES_MINIMAL)
267            .flat(true)
268            .flat_lists(true)
269            .build();
270        let q = opts.to_query();
271        assert!(q.contains(&("shape".into(), crate::SHAPE_ENTITIES_MINIMAL.into())));
272        assert!(q.contains(&("flat".into(), "true".into())));
273        assert!(q.contains(&("flat_lists".into(), "true".into())));
274    }
275
276    #[tokio::test]
277    async fn get_entity_empty_uei_returns_validation() {
278        let client = Client::builder().api_key("x").build().expect("build");
279        let err = client.get_entity("", None).await.expect_err("must error");
280        match err {
281            Error::Validation { message, .. } => assert!(message.contains("uei")),
282            other => panic!("expected Validation, got {other:?}"),
283        }
284    }
285}