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 let bytes = self.get_bytes("/api/entities/", &q).await?;
167 Page::decode(&bytes)
168 }
169
170 pub async fn get_entity(&self, uei: &str, opts: Option<GetEntityOptions>) -> Result<Record> {
172 if uei.is_empty() {
173 return Err(Error::Validation {
174 message: "get_entity: uei is required".into(),
175 response: None,
176 });
177 }
178 let q = opts.unwrap_or_default().to_query();
179 let path = format!("/api/entities/{}/", urlencoding(uei));
180 self.get_json::<Record>(&path, &q).await
181 }
182
183 pub fn iterate_entities(&self, opts: ListEntitiesOptions) -> PageStream<Record> {
185 let opts = Arc::new(opts);
186 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
187 let mut next = (*opts).clone();
188 next.page = page;
189 next.cursor = cursor;
190 Box::pin(async move { client.list_entities(next).await })
191 });
192 PageStream::new(self.clone(), fetch)
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
201 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
202 }
203
204 #[test]
205 fn list_entities_all_filters_emit() {
206 let opts = ListEntitiesOptions::builder()
207 .search("Acme")
208 .cage_code("1ABC5")
209 .cage("1ABC5")
210 .naics("541512")
211 .name("Acme Corp")
212 .psc("D302")
213 .purpose_of_registration_code("Z1")
214 .socioeconomic("A5")
215 .state("VA")
216 .total_awards_obligated_gte("100000")
217 .total_awards_obligated_lte("999999")
218 .uei("UEI123456789")
219 .zip_code("22201")
220 .build();
221 let q = opts.to_query();
222 assert_eq!(get_q(&q, "search").as_deref(), Some("Acme"));
223 assert_eq!(get_q(&q, "cage_code").as_deref(), Some("1ABC5"));
224 assert_eq!(get_q(&q, "cage").as_deref(), Some("1ABC5"));
225 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
226 assert_eq!(get_q(&q, "name").as_deref(), Some("Acme Corp"));
227 assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
228 assert_eq!(
229 get_q(&q, "purpose_of_registration_code").as_deref(),
230 Some("Z1")
231 );
232 assert_eq!(get_q(&q, "socioeconomic").as_deref(), Some("A5"));
233 assert_eq!(get_q(&q, "state").as_deref(), Some("VA"));
234 assert_eq!(
235 get_q(&q, "total_awards_obligated_gte").as_deref(),
236 Some("100000")
237 );
238 assert_eq!(
239 get_q(&q, "total_awards_obligated_lte").as_deref(),
240 Some("999999")
241 );
242 assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI123456789"));
243 assert_eq!(get_q(&q, "zip_code").as_deref(), Some("22201"));
244 }
245
246 #[test]
247 fn list_entities_zero_value_omitted() {
248 let opts = ListEntitiesOptions::builder().build();
249 let q = opts.to_query();
250 assert!(q.is_empty(), "expected empty query, got {q:?}");
251 }
252
253 #[test]
254 fn list_entities_pagination_applied() {
255 let opts = ListEntitiesOptions::builder()
256 .page(2u32)
257 .limit(25u32)
258 .build();
259 let q = opts.to_query();
260 assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
261 assert_eq!(get_q(&q, "limit").as_deref(), Some("25"));
262 }
263
264 #[test]
265 fn get_entity_opts_emits_shape_and_flat() {
266 let opts = GetEntityOptions::builder()
267 .shape(crate::SHAPE_ENTITIES_MINIMAL)
268 .flat(true)
269 .flat_lists(true)
270 .build();
271 let q = opts.to_query();
272 assert!(q.contains(&("shape".into(), crate::SHAPE_ENTITIES_MINIMAL.into())));
273 assert!(q.contains(&("flat".into(), "true".into())));
274 assert!(q.contains(&("flat_lists".into(), "true".into())));
275 }
276
277 #[tokio::test]
278 async fn get_entity_empty_uei_returns_validation() {
279 let client = Client::builder().api_key("x").build().expect("build");
280 let err = client.get_entity("", None).await.expect_err("must error");
281 match err {
282 Error::Validation { message, .. } => assert!(message.contains("uei")),
283 other => panic!("expected Validation, got {other:?}"),
284 }
285 }
286}