Skip to main content

tango/resources/
gsa.rs

1//! `GET /api/gsa_elibrary_contracts/` — GSA eLibrary contract listings.
2
3use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::resources::agencies::urlencoding;
8use crate::Record;
9use bon::Builder;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13/// Options for [`Client::list_gsa_elibrary_contracts`] and
14/// [`Client::iterate_gsa_elibrary_contracts`].
15#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct ListGsaElibraryContractsOptions {
18    /// 1-based page number.
19    #[builder(into)]
20    pub page: Option<u32>,
21    /// Page size (server caps at 100).
22    #[builder(into)]
23    pub limit: Option<u32>,
24    /// Keyset cursor.
25    #[builder(into)]
26    pub cursor: Option<String>,
27    /// Comma-separated field selector. Use
28    /// [`SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL`](crate::SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL)
29    /// or roll your own.
30    #[builder(into)]
31    pub shape: Option<String>,
32    /// Collapse nested objects into dot-separated keys.
33    #[builder(default)]
34    pub flat: bool,
35    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
36    #[builder(default)]
37    pub flat_lists: bool,
38
39    /// GSA schedule filter (e.g. `"MAS"`).
40    #[builder(into)]
41    pub schedule: Option<String>,
42    /// Contract-number filter.
43    #[builder(into)]
44    pub contract_number: Option<String>,
45    /// Internal Tango key filter.
46    #[builder(into)]
47    pub key: Option<String>,
48    /// Procurement Instrument Identifier filter.
49    #[builder(into)]
50    pub piid: Option<String>,
51    /// Recipient UEI filter.
52    #[builder(into)]
53    pub uei: Option<String>,
54    /// Special Item Number filter.
55    #[builder(into)]
56    pub sin: Option<String>,
57    /// Free-text search filter.
58    #[builder(into)]
59    pub search: Option<String>,
60    /// Server-side sort spec (prefix `-` for descending).
61    #[builder(into)]
62    pub ordering: Option<String>,
63
64    /// Escape hatch for filter keys not yet first-classed on this struct.
65    #[builder(default)]
66    pub extra: BTreeMap<String, String>,
67}
68
69impl ListGsaElibraryContractsOptions {
70    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
71        let mut q = Vec::new();
72        apply_pagination(
73            &mut q,
74            self.page,
75            self.limit,
76            self.cursor.as_deref(),
77            self.shape.as_deref(),
78            self.flat,
79            self.flat_lists,
80        );
81        push_opt(&mut q, "schedule", self.schedule.as_deref());
82        push_opt(&mut q, "contract_number", self.contract_number.as_deref());
83        push_opt(&mut q, "key", self.key.as_deref());
84        push_opt(&mut q, "piid", self.piid.as_deref());
85        push_opt(&mut q, "uei", self.uei.as_deref());
86        push_opt(&mut q, "sin", self.sin.as_deref());
87        push_opt(&mut q, "search", self.search.as_deref());
88        push_opt(&mut q, "ordering", self.ordering.as_deref());
89        for (k, v) in &self.extra {
90            if !v.is_empty() {
91                q.push((k.clone(), v.clone()));
92            }
93        }
94        q
95    }
96}
97
98/// Options for [`Client::get_gsa_elibrary_contract`].
99#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
100#[non_exhaustive]
101pub struct GetGsaElibraryContractOptions {
102    /// Shape selector. When empty, the server returns its comprehensive default.
103    #[builder(into)]
104    pub shape: Option<String>,
105    /// Flatten nested objects into dot-separated keys.
106    #[builder(default)]
107    pub flat: bool,
108    /// When `flat=true`, also flatten list-valued nested fields.
109    #[builder(default)]
110    pub flat_lists: bool,
111}
112
113impl GetGsaElibraryContractOptions {
114    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
115        let mut q = Vec::new();
116        push_opt(&mut q, "shape", self.shape.as_deref());
117        if self.flat {
118            q.push(("flat".into(), "true".into()));
119        }
120        if self.flat_lists {
121            q.push(("flat_lists".into(), "true".into()));
122        }
123        q
124    }
125}
126
127impl Client {
128    /// `GET /api/gsa_elibrary_contracts/` — one page of GSA eLibrary contracts.
129    pub async fn list_gsa_elibrary_contracts(
130        &self,
131        opts: ListGsaElibraryContractsOptions,
132    ) -> Result<Page<Record>> {
133        let q = opts.to_query();
134        let bytes = self.get_bytes("/api/gsa_elibrary_contracts/", &q).await?;
135        Page::decode(&bytes)
136    }
137
138    /// `GET /api/gsa_elibrary_contracts/{uuid}/` — fetch a single GSA eLibrary
139    /// contract by UUID.
140    pub async fn get_gsa_elibrary_contract(
141        &self,
142        uuid: &str,
143        opts: Option<GetGsaElibraryContractOptions>,
144    ) -> Result<Record> {
145        if uuid.is_empty() {
146            return Err(Error::Validation {
147                message: "get_gsa_elibrary_contract: uuid is required".into(),
148                response: None,
149            });
150        }
151        let q = opts.unwrap_or_default().to_query();
152        let path = format!("/api/gsa_elibrary_contracts/{}/", urlencoding(uuid));
153        self.get_json::<Record>(&path, &q).await
154    }
155
156    /// Stream every GSA eLibrary contract matching `opts`.
157    pub fn iterate_gsa_elibrary_contracts(
158        &self,
159        opts: ListGsaElibraryContractsOptions,
160    ) -> PageStream<Record> {
161        let opts = Arc::new(opts);
162        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
163            let mut next = (*opts).clone();
164            next.page = page;
165            next.cursor = cursor;
166            Box::pin(async move { client.list_gsa_elibrary_contracts(next).await })
167        });
168        PageStream::new(self.clone(), fetch)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
177        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
178    }
179
180    #[test]
181    fn list_gsa_all_filters_emit() {
182        let opts = ListGsaElibraryContractsOptions::builder()
183            .schedule("MAS")
184            .contract_number("GS-35F-0119Y")
185            .key("internal-key")
186            .piid("GS35F0119Y")
187            .uei("UEI12345")
188            .sin("54151S")
189            .search("cyber")
190            .ordering("-contract_number")
191            .build();
192        let q = opts.to_query();
193        assert_eq!(get_q(&q, "schedule").as_deref(), Some("MAS"));
194        assert_eq!(
195            get_q(&q, "contract_number").as_deref(),
196            Some("GS-35F-0119Y")
197        );
198        assert_eq!(get_q(&q, "key").as_deref(), Some("internal-key"));
199        assert_eq!(get_q(&q, "piid").as_deref(), Some("GS35F0119Y"));
200        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI12345"));
201        assert_eq!(get_q(&q, "sin").as_deref(), Some("54151S"));
202        assert_eq!(get_q(&q, "search").as_deref(), Some("cyber"));
203        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-contract_number"));
204    }
205
206    #[test]
207    fn list_gsa_zero_value_omitted() {
208        let opts = ListGsaElibraryContractsOptions::builder().build();
209        let q = opts.to_query();
210        assert!(q.is_empty(), "expected empty query, got {q:?}");
211    }
212
213    #[test]
214    fn list_gsa_shape_emits() {
215        let opts = ListGsaElibraryContractsOptions::builder()
216            .shape(crate::SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL)
217            .build();
218        let q = opts.to_query();
219        assert_eq!(
220            get_q(&q, "shape").as_deref(),
221            Some(crate::SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL)
222        );
223    }
224
225    #[test]
226    fn list_gsa_cursor_wins_over_page() {
227        let opts = ListGsaElibraryContractsOptions::builder()
228            .page(4u32)
229            .cursor("c0".to_string())
230            .build();
231        let q = opts.to_query();
232        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
233        assert_eq!(get_q(&q, "page"), None);
234    }
235
236    #[test]
237    fn list_gsa_extra_emits() {
238        let mut extra = BTreeMap::new();
239        extra.insert("custom_x".to_string(), "xv".to_string());
240        let opts = ListGsaElibraryContractsOptions::builder()
241            .extra(extra)
242            .build();
243        let q = opts.to_query();
244        assert!(q.contains(&("custom_x".into(), "xv".into())));
245    }
246
247    #[tokio::test]
248    async fn get_gsa_validates_empty_uuid() {
249        let client = Client::builder().api_key("x").build().expect("client");
250        let err = client
251            .get_gsa_elibrary_contract("", None)
252            .await
253            .unwrap_err();
254        match err {
255            Error::Validation { message, .. } => {
256                assert!(message.contains("uuid is required"));
257            }
258            other => panic!("expected Validation, got {other:?}"),
259        }
260    }
261}