1use 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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct ListEbuyRequestsOptions {
31 #[builder(into)]
33 pub page: Option<u32>,
34 #[builder(into)]
36 pub limit: Option<u32>,
37 #[builder(into)]
39 pub cursor: Option<String>,
40 #[builder(into)]
42 pub shape: Option<String>,
43 #[builder(default)]
45 pub flat: bool,
46 #[builder(default)]
48 pub flat_lists: bool,
49
50 #[builder(into)]
52 pub search: Option<String>,
53 #[builder(into)]
55 pub rfq_id: Option<String>,
56 #[builder(into)]
58 pub reference_number: Option<String>,
59 #[builder(into)]
61 pub request_type: Option<String>,
62 #[builder(into)]
64 pub status: Option<String>,
65 #[builder(into)]
67 pub sin: Option<String>,
68 #[builder(into)]
70 pub schedule: Option<String>,
71 #[builder(into)]
73 pub buyer_agency: Option<String>,
74 #[builder(into)]
76 pub agency: Option<String>,
77 #[builder(into)]
79 pub contract_number: Option<String>,
80
81 #[builder(into)]
83 pub issue_date_after: Option<String>,
84 #[builder(into)]
86 pub issue_date_before: Option<String>,
87 #[builder(into)]
89 pub close_date_after: Option<String>,
90 #[builder(into)]
92 pub close_date_before: Option<String>,
93
94 #[builder(into)]
96 pub ordering: Option<String>,
97
98 #[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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
149#[non_exhaustive]
150pub struct GetEbuyRequestOptions {
151 #[builder(into)]
153 pub shape: Option<String>,
154 #[builder(default)]
156 pub flat: bool,
157 #[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 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 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 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 pub async fn get_ebuy_access(&self) -> Result<EbuyAccess> {
257 self.get_json::<EbuyAccess>("/api/ebuy/access/", &[]).await
258 }
259
260 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}