1use 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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct ListEntitiesOptions {
19 #[builder(into)]
22 pub page: Option<u32>,
23 #[builder(into)]
25 pub limit: Option<u32>,
26 #[builder(into)]
28 pub cursor: Option<String>,
29 #[builder(into)]
31 pub shape: Option<String>,
32 #[builder(default)]
34 pub flat: bool,
35 #[builder(default)]
37 pub flat_lists: bool,
38
39 #[builder(into)]
42 pub search: Option<String>,
43 #[builder(into)]
45 pub cage_code: Option<String>,
46 #[builder(into)]
49 pub cage: Option<String>,
50 #[builder(into)]
52 pub naics: Option<String>,
53 #[builder(into)]
55 pub name: Option<String>,
56 #[builder(into)]
58 pub psc: Option<String>,
59 #[builder(into)]
61 pub purpose_of_registration_code: Option<String>,
62 #[builder(into)]
64 pub socioeconomic: Option<String>,
65 #[builder(into)]
67 pub state: Option<String>,
68 #[builder(into)]
70 pub total_awards_obligated_gte: Option<String>,
71 #[builder(into)]
73 pub total_awards_obligated_lte: Option<String>,
74 #[builder(into)]
76 pub uei: Option<String>,
77 #[builder(into)]
79 pub zip_code: Option<String>,
80
81 #[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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
135#[non_exhaustive]
136pub struct GetEntityOptions {
137 #[builder(into)]
139 pub shape: Option<String>,
140 #[builder(default)]
142 pub flat: bool,
143 #[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 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 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 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}