Skip to main content

tango/resources/
ebuy.rs

1//! `GET /api/ebuy/requests/` — GSA eBuy requests (RFQs, RFPs and RFIs) posted under your own GSA schedule contracts.
2//!
3//! Requires the Pro plan or above, and is scoped to your account: you see a request only while your account is linked to a schedule contract it was posted under.
4//! A caller with no linked contract gets an empty list, not an error — [`Client::get_ebuy_access`] tells "no access" apart from "no matches".
5//! A request outside your scope 404s on lookup, the same answer as an id that never existed.
6//!
7//! `status` is frozen at the state it was last seen in. Only currently-active requests are published, so a request that closes stops appearing rather than getting a final row: `Open` means "open the last time it was seen", and `last_seen` is the staleness signal.
8//! The contract number a request was posted under is never returned in any payload, and `buyer_agency_code` and some other buyer and contact fields are sparse on older requests.
9//!
10//! An attachment's document text is the `attachments(extracted_text)` shape leaf, from Tango API 5.9.1.
11//! It must be named: neither the default shape nor `attachments(*)` carries it.
12//! Its key is absent rather than null on an attachment with no text, which is a link, an empty scan, or a document not yet extracted.
13
14use crate::client::Client;
15use crate::error::{Error, Result};
16use crate::internal::{apply_pagination, push_opt};
17use crate::models::EbuyAccess;
18use crate::pagination::{FetchFn, Page, PageStream};
19use crate::resources::agencies::urlencoding;
20use crate::Record;
21use bon::Builder;
22use std::collections::BTreeMap;
23use std::sync::Arc;
24
25/// Options for [`Client::list_ebuy_requests`] and [`Client::iterate_ebuy_requests`].
26///
27/// Every string filter except the date bounds accepts `|` for OR.
28#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct ListEbuyRequestsOptions {
31    /// 1-based page number.
32    #[builder(into)]
33    pub page: Option<u32>,
34    /// Page size (server caps at 100).
35    #[builder(into)]
36    pub limit: Option<u32>,
37    /// Keyset cursor.
38    #[builder(into)]
39    pub cursor: Option<String>,
40    /// Comma-separated field selector. Use [`SHAPE_EBUY_REQUESTS_MINIMAL`](crate::SHAPE_EBUY_REQUESTS_MINIMAL) or roll your own; `organization(*)` and `attachments(*)` are the expands. `attachments(*)` does not carry a document's text: name `attachments(extracted_text)` for that.
41    #[builder(into)]
42    pub shape: Option<String>,
43    /// Collapse nested objects into dot-separated keys.
44    #[builder(default)]
45    pub flat: bool,
46    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
47    #[builder(default)]
48    pub flat_lists: bool,
49
50    /// Full-text search over title, description, reference number, request id and attachment text. Results rank by relevance unless [`ordering`](Self::ordering) is set, and a title, description or identifier hit always ranks above an attachment-text-only hit.
51    #[builder(into)]
52    pub search: Option<String>,
53    /// eBuy request id, exact (e.g. `RFQ1835158`).
54    #[builder(into)]
55    pub rfq_id: Option<String>,
56    /// The buyer's own solicitation or reference number. Dash-insensitive.
57    #[builder(into)]
58    pub reference_number: Option<String>,
59    /// Request type: `RFQ`, `RFP` or `RFI`.
60    #[builder(into)]
61    pub request_type: Option<String>,
62    /// Status as last seen: `Open` or `Cancelled`. Frozen, not a currency signal — an `Open` request may have closed since; read `last_seen` for staleness.
63    #[builder(into)]
64    pub status: Option<String>,
65    /// Special Item Number the request was posted under.
66    #[builder(into)]
67    pub sin: Option<String>,
68    /// GSA schedule the request was posted under.
69    #[builder(into)]
70    pub schedule: Option<String>,
71    /// Buying department name, exactly as published (free text).
72    #[builder(into)]
73    pub buyer_agency: Option<String>,
74    /// Agency name, abbreviation or code, matched against the buyer's resolved organization including every sub-agency and office beneath it.
75    #[builder(into)]
76    pub agency: Option<String>,
77    /// Narrow to one of your own linked contracts. A contract you are not linked to returns nothing rather than an error.
78    #[builder(into)]
79    pub contract_number: Option<String>,
80
81    /// Lower bound on `issue_date` (ISO `YYYY-MM-DD`, inclusive).
82    #[builder(into)]
83    pub issue_date_after: Option<String>,
84    /// Upper bound on `issue_date` (inclusive of that whole day).
85    #[builder(into)]
86    pub issue_date_before: Option<String>,
87    /// Lower bound on `close_date` (inclusive).
88    #[builder(into)]
89    pub close_date_after: Option<String>,
90    /// Upper bound on `close_date` (inclusive of that whole day).
91    #[builder(into)]
92    pub close_date_before: Option<String>,
93
94    /// Sort key: `issue_date`, `close_date`, `last_seen` or `modified`, prefixed with `-` for descending. The server default is `-issue_date`.
95    #[builder(into)]
96    pub ordering: Option<String>,
97
98    /// Escape hatch for filter keys not yet first-classed on this struct.
99    #[builder(default)]
100    pub extra: BTreeMap<String, String>,
101}
102
103impl ListEbuyRequestsOptions {
104    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
105        let mut q = Vec::new();
106        apply_pagination(
107            &mut q,
108            self.page,
109            self.limit,
110            self.cursor.as_deref(),
111            self.shape.as_deref(),
112            self.flat,
113            self.flat_lists,
114        );
115        push_opt(&mut q, "search", self.search.as_deref());
116        push_opt(&mut q, "rfq_id", self.rfq_id.as_deref());
117        push_opt(&mut q, "reference_number", self.reference_number.as_deref());
118        push_opt(&mut q, "request_type", self.request_type.as_deref());
119        push_opt(&mut q, "status", self.status.as_deref());
120        push_opt(&mut q, "sin", self.sin.as_deref());
121        push_opt(&mut q, "schedule", self.schedule.as_deref());
122        push_opt(&mut q, "buyer_agency", self.buyer_agency.as_deref());
123        push_opt(&mut q, "agency", self.agency.as_deref());
124        push_opt(&mut q, "contract_number", self.contract_number.as_deref());
125        push_opt(&mut q, "issue_date_after", self.issue_date_after.as_deref());
126        push_opt(
127            &mut q,
128            "issue_date_before",
129            self.issue_date_before.as_deref(),
130        );
131        push_opt(&mut q, "close_date_after", self.close_date_after.as_deref());
132        push_opt(
133            &mut q,
134            "close_date_before",
135            self.close_date_before.as_deref(),
136        );
137        push_opt(&mut q, "ordering", self.ordering.as_deref());
138        for (k, v) in &self.extra {
139            if !v.is_empty() {
140                q.push((k.clone(), v.clone()));
141            }
142        }
143        q
144    }
145}
146
147/// Options for [`Client::get_ebuy_request`].
148#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
149#[non_exhaustive]
150pub struct GetEbuyRequestOptions {
151    /// Shape selector. When empty, the server returns every field plus `organization(*)` and `attachments(*)`. Neither that default nor `attachments(*)` carries a document's text: name `attachments(extracted_text)` for that.
152    #[builder(into)]
153    pub shape: Option<String>,
154    /// Flatten nested objects into dot-separated keys.
155    #[builder(default)]
156    pub flat: bool,
157    /// When `flat=true`, also flatten list-valued nested fields.
158    #[builder(default)]
159    pub flat_lists: bool,
160}
161
162impl GetEbuyRequestOptions {
163    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
164        let mut q = Vec::new();
165        push_opt(&mut q, "shape", self.shape.as_deref());
166        if self.flat {
167            q.push(("flat".into(), "true".into()));
168        }
169        if self.flat_lists {
170            q.push(("flat_lists".into(), "true".into()));
171        }
172        q
173    }
174}
175
176impl Client {
177    /// `GET /api/ebuy/requests/` — one page of the eBuy requests visible to your account.
178    ///
179    /// Requires the Pro plan (below it the API returns 403). With no linked schedule contract the page is empty rather than an error; call [`get_ebuy_access`](Self::get_ebuy_access) to tell the two apart.
180    pub async fn list_ebuy_requests(&self, opts: ListEbuyRequestsOptions) -> Result<Page<Record>> {
181        let q = opts.to_query();
182        self.get_page("/api/ebuy/requests/", &q).await
183    }
184
185    /// `GET /api/ebuy/requests/{rfq_id}/` — one eBuy request.
186    ///
187    /// The default shape carries every field plus `organization(*)` and `attachments(*)`. An attachment with `is_link = true` is an outbound URL in `doc_path` with no stored document behind it.
188    /// A request outside your scope returns [`Error::NotFound`], the same as an unknown id.
189    ///
190    /// Neither the default shape nor `attachments(*)` carries a document's text; name the `attachments(extracted_text)` leaf to read it (Tango API 5.9.1 and later):
191    ///
192    /// ```text
193    /// rfq_id,attachments(doc_seq_num,doc_name,extracted_text)
194    /// ```
195    ///
196    /// The `extracted_text` key is absent rather than null on an attachment with no text (a link, an empty scan, or a document not yet extracted), so read it with `get` instead of indexing.
197    pub async fn get_ebuy_request(
198        &self,
199        rfq_id: &str,
200        opts: Option<GetEbuyRequestOptions>,
201    ) -> Result<Record> {
202        if rfq_id.is_empty() {
203            return Err(Error::Validation {
204                message: "get_ebuy_request: rfq_id is required".into(),
205                response: None,
206            });
207        }
208        let q = opts.unwrap_or_default().to_query();
209        let path = format!("/api/ebuy/requests/{}/", urlencoding(rfq_id));
210        self.get_json::<Record>(&path, &q).await
211    }
212
213    /// `GET /api/ebuy/requests/{rfq_id}/attachments/{doc_seq_num}/download/` — a short-lived download URL for one stored attachment.
214    ///
215    /// The API answers with a redirect to a presigned URL valid for about five minutes. This method does not follow it: it returns the redirect target, so fetch it promptly and without your API key.
216    ///
217    /// Errors: [`Error::ExternalLink`] (carrying the link's `url`) when the attachment is an outbound link rather than a stored document, and [`Error::NotFound`] when the request is outside your scope or the document has not been captured yet.
218    pub async fn get_ebuy_attachment_url(&self, rfq_id: &str, doc_seq_num: u32) -> Result<String> {
219        if rfq_id.is_empty() {
220            return Err(Error::Validation {
221                message: "get_ebuy_attachment_url: rfq_id is required".into(),
222                response: None,
223            });
224        }
225        let path = format!(
226            "/api/ebuy/requests/{}/attachments/{doc_seq_num}/download/",
227            urlencoding(rfq_id)
228        );
229        match self.get_redirect_location(&path, &[]).await {
230            Err(Error::Validation {
231                message,
232                response: Some(body),
233            }) => {
234                let link = body
235                    .raw
236                    .as_ref()
237                    .and_then(|v| v.get("url"))
238                    .and_then(serde_json::Value::as_str)
239                    .map(str::to_string);
240                match link {
241                    Some(url) => Err(Error::ExternalLink {
242                        url,
243                        response: Some(body),
244                    }),
245                    None => Err(Error::Validation {
246                        message,
247                        response: Some(body),
248                    }),
249                }
250            }
251            other => other,
252        }
253    }
254
255    /// `GET /api/ebuy/access/` — whether your account can read eBuy requests, and why not when it can't.
256    pub async fn get_ebuy_access(&self) -> Result<EbuyAccess> {
257        self.get_json::<EbuyAccess>("/api/ebuy/access/", &[]).await
258    }
259
260    /// Stream every eBuy request matching `opts`.
261    pub fn iterate_ebuy_requests(&self, opts: ListEbuyRequestsOptions) -> PageStream<Record> {
262        let opts = Arc::new(opts);
263        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
264            let mut next = (*opts).clone();
265            next.page = page;
266            next.cursor = cursor;
267            Box::pin(async move { client.list_ebuy_requests(next).await })
268        });
269        PageStream::new(self.clone(), fetch)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
278        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
279    }
280
281    #[test]
282    fn list_ebuy_requests_all_filters_emit() {
283        let opts = ListEbuyRequestsOptions::builder()
284            .search("cybersecurity")
285            .rfq_id("RFQ1835158")
286            .reference_number("W912DY-26-Q-0012")
287            .request_type("RFQ|RFI")
288            .status("Open")
289            .sin("54151S")
290            .schedule("MAS")
291            .buyer_agency("Department of the Army")
292            .agency("DOD")
293            .contract_number("C-1")
294            .issue_date_after("2026-01-01")
295            .issue_date_before("2026-06-30")
296            .close_date_after("2026-02-01")
297            .close_date_before("2026-07-31")
298            .ordering("-close_date")
299            .build();
300        let q = opts.to_query();
301        for (k, v) in [
302            ("search", "cybersecurity"),
303            ("rfq_id", "RFQ1835158"),
304            ("reference_number", "W912DY-26-Q-0012"),
305            ("request_type", "RFQ|RFI"),
306            ("status", "Open"),
307            ("sin", "54151S"),
308            ("schedule", "MAS"),
309            ("buyer_agency", "Department of the Army"),
310            ("agency", "DOD"),
311            ("contract_number", "C-1"),
312            ("issue_date_after", "2026-01-01"),
313            ("issue_date_before", "2026-06-30"),
314            ("close_date_after", "2026-02-01"),
315            ("close_date_before", "2026-07-31"),
316            ("ordering", "-close_date"),
317        ] {
318            assert_eq!(get_q(&q, k).as_deref(), Some(v), "{k}");
319        }
320        assert_eq!(q.len(), 15);
321    }
322
323    #[test]
324    fn list_ebuy_requests_zero_value_omitted() {
325        assert!(ListEbuyRequestsOptions::builder()
326            .build()
327            .to_query()
328            .is_empty());
329    }
330
331    #[test]
332    fn get_ebuy_request_options_emit() {
333        let q = GetEbuyRequestOptions::builder()
334            .shape("rfq_id,attachments(*)")
335            .flat(true)
336            .build()
337            .to_query();
338        assert_eq!(get_q(&q, "shape").as_deref(), Some("rfq_id,attachments(*)"));
339        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
340        assert_eq!(get_q(&q, "flat_lists"), None);
341    }
342
343    #[test]
344    fn ebuy_access_decodes() {
345        let access: EbuyAccess = serde_json::from_value(serde_json::json!({
346            "enabled": false,
347            "reason": "no_contract_grant",
348            "contracts": []
349        }))
350        .expect("decode");
351        assert!(!access.enabled);
352        assert_eq!(access.reason.as_deref(), Some("no_contract_grant"));
353        assert!(access.contracts.is_empty());
354    }
355
356    #[tokio::test]
357    async fn empty_rfq_id_is_rejected_before_any_request() {
358        let client = Client::builder().api_key("x").build().expect("client");
359        let err = client.get_ebuy_request("", None).await.unwrap_err();
360        assert!(matches!(err, Error::Validation { .. }));
361        let err = client.get_ebuy_attachment_url("", 1).await.unwrap_err();
362        assert!(matches!(err, Error::Validation { .. }));
363    }
364}