Skip to main content

tango/resources/
protests.rs

1//! `GET /api/protests/` — bid-protest records from GAO, the U.S. Court of Federal Claims (COFC), and the SBA Office of Hearings and Appeals (SBA OHA).
2
3use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt};
6use crate::models::ProtestRecord;
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/// Options for [`Client::list_protests`] and [`Client::iterate_protests`].
15///
16/// Mirrors the Go SDK's `ListProtestsOptions`. The protests viewset does not
17/// accept server-side ordering — passing it returns 400 — so there's no
18/// `ordering` field. The `_after` / `_before` date suffixes mirror the
19/// Python SDK's naming.
20#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct ListProtestsOptions {
23    /// 1-based page number.
24    #[builder(into)]
25    pub page: Option<u32>,
26    /// Page size (server caps at 100).
27    #[builder(into)]
28    pub limit: Option<u32>,
29    /// Keyset cursor.
30    #[builder(into)]
31    pub cursor: Option<String>,
32    /// Comma-separated field selector. Use
33    /// [`SHAPE_PROTESTS_MINIMAL`](crate::SHAPE_PROTESTS_MINIMAL) or roll your own.
34    #[builder(into)]
35    pub shape: Option<String>,
36    /// Collapse nested objects into dot-separated keys.
37    #[builder(default)]
38    pub flat: bool,
39    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
40    #[builder(default)]
41    pub flat_lists: bool,
42
43    /// Source system filter: `"gao"`, `"cofc"` or `"sba_oha"`.
44    ///
45    /// The API returns these lowercase; the filter itself is case-insensitive.
46    #[builder(into)]
47    pub source_system: Option<String>,
48    /// Outcome filter: `"Denied"`, `"Dismissed"`, `"Withdrawn"` or `"Sustained"`; SBA OHA adds `"Granted"`, `"Remanded"`, `"Reversed"` and `"Vacated"`.
49    ///
50    /// The API returns these title-case; the filter itself is case-insensitive.
51    #[builder(into)]
52    pub outcome: Option<String>,
53    /// Case type filter.
54    #[builder(into)]
55    pub case_type: Option<String>,
56    /// Agency filter: a name, abbreviation, or code (CGAC, FPDS, AAC). Multi-value OR via `|`.
57    #[builder(into)]
58    pub agency: Option<String>,
59    /// Source-system case number filter.
60    #[builder(into)]
61    pub case_number: Option<String>,
62    /// Solicitation number filter.
63    #[builder(into)]
64    pub solicitation_number: Option<String>,
65    /// NAICS code at issue. Only SBA OHA size and NAICS appeals carry one, so GAO and COFC records never match.
66    #[builder(into)]
67    pub naics_code: Option<String>,
68    /// Protester name filter.
69    #[builder(into)]
70    pub protester: Option<String>,
71    /// Free-text search filter.
72    #[builder(into)]
73    pub search: Option<String>,
74
75    /// Lower bound on `filed_date` (ISO `YYYY-MM-DD`, inclusive).
76    #[builder(into)]
77    pub filed_date_after: Option<String>,
78    /// Upper bound on `filed_date` (inclusive).
79    #[builder(into)]
80    pub filed_date_before: Option<String>,
81    /// Lower bound on `decision_date` (inclusive).
82    #[builder(into)]
83    pub decision_date_after: Option<String>,
84    /// Upper bound on `decision_date` (inclusive).
85    #[builder(into)]
86    pub decision_date_before: Option<String>,
87
88    /// Escape hatch for filter keys not yet first-classed on this struct.
89    #[builder(default)]
90    pub extra: BTreeMap<String, String>,
91}
92
93impl ListProtestsOptions {
94    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
95        let mut q = Vec::new();
96        apply_pagination(
97            &mut q,
98            self.page,
99            self.limit,
100            self.cursor.as_deref(),
101            self.shape.as_deref(),
102            self.flat,
103            self.flat_lists,
104        );
105        push_opt(&mut q, "source_system", self.source_system.as_deref());
106        push_opt(&mut q, "outcome", self.outcome.as_deref());
107        push_opt(&mut q, "case_type", self.case_type.as_deref());
108        push_opt(&mut q, "agency", self.agency.as_deref());
109        push_opt(&mut q, "case_number", self.case_number.as_deref());
110        push_opt(
111            &mut q,
112            "solicitation_number",
113            self.solicitation_number.as_deref(),
114        );
115        push_opt(&mut q, "naics_code", self.naics_code.as_deref());
116        push_opt(&mut q, "protester", self.protester.as_deref());
117        push_opt(&mut q, "search", self.search.as_deref());
118        push_opt(&mut q, "filed_date_after", self.filed_date_after.as_deref());
119        push_opt(
120            &mut q,
121            "filed_date_before",
122            self.filed_date_before.as_deref(),
123        );
124        push_opt(
125            &mut q,
126            "decision_date_after",
127            self.decision_date_after.as_deref(),
128        );
129        push_opt(
130            &mut q,
131            "decision_date_before",
132            self.decision_date_before.as_deref(),
133        );
134        for (k, v) in &self.extra {
135            if !v.is_empty() {
136                q.push((k.clone(), v.clone()));
137            }
138        }
139        q
140    }
141}
142
143/// Options for [`Client::get_protest`]. The detail endpoint returns a typed
144/// [`ProtestRecord`]; `shape` lets callers override the server's default.
145#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct GetProtestOptions {
148    /// Shape selector. When empty, the server returns its default detail shape.
149    #[builder(into)]
150    pub shape: Option<String>,
151    /// Flatten nested objects into dot-separated keys.
152    #[builder(default)]
153    pub flat: bool,
154    /// When `flat=true`, also flatten list-valued nested fields.
155    #[builder(default)]
156    pub flat_lists: bool,
157}
158
159impl GetProtestOptions {
160    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
161        let mut q = Vec::new();
162        push_opt(&mut q, "shape", self.shape.as_deref());
163        if self.flat {
164            q.push(("flat".into(), "true".into()));
165        }
166        if self.flat_lists {
167            q.push(("flat_lists".into(), "true".into()));
168        }
169        q
170    }
171}
172
173impl Client {
174    /// `GET /api/protests/` — one page of bid-protest records.
175    pub async fn list_protests(&self, opts: ListProtestsOptions) -> Result<Page<Record>> {
176        let q = opts.to_query();
177        let bytes = self.get_bytes("/api/protests/", &q).await?;
178        Page::decode(&bytes)
179    }
180
181    /// `GET /api/protests/{case_id}/` — fetch a single protest by case ID.
182    ///
183    /// Returns a typed [`ProtestRecord`]; forward-compatible server fields land
184    /// in [`ProtestRecord::extra`].
185    pub async fn get_protest(
186        &self,
187        case_id: &str,
188        opts: Option<GetProtestOptions>,
189    ) -> Result<ProtestRecord> {
190        if case_id.is_empty() {
191            return Err(Error::Validation {
192                message: "get_protest: case_id is required".into(),
193                response: None,
194            });
195        }
196        let q = opts.unwrap_or_default().to_query();
197        let path = format!("/api/protests/{}/", urlencoding(case_id));
198        self.get_json::<ProtestRecord>(&path, &q).await
199    }
200
201    /// Stream every protest matching `opts`.
202    pub fn iterate_protests(&self, opts: ListProtestsOptions) -> PageStream<Record> {
203        let opts = Arc::new(opts);
204        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
205            let mut next = (*opts).clone();
206            next.page = page;
207            next.cursor = cursor;
208            Box::pin(async move { client.list_protests(next).await })
209        });
210        PageStream::new(self.clone(), fetch)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
219        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
220    }
221
222    #[test]
223    fn list_protests_all_filters_emit() {
224        let opts = ListProtestsOptions::builder()
225            .source_system("gao")
226            .outcome("Sustained")
227            .case_type("Bid Protest")
228            .agency("9700")
229            .case_number("B-12345.1")
230            .solicitation_number("SOL-001")
231            .protester("Acme Corp")
232            .search("infrastructure")
233            .filed_date_after("2024-01-01")
234            .filed_date_before("2024-12-31")
235            .decision_date_after("2024-02-01")
236            .decision_date_before("2024-12-31")
237            .build();
238        let q = opts.to_query();
239        assert_eq!(get_q(&q, "source_system").as_deref(), Some("gao"));
240        assert_eq!(get_q(&q, "outcome").as_deref(), Some("Sustained"));
241        assert_eq!(get_q(&q, "case_type").as_deref(), Some("Bid Protest"));
242        assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
243        assert_eq!(get_q(&q, "case_number").as_deref(), Some("B-12345.1"));
244        assert_eq!(get_q(&q, "solicitation_number").as_deref(), Some("SOL-001"));
245        assert_eq!(get_q(&q, "protester").as_deref(), Some("Acme Corp"));
246        assert_eq!(get_q(&q, "search").as_deref(), Some("infrastructure"));
247        assert_eq!(get_q(&q, "filed_date_after").as_deref(), Some("2024-01-01"));
248        assert_eq!(
249            get_q(&q, "filed_date_before").as_deref(),
250            Some("2024-12-31")
251        );
252        assert_eq!(
253            get_q(&q, "decision_date_after").as_deref(),
254            Some("2024-02-01")
255        );
256        assert_eq!(
257            get_q(&q, "decision_date_before").as_deref(),
258            Some("2024-12-31")
259        );
260    }
261
262    #[test]
263    fn list_protests_naics_code_emits() {
264        let opts = ListProtestsOptions::builder().naics_code("541512").build();
265        let q = opts.to_query();
266        assert_eq!(get_q(&q, "naics_code").as_deref(), Some("541512"));
267    }
268
269    #[test]
270    fn list_protests_zero_value_omitted() {
271        let opts = ListProtestsOptions::builder().build();
272        let q = opts.to_query();
273        assert!(q.is_empty(), "expected empty query, got {q:?}");
274    }
275
276    #[test]
277    fn list_protests_cursor_wins_over_page() {
278        let opts = ListProtestsOptions::builder()
279            .page(2u32)
280            .cursor("xyz".to_string())
281            .build();
282        let q = opts.to_query();
283        assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
284        assert_eq!(get_q(&q, "page"), None);
285    }
286
287    #[test]
288    fn list_protests_shape_emits() {
289        let opts = ListProtestsOptions::builder()
290            .shape(crate::SHAPE_PROTESTS_MINIMAL)
291            .flat(true)
292            .build();
293        let q = opts.to_query();
294        assert_eq!(
295            get_q(&q, "shape").as_deref(),
296            Some(crate::SHAPE_PROTESTS_MINIMAL)
297        );
298        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
299    }
300
301    #[test]
302    fn list_protests_extra_emits() {
303        let mut extra = BTreeMap::new();
304        extra.insert("custom_x".to_string(), "xv".to_string());
305        let opts = ListProtestsOptions::builder().extra(extra).build();
306        let q = opts.to_query();
307        assert!(q.contains(&("custom_x".into(), "xv".into())));
308    }
309
310    #[test]
311    fn get_protest_options_emit() {
312        let opts = GetProtestOptions::builder()
313            .shape("docket(*)")
314            .flat(true)
315            .flat_lists(true)
316            .build();
317        let q = opts.to_query();
318        assert_eq!(get_q(&q, "shape").as_deref(), Some("docket(*)"));
319        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
320        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
321    }
322
323    #[test]
324    fn protest_record_decodes_from_sample_json() {
325        let value = serde_json::json!({
326            "case_id": "b-12345-1",
327            "case_number": "B-12345.1",
328            "title": "Acme Corp Protest",
329            "source_system": "gao",
330            "outcome": "Sustained",
331            "filed_date": "2024-01-15",
332            "decision_date": "2024-04-20",
333            "agency": {"code": "9700", "name": "DoD"},
334            "future_field": "still here"
335        });
336        let rec: ProtestRecord = serde_json::from_value(value).expect("decode");
337        assert_eq!(rec.case_id.as_deref(), Some("b-12345-1"));
338        assert_eq!(rec.case_number.as_deref(), Some("B-12345.1"));
339        assert_eq!(rec.title.as_deref(), Some("Acme Corp Protest"));
340        assert_eq!(rec.source_system.as_deref(), Some("gao"));
341        assert_eq!(rec.outcome.as_deref(), Some("Sustained"));
342        assert_eq!(rec.filed_date.as_deref(), Some("2024-01-15"));
343        assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20"));
344        // Unknown / not-first-classed fields land in `extra` via #[serde(flatten)].
345        assert!(rec.extra.contains_key("agency"));
346        assert_eq!(
347            rec.extra.get("future_field").and_then(|v| v.as_str()),
348            Some("still here")
349        );
350    }
351
352    #[tokio::test]
353    async fn get_protest_validates_empty_case_id() {
354        let client = Client::builder().api_key("x").build().expect("client");
355        let err = client.get_protest("", None).await.unwrap_err();
356        match err {
357            Error::Validation { message, .. } => {
358                assert!(message.contains("case_id is required"));
359            }
360            other => panic!("expected Validation, got {other:?}"),
361        }
362    }
363}