Skip to main content

tango/resources/
protests.rs

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