Skip to main content

tango/resources/
lookups.rs

1//! Reference / lookup endpoints — organizations, NAICS, PSC, MAS SINs,
2//! Assistance Listings (CFDA), business types, offices, departments.
3//!
4//! These are read-only catalogues exposed by the Tango API for cross-referencing
5//! contracts, IDVs, and other records.
6
7use crate::client::Client;
8use crate::error::{Error, Result};
9use crate::internal::{apply_pagination, push_opt, push_opt_bool};
10use crate::pagination::{FetchFn, Page, PageStream};
11use crate::resources::agencies::urlencoding;
12use crate::Record;
13use bon::Builder;
14use std::collections::BTreeMap;
15use std::sync::Arc;
16
17// =====================================================================
18// Organizations
19// =====================================================================
20
21/// Options for [`Client::list_organizations`] / [`Client::iterate_organizations`].
22#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
23#[non_exhaustive]
24pub struct ListOrganizationsOptions {
25    /// 1-based page number.
26    #[builder(into)]
27    pub page: Option<u32>,
28    /// Page size (server caps at 100).
29    #[builder(into)]
30    pub limit: Option<u32>,
31    /// Keyset cursor (mutually exclusive with page).
32    #[builder(into)]
33    pub cursor: Option<String>,
34    /// Comma-separated field selector.
35    #[builder(into)]
36    pub shape: Option<String>,
37    /// Collapse nested objects into dot-separated keys.
38    #[builder(default)]
39    pub flat: bool,
40    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
41    #[builder(default)]
42    pub flat_lists: bool,
43
44    /// Free-text search filter.
45    #[builder(into)]
46    pub search: Option<String>,
47    /// Filter by organization type.
48    #[builder(into)]
49    pub r#type: Option<String>,
50    /// Filter by hierarchy level (e.g. `"1"` for departments).
51    #[builder(into)]
52    pub level: Option<String>,
53    /// CGAC code filter.
54    #[builder(into)]
55    pub cgac: Option<String>,
56    /// Parent organization filter.
57    #[builder(into)]
58    pub parent: Option<String>,
59    /// When `Some(true)`, include inactive organizations; `Some(false)`
60    /// excludes them. `None` leaves it to the server default.
61    pub include_inactive: Option<bool>,
62
63    /// Escape hatch for filter keys not first-classed here.
64    #[builder(default)]
65    pub extra: BTreeMap<String, String>,
66}
67
68impl ListOrganizationsOptions {
69    fn to_query(&self) -> Vec<(String, String)> {
70        let mut q = Vec::new();
71        apply_pagination(
72            &mut q,
73            self.page,
74            self.limit,
75            self.cursor.as_deref(),
76            self.shape.as_deref(),
77            self.flat,
78            self.flat_lists,
79        );
80        push_opt(&mut q, "search", self.search.as_deref());
81        push_opt(&mut q, "type", self.r#type.as_deref());
82        push_opt(&mut q, "level", self.level.as_deref());
83        push_opt(&mut q, "cgac", self.cgac.as_deref());
84        push_opt(&mut q, "parent", self.parent.as_deref());
85        push_opt_bool(&mut q, "include_inactive", self.include_inactive);
86        for (k, v) in &self.extra {
87            if !v.is_empty() {
88                q.push((k.clone(), v.clone()));
89            }
90        }
91        q
92    }
93}
94
95// =====================================================================
96// NAICS
97// =====================================================================
98
99/// Options for [`Client::list_naics`] / [`Client::iterate_naics`].
100#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
101#[non_exhaustive]
102pub struct ListNaicsOptions {
103    /// 1-based page number.
104    #[builder(into)]
105    pub page: Option<u32>,
106    /// Page size (server caps at 100).
107    #[builder(into)]
108    pub limit: Option<u32>,
109    /// Keyset cursor.
110    #[builder(into)]
111    pub cursor: Option<String>,
112    /// Comma-separated field selector.
113    #[builder(into)]
114    pub shape: Option<String>,
115    /// Collapse nested objects into dot-separated keys.
116    #[builder(default)]
117    pub flat: bool,
118    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
119    #[builder(default)]
120    pub flat_lists: bool,
121
122    /// Free-text search filter.
123    #[builder(into)]
124    pub search: Option<String>,
125    /// SBA revenue size standard (exact match).
126    #[builder(into)]
127    pub revenue_limit: Option<String>,
128    /// SBA employee size standard (exact match).
129    #[builder(into)]
130    pub employee_limit: Option<String>,
131    /// Lower bound for SBA revenue size standard.
132    #[builder(into)]
133    pub revenue_limit_gte: Option<String>,
134    /// Upper bound for SBA revenue size standard.
135    #[builder(into)]
136    pub revenue_limit_lte: Option<String>,
137    /// Lower bound for SBA employee size standard.
138    #[builder(into)]
139    pub employee_limit_gte: Option<String>,
140    /// Upper bound for SBA employee size standard.
141    #[builder(into)]
142    pub employee_limit_lte: Option<String>,
143
144    /// Escape hatch for filter keys not first-classed here.
145    #[builder(default)]
146    pub extra: BTreeMap<String, String>,
147}
148
149impl ListNaicsOptions {
150    fn to_query(&self) -> Vec<(String, String)> {
151        let mut q = Vec::new();
152        apply_pagination(
153            &mut q,
154            self.page,
155            self.limit,
156            self.cursor.as_deref(),
157            self.shape.as_deref(),
158            self.flat,
159            self.flat_lists,
160        );
161        push_opt(&mut q, "search", self.search.as_deref());
162        push_opt(&mut q, "revenue_limit", self.revenue_limit.as_deref());
163        push_opt(&mut q, "employee_limit", self.employee_limit.as_deref());
164        push_opt(
165            &mut q,
166            "revenue_limit_gte",
167            self.revenue_limit_gte.as_deref(),
168        );
169        push_opt(
170            &mut q,
171            "revenue_limit_lte",
172            self.revenue_limit_lte.as_deref(),
173        );
174        push_opt(
175            &mut q,
176            "employee_limit_gte",
177            self.employee_limit_gte.as_deref(),
178        );
179        push_opt(
180            &mut q,
181            "employee_limit_lte",
182            self.employee_limit_lte.as_deref(),
183        );
184        for (k, v) in &self.extra {
185            if !v.is_empty() {
186                q.push((k.clone(), v.clone()));
187            }
188        }
189        q
190    }
191}
192
193// =====================================================================
194// PSC
195// =====================================================================
196
197/// Options for [`Client::list_psc`] / [`Client::iterate_psc`].
198#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
199#[non_exhaustive]
200pub struct ListPscOptions {
201    /// 1-based page number.
202    #[builder(into)]
203    pub page: Option<u32>,
204    /// Page size (server caps at 100).
205    #[builder(into)]
206    pub limit: Option<u32>,
207    /// Keyset cursor.
208    #[builder(into)]
209    pub cursor: Option<String>,
210    /// Comma-separated field selector.
211    #[builder(into)]
212    pub shape: Option<String>,
213    /// Collapse nested objects into dot-separated keys.
214    #[builder(default)]
215    pub flat: bool,
216    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
217    #[builder(default)]
218    pub flat_lists: bool,
219
220    /// Free-text search filter.
221    #[builder(into)]
222    pub search: Option<String>,
223    /// `Some(true)` returns only codes with contract award history. `Some(false)` is the same as unset: the server applies no filter.
224    pub has_awards: Option<bool>,
225
226    /// Escape hatch for filter keys not first-classed here.
227    #[builder(default)]
228    pub extra: BTreeMap<String, String>,
229}
230
231impl ListPscOptions {
232    fn to_query(&self) -> Vec<(String, String)> {
233        let mut q = Vec::new();
234        apply_pagination(
235            &mut q,
236            self.page,
237            self.limit,
238            self.cursor.as_deref(),
239            self.shape.as_deref(),
240            self.flat,
241            self.flat_lists,
242        );
243        push_opt(&mut q, "search", self.search.as_deref());
244        push_opt_bool(&mut q, "has_awards", self.has_awards);
245        for (k, v) in &self.extra {
246            if !v.is_empty() {
247                q.push((k.clone(), v.clone()));
248            }
249        }
250        q
251    }
252}
253
254// =====================================================================
255// MAS SINs
256// =====================================================================
257
258/// Options for [`Client::list_mas_sins`] / [`Client::iterate_mas_sins`].
259#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
260#[non_exhaustive]
261pub struct ListMasSinsOptions {
262    /// 1-based page number.
263    #[builder(into)]
264    pub page: Option<u32>,
265    /// Page size.
266    #[builder(into)]
267    pub limit: Option<u32>,
268    /// Keyset cursor.
269    #[builder(into)]
270    pub cursor: Option<String>,
271    /// Comma-separated field selector.
272    #[builder(into)]
273    pub shape: Option<String>,
274    /// Collapse nested objects into dot-separated keys.
275    #[builder(default)]
276    pub flat: bool,
277    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
278    #[builder(default)]
279    pub flat_lists: bool,
280
281    /// Free-text search filter (SIN number, title, description).
282    #[builder(into)]
283    pub search: Option<String>,
284
285    /// Escape hatch for filter keys not first-classed here.
286    #[builder(default)]
287    pub extra: BTreeMap<String, String>,
288}
289
290impl ListMasSinsOptions {
291    fn to_query(&self) -> Vec<(String, String)> {
292        let mut q = Vec::new();
293        apply_pagination(
294            &mut q,
295            self.page,
296            self.limit,
297            self.cursor.as_deref(),
298            self.shape.as_deref(),
299            self.flat,
300            self.flat_lists,
301        );
302        push_opt(&mut q, "search", self.search.as_deref());
303        for (k, v) in &self.extra {
304            if !v.is_empty() {
305                q.push((k.clone(), v.clone()));
306            }
307        }
308        q
309    }
310}
311
312// =====================================================================
313// Assistance Listings (CFDA)
314// =====================================================================
315
316/// Options for [`Client::list_assistance_listings`] / [`Client::iterate_assistance_listings`].
317#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
318#[non_exhaustive]
319pub struct ListAssistanceListingsOptions {
320    /// 1-based page number.
321    #[builder(into)]
322    pub page: Option<u32>,
323    /// Page size.
324    #[builder(into)]
325    pub limit: Option<u32>,
326    /// Keyset cursor.
327    #[builder(into)]
328    pub cursor: Option<String>,
329    /// Comma-separated field selector.
330    #[builder(into)]
331    pub shape: Option<String>,
332    /// Collapse nested objects into dot-separated keys.
333    #[builder(default)]
334    pub flat: bool,
335    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
336    #[builder(default)]
337    pub flat_lists: bool,
338
339    /// Free-text search filter.
340    #[builder(into)]
341    pub search: Option<String>,
342
343    /// Escape hatch for filter keys not first-classed here.
344    #[builder(default)]
345    pub extra: BTreeMap<String, String>,
346}
347
348impl ListAssistanceListingsOptions {
349    fn to_query(&self) -> Vec<(String, String)> {
350        let mut q = Vec::new();
351        apply_pagination(
352            &mut q,
353            self.page,
354            self.limit,
355            self.cursor.as_deref(),
356            self.shape.as_deref(),
357            self.flat,
358            self.flat_lists,
359        );
360        push_opt(&mut q, "search", self.search.as_deref());
361        for (k, v) in &self.extra {
362            if !v.is_empty() {
363                q.push((k.clone(), v.clone()));
364            }
365        }
366        q
367    }
368}
369
370// =====================================================================
371// Business Types
372// =====================================================================
373
374/// Options for [`Client::list_business_types`] / [`Client::iterate_business_types`].
375#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
376#[non_exhaustive]
377pub struct ListBusinessTypesOptions {
378    /// 1-based page number.
379    #[builder(into)]
380    pub page: Option<u32>,
381    /// Page size.
382    #[builder(into)]
383    pub limit: Option<u32>,
384    /// Keyset cursor.
385    #[builder(into)]
386    pub cursor: Option<String>,
387    /// Comma-separated field selector.
388    #[builder(into)]
389    pub shape: Option<String>,
390    /// Collapse nested objects into dot-separated keys.
391    #[builder(default)]
392    pub flat: bool,
393    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
394    #[builder(default)]
395    pub flat_lists: bool,
396
397    /// Free-text search filter.
398    #[builder(into)]
399    pub search: Option<String>,
400
401    /// Escape hatch for filter keys not first-classed here.
402    #[builder(default)]
403    pub extra: BTreeMap<String, String>,
404}
405
406impl ListBusinessTypesOptions {
407    fn to_query(&self) -> Vec<(String, String)> {
408        let mut q = Vec::new();
409        apply_pagination(
410            &mut q,
411            self.page,
412            self.limit,
413            self.cursor.as_deref(),
414            self.shape.as_deref(),
415            self.flat,
416            self.flat_lists,
417        );
418        push_opt(&mut q, "search", self.search.as_deref());
419        for (k, v) in &self.extra {
420            if !v.is_empty() {
421                q.push((k.clone(), v.clone()));
422            }
423        }
424        q
425    }
426}
427
428// =====================================================================
429// Offices
430// =====================================================================
431
432/// Options for [`Client::list_offices`] / [`Client::iterate_offices`].
433#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
434#[non_exhaustive]
435pub struct ListOfficesOptions {
436    /// 1-based page number.
437    #[builder(into)]
438    pub page: Option<u32>,
439    /// Page size.
440    #[builder(into)]
441    pub limit: Option<u32>,
442    /// Keyset cursor.
443    #[builder(into)]
444    pub cursor: Option<String>,
445    /// Comma-separated field selector.
446    #[builder(into)]
447    pub shape: Option<String>,
448    /// Collapse nested objects into dot-separated keys.
449    #[builder(default)]
450    pub flat: bool,
451    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
452    #[builder(default)]
453    pub flat_lists: bool,
454
455    /// Free-text search filter (office name, code).
456    #[builder(into)]
457    pub search: Option<String>,
458
459    /// Escape hatch for filter keys not first-classed here.
460    #[builder(default)]
461    pub extra: BTreeMap<String, String>,
462}
463
464impl ListOfficesOptions {
465    fn to_query(&self) -> Vec<(String, String)> {
466        let mut q = Vec::new();
467        apply_pagination(
468            &mut q,
469            self.page,
470            self.limit,
471            self.cursor.as_deref(),
472            self.shape.as_deref(),
473            self.flat,
474            self.flat_lists,
475        );
476        push_opt(&mut q, "search", self.search.as_deref());
477        for (k, v) in &self.extra {
478            if !v.is_empty() {
479                q.push((k.clone(), v.clone()));
480            }
481        }
482        q
483    }
484}
485
486// =====================================================================
487// Departments (deprecated)
488// =====================================================================
489
490/// Options for [`Client::list_departments`] / [`Client::iterate_departments`].
491///
492/// Deprecated in spirit: prefer [`ListOrganizationsOptions`] with `level = "1"`.
493/// The standalone `/api/departments/` endpoint is retained for backward
494/// compatibility.
495#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
496#[non_exhaustive]
497pub struct ListDepartmentsOptions {
498    /// 1-based page number.
499    #[builder(into)]
500    pub page: Option<u32>,
501    /// Page size.
502    #[builder(into)]
503    pub limit: Option<u32>,
504    /// Keyset cursor.
505    #[builder(into)]
506    pub cursor: Option<String>,
507    /// Comma-separated field selector.
508    #[builder(into)]
509    pub shape: Option<String>,
510    /// Collapse nested objects into dot-separated keys.
511    #[builder(default)]
512    pub flat: bool,
513    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
514    #[builder(default)]
515    pub flat_lists: bool,
516
517    /// Free-text search filter.
518    #[builder(into)]
519    pub search: Option<String>,
520
521    /// Escape hatch for filter keys not first-classed here.
522    #[builder(default)]
523    pub extra: BTreeMap<String, String>,
524}
525
526impl ListDepartmentsOptions {
527    fn to_query(&self) -> Vec<(String, String)> {
528        let mut q = Vec::new();
529        apply_pagination(
530            &mut q,
531            self.page,
532            self.limit,
533            self.cursor.as_deref(),
534            self.shape.as_deref(),
535            self.flat,
536            self.flat_lists,
537        );
538        push_opt(&mut q, "search", self.search.as_deref());
539        for (k, v) in &self.extra {
540            if !v.is_empty() {
541                q.push((k.clone(), v.clone()));
542            }
543        }
544        q
545    }
546}
547
548// =====================================================================
549// Client methods
550// =====================================================================
551
552impl Client {
553    // ----- Organizations -----
554
555    /// `GET /api/organizations/` — one page of organization records.
556    pub async fn list_organizations(&self, opts: ListOrganizationsOptions) -> Result<Page<Record>> {
557        let q = opts.to_query();
558        self.get_page("/api/organizations/", &q).await
559    }
560
561    /// Stream every organization record matching `opts`.
562    pub fn iterate_organizations(&self, opts: ListOrganizationsOptions) -> PageStream<Record> {
563        let opts = Arc::new(opts);
564        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
565            let mut next = (*opts).clone();
566            next.page = page;
567            next.cursor = cursor;
568            Box::pin(async move { client.list_organizations(next).await })
569        });
570        PageStream::new(self.clone(), fetch)
571    }
572
573    /// `GET /api/organizations/{key}/` — fetch a single organization.
574    pub async fn get_organization(&self, key: &str) -> Result<Record> {
575        if key.is_empty() {
576            return Err(Error::Validation {
577                message: "get_organization: organization key is required".into(),
578                response: None,
579            });
580        }
581        let path = format!("/api/organizations/{}/", urlencoding(key));
582        self.get_json::<Record>(&path, &[]).await
583    }
584
585    // ----- NAICS -----
586
587    /// `GET /api/naics/` — one page of NAICS records.
588    pub async fn list_naics(&self, opts: ListNaicsOptions) -> Result<Page<Record>> {
589        let q = opts.to_query();
590        self.get_page("/api/naics/", &q).await
591    }
592
593    /// Stream every NAICS record matching `opts`.
594    pub fn iterate_naics(&self, opts: ListNaicsOptions) -> PageStream<Record> {
595        let opts = Arc::new(opts);
596        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
597            let mut next = (*opts).clone();
598            next.page = page;
599            next.cursor = cursor;
600            Box::pin(async move { client.list_naics(next).await })
601        });
602        PageStream::new(self.clone(), fetch)
603    }
604
605    /// `GET /api/naics/{code}/` — fetch a single NAICS record.
606    pub async fn get_naics(&self, code: &str) -> Result<Record> {
607        if code.is_empty() {
608            return Err(Error::Validation {
609                message: "get_naics: NAICS code is required".into(),
610                response: None,
611            });
612        }
613        let path = format!("/api/naics/{}/", urlencoding(code));
614        self.get_json::<Record>(&path, &[]).await
615    }
616
617    // ----- PSC -----
618
619    /// `GET /api/psc/` — one page of PSC (Product/Service Code) records.
620    pub async fn list_psc(&self, opts: ListPscOptions) -> Result<Page<Record>> {
621        let q = opts.to_query();
622        self.get_page("/api/psc/", &q).await
623    }
624
625    /// Stream every PSC record matching `opts`.
626    pub fn iterate_psc(&self, opts: ListPscOptions) -> PageStream<Record> {
627        let opts = Arc::new(opts);
628        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
629            let mut next = (*opts).clone();
630            next.page = page;
631            next.cursor = cursor;
632            Box::pin(async move { client.list_psc(next).await })
633        });
634        PageStream::new(self.clone(), fetch)
635    }
636
637    /// `GET /api/psc/{code}/` — fetch a single PSC record.
638    pub async fn get_psc(&self, code: &str) -> Result<Record> {
639        if code.is_empty() {
640            return Err(Error::Validation {
641                message: "get_psc: PSC code is required".into(),
642                response: None,
643            });
644        }
645        let path = format!("/api/psc/{}/", urlencoding(code));
646        self.get_json::<Record>(&path, &[]).await
647    }
648
649    // ----- MAS SINs -----
650
651    /// `GET /api/mas_sins/` — one page of GSA MAS Special Item Number records.
652    pub async fn list_mas_sins(&self, opts: ListMasSinsOptions) -> Result<Page<Record>> {
653        let q = opts.to_query();
654        self.get_page("/api/mas_sins/", &q).await
655    }
656
657    /// Stream every MAS SIN record matching `opts`.
658    pub fn iterate_mas_sins(&self, opts: ListMasSinsOptions) -> PageStream<Record> {
659        let opts = Arc::new(opts);
660        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
661            let mut next = (*opts).clone();
662            next.page = page;
663            next.cursor = cursor;
664            Box::pin(async move { client.list_mas_sins(next).await })
665        });
666        PageStream::new(self.clone(), fetch)
667    }
668
669    /// `GET /api/mas_sins/{sin}/` — fetch a single MAS SIN record.
670    pub async fn get_mas_sin(&self, sin: &str) -> Result<Record> {
671        if sin.is_empty() {
672            return Err(Error::Validation {
673                message: "get_mas_sin: MAS SIN is required".into(),
674                response: None,
675            });
676        }
677        let path = format!("/api/mas_sins/{}/", urlencoding(sin));
678        self.get_json::<Record>(&path, &[]).await
679    }
680
681    // ----- Assistance Listings -----
682
683    /// `GET /api/assistance_listings/` — one page of CFDA program records.
684    pub async fn list_assistance_listings(
685        &self,
686        opts: ListAssistanceListingsOptions,
687    ) -> Result<Page<Record>> {
688        let q = opts.to_query();
689        self.get_page("/api/assistance_listings/", &q).await
690    }
691
692    /// Stream every Assistance Listing record matching `opts`.
693    pub fn iterate_assistance_listings(
694        &self,
695        opts: ListAssistanceListingsOptions,
696    ) -> PageStream<Record> {
697        let opts = Arc::new(opts);
698        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
699            let mut next = (*opts).clone();
700            next.page = page;
701            next.cursor = cursor;
702            Box::pin(async move { client.list_assistance_listings(next).await })
703        });
704        PageStream::new(self.clone(), fetch)
705    }
706
707    /// `GET /api/assistance_listings/{number}/` — fetch a single Assistance
708    /// Listing (CFDA program) by its CFDA number (e.g. `"10.001"`).
709    pub async fn get_assistance_listing(&self, number: &str) -> Result<Record> {
710        if number.is_empty() {
711            return Err(Error::Validation {
712                message: "get_assistance_listing: assistance listing number is required".into(),
713                response: None,
714            });
715        }
716        let path = format!("/api/assistance_listings/{}/", urlencoding(number));
717        self.get_json::<Record>(&path, &[]).await
718    }
719
720    // ----- Business Types -----
721
722    /// `GET /api/business_types/` — one page of business-type reference
723    /// records (SBA / SAM.gov socioeconomic designations).
724    pub async fn list_business_types(
725        &self,
726        opts: ListBusinessTypesOptions,
727    ) -> Result<Page<Record>> {
728        let q = opts.to_query();
729        self.get_page("/api/business_types/", &q).await
730    }
731
732    /// Stream every business-type record matching `opts`.
733    pub fn iterate_business_types(&self, opts: ListBusinessTypesOptions) -> PageStream<Record> {
734        let opts = Arc::new(opts);
735        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
736            let mut next = (*opts).clone();
737            next.page = page;
738            next.cursor = cursor;
739            Box::pin(async move { client.list_business_types(next).await })
740        });
741        PageStream::new(self.clone(), fetch)
742    }
743
744    /// `GET /api/business_types/{code}/` — fetch a single business-type
745    /// reference record by its short code.
746    pub async fn get_business_type(&self, code: &str) -> Result<Record> {
747        if code.is_empty() {
748            return Err(Error::Validation {
749                message: "get_business_type: business type code is required".into(),
750                response: None,
751            });
752        }
753        let path = format!("/api/business_types/{}/", urlencoding(code));
754        self.get_json::<Record>(&path, &[]).await
755    }
756
757    // ----- Offices -----
758
759    /// `GET /api/offices/` — one page of federal contracting office records.
760    pub async fn list_offices(&self, opts: ListOfficesOptions) -> Result<Page<Record>> {
761        let q = opts.to_query();
762        self.get_page("/api/offices/", &q).await
763    }
764
765    /// Stream every office record matching `opts`.
766    pub fn iterate_offices(&self, opts: ListOfficesOptions) -> PageStream<Record> {
767        let opts = Arc::new(opts);
768        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
769            let mut next = (*opts).clone();
770            next.page = page;
771            next.cursor = cursor;
772            Box::pin(async move { client.list_offices(next).await })
773        });
774        PageStream::new(self.clone(), fetch)
775    }
776
777    /// `GET /api/offices/{code}/` — fetch a single office record by its
778    /// FPDS-NG office code.
779    pub async fn get_office(&self, code: &str) -> Result<Record> {
780        if code.is_empty() {
781            return Err(Error::Validation {
782                message: "get_office: office code is required".into(),
783                response: None,
784            });
785        }
786        let path = format!("/api/offices/{}/", urlencoding(code));
787        self.get_json::<Record>(&path, &[]).await
788    }
789
790    // ----- Departments -----
791
792    /// `GET /api/departments/` — one page of department records.
793    ///
794    /// Prefer [`Client::list_organizations`] with `level = "1"` for new code.
795    /// The standalone `/api/departments/` endpoint is retained for backward
796    /// compatibility.
797    pub async fn list_departments(&self, opts: ListDepartmentsOptions) -> Result<Page<Record>> {
798        let q = opts.to_query();
799        self.get_page("/api/departments/", &q).await
800    }
801
802    /// Stream every department record matching `opts`.
803    pub fn iterate_departments(&self, opts: ListDepartmentsOptions) -> PageStream<Record> {
804        let opts = Arc::new(opts);
805        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
806            let mut next = (*opts).clone();
807            next.page = page;
808            next.cursor = cursor;
809            Box::pin(async move { client.list_departments(next).await })
810        });
811        PageStream::new(self.clone(), fetch)
812    }
813
814    /// `GET /api/departments/{code}/` — fetch a single department by code (e.g. `"97"` for DoD).
815    ///
816    /// The API's department `code` is an integer, so the returned record carries it as a JSON number (`97`), not a string.
817    /// An agency's nested `department.code` is the same integer, while the agency's own `code` is a string (`"9700"`).
818    pub async fn get_department(&self, code: &str) -> Result<Record> {
819        if code.is_empty() {
820            return Err(Error::Validation {
821                message: "get_department: department code is required".into(),
822                response: None,
823            });
824        }
825        let path = format!("/api/departments/{}/", urlencoding(code));
826        self.get_json::<Record>(&path, &[]).await
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
835        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
836    }
837
838    #[test]
839    fn psc_has_awards_emits() {
840        let q = ListPscOptions::builder()
841            .has_awards(true)
842            .build()
843            .to_query();
844        assert_eq!(get_q(&q, "has_awards").as_deref(), Some("true"));
845        assert!(ListPscOptions::builder().build().to_query().is_empty());
846    }
847
848    // ----- Organizations -----
849
850    #[test]
851    fn organizations_emits_filters() {
852        let opts = ListOrganizationsOptions::builder()
853            .search("Defense")
854            .r#type("agency")
855            .level("1")
856            .cgac("097")
857            .parent("DOD")
858            .include_inactive(false)
859            .build();
860        let q = opts.to_query();
861        assert_eq!(get_q(&q, "search").as_deref(), Some("Defense"));
862        assert_eq!(get_q(&q, "type").as_deref(), Some("agency"));
863        assert_eq!(get_q(&q, "level").as_deref(), Some("1"));
864        assert_eq!(get_q(&q, "cgac").as_deref(), Some("097"));
865        assert_eq!(get_q(&q, "parent").as_deref(), Some("DOD"));
866        assert_eq!(get_q(&q, "include_inactive").as_deref(), Some("false"));
867    }
868
869    #[tokio::test]
870    async fn get_organization_empty_key_is_validation() {
871        let c = Client::builder().api_key("k").build().expect("client");
872        let err = c.get_organization("").await.expect_err("must error");
873        assert!(matches!(err, Error::Validation { .. }));
874    }
875
876    // ----- NAICS -----
877
878    #[test]
879    fn naics_emits_size_filters() {
880        let opts = ListNaicsOptions::builder()
881            .search("software")
882            .revenue_limit_gte("1000000")
883            .employee_limit_lte("500")
884            .build();
885        let q = opts.to_query();
886        assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
887        assert_eq!(get_q(&q, "revenue_limit_gte").as_deref(), Some("1000000"));
888        assert_eq!(get_q(&q, "employee_limit_lte").as_deref(), Some("500"));
889    }
890
891    #[tokio::test]
892    async fn get_naics_empty_code_is_validation() {
893        let c = Client::builder().api_key("k").build().expect("client");
894        let err = c.get_naics("").await.expect_err("must error");
895        assert!(matches!(err, Error::Validation { .. }));
896    }
897
898    // ----- PSC -----
899
900    #[test]
901    fn psc_emits_search() {
902        let opts = ListPscOptions::builder().search("services").build();
903        let q = opts.to_query();
904        assert_eq!(get_q(&q, "search").as_deref(), Some("services"));
905    }
906
907    #[tokio::test]
908    async fn get_psc_empty_code_is_validation() {
909        let c = Client::builder().api_key("k").build().expect("client");
910        let err = c.get_psc("").await.expect_err("must error");
911        assert!(matches!(err, Error::Validation { .. }));
912    }
913
914    // ----- MAS SINs -----
915
916    #[test]
917    fn mas_sins_emits_search() {
918        let opts = ListMasSinsOptions::builder().search("54151S").build();
919        let q = opts.to_query();
920        assert_eq!(get_q(&q, "search").as_deref(), Some("54151S"));
921    }
922
923    #[tokio::test]
924    async fn get_mas_sin_empty_is_validation() {
925        let c = Client::builder().api_key("k").build().expect("client");
926        let err = c.get_mas_sin("").await.expect_err("must error");
927        assert!(matches!(err, Error::Validation { .. }));
928    }
929
930    // ----- Assistance Listings -----
931
932    #[test]
933    fn assistance_listings_paginates() {
934        let opts = ListAssistanceListingsOptions::builder()
935            .page(2u32)
936            .limit(50u32)
937            .build();
938        let q = opts.to_query();
939        assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
940        assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
941    }
942
943    #[tokio::test]
944    async fn get_assistance_listing_empty_is_validation() {
945        let c = Client::builder().api_key("k").build().expect("client");
946        let err = c.get_assistance_listing("").await.expect_err("must error");
947        assert!(matches!(err, Error::Validation { .. }));
948    }
949
950    // ----- Business Types -----
951
952    #[test]
953    fn business_types_passes_shape() {
954        let opts = ListBusinessTypesOptions::builder()
955            .shape("code,name")
956            .build();
957        let q = opts.to_query();
958        assert_eq!(get_q(&q, "shape").as_deref(), Some("code,name"));
959    }
960
961    #[tokio::test]
962    async fn get_business_type_empty_is_validation() {
963        let c = Client::builder().api_key("k").build().expect("client");
964        let err = c.get_business_type("").await.expect_err("must error");
965        assert!(matches!(err, Error::Validation { .. }));
966    }
967
968    // ----- Offices -----
969
970    #[test]
971    fn offices_emits_search() {
972        let opts = ListOfficesOptions::builder().search("FA8650").build();
973        let q = opts.to_query();
974        assert_eq!(get_q(&q, "search").as_deref(), Some("FA8650"));
975    }
976
977    #[tokio::test]
978    async fn get_office_empty_is_validation() {
979        let c = Client::builder().api_key("k").build().expect("client");
980        let err = c.get_office("").await.expect_err("must error");
981        assert!(matches!(err, Error::Validation { .. }));
982    }
983
984    // ----- Departments -----
985
986    #[test]
987    fn departments_passes_cursor() {
988        let opts = ListDepartmentsOptions::builder()
989            .cursor("abc".to_string())
990            .build();
991        let q = opts.to_query();
992        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
993    }
994
995    #[tokio::test]
996    async fn get_department_empty_is_validation() {
997        let c = Client::builder().api_key("k").build().expect("client");
998        let err = c.get_department("").await.expect_err("must error");
999        assert!(matches!(err, Error::Validation { .. }));
1000    }
1001
1002    // ----- Extra escape hatch -----
1003
1004    #[test]
1005    fn extra_keys_pass_through() {
1006        let mut extra = BTreeMap::new();
1007        extra.insert("custom".into(), "value".into());
1008        let opts = ListNaicsOptions::builder().extra(extra).build();
1009        let q = opts.to_query();
1010        assert_eq!(get_q(&q, "custom").as_deref(), Some("value"));
1011    }
1012
1013    #[test]
1014    fn empty_extra_value_is_skipped() {
1015        let mut extra = BTreeMap::new();
1016        extra.insert("skip".into(), String::new());
1017        let opts = ListPscOptions::builder().extra(extra).build();
1018        let q = opts.to_query();
1019        assert_eq!(get_q(&q, "skip"), None);
1020    }
1021}