Skip to main content

tango/resources/
sbir.rs

1//! `GET /api/sbir/` — SBIR/STTR topics and DoD DSIP solicitation cycles.
2//!
3//! `activity` (open/closed) is derived at query time from the close or end date; there is no stored flag.
4
5use crate::client::Client;
6use crate::error::{Error, Result};
7use crate::internal::{apply_pagination, push_opt, push_opt_bool};
8use crate::pagination::{FetchFn, Page, PageStream};
9use crate::resources::agencies::urlencoding;
10use crate::Record;
11use bon::Builder;
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15/// Options for [`Client::list_sbir_topics`] and [`Client::iterate_sbir_topics`].
16#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct ListSbirTopicsOptions {
19    /// 1-based page number.
20    #[builder(into)]
21    pub page: Option<u32>,
22    /// Page size (server caps at 100).
23    #[builder(into)]
24    pub limit: Option<u32>,
25    /// Keyset cursor.
26    #[builder(into)]
27    pub cursor: Option<String>,
28    /// Comma-separated field selector. Use [`SHAPE_SBIR_TOPICS_MINIMAL`](crate::SHAPE_SBIR_TOPICS_MINIMAL) or roll your own.
29    #[builder(into)]
30    pub shape: Option<String>,
31    /// Collapse nested objects into dot-separated keys.
32    #[builder(default)]
33    pub flat: bool,
34    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
35    #[builder(default)]
36    pub flat_lists: bool,
37
38    /// Whether the topic is accepting proposals today: `open`, `closed`, or `unknown` (no close date and no parent solicitation to inherit one from).
39    #[builder(into)]
40    pub activity: Option<String>,
41    /// Agency name, abbreviation or code (e.g. `HHS`), matched against the topic's resolved organization and rolled up the hierarchy, so `HHS` includes NIH topics. OR several with `|`. A token that does not resolve falls back to a partial, case-insensitive match on the raw agency text.
42    #[builder(into)]
43    pub agency: Option<String>,
44    /// Topic number.
45    #[builder(into)]
46    pub topic_number: Option<String>,
47    /// Exact DoD DSIP cycle name, e.g. `DOD_SBIR_2026_P1_CBZ` (Tango API 5.3.1). Only topics fed from DSIP carry one.
48    #[builder(into)]
49    pub cycle_name: Option<String>,
50    /// Parent solicitation number.
51    #[builder(into)]
52    pub solicitation_number: Option<String>,
53    /// Solicitation year.
54    #[builder(into)]
55    pub year: Option<String>,
56    /// Document source.
57    #[builder(into)]
58    pub doc_source: Option<String>,
59    /// Close date on or after (`YYYY-MM-DD`).
60    #[builder(into)]
61    pub close_date_after: Option<String>,
62    /// Close date on or before (`YYYY-MM-DD`).
63    #[builder(into)]
64    pub close_date_before: Option<String>,
65    /// Open date on or after (`YYYY-MM-DD`).
66    #[builder(into)]
67    pub open_date_after: Option<String>,
68    /// Open date on or before (`YYYY-MM-DD`).
69    #[builder(into)]
70    pub open_date_before: Option<String>,
71    /// Release date on or after (`YYYY-MM-DD`).
72    #[builder(into)]
73    pub release_date_after: Option<String>,
74    /// Release date on or before (`YYYY-MM-DD`).
75    #[builder(into)]
76    pub release_date_before: Option<String>,
77    /// Case-insensitive substring match across title, description, and topic number.
78    #[builder(into)]
79    pub search: Option<String>,
80    /// One of `activity`, `close_date`, `open_date`, `release_date`, `year`, `modified`, each optionally `-` prefixed.
81    #[builder(into)]
82    pub ordering: Option<String>,
83
84    /// Escape hatch for filter keys not yet first-classed on this struct.
85    #[builder(default)]
86    pub extra: BTreeMap<String, String>,
87}
88
89impl ListSbirTopicsOptions {
90    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
91        let mut q = Vec::new();
92        apply_pagination(
93            &mut q,
94            self.page,
95            self.limit,
96            self.cursor.as_deref(),
97            self.shape.as_deref(),
98            self.flat,
99            self.flat_lists,
100        );
101        push_opt(&mut q, "activity", self.activity.as_deref());
102        push_opt(&mut q, "agency", self.agency.as_deref());
103        push_opt(&mut q, "topic_number", self.topic_number.as_deref());
104        push_opt(&mut q, "cycle_name", self.cycle_name.as_deref());
105        push_opt(
106            &mut q,
107            "solicitation_number",
108            self.solicitation_number.as_deref(),
109        );
110        push_opt(&mut q, "year", self.year.as_deref());
111        push_opt(&mut q, "doc_source", self.doc_source.as_deref());
112        push_opt(&mut q, "close_date_after", self.close_date_after.as_deref());
113        push_opt(
114            &mut q,
115            "close_date_before",
116            self.close_date_before.as_deref(),
117        );
118        push_opt(&mut q, "open_date_after", self.open_date_after.as_deref());
119        push_opt(&mut q, "open_date_before", self.open_date_before.as_deref());
120        push_opt(
121            &mut q,
122            "release_date_after",
123            self.release_date_after.as_deref(),
124        );
125        push_opt(
126            &mut q,
127            "release_date_before",
128            self.release_date_before.as_deref(),
129        );
130        push_opt(&mut q, "search", self.search.as_deref());
131        push_opt(&mut q, "ordering", self.ordering.as_deref());
132        for (k, v) in &self.extra {
133            if !v.is_empty() {
134                q.push((k.clone(), v.clone()));
135            }
136        }
137        q
138    }
139}
140
141/// Options for [`Client::list_sbir_solicitations`] and [`Client::iterate_sbir_solicitations`].
142#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct ListSbirSolicitationsOptions {
145    /// 1-based page number.
146    #[builder(into)]
147    pub page: Option<u32>,
148    /// Page size (server caps at 100).
149    #[builder(into)]
150    pub limit: Option<u32>,
151    /// Keyset cursor.
152    #[builder(into)]
153    pub cursor: Option<String>,
154    /// Comma-separated field selector. Use [`SHAPE_SBIR_SOLICITATIONS_MINIMAL`](crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL) or roll your own.
155    #[builder(into)]
156    pub shape: Option<String>,
157    /// Collapse nested objects into dot-separated keys.
158    #[builder(default)]
159    pub flat: bool,
160    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
161    #[builder(default)]
162    pub flat_lists: bool,
163
164    /// Whether the cycle is open today: `open` or `closed`.
165    #[builder(into)]
166    pub activity: Option<String>,
167    /// Program (`SBIR` or `STTR`).
168    #[builder(into)]
169    pub program: Option<String>,
170    /// Solicitation number.
171    #[builder(into)]
172    pub solicitation_number: Option<String>,
173    /// Cycle name.
174    #[builder(into)]
175    pub cycle_name: Option<String>,
176    /// Raw feed status.
177    #[builder(into)]
178    pub solicitation_status: Option<String>,
179    /// Out-of-cycle solicitation flag. `Some(false)` reaches the server as a filter value.
180    pub out_of_cycle: Option<bool>,
181    /// Solicitation year.
182    #[builder(into)]
183    pub year: Option<String>,
184    /// Cycle start date on or after (`YYYY-MM-DD`).
185    #[builder(into)]
186    pub start_date_after: Option<String>,
187    /// Cycle start date on or before (`YYYY-MM-DD`).
188    #[builder(into)]
189    pub start_date_before: Option<String>,
190    /// Cycle end date on or after (`YYYY-MM-DD`).
191    #[builder(into)]
192    pub end_date_after: Option<String>,
193    /// Cycle end date on or before (`YYYY-MM-DD`).
194    #[builder(into)]
195    pub end_date_before: Option<String>,
196    /// Case-insensitive substring match across title and solicitation number.
197    #[builder(into)]
198    pub search: Option<String>,
199    /// One of `activity`, `start_date`, `end_date`, `year`, `modified`, each optionally `-` prefixed.
200    #[builder(into)]
201    pub ordering: Option<String>,
202
203    /// Escape hatch for filter keys not yet first-classed on this struct.
204    #[builder(default)]
205    pub extra: BTreeMap<String, String>,
206}
207
208impl ListSbirSolicitationsOptions {
209    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
210        let mut q = Vec::new();
211        apply_pagination(
212            &mut q,
213            self.page,
214            self.limit,
215            self.cursor.as_deref(),
216            self.shape.as_deref(),
217            self.flat,
218            self.flat_lists,
219        );
220        push_opt(&mut q, "activity", self.activity.as_deref());
221        push_opt(&mut q, "program", self.program.as_deref());
222        push_opt(
223            &mut q,
224            "solicitation_number",
225            self.solicitation_number.as_deref(),
226        );
227        push_opt(&mut q, "cycle_name", self.cycle_name.as_deref());
228        push_opt(
229            &mut q,
230            "solicitation_status",
231            self.solicitation_status.as_deref(),
232        );
233        push_opt_bool(&mut q, "out_of_cycle", self.out_of_cycle);
234        push_opt(&mut q, "year", self.year.as_deref());
235        push_opt(&mut q, "start_date_after", self.start_date_after.as_deref());
236        push_opt(
237            &mut q,
238            "start_date_before",
239            self.start_date_before.as_deref(),
240        );
241        push_opt(&mut q, "end_date_after", self.end_date_after.as_deref());
242        push_opt(&mut q, "end_date_before", self.end_date_before.as_deref());
243        push_opt(&mut q, "search", self.search.as_deref());
244        push_opt(&mut q, "ordering", self.ordering.as_deref());
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/// Options for [`Client::get_sbir_topic`], [`Client::get_sbir_solicitation`].
255#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct GetSbirOptions {
258    /// Shape selector. When empty, the server returns its default detail shape.
259    #[builder(into)]
260    pub shape: Option<String>,
261    /// Flatten nested objects into dot-separated keys.
262    #[builder(default)]
263    pub flat: bool,
264    /// When `flat=true`, also flatten list-valued nested fields.
265    #[builder(default)]
266    pub flat_lists: bool,
267}
268
269impl GetSbirOptions {
270    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
271        let mut q = Vec::new();
272        push_opt(&mut q, "shape", self.shape.as_deref());
273        if self.flat {
274            q.push(("flat".into(), "true".into()));
275        }
276        if self.flat_lists {
277            q.push(("flat_lists".into(), "true".into()));
278        }
279        q
280    }
281}
282
283impl Client {
284    /// `GET /api/sbir/topics/` — one page of SBIR/STTR topic records.
285    pub async fn list_sbir_topics(&self, opts: ListSbirTopicsOptions) -> Result<Page<Record>> {
286        let q = opts.to_query();
287        self.get_page("/api/sbir/topics/", &q).await
288    }
289
290    /// `GET /api/sbir/topics/{topic_id}/` — a single SBIR/STTR topic.
291    pub async fn get_sbir_topic(
292        &self,
293        topic_id: &str,
294        opts: Option<GetSbirOptions>,
295    ) -> Result<Record> {
296        if topic_id.is_empty() {
297            return Err(Error::Validation {
298                message: "get_sbir_topic: topic_id is required".into(),
299                response: None,
300            });
301        }
302        let q = opts.unwrap_or_default().to_query();
303        let path = format!("/api/sbir/topics/{}/", urlencoding(topic_id));
304        self.get_json::<Record>(&path, &q).await
305    }
306
307    /// Stream every SBIR/STTR topic matching `opts`.
308    pub fn iterate_sbir_topics(&self, opts: ListSbirTopicsOptions) -> PageStream<Record> {
309        let opts = Arc::new(opts);
310        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
311            let mut next = (*opts).clone();
312            next.page = page;
313            next.cursor = cursor;
314            Box::pin(async move { client.list_sbir_topics(next).await })
315        });
316        PageStream::new(self.clone(), fetch)
317    }
318
319    /// `GET /api/sbir/solicitations/` — one page of DoD DSIP solicitation cycle records.
320    pub async fn list_sbir_solicitations(
321        &self,
322        opts: ListSbirSolicitationsOptions,
323    ) -> Result<Page<Record>> {
324        let q = opts.to_query();
325        self.get_page("/api/sbir/solicitations/", &q).await
326    }
327
328    /// `GET /api/sbir/solicitations/{solicitation_id}/` — a single DoD DSIP solicitation cycle.
329    pub async fn get_sbir_solicitation(
330        &self,
331        solicitation_id: &str,
332        opts: Option<GetSbirOptions>,
333    ) -> Result<Record> {
334        if solicitation_id.is_empty() {
335            return Err(Error::Validation {
336                message: "get_sbir_solicitation: solicitation_id is required".into(),
337                response: None,
338            });
339        }
340        let q = opts.unwrap_or_default().to_query();
341        let path = format!("/api/sbir/solicitations/{}/", urlencoding(solicitation_id));
342        self.get_json::<Record>(&path, &q).await
343    }
344
345    /// Stream every DoD DSIP solicitation cycle matching `opts`.
346    pub fn iterate_sbir_solicitations(
347        &self,
348        opts: ListSbirSolicitationsOptions,
349    ) -> PageStream<Record> {
350        let opts = Arc::new(opts);
351        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
352            let mut next = (*opts).clone();
353            next.page = page;
354            next.cursor = cursor;
355            Box::pin(async move { client.list_sbir_solicitations(next).await })
356        });
357        PageStream::new(self.clone(), fetch)
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
366        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
367    }
368
369    #[test]
370    fn list_sbir_topics_all_filters_emit() {
371        let opts = ListSbirTopicsOptions::builder()
372            .activity("v-activity")
373            .agency("v-agency")
374            .topic_number("v-topic_number")
375            .solicitation_number("v-solicitation_number")
376            .year("v-year")
377            .doc_source("v-doc_source")
378            .close_date_after("v-close_date_after")
379            .close_date_before("v-close_date_before")
380            .open_date_after("v-open_date_after")
381            .open_date_before("v-open_date_before")
382            .release_date_after("v-release_date_after")
383            .release_date_before("v-release_date_before")
384            .search("v-search")
385            .ordering("v-ordering")
386            .build();
387        let q = opts.to_query();
388        assert_eq!(get_q(&q, "activity").as_deref(), Some("v-activity"));
389        assert_eq!(get_q(&q, "agency").as_deref(), Some("v-agency"));
390        assert_eq!(get_q(&q, "topic_number").as_deref(), Some("v-topic_number"));
391        assert_eq!(
392            get_q(&q, "solicitation_number").as_deref(),
393            Some("v-solicitation_number")
394        );
395        assert_eq!(get_q(&q, "year").as_deref(), Some("v-year"));
396        assert_eq!(get_q(&q, "doc_source").as_deref(), Some("v-doc_source"));
397        assert_eq!(
398            get_q(&q, "close_date_after").as_deref(),
399            Some("v-close_date_after")
400        );
401        assert_eq!(
402            get_q(&q, "close_date_before").as_deref(),
403            Some("v-close_date_before")
404        );
405        assert_eq!(
406            get_q(&q, "open_date_after").as_deref(),
407            Some("v-open_date_after")
408        );
409        assert_eq!(
410            get_q(&q, "open_date_before").as_deref(),
411            Some("v-open_date_before")
412        );
413        assert_eq!(
414            get_q(&q, "release_date_after").as_deref(),
415            Some("v-release_date_after")
416        );
417        assert_eq!(
418            get_q(&q, "release_date_before").as_deref(),
419            Some("v-release_date_before")
420        );
421        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
422        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
423        assert_eq!(q.len(), 14);
424    }
425
426    #[test]
427    fn list_sbir_topics_zero_value_omitted() {
428        assert!(ListSbirTopicsOptions::builder()
429            .build()
430            .to_query()
431            .is_empty());
432    }
433
434    #[test]
435    fn list_sbir_topics_cursor_wins_over_page_and_shape_emits() {
436        let opts = ListSbirTopicsOptions::builder()
437            .page(3u32)
438            .cursor("c0".to_string())
439            .shape(crate::SHAPE_SBIR_TOPICS_MINIMAL)
440            .build();
441        let q = opts.to_query();
442        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
443        assert_eq!(get_q(&q, "page"), None);
444        assert_eq!(
445            get_q(&q, "shape").as_deref(),
446            Some(crate::SHAPE_SBIR_TOPICS_MINIMAL)
447        );
448    }
449
450    #[test]
451    fn list_sbir_topics_extra_emits() {
452        let mut extra = BTreeMap::new();
453        extra.insert("custom_x".to_string(), "xv".to_string());
454        let q = ListSbirTopicsOptions::builder()
455            .extra(extra)
456            .build()
457            .to_query();
458        assert!(q.contains(&("custom_x".into(), "xv".into())));
459    }
460
461    #[tokio::test]
462    async fn get_sbir_topic_validates_empty_topic_id() {
463        let client = Client::builder().api_key("x").build().expect("client");
464        let err = client.get_sbir_topic("", None).await.unwrap_err();
465        match err {
466            Error::Validation { message, .. } => {
467                assert!(message.contains("topic_id is required"));
468            }
469            other => panic!("expected Validation, got {other:?}"),
470        }
471    }
472
473    #[test]
474    fn list_sbir_solicitations_all_filters_emit() {
475        let opts = ListSbirSolicitationsOptions::builder()
476            .activity("v-activity")
477            .program("v-program")
478            .solicitation_number("v-solicitation_number")
479            .cycle_name("v-cycle_name")
480            .solicitation_status("v-solicitation_status")
481            .out_of_cycle(true)
482            .year("v-year")
483            .start_date_after("v-start_date_after")
484            .start_date_before("v-start_date_before")
485            .end_date_after("v-end_date_after")
486            .end_date_before("v-end_date_before")
487            .search("v-search")
488            .ordering("v-ordering")
489            .build();
490        let q = opts.to_query();
491        assert_eq!(get_q(&q, "activity").as_deref(), Some("v-activity"));
492        assert_eq!(get_q(&q, "program").as_deref(), Some("v-program"));
493        assert_eq!(
494            get_q(&q, "solicitation_number").as_deref(),
495            Some("v-solicitation_number")
496        );
497        assert_eq!(get_q(&q, "cycle_name").as_deref(), Some("v-cycle_name"));
498        assert_eq!(
499            get_q(&q, "solicitation_status").as_deref(),
500            Some("v-solicitation_status")
501        );
502        assert_eq!(get_q(&q, "out_of_cycle").as_deref(), Some("true"));
503        assert_eq!(get_q(&q, "year").as_deref(), Some("v-year"));
504        assert_eq!(
505            get_q(&q, "start_date_after").as_deref(),
506            Some("v-start_date_after")
507        );
508        assert_eq!(
509            get_q(&q, "start_date_before").as_deref(),
510            Some("v-start_date_before")
511        );
512        assert_eq!(
513            get_q(&q, "end_date_after").as_deref(),
514            Some("v-end_date_after")
515        );
516        assert_eq!(
517            get_q(&q, "end_date_before").as_deref(),
518            Some("v-end_date_before")
519        );
520        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
521        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
522        assert_eq!(q.len(), 13);
523    }
524
525    #[test]
526    fn list_sbir_solicitations_out_of_cycle_false_is_a_filter_not_an_absence() {
527        let opts = ListSbirSolicitationsOptions::builder()
528            .out_of_cycle(false)
529            .build();
530        assert_eq!(
531            get_q(&opts.to_query(), "out_of_cycle").as_deref(),
532            Some("false")
533        );
534    }
535
536    #[test]
537    fn list_sbir_solicitations_zero_value_omitted() {
538        assert!(ListSbirSolicitationsOptions::builder()
539            .build()
540            .to_query()
541            .is_empty());
542    }
543
544    #[test]
545    fn list_sbir_solicitations_cursor_wins_over_page_and_shape_emits() {
546        let opts = ListSbirSolicitationsOptions::builder()
547            .page(3u32)
548            .cursor("c0".to_string())
549            .shape(crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL)
550            .build();
551        let q = opts.to_query();
552        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
553        assert_eq!(get_q(&q, "page"), None);
554        assert_eq!(
555            get_q(&q, "shape").as_deref(),
556            Some(crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL)
557        );
558    }
559
560    #[test]
561    fn list_sbir_solicitations_extra_emits() {
562        let mut extra = BTreeMap::new();
563        extra.insert("custom_x".to_string(), "xv".to_string());
564        let q = ListSbirSolicitationsOptions::builder()
565            .extra(extra)
566            .build()
567            .to_query();
568        assert!(q.contains(&("custom_x".into(), "xv".into())));
569    }
570
571    #[tokio::test]
572    async fn get_sbir_solicitation_validates_empty_solicitation_id() {
573        let client = Client::builder().api_key("x").build().expect("client");
574        let err = client.get_sbir_solicitation("", None).await.unwrap_err();
575        match err {
576            Error::Validation { message, .. } => {
577                assert!(message.contains("solicitation_id is required"));
578            }
579            other => panic!("expected Validation, got {other:?}"),
580        }
581    }
582
583    #[test]
584    fn get_options_emits() {
585        let q = GetSbirOptions::builder()
586            .shape("a,b")
587            .flat(true)
588            .flat_lists(true)
589            .build()
590            .to_query();
591        assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b"));
592        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
593        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
594    }
595
596    #[test]
597    fn list_sbir_topics_cycle_name_filter_emits() {
598        let q = ListSbirTopicsOptions::builder()
599            .cycle_name("DOD_SBIR_2026_P1_CBZ")
600            .build()
601            .to_query();
602        assert_eq!(
603            get_q(&q, "cycle_name").as_deref(),
604            Some("DOD_SBIR_2026_P1_CBZ")
605        );
606    }
607}