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
224    /// Escape hatch for filter keys not first-classed here.
225    #[builder(default)]
226    pub extra: BTreeMap<String, String>,
227}
228
229impl ListPscOptions {
230    fn to_query(&self) -> Vec<(String, String)> {
231        let mut q = Vec::new();
232        apply_pagination(
233            &mut q,
234            self.page,
235            self.limit,
236            self.cursor.as_deref(),
237            self.shape.as_deref(),
238            self.flat,
239            self.flat_lists,
240        );
241        push_opt(&mut q, "search", self.search.as_deref());
242        for (k, v) in &self.extra {
243            if !v.is_empty() {
244                q.push((k.clone(), v.clone()));
245            }
246        }
247        q
248    }
249}
250
251// =====================================================================
252// MAS SINs
253// =====================================================================
254
255/// Options for [`Client::list_mas_sins`] / [`Client::iterate_mas_sins`].
256#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
257#[non_exhaustive]
258pub struct ListMasSinsOptions {
259    /// 1-based page number.
260    #[builder(into)]
261    pub page: Option<u32>,
262    /// Page size.
263    #[builder(into)]
264    pub limit: Option<u32>,
265    /// Keyset cursor.
266    #[builder(into)]
267    pub cursor: Option<String>,
268    /// Comma-separated field selector.
269    #[builder(into)]
270    pub shape: Option<String>,
271    /// Collapse nested objects into dot-separated keys.
272    #[builder(default)]
273    pub flat: bool,
274    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
275    #[builder(default)]
276    pub flat_lists: bool,
277
278    /// Free-text search filter (SIN number, title, description).
279    #[builder(into)]
280    pub search: Option<String>,
281
282    /// Escape hatch for filter keys not first-classed here.
283    #[builder(default)]
284    pub extra: BTreeMap<String, String>,
285}
286
287impl ListMasSinsOptions {
288    fn to_query(&self) -> Vec<(String, String)> {
289        let mut q = Vec::new();
290        apply_pagination(
291            &mut q,
292            self.page,
293            self.limit,
294            self.cursor.as_deref(),
295            self.shape.as_deref(),
296            self.flat,
297            self.flat_lists,
298        );
299        push_opt(&mut q, "search", self.search.as_deref());
300        for (k, v) in &self.extra {
301            if !v.is_empty() {
302                q.push((k.clone(), v.clone()));
303            }
304        }
305        q
306    }
307}
308
309// =====================================================================
310// Assistance Listings (CFDA)
311// =====================================================================
312
313/// Options for [`Client::list_assistance_listings`] / [`Client::iterate_assistance_listings`].
314#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
315#[non_exhaustive]
316pub struct ListAssistanceListingsOptions {
317    /// 1-based page number.
318    #[builder(into)]
319    pub page: Option<u32>,
320    /// Page size.
321    #[builder(into)]
322    pub limit: Option<u32>,
323    /// Keyset cursor.
324    #[builder(into)]
325    pub cursor: Option<String>,
326    /// Comma-separated field selector.
327    #[builder(into)]
328    pub shape: Option<String>,
329    /// Collapse nested objects into dot-separated keys.
330    #[builder(default)]
331    pub flat: bool,
332    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
333    #[builder(default)]
334    pub flat_lists: bool,
335
336    /// Free-text search filter.
337    #[builder(into)]
338    pub search: Option<String>,
339
340    /// Escape hatch for filter keys not first-classed here.
341    #[builder(default)]
342    pub extra: BTreeMap<String, String>,
343}
344
345impl ListAssistanceListingsOptions {
346    fn to_query(&self) -> Vec<(String, String)> {
347        let mut q = Vec::new();
348        apply_pagination(
349            &mut q,
350            self.page,
351            self.limit,
352            self.cursor.as_deref(),
353            self.shape.as_deref(),
354            self.flat,
355            self.flat_lists,
356        );
357        push_opt(&mut q, "search", self.search.as_deref());
358        for (k, v) in &self.extra {
359            if !v.is_empty() {
360                q.push((k.clone(), v.clone()));
361            }
362        }
363        q
364    }
365}
366
367// =====================================================================
368// Business Types
369// =====================================================================
370
371/// Options for [`Client::list_business_types`] / [`Client::iterate_business_types`].
372#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
373#[non_exhaustive]
374pub struct ListBusinessTypesOptions {
375    /// 1-based page number.
376    #[builder(into)]
377    pub page: Option<u32>,
378    /// Page size.
379    #[builder(into)]
380    pub limit: Option<u32>,
381    /// Keyset cursor.
382    #[builder(into)]
383    pub cursor: Option<String>,
384    /// Comma-separated field selector.
385    #[builder(into)]
386    pub shape: Option<String>,
387    /// Collapse nested objects into dot-separated keys.
388    #[builder(default)]
389    pub flat: bool,
390    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
391    #[builder(default)]
392    pub flat_lists: bool,
393
394    /// Free-text search filter.
395    #[builder(into)]
396    pub search: Option<String>,
397
398    /// Escape hatch for filter keys not first-classed here.
399    #[builder(default)]
400    pub extra: BTreeMap<String, String>,
401}
402
403impl ListBusinessTypesOptions {
404    fn to_query(&self) -> Vec<(String, String)> {
405        let mut q = Vec::new();
406        apply_pagination(
407            &mut q,
408            self.page,
409            self.limit,
410            self.cursor.as_deref(),
411            self.shape.as_deref(),
412            self.flat,
413            self.flat_lists,
414        );
415        push_opt(&mut q, "search", self.search.as_deref());
416        for (k, v) in &self.extra {
417            if !v.is_empty() {
418                q.push((k.clone(), v.clone()));
419            }
420        }
421        q
422    }
423}
424
425// =====================================================================
426// Offices
427// =====================================================================
428
429/// Options for [`Client::list_offices`] / [`Client::iterate_offices`].
430#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
431#[non_exhaustive]
432pub struct ListOfficesOptions {
433    /// 1-based page number.
434    #[builder(into)]
435    pub page: Option<u32>,
436    /// Page size.
437    #[builder(into)]
438    pub limit: Option<u32>,
439    /// Keyset cursor.
440    #[builder(into)]
441    pub cursor: Option<String>,
442    /// Comma-separated field selector.
443    #[builder(into)]
444    pub shape: Option<String>,
445    /// Collapse nested objects into dot-separated keys.
446    #[builder(default)]
447    pub flat: bool,
448    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
449    #[builder(default)]
450    pub flat_lists: bool,
451
452    /// Free-text search filter (office name, code).
453    #[builder(into)]
454    pub search: Option<String>,
455
456    /// Escape hatch for filter keys not first-classed here.
457    #[builder(default)]
458    pub extra: BTreeMap<String, String>,
459}
460
461impl ListOfficesOptions {
462    fn to_query(&self) -> Vec<(String, String)> {
463        let mut q = Vec::new();
464        apply_pagination(
465            &mut q,
466            self.page,
467            self.limit,
468            self.cursor.as_deref(),
469            self.shape.as_deref(),
470            self.flat,
471            self.flat_lists,
472        );
473        push_opt(&mut q, "search", self.search.as_deref());
474        for (k, v) in &self.extra {
475            if !v.is_empty() {
476                q.push((k.clone(), v.clone()));
477            }
478        }
479        q
480    }
481}
482
483// =====================================================================
484// Departments (deprecated)
485// =====================================================================
486
487/// Options for [`Client::list_departments`] / [`Client::iterate_departments`].
488///
489/// Deprecated in spirit: prefer [`ListOrganizationsOptions`] with `level = "1"`.
490/// The standalone `/api/departments/` endpoint is retained for backward
491/// compatibility.
492#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
493#[non_exhaustive]
494pub struct ListDepartmentsOptions {
495    /// 1-based page number.
496    #[builder(into)]
497    pub page: Option<u32>,
498    /// Page size.
499    #[builder(into)]
500    pub limit: Option<u32>,
501    /// Keyset cursor.
502    #[builder(into)]
503    pub cursor: Option<String>,
504    /// Comma-separated field selector.
505    #[builder(into)]
506    pub shape: Option<String>,
507    /// Collapse nested objects into dot-separated keys.
508    #[builder(default)]
509    pub flat: bool,
510    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
511    #[builder(default)]
512    pub flat_lists: bool,
513
514    /// Free-text search filter.
515    #[builder(into)]
516    pub search: Option<String>,
517
518    /// Escape hatch for filter keys not first-classed here.
519    #[builder(default)]
520    pub extra: BTreeMap<String, String>,
521}
522
523impl ListDepartmentsOptions {
524    fn to_query(&self) -> Vec<(String, String)> {
525        let mut q = Vec::new();
526        apply_pagination(
527            &mut q,
528            self.page,
529            self.limit,
530            self.cursor.as_deref(),
531            self.shape.as_deref(),
532            self.flat,
533            self.flat_lists,
534        );
535        push_opt(&mut q, "search", self.search.as_deref());
536        for (k, v) in &self.extra {
537            if !v.is_empty() {
538                q.push((k.clone(), v.clone()));
539            }
540        }
541        q
542    }
543}
544
545// =====================================================================
546// Client methods
547// =====================================================================
548
549impl Client {
550    // ----- Organizations -----
551
552    /// `GET /api/organizations/` — one page of organization records.
553    pub async fn list_organizations(&self, opts: ListOrganizationsOptions) -> Result<Page<Record>> {
554        let q = opts.to_query();
555        let bytes = self.get_bytes("/api/organizations/", &q).await?;
556        Page::decode(&bytes)
557    }
558
559    /// Stream every organization record matching `opts`.
560    pub fn iterate_organizations(&self, opts: ListOrganizationsOptions) -> PageStream<Record> {
561        let opts = Arc::new(opts);
562        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
563            let mut next = (*opts).clone();
564            next.page = page;
565            next.cursor = cursor;
566            Box::pin(async move { client.list_organizations(next).await })
567        });
568        PageStream::new(self.clone(), fetch)
569    }
570
571    /// `GET /api/organizations/{key}/` — fetch a single organization.
572    pub async fn get_organization(&self, key: &str) -> Result<Record> {
573        if key.is_empty() {
574            return Err(Error::Validation {
575                message: "get_organization: organization key is required".into(),
576                response: None,
577            });
578        }
579        let path = format!("/api/organizations/{}/", urlencoding(key));
580        self.get_json::<Record>(&path, &[]).await
581    }
582
583    // ----- NAICS -----
584
585    /// `GET /api/naics/` — one page of NAICS records.
586    pub async fn list_naics(&self, opts: ListNaicsOptions) -> Result<Page<Record>> {
587        let q = opts.to_query();
588        let bytes = self.get_bytes("/api/naics/", &q).await?;
589        Page::decode(&bytes)
590    }
591
592    /// Stream every NAICS record matching `opts`.
593    pub fn iterate_naics(&self, opts: ListNaicsOptions) -> PageStream<Record> {
594        let opts = Arc::new(opts);
595        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
596            let mut next = (*opts).clone();
597            next.page = page;
598            next.cursor = cursor;
599            Box::pin(async move { client.list_naics(next).await })
600        });
601        PageStream::new(self.clone(), fetch)
602    }
603
604    /// `GET /api/naics/{code}/` — fetch a single NAICS record.
605    pub async fn get_naics(&self, code: &str) -> Result<Record> {
606        if code.is_empty() {
607            return Err(Error::Validation {
608                message: "get_naics: NAICS code is required".into(),
609                response: None,
610            });
611        }
612        let path = format!("/api/naics/{}/", urlencoding(code));
613        self.get_json::<Record>(&path, &[]).await
614    }
615
616    // ----- PSC -----
617
618    /// `GET /api/psc/` — one page of PSC (Product/Service Code) records.
619    pub async fn list_psc(&self, opts: ListPscOptions) -> Result<Page<Record>> {
620        let q = opts.to_query();
621        let bytes = self.get_bytes("/api/psc/", &q).await?;
622        Page::decode(&bytes)
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        let bytes = self.get_bytes("/api/mas_sins/", &q).await?;
655        Page::decode(&bytes)
656    }
657
658    /// Stream every MAS SIN record matching `opts`.
659    pub fn iterate_mas_sins(&self, opts: ListMasSinsOptions) -> PageStream<Record> {
660        let opts = Arc::new(opts);
661        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
662            let mut next = (*opts).clone();
663            next.page = page;
664            next.cursor = cursor;
665            Box::pin(async move { client.list_mas_sins(next).await })
666        });
667        PageStream::new(self.clone(), fetch)
668    }
669
670    /// `GET /api/mas_sins/{sin}/` — fetch a single MAS SIN record.
671    pub async fn get_mas_sin(&self, sin: &str) -> Result<Record> {
672        if sin.is_empty() {
673            return Err(Error::Validation {
674                message: "get_mas_sin: MAS SIN is required".into(),
675                response: None,
676            });
677        }
678        let path = format!("/api/mas_sins/{}/", urlencoding(sin));
679        self.get_json::<Record>(&path, &[]).await
680    }
681
682    // ----- Assistance Listings -----
683
684    /// `GET /api/assistance_listings/` — one page of CFDA program records.
685    pub async fn list_assistance_listings(
686        &self,
687        opts: ListAssistanceListingsOptions,
688    ) -> Result<Page<Record>> {
689        let q = opts.to_query();
690        let bytes = self.get_bytes("/api/assistance_listings/", &q).await?;
691        Page::decode(&bytes)
692    }
693
694    /// Stream every Assistance Listing record matching `opts`.
695    pub fn iterate_assistance_listings(
696        &self,
697        opts: ListAssistanceListingsOptions,
698    ) -> PageStream<Record> {
699        let opts = Arc::new(opts);
700        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
701            let mut next = (*opts).clone();
702            next.page = page;
703            next.cursor = cursor;
704            Box::pin(async move { client.list_assistance_listings(next).await })
705        });
706        PageStream::new(self.clone(), fetch)
707    }
708
709    /// `GET /api/assistance_listings/{number}/` — fetch a single Assistance
710    /// Listing (CFDA program) by its CFDA number (e.g. `"10.001"`).
711    pub async fn get_assistance_listing(&self, number: &str) -> Result<Record> {
712        if number.is_empty() {
713            return Err(Error::Validation {
714                message: "get_assistance_listing: assistance listing number is required".into(),
715                response: None,
716            });
717        }
718        let path = format!("/api/assistance_listings/{}/", urlencoding(number));
719        self.get_json::<Record>(&path, &[]).await
720    }
721
722    // ----- Business Types -----
723
724    /// `GET /api/business_types/` — one page of business-type reference
725    /// records (SBA / SAM.gov socioeconomic designations).
726    pub async fn list_business_types(
727        &self,
728        opts: ListBusinessTypesOptions,
729    ) -> Result<Page<Record>> {
730        let q = opts.to_query();
731        let bytes = self.get_bytes("/api/business_types/", &q).await?;
732        Page::decode(&bytes)
733    }
734
735    /// Stream every business-type record matching `opts`.
736    pub fn iterate_business_types(&self, opts: ListBusinessTypesOptions) -> PageStream<Record> {
737        let opts = Arc::new(opts);
738        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
739            let mut next = (*opts).clone();
740            next.page = page;
741            next.cursor = cursor;
742            Box::pin(async move { client.list_business_types(next).await })
743        });
744        PageStream::new(self.clone(), fetch)
745    }
746
747    /// `GET /api/business_types/{code}/` — fetch a single business-type
748    /// reference record by its short code.
749    pub async fn get_business_type(&self, code: &str) -> Result<Record> {
750        if code.is_empty() {
751            return Err(Error::Validation {
752                message: "get_business_type: business type code is required".into(),
753                response: None,
754            });
755        }
756        let path = format!("/api/business_types/{}/", urlencoding(code));
757        self.get_json::<Record>(&path, &[]).await
758    }
759
760    // ----- Offices -----
761
762    /// `GET /api/offices/` — one page of federal contracting office records.
763    pub async fn list_offices(&self, opts: ListOfficesOptions) -> Result<Page<Record>> {
764        let q = opts.to_query();
765        let bytes = self.get_bytes("/api/offices/", &q).await?;
766        Page::decode(&bytes)
767    }
768
769    /// Stream every office record matching `opts`.
770    pub fn iterate_offices(&self, opts: ListOfficesOptions) -> PageStream<Record> {
771        let opts = Arc::new(opts);
772        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
773            let mut next = (*opts).clone();
774            next.page = page;
775            next.cursor = cursor;
776            Box::pin(async move { client.list_offices(next).await })
777        });
778        PageStream::new(self.clone(), fetch)
779    }
780
781    /// `GET /api/offices/{code}/` — fetch a single office record by its
782    /// FPDS-NG office code.
783    pub async fn get_office(&self, code: &str) -> Result<Record> {
784        if code.is_empty() {
785            return Err(Error::Validation {
786                message: "get_office: office code is required".into(),
787                response: None,
788            });
789        }
790        let path = format!("/api/offices/{}/", urlencoding(code));
791        self.get_json::<Record>(&path, &[]).await
792    }
793
794    // ----- Departments -----
795
796    /// `GET /api/departments/` — one page of department records.
797    ///
798    /// Prefer [`Client::list_organizations`] with `level = "1"` for new code.
799    /// The standalone `/api/departments/` endpoint is retained for backward
800    /// compatibility.
801    pub async fn list_departments(&self, opts: ListDepartmentsOptions) -> Result<Page<Record>> {
802        let q = opts.to_query();
803        let bytes = self.get_bytes("/api/departments/", &q).await?;
804        Page::decode(&bytes)
805    }
806
807    /// Stream every department record matching `opts`.
808    pub fn iterate_departments(&self, opts: ListDepartmentsOptions) -> PageStream<Record> {
809        let opts = Arc::new(opts);
810        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
811            let mut next = (*opts).clone();
812            next.page = page;
813            next.cursor = cursor;
814            Box::pin(async move { client.list_departments(next).await })
815        });
816        PageStream::new(self.clone(), fetch)
817    }
818
819    /// `GET /api/departments/{code}/` — fetch a single department by code
820    /// (typically the CGAC department code, e.g. `"097"` for DoD).
821    pub async fn get_department(&self, code: &str) -> Result<Record> {
822        if code.is_empty() {
823            return Err(Error::Validation {
824                message: "get_department: department code is required".into(),
825                response: None,
826            });
827        }
828        let path = format!("/api/departments/{}/", urlencoding(code));
829        self.get_json::<Record>(&path, &[]).await
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
838        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
839    }
840
841    // ----- Organizations -----
842
843    #[test]
844    fn organizations_emits_filters() {
845        let opts = ListOrganizationsOptions::builder()
846            .search("Defense")
847            .r#type("agency")
848            .level("1")
849            .cgac("097")
850            .parent("DOD")
851            .include_inactive(false)
852            .build();
853        let q = opts.to_query();
854        assert_eq!(get_q(&q, "search").as_deref(), Some("Defense"));
855        assert_eq!(get_q(&q, "type").as_deref(), Some("agency"));
856        assert_eq!(get_q(&q, "level").as_deref(), Some("1"));
857        assert_eq!(get_q(&q, "cgac").as_deref(), Some("097"));
858        assert_eq!(get_q(&q, "parent").as_deref(), Some("DOD"));
859        assert_eq!(get_q(&q, "include_inactive").as_deref(), Some("false"));
860    }
861
862    #[tokio::test]
863    async fn get_organization_empty_key_is_validation() {
864        let c = Client::builder().api_key("k").build().expect("client");
865        let err = c.get_organization("").await.expect_err("must error");
866        assert!(matches!(err, Error::Validation { .. }));
867    }
868
869    // ----- NAICS -----
870
871    #[test]
872    fn naics_emits_size_filters() {
873        let opts = ListNaicsOptions::builder()
874            .search("software")
875            .revenue_limit_gte("1000000")
876            .employee_limit_lte("500")
877            .build();
878        let q = opts.to_query();
879        assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
880        assert_eq!(get_q(&q, "revenue_limit_gte").as_deref(), Some("1000000"));
881        assert_eq!(get_q(&q, "employee_limit_lte").as_deref(), Some("500"));
882    }
883
884    #[tokio::test]
885    async fn get_naics_empty_code_is_validation() {
886        let c = Client::builder().api_key("k").build().expect("client");
887        let err = c.get_naics("").await.expect_err("must error");
888        assert!(matches!(err, Error::Validation { .. }));
889    }
890
891    // ----- PSC -----
892
893    #[test]
894    fn psc_emits_search() {
895        let opts = ListPscOptions::builder().search("services").build();
896        let q = opts.to_query();
897        assert_eq!(get_q(&q, "search").as_deref(), Some("services"));
898    }
899
900    #[tokio::test]
901    async fn get_psc_empty_code_is_validation() {
902        let c = Client::builder().api_key("k").build().expect("client");
903        let err = c.get_psc("").await.expect_err("must error");
904        assert!(matches!(err, Error::Validation { .. }));
905    }
906
907    // ----- MAS SINs -----
908
909    #[test]
910    fn mas_sins_emits_search() {
911        let opts = ListMasSinsOptions::builder().search("54151S").build();
912        let q = opts.to_query();
913        assert_eq!(get_q(&q, "search").as_deref(), Some("54151S"));
914    }
915
916    #[tokio::test]
917    async fn get_mas_sin_empty_is_validation() {
918        let c = Client::builder().api_key("k").build().expect("client");
919        let err = c.get_mas_sin("").await.expect_err("must error");
920        assert!(matches!(err, Error::Validation { .. }));
921    }
922
923    // ----- Assistance Listings -----
924
925    #[test]
926    fn assistance_listings_paginates() {
927        let opts = ListAssistanceListingsOptions::builder()
928            .page(2u32)
929            .limit(50u32)
930            .build();
931        let q = opts.to_query();
932        assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
933        assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
934    }
935
936    #[tokio::test]
937    async fn get_assistance_listing_empty_is_validation() {
938        let c = Client::builder().api_key("k").build().expect("client");
939        let err = c.get_assistance_listing("").await.expect_err("must error");
940        assert!(matches!(err, Error::Validation { .. }));
941    }
942
943    // ----- Business Types -----
944
945    #[test]
946    fn business_types_passes_shape() {
947        let opts = ListBusinessTypesOptions::builder()
948            .shape("code,name")
949            .build();
950        let q = opts.to_query();
951        assert_eq!(get_q(&q, "shape").as_deref(), Some("code,name"));
952    }
953
954    #[tokio::test]
955    async fn get_business_type_empty_is_validation() {
956        let c = Client::builder().api_key("k").build().expect("client");
957        let err = c.get_business_type("").await.expect_err("must error");
958        assert!(matches!(err, Error::Validation { .. }));
959    }
960
961    // ----- Offices -----
962
963    #[test]
964    fn offices_emits_search() {
965        let opts = ListOfficesOptions::builder().search("FA8650").build();
966        let q = opts.to_query();
967        assert_eq!(get_q(&q, "search").as_deref(), Some("FA8650"));
968    }
969
970    #[tokio::test]
971    async fn get_office_empty_is_validation() {
972        let c = Client::builder().api_key("k").build().expect("client");
973        let err = c.get_office("").await.expect_err("must error");
974        assert!(matches!(err, Error::Validation { .. }));
975    }
976
977    // ----- Departments -----
978
979    #[test]
980    fn departments_passes_cursor() {
981        let opts = ListDepartmentsOptions::builder()
982            .cursor("abc".to_string())
983            .build();
984        let q = opts.to_query();
985        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
986    }
987
988    #[tokio::test]
989    async fn get_department_empty_is_validation() {
990        let c = Client::builder().api_key("k").build().expect("client");
991        let err = c.get_department("").await.expect_err("must error");
992        assert!(matches!(err, Error::Validation { .. }));
993    }
994
995    // ----- Extra escape hatch -----
996
997    #[test]
998    fn extra_keys_pass_through() {
999        let mut extra = BTreeMap::new();
1000        extra.insert("custom".into(), "value".into());
1001        let opts = ListNaicsOptions::builder().extra(extra).build();
1002        let q = opts.to_query();
1003        assert_eq!(get_q(&q, "custom").as_deref(), Some("value"));
1004    }
1005
1006    #[test]
1007    fn empty_extra_value_is_skipped() {
1008        let mut extra = BTreeMap::new();
1009        extra.insert("skip".into(), String::new());
1010        let opts = ListPscOptions::builder().extra(extra).build();
1011        let q = opts.to_query();
1012        assert_eq!(get_q(&q, "skip"), None);
1013    }
1014}