Skip to main content

tango/resources/
otas.rs

1//! `GET /api/otas/` and `GET /api/otidvs/` — Other Transaction Authority
2//! award actions and their IDV parents.
3
4use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14// ============================================================================
15// OTAs (/api/otas/)
16// ============================================================================
17
18/// Options for [`Client::list_otas`] and [`Client::iterate_otas`].
19///
20/// Mirrors the Go SDK's `ListOTAsOptions`. All filters are sent as query
21/// parameters; empty/None values are omitted.
22#[allow(clippy::upper_case_acronyms)]
23#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct ListOTAsOptions {
26    // ----- Pagination + shape (shared across every list endpoint) -----
27    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
28    #[builder(into)]
29    pub page: Option<u32>,
30    /// Page size (server caps at 100 on most endpoints).
31    #[builder(into)]
32    pub limit: Option<u32>,
33    /// Keyset cursor for cursor-paginated endpoints. Pass the `cursor` field
34    /// from the previous [`Page`](crate::Page).
35    #[builder(into)]
36    pub cursor: Option<String>,
37    /// Comma-separated field selector. Use [`SHAPE_OTAS_MINIMAL`](crate::SHAPE_OTAS_MINIMAL)
38    /// or roll your own.
39    #[builder(into)]
40    pub shape: Option<String>,
41    /// Collapse nested objects into dot-separated keys.
42    #[builder(default)]
43    pub flat: bool,
44    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
45    #[builder(default)]
46    pub flat_lists: bool,
47
48    /// Joiner used between flattened keys when `flat=true`. Defaults to `.` server-side.
49    #[builder(into)]
50    pub joiner: Option<String>,
51
52    // ----- Identifiers + agencies -----
53    /// Awarding agency CGAC code.
54    #[builder(into)]
55    pub awarding_agency: Option<String>,
56    /// Funding agency CGAC code.
57    #[builder(into)]
58    pub funding_agency: Option<String>,
59    /// Procurement Instrument Identifier.
60    #[builder(into)]
61    pub piid: Option<String>,
62    /// Recipient name filter.
63    #[builder(into)]
64    pub recipient: Option<String>,
65    /// Recipient UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0).
66    #[builder(into)]
67    pub uei: Option<String>,
68
69    // ----- Fiscal-year bounds -----
70    /// `fiscal_year` filter.
71    #[builder(into)]
72    pub fiscal_year: Option<String>,
73    /// Lower bound for `fiscal_year`.
74    #[builder(into)]
75    pub fiscal_year_gte: Option<String>,
76    /// Upper bound for `fiscal_year`.
77    #[builder(into)]
78    pub fiscal_year_lte: Option<String>,
79
80    // ----- Date bounds -----
81    /// Single-day `award_date` filter (ISO `YYYY-MM-DD`).
82    #[builder(into)]
83    pub award_date: Option<String>,
84    /// Lower bound for `award_date` (inclusive).
85    #[builder(into)]
86    pub award_date_gte: Option<String>,
87    /// Upper bound for `award_date` (inclusive).
88    #[builder(into)]
89    pub award_date_lte: Option<String>,
90    /// Lower bound for the OTA's expiration date.
91    #[builder(into)]
92    pub expiring_gte: Option<String>,
93    /// Upper bound for the OTA's expiration date.
94    #[builder(into)]
95    pub expiring_lte: Option<String>,
96    /// Lower bound for period-of-performance start date.
97    #[builder(into)]
98    pub pop_start_date_gte: Option<String>,
99    /// Upper bound for period-of-performance start date.
100    #[builder(into)]
101    pub pop_start_date_lte: Option<String>,
102    /// Lower bound for period-of-performance end date.
103    #[builder(into)]
104    pub pop_end_date_gte: Option<String>,
105    /// Upper bound for period-of-performance end date.
106    #[builder(into)]
107    pub pop_end_date_lte: Option<String>,
108
109    /// PSC code.
110    #[builder(into)]
111    pub psc: Option<String>,
112
113    /// Free-text search filter.
114    #[builder(into)]
115    pub search: Option<String>,
116    /// Server-side sort spec (prefix `-` for descending).
117    #[builder(into)]
118    pub ordering: Option<String>,
119
120    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
121    #[builder(into)]
122    pub key: Option<String>,
123
124    /// Escape hatch for filter keys not yet first-classed on this struct.
125    #[builder(default)]
126    pub extra: BTreeMap<String, String>,
127}
128
129impl ListOTAsOptions {
130    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
131        let mut q = Vec::new();
132        apply_pagination(
133            &mut q,
134            self.page,
135            self.limit,
136            self.cursor.as_deref(),
137            self.shape.as_deref(),
138            self.flat,
139            self.flat_lists,
140        );
141        push_opt(&mut q, "joiner", self.joiner.as_deref());
142        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
143        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
144        push_opt(&mut q, "piid", self.piid.as_deref());
145        push_opt(&mut q, "key", self.key.as_deref());
146        push_opt(&mut q, "recipient", self.recipient.as_deref());
147        push_opt(&mut q, "uei", self.uei.as_deref());
148        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
149        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
150        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
151        push_opt(&mut q, "award_date", self.award_date.as_deref());
152        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
153        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
154        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
155        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
156        push_opt(
157            &mut q,
158            "pop_start_date_gte",
159            self.pop_start_date_gte.as_deref(),
160        );
161        push_opt(
162            &mut q,
163            "pop_start_date_lte",
164            self.pop_start_date_lte.as_deref(),
165        );
166        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
167        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
168        push_opt(&mut q, "psc", self.psc.as_deref());
169        push_opt(&mut q, "search", self.search.as_deref());
170        push_opt(&mut q, "ordering", self.ordering.as_deref());
171        for (k, v) in &self.extra {
172            if !v.is_empty() {
173                q.push((k.clone(), v.clone()));
174            }
175        }
176        q
177    }
178}
179
180/// Options for [`Client::get_ota`].
181#[allow(clippy::upper_case_acronyms)]
182#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
183#[non_exhaustive]
184pub struct GetOTAOptions {
185    /// Shape selector. When empty, the server returns its comprehensive default.
186    #[builder(into)]
187    pub shape: Option<String>,
188    /// Flatten nested objects into dot-separated keys.
189    #[builder(default)]
190    pub flat: bool,
191    /// When `flat=true`, also flatten list-valued nested fields.
192    #[builder(default)]
193    pub flat_lists: bool,
194}
195
196impl GetOTAOptions {
197    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
198        let mut q = Vec::new();
199        push_opt(&mut q, "shape", self.shape.as_deref());
200        if self.flat {
201            q.push(("flat".into(), "true".into()));
202        }
203        if self.flat_lists {
204            q.push(("flat_lists".into(), "true".into()));
205        }
206        q
207    }
208}
209
210// ============================================================================
211// OTIDVs (/api/otidvs/)
212// ============================================================================
213
214/// Options for [`Client::list_otidvs`] and [`Client::iterate_otidvs`].
215///
216/// Filter shape is identical to [`ListOTAsOptions`] per the sibling SDKs.
217#[allow(clippy::upper_case_acronyms)]
218#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
219#[non_exhaustive]
220pub struct ListOTIDVsOptions {
221    /// 1-based page number.
222    #[builder(into)]
223    pub page: Option<u32>,
224    /// Page size.
225    #[builder(into)]
226    pub limit: Option<u32>,
227    /// Keyset cursor.
228    #[builder(into)]
229    pub cursor: Option<String>,
230    /// Comma-separated field selector. Use
231    /// [`SHAPE_OTIDVS_MINIMAL`](crate::SHAPE_OTIDVS_MINIMAL) or roll your own.
232    #[builder(into)]
233    pub shape: Option<String>,
234    /// Collapse nested objects into dot-separated keys.
235    #[builder(default)]
236    pub flat: bool,
237    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
238    #[builder(default)]
239    pub flat_lists: bool,
240
241    /// Joiner used between flattened keys when `flat=true`.
242    #[builder(into)]
243    pub joiner: Option<String>,
244
245    /// Awarding agency CGAC code.
246    #[builder(into)]
247    pub awarding_agency: Option<String>,
248    /// Funding agency CGAC code.
249    #[builder(into)]
250    pub funding_agency: Option<String>,
251    /// Procurement Instrument Identifier.
252    #[builder(into)]
253    pub piid: Option<String>,
254    /// Recipient name filter.
255    #[builder(into)]
256    pub recipient: Option<String>,
257    /// Recipient UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0).
258    #[builder(into)]
259    pub uei: Option<String>,
260
261    /// `fiscal_year` filter.
262    #[builder(into)]
263    pub fiscal_year: Option<String>,
264    /// Lower bound for `fiscal_year`.
265    #[builder(into)]
266    pub fiscal_year_gte: Option<String>,
267    /// Upper bound for `fiscal_year`.
268    #[builder(into)]
269    pub fiscal_year_lte: Option<String>,
270
271    /// Single-day `award_date` filter (ISO `YYYY-MM-DD`).
272    #[builder(into)]
273    pub award_date: Option<String>,
274    /// Lower bound for `award_date`.
275    #[builder(into)]
276    pub award_date_gte: Option<String>,
277    /// Upper bound for `award_date`.
278    #[builder(into)]
279    pub award_date_lte: Option<String>,
280    /// Lower bound for the OTIDV's expiration date.
281    #[builder(into)]
282    pub expiring_gte: Option<String>,
283    /// Upper bound for the OTIDV's expiration date.
284    #[builder(into)]
285    pub expiring_lte: Option<String>,
286    /// Lower bound for period-of-performance start date.
287    #[builder(into)]
288    pub pop_start_date_gte: Option<String>,
289    /// Upper bound for period-of-performance start date.
290    #[builder(into)]
291    pub pop_start_date_lte: Option<String>,
292    /// Lower bound for period-of-performance end date.
293    #[builder(into)]
294    pub pop_end_date_gte: Option<String>,
295    /// Upper bound for period-of-performance end date.
296    #[builder(into)]
297    pub pop_end_date_lte: Option<String>,
298
299    /// PSC code.
300    #[builder(into)]
301    pub psc: Option<String>,
302
303    /// Free-text search filter.
304    #[builder(into)]
305    pub search: Option<String>,
306    /// Server-side sort spec (prefix `-` for descending).
307    #[builder(into)]
308    pub ordering: Option<String>,
309
310    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
311    #[builder(into)]
312    pub key: Option<String>,
313
314    /// Escape hatch for filter keys not yet first-classed.
315    #[builder(default)]
316    pub extra: BTreeMap<String, String>,
317}
318
319impl ListOTIDVsOptions {
320    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
321        let mut q = Vec::new();
322        apply_pagination(
323            &mut q,
324            self.page,
325            self.limit,
326            self.cursor.as_deref(),
327            self.shape.as_deref(),
328            self.flat,
329            self.flat_lists,
330        );
331        push_opt(&mut q, "joiner", self.joiner.as_deref());
332        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
333        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
334        push_opt(&mut q, "piid", self.piid.as_deref());
335        push_opt(&mut q, "key", self.key.as_deref());
336        push_opt(&mut q, "recipient", self.recipient.as_deref());
337        push_opt(&mut q, "uei", self.uei.as_deref());
338        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
339        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
340        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
341        push_opt(&mut q, "award_date", self.award_date.as_deref());
342        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
343        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
344        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
345        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
346        push_opt(
347            &mut q,
348            "pop_start_date_gte",
349            self.pop_start_date_gte.as_deref(),
350        );
351        push_opt(
352            &mut q,
353            "pop_start_date_lte",
354            self.pop_start_date_lte.as_deref(),
355        );
356        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
357        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
358        push_opt(&mut q, "psc", self.psc.as_deref());
359        push_opt(&mut q, "search", self.search.as_deref());
360        push_opt(&mut q, "ordering", self.ordering.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/// Options for [`Client::get_otidv`].
371#[allow(clippy::upper_case_acronyms)]
372#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
373#[non_exhaustive]
374pub struct GetOTIDVOptions {
375    /// Shape selector. When empty, the server returns its comprehensive default.
376    #[builder(into)]
377    pub shape: Option<String>,
378    /// Flatten nested objects into dot-separated keys.
379    #[builder(default)]
380    pub flat: bool,
381    /// When `flat=true`, also flatten list-valued nested fields.
382    #[builder(default)]
383    pub flat_lists: bool,
384}
385
386impl GetOTIDVOptions {
387    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
388        let mut q = Vec::new();
389        push_opt(&mut q, "shape", self.shape.as_deref());
390        if self.flat {
391            q.push(("flat".into(), "true".into()));
392        }
393        if self.flat_lists {
394            q.push(("flat_lists".into(), "true".into()));
395        }
396        q
397    }
398}
399
400// ============================================================================
401// OTIDV awards (/api/otidvs/{key}/awards/)
402// ============================================================================
403
404/// Options for [`Client::list_otidv_awards`] and [`Client::iterate_otidv_awards`].
405///
406/// Same filter shape as [`ListOTAsOptions`].
407#[allow(clippy::upper_case_acronyms)]
408#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
409#[non_exhaustive]
410pub struct ListOTIDVAwardsOptions {
411    /// 1-based page number.
412    #[builder(into)]
413    pub page: Option<u32>,
414    /// Page size.
415    #[builder(into)]
416    pub limit: Option<u32>,
417    /// Keyset cursor.
418    #[builder(into)]
419    pub cursor: Option<String>,
420    /// Comma-separated field selector.
421    #[builder(into)]
422    pub shape: Option<String>,
423    /// Collapse nested objects into dot-separated keys.
424    #[builder(default)]
425    pub flat: bool,
426    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
427    #[builder(default)]
428    pub flat_lists: bool,
429
430    /// Joiner used between flattened keys when `flat=true`.
431    #[builder(into)]
432    pub joiner: Option<String>,
433
434    /// Awarding agency CGAC code.
435    #[builder(into)]
436    pub awarding_agency: Option<String>,
437    /// Funding agency CGAC code.
438    #[builder(into)]
439    pub funding_agency: Option<String>,
440    /// Procurement Instrument Identifier.
441    #[builder(into)]
442    pub piid: Option<String>,
443    /// Recipient name filter.
444    #[builder(into)]
445    pub recipient: Option<String>,
446    /// Recipient UEI.
447    #[builder(into)]
448    pub uei: Option<String>,
449
450    /// `fiscal_year` filter.
451    #[builder(into)]
452    pub fiscal_year: Option<String>,
453    /// Lower bound for `fiscal_year`.
454    #[builder(into)]
455    pub fiscal_year_gte: Option<String>,
456    /// Upper bound for `fiscal_year`.
457    #[builder(into)]
458    pub fiscal_year_lte: Option<String>,
459
460    /// Single-day `award_date` filter.
461    #[builder(into)]
462    pub award_date: Option<String>,
463    /// Lower bound for `award_date`.
464    #[builder(into)]
465    pub award_date_gte: Option<String>,
466    /// Upper bound for `award_date`.
467    #[builder(into)]
468    pub award_date_lte: Option<String>,
469    /// Lower bound for the award's expiration date.
470    #[builder(into)]
471    pub expiring_gte: Option<String>,
472    /// Upper bound for the award's expiration date.
473    #[builder(into)]
474    pub expiring_lte: Option<String>,
475    /// Lower bound for period-of-performance start date.
476    #[builder(into)]
477    pub pop_start_date_gte: Option<String>,
478    /// Upper bound for period-of-performance start date.
479    #[builder(into)]
480    pub pop_start_date_lte: Option<String>,
481    /// Lower bound for period-of-performance end date.
482    #[builder(into)]
483    pub pop_end_date_gte: Option<String>,
484    /// Upper bound for period-of-performance end date.
485    #[builder(into)]
486    pub pop_end_date_lte: Option<String>,
487
488    /// PSC code.
489    #[builder(into)]
490    pub psc: Option<String>,
491
492    /// Free-text search filter.
493    #[builder(into)]
494    pub search: Option<String>,
495    /// Server-side sort spec.
496    #[builder(into)]
497    pub ordering: Option<String>,
498
499    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
500    #[builder(into)]
501    pub key: Option<String>,
502
503    /// Escape hatch for filter keys not yet first-classed.
504    #[builder(default)]
505    pub extra: BTreeMap<String, String>,
506}
507
508impl ListOTIDVAwardsOptions {
509    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
510        let mut q = Vec::new();
511        apply_pagination(
512            &mut q,
513            self.page,
514            self.limit,
515            self.cursor.as_deref(),
516            self.shape.as_deref(),
517            self.flat,
518            self.flat_lists,
519        );
520        push_opt(&mut q, "joiner", self.joiner.as_deref());
521        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
522        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
523        push_opt(&mut q, "piid", self.piid.as_deref());
524        push_opt(&mut q, "key", self.key.as_deref());
525        push_opt(&mut q, "recipient", self.recipient.as_deref());
526        push_opt(&mut q, "uei", self.uei.as_deref());
527        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
528        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
529        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
530        push_opt(&mut q, "award_date", self.award_date.as_deref());
531        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
532        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
533        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
534        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
535        push_opt(
536            &mut q,
537            "pop_start_date_gte",
538            self.pop_start_date_gte.as_deref(),
539        );
540        push_opt(
541            &mut q,
542            "pop_start_date_lte",
543            self.pop_start_date_lte.as_deref(),
544        );
545        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
546        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
547        push_opt(&mut q, "psc", self.psc.as_deref());
548        push_opt(&mut q, "search", self.search.as_deref());
549        push_opt(&mut q, "ordering", self.ordering.as_deref());
550        for (k, v) in &self.extra {
551            if !v.is_empty() {
552                q.push((k.clone(), v.clone()));
553            }
554        }
555        q
556    }
557}
558
559// ============================================================================
560// Client methods
561// ============================================================================
562
563impl Client {
564    /// `GET /api/otas/` — one page of Other Transaction Authority award actions.
565    pub async fn list_otas(&self, opts: ListOTAsOptions) -> Result<Page<Record>> {
566        let q = opts.to_query();
567        self.get_page("/api/otas/", &q).await
568    }
569
570    /// `GET /api/otas/{key}/` — fetch a single OTA by key.
571    pub async fn get_ota(&self, key: &str, opts: Option<GetOTAOptions>) -> Result<Record> {
572        if key.is_empty() {
573            return Err(Error::Validation {
574                message: "get_ota: key is required".into(),
575                response: None,
576            });
577        }
578        let q = opts.unwrap_or_default().to_query();
579        let path = format!("/api/otas/{}/", urlencoding(key));
580        self.get_json::<Record>(&path, &q).await
581    }
582
583    /// Stream every OTA matching `opts`.
584    pub fn iterate_otas(&self, opts: ListOTAsOptions) -> PageStream<Record> {
585        let opts = Arc::new(opts);
586        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
587            let mut next = (*opts).clone();
588            next.page = page;
589            next.cursor = cursor;
590            Box::pin(async move { client.list_otas(next).await })
591        });
592        PageStream::new(self.clone(), fetch)
593    }
594
595    /// `GET /api/otidvs/` — one page of Other Transaction IDV parent records.
596    pub async fn list_otidvs(&self, opts: ListOTIDVsOptions) -> Result<Page<Record>> {
597        let q = opts.to_query();
598        self.get_page("/api/otidvs/", &q).await
599    }
600
601    /// `GET /api/otidvs/{key}/` — fetch a single OTIDV by key.
602    pub async fn get_otidv(&self, key: &str, opts: Option<GetOTIDVOptions>) -> Result<Record> {
603        if key.is_empty() {
604            return Err(Error::Validation {
605                message: "get_otidv: key is required".into(),
606                response: None,
607            });
608        }
609        let q = opts.unwrap_or_default().to_query();
610        let path = format!("/api/otidvs/{}/", urlencoding(key));
611        self.get_json::<Record>(&path, &q).await
612    }
613
614    /// Stream every OTIDV matching `opts`.
615    pub fn iterate_otidvs(&self, opts: ListOTIDVsOptions) -> PageStream<Record> {
616        let opts = Arc::new(opts);
617        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
618            let mut next = (*opts).clone();
619            next.page = page;
620            next.cursor = cursor;
621            Box::pin(async move { client.list_otidvs(next).await })
622        });
623        PageStream::new(self.clone(), fetch)
624    }
625
626    /// `GET /api/otidvs/{key}/awards/` — child awards under an OTIDV parent.
627    pub async fn list_otidv_awards(
628        &self,
629        key: &str,
630        opts: ListOTIDVAwardsOptions,
631    ) -> Result<Page<Record>> {
632        if key.is_empty() {
633            return Err(Error::Validation {
634                message: "list_otidv_awards: key is required".into(),
635                response: None,
636            });
637        }
638        let q = opts.to_query();
639        let path = format!("/api/otidvs/{}/awards/", urlencoding(key));
640        self.get_page(&path, &q).await
641    }
642
643    /// Stream every child award under the given OTIDV parent.
644    pub fn iterate_otidv_awards(
645        &self,
646        key: &str,
647        opts: ListOTIDVAwardsOptions,
648    ) -> PageStream<Record> {
649        let opts = Arc::new(opts);
650        let key = Arc::new(key.to_string());
651        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
652            let mut next = (*opts).clone();
653            next.page = page;
654            next.cursor = cursor;
655            let key = key.clone();
656            Box::pin(async move { client.list_otidv_awards(&key, next).await })
657        });
658        PageStream::new(self.clone(), fetch)
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
667        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
668    }
669
670    #[test]
671    fn list_otas_all_filters_emit() {
672        let opts = ListOTAsOptions::builder()
673            .awarding_agency("9700")
674            .funding_agency("9800")
675            .piid("FA8650")
676            .recipient("Acme")
677            .uei("UEI123")
678            .fiscal_year("2024")
679            .fiscal_year_gte("2020")
680            .fiscal_year_lte("2024")
681            .award_date("2024-01-01")
682            .award_date_gte("2023-01-01")
683            .award_date_lte("2024-12-31")
684            .expiring_gte("2024-01-01")
685            .expiring_lte("2025-12-31")
686            .pop_start_date_gte("2024-01-01")
687            .pop_start_date_lte("2024-06-30")
688            .pop_end_date_gte("2024-07-01")
689            .pop_end_date_lte("2024-12-31")
690            .psc("D302")
691            .search("hypersonics")
692            .ordering("-award_date")
693            .build();
694        let q = opts.to_query();
695        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
696        assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9800"));
697        assert_eq!(get_q(&q, "piid").as_deref(), Some("FA8650"));
698        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
699        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI123"));
700        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
701        assert_eq!(get_q(&q, "fiscal_year_gte").as_deref(), Some("2020"));
702        assert_eq!(get_q(&q, "fiscal_year_lte").as_deref(), Some("2024"));
703        assert_eq!(get_q(&q, "award_date").as_deref(), Some("2024-01-01"));
704        assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
705        assert_eq!(get_q(&q, "search").as_deref(), Some("hypersonics"));
706        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
707    }
708
709    #[test]
710    fn list_otas_zero_value_omitted() {
711        let opts = ListOTAsOptions::builder().build();
712        let q = opts.to_query();
713        assert!(q.is_empty(), "expected empty query, got {q:?}");
714    }
715
716    #[test]
717    fn list_otas_extra_emits() {
718        let mut extra = BTreeMap::new();
719        extra.insert("custom_x".to_string(), "xval".to_string());
720        let opts = ListOTAsOptions::builder().extra(extra).build();
721        let q = opts.to_query();
722        assert!(q.contains(&("custom_x".into(), "xval".into())));
723    }
724
725    #[test]
726    fn list_otas_cursor_wins_over_page() {
727        let opts = ListOTAsOptions::builder()
728            .page(3u32)
729            .cursor("abc".to_string())
730            .build();
731        let q = opts.to_query();
732        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
733        assert_eq!(get_q(&q, "page"), None);
734    }
735
736    #[test]
737    fn list_otas_shape_and_flat_emit() {
738        let opts = ListOTAsOptions::builder()
739            .shape(crate::SHAPE_OTAS_MINIMAL)
740            .flat(true)
741            .flat_lists(true)
742            .joiner("__")
743            .build();
744        let q = opts.to_query();
745        assert_eq!(
746            get_q(&q, "shape").as_deref(),
747            Some(crate::SHAPE_OTAS_MINIMAL)
748        );
749        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
750        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
751        assert_eq!(get_q(&q, "joiner").as_deref(), Some("__"));
752    }
753
754    #[test]
755    fn list_otidvs_all_filters_emit() {
756        let opts = ListOTIDVsOptions::builder()
757            .awarding_agency("9700")
758            .piid("FA8650")
759            .uei("UEI123")
760            .fiscal_year("2024")
761            .award_date_gte("2023-01-01")
762            .search("hypersonics")
763            .ordering("-award_date")
764            .build();
765        let q = opts.to_query();
766        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
767        assert_eq!(get_q(&q, "piid").as_deref(), Some("FA8650"));
768        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI123"));
769        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
770        assert_eq!(get_q(&q, "award_date_gte").as_deref(), Some("2023-01-01"));
771        assert_eq!(get_q(&q, "search").as_deref(), Some("hypersonics"));
772        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
773    }
774
775    #[test]
776    fn list_otidv_awards_emits_filters() {
777        let opts = ListOTIDVAwardsOptions::builder()
778            .awarding_agency("9700")
779            .recipient("Acme")
780            .search("kw")
781            .build();
782        let q = opts.to_query();
783        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
784        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
785        assert_eq!(get_q(&q, "search").as_deref(), Some("kw"));
786    }
787
788    #[tokio::test]
789    async fn get_ota_validates_empty_key() {
790        let client = Client::builder().api_key("x").build().expect("client");
791        let err = client.get_ota("", None).await.unwrap_err();
792        match err {
793            Error::Validation { message, .. } => {
794                assert!(message.contains("key is required"));
795            }
796            other => panic!("expected Validation, got {other:?}"),
797        }
798    }
799
800    #[tokio::test]
801    async fn get_otidv_validates_empty_key() {
802        let client = Client::builder().api_key("x").build().expect("client");
803        let err = client.get_otidv("", None).await.unwrap_err();
804        match err {
805            Error::Validation { message, .. } => {
806                assert!(message.contains("key is required"));
807            }
808            other => panic!("expected Validation, got {other:?}"),
809        }
810    }
811
812    #[tokio::test]
813    async fn list_otidv_awards_validates_empty_key() {
814        let client = Client::builder().api_key("x").build().expect("client");
815        let err = client
816            .list_otidv_awards("", ListOTIDVAwardsOptions::default())
817            .await
818            .unwrap_err();
819        match err {
820            Error::Validation { message, .. } => {
821                assert!(message.contains("key is required"));
822            }
823            other => panic!("expected Validation, got {other:?}"),
824        }
825    }
826
827    #[test]
828    fn ota_family_key_filter_emits() {
829        let q = ListOTAsOptions::builder().key("K1").build().to_query();
830        assert_eq!(get_q(&q, "key").as_deref(), Some("K1"));
831        let q = ListOTIDVsOptions::builder().key("K2").build().to_query();
832        assert_eq!(get_q(&q, "key").as_deref(), Some("K2"));
833        let q = ListOTIDVAwardsOptions::builder()
834            .key("K3")
835            .build()
836            .to_query();
837        assert_eq!(get_q(&q, "key").as_deref(), Some("K3"));
838    }
839}