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