diff --git a/CHANGELOG.md b/CHANGELOG.md index 8436a71..f1c6b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### `makegov-tango` + +#### Added + +- **GSA eBuy requests** (Tango API 5.1.0). `resources/ebuy.rs` adds `list_ebuy_requests` / `iterate_ebuy_requests`, `get_ebuy_request`, `get_ebuy_attachment_url` and `get_ebuy_access`, with `ListEbuyRequestsOptions` (every filter the endpoint accepts, plus `ordering`), `GetEbuyRequestOptions`, the typed `models::EbuyAccess` and `SHAPE_EBUY_REQUESTS_MINIMAL`. + + **The resource needs the Pro plan and is scoped to your own GSA schedule contracts.** With no linked contract the list is empty rather than an error; `get_ebuy_access` returns `enabled`, a `reason` (`tier_required` or `no_contract_grant`) and your linked `contracts`, which is how to tell "no access" from "no matches". **`status` is frozen at the state it was last seen in** — a request that closes stops appearing rather than getting a final row — so read `last_seen` for staleness. + + **`get_ebuy_attachment_url` returns the redirect target instead of following it**: the API redirects to a presigned URL valid for about five minutes, and following it would download the document and send your API key to the download host. The call runs on a client with redirects disabled, which the new `no_redirect_http_client` builder option overrides. An attachment that is an outbound link returns the new `Error::ExternalLink { url, .. }` variant (status 400, not retryable). + +#### Changed + +- The retry loop is shared between body-returning requests and the redirect-reading call, so `get_ebuy_attachment_url` retries 429, 5xx and transport failures exactly as every other method does. + ## [0.2.0] — 2026-09-23 Pre-1.0 (SemVer 0.x): the removals under **Breaking** ship without a deprecation cycle. diff --git a/README.md b/README.md index 7d3a9f7..2d42d34 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs | Protests | `list_protests` | `get_protest` *(typed: `ProtestRecord`)* | `iterate_protests` | | Contract appeals | `list_contract_appeals` | `get_contract_appeal` *(typed: `ContractAppealRecord`)* | `iterate_contract_appeals` | | DIBBS (DLA) | `list_dibbs_rfqs` / `list_dibbs_rfps` / `list_dibbs_awards` | `get_dibbs_rfq` / `get_dibbs_rfp` / `get_dibbs_award` | `iterate_dibbs_*` | +| GSA eBuy | `list_ebuy_requests` | `get_ebuy_request` | `iterate_ebuy_requests` | | Exclusions | `list_exclusions` | `get_exclusion` | `iterate_exclusions` | | SBIR / STTR | `list_sbir_topics` / `list_sbir_solicitations` | `get_sbir_topic` / `get_sbir_solicitation` | `iterate_sbir_*` | | State & local (SLED) | `list_sled_opportunities` / `list_sled_forecasts` | `get_sled_opportunity` / `get_sled_forecast` | `iterate_sled_*` | @@ -268,7 +269,7 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs | NAICS / PSC | `list_naics` / `list_psc` | `get_naics` / `get_psc` | — | | Webhooks (CRUD) | `list_webhook_endpoints` / `list_webhook_alerts` | `get_` / `create_` / `update_` / `delete_` / `test_` | — | -Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `list_opportunities` with `search` also matches attachment text. Metrics dispatcher: `list_metrics`. +Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `get_ebuy_attachment_url` / `get_ebuy_access`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `list_opportunities` with `search` also matches attachment text. Metrics dispatcher: `list_metrics`. See [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) for full signatures, filter fields, and quirks. diff --git a/crates/tango/src/client.rs b/crates/tango/src/client.rs index c5aede8..c16caff 100644 --- a/crates/tango/src/client.rs +++ b/crates/tango/src/client.rs @@ -25,6 +25,7 @@ pub(crate) struct ClientInner { pub(crate) api_key: String, pub(crate) base_url: String, pub(crate) http: reqwest::Client, + pub(crate) http_no_redirect: reqwest::Client, pub(crate) timeout: Duration, pub(crate) retries: u32, pub(crate) retry_backoff: Duration, @@ -129,6 +130,14 @@ impl Client { /// supplied, its built-in timeout is ignored — per-request /// deadlines are applied here. http_client: Option, + /// Custom `reqwest::Client` for the few calls that must read a redirect + /// rather than follow it, such as + /// [`get_ebuy_attachment_url`](Client::get_ebuy_attachment_url). It must + /// be built with `redirect(reqwest::redirect::Policy::none())`. Defaults + /// to a plain client with redirects disabled, so set it alongside + /// `http_client` when your proxy or TLS setup has to apply to those + /// calls too. + no_redirect_http_client: Option, ) -> Result { let api_key = match api_key.filter(|s| !s.is_empty()) { Some(k) => k, @@ -153,12 +162,20 @@ impl Client { .build() .map_err(|e| Error::Build(format!("build reqwest client: {e}")))?, }; + let http_no_redirect = match no_redirect_http_client { + Some(c) => c, + None => reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| Error::Build(format!("build reqwest client: {e}")))?, + }; Ok(Self { inner: Arc::new(ClientInner { api_key, base_url, http, + http_no_redirect, timeout, retries, retry_backoff, @@ -247,6 +264,17 @@ impl Client { transport::send_with_retries(&self.inner, reqwest::Method::GET, url, Body::None).await } + /// Internal: GET `path` without following redirects and return the + /// resolved `Location` of the 3xx response. + pub(crate) async fn get_redirect_location( + &self, + path: &str, + query: &[(String, String)], + ) -> Result { + let url = self.build_url(path, query)?; + transport::redirect_location_with_retries(&self.inner, url).await + } + /// Internal: POST `path` with JSON `body`, decode response as `T`. pub(crate) async fn post_json( &self, diff --git a/crates/tango/src/error.rs b/crates/tango/src/error.rs index 1bcb74a..6010de5 100644 --- a/crates/tango/src/error.rs +++ b/crates/tango/src/error.rs @@ -70,6 +70,19 @@ pub enum Error { response: Option, }, + /// HTTP 400 from an attachment-download route whose entry is an outbound + /// link rather than a stored document. There is nothing to download; `url` + /// is where the link points. + /// + /// Returned by [`Client::get_ebuy_attachment_url`](crate::Client::get_ebuy_attachment_url). + #[error("tango: attachment is an external link, not a stored document (status 400): {url}")] + ExternalLink { + /// The link's target URL. + url: String, + /// The parsed error body, when one was returned. + response: Option, + }, + /// HTTP 429 — the caller exceeded a rate limit. /// /// `retry_after` is populated from the `Retry-After` header when present @@ -126,7 +139,7 @@ impl Error { match self { Self::Auth { .. } => Some(401), Self::NotFound { .. } => Some(404), - Self::Validation { .. } => Some(400), + Self::Validation { .. } | Self::ExternalLink { .. } => Some(400), Self::RateLimit { .. } => Some(429), Self::Api { status, .. } => Some(*status), Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None, @@ -154,6 +167,7 @@ impl Error { Self::Auth { .. } | Self::NotFound { .. } | Self::Validation { .. } + | Self::ExternalLink { .. } | Self::Decode(_) | Self::Build(_) => false, } @@ -166,6 +180,7 @@ impl Error { Self::Auth { response, .. } | Self::NotFound { response, .. } | Self::Validation { response, .. } + | Self::ExternalLink { response, .. } | Self::RateLimit { response, .. } | Self::Api { response, .. } => response.as_ref(), Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None, @@ -257,6 +272,11 @@ mod tests { response: None } .is_retryable()); + assert!(!Error::ExternalLink { + url: "https://example.test/doc".into(), + response: None + } + .is_retryable()); assert!(!Error::Build("x".into()).is_retryable()); } } diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index 25c6be2..bae2fda 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -121,12 +121,12 @@ pub use pagination::{Page, PageStream}; pub use shapes::{ DEFAULT_BASE_URL, SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL, SHAPE_CONTRACT_APPEALS_MINIMAL, SHAPE_DIBBS_AWARDS_MINIMAL, SHAPE_DIBBS_RFPS_MINIMAL, - SHAPE_DIBBS_RFQS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL, - SHAPE_EXCLUSIONS_MINIMAL, SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL, - SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL, - SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, - SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, SHAPE_ORGANIZATIONS_MINIMAL, - SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, + SHAPE_DIBBS_RFQS_MINIMAL, SHAPE_EBUY_REQUESTS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, + SHAPE_ENTITIES_MINIMAL, SHAPE_EXCLUSIONS_MINIMAL, SHAPE_FORECASTS_MINIMAL, + SHAPE_GRANTS_MINIMAL, SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, + SHAPE_IDVS_MINIMAL, SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, + SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, + SHAPE_ORGANIZATIONS_MINIMAL, SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, SHAPE_SBIR_SOLICITATIONS_MINIMAL, SHAPE_SBIR_TOPICS_MINIMAL, SHAPE_SLED_FORECASTS_COMPREHENSIVE, SHAPE_SLED_FORECASTS_MINIMAL, SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE, SHAPE_SLED_OPPORTUNITIES_MINIMAL, diff --git a/crates/tango/src/models/ebuy.rs b/crates/tango/src/models/ebuy.rs new file mode 100644 index 0000000..d79a97e --- /dev/null +++ b/crates/tango/src/models/ebuy.rs @@ -0,0 +1,28 @@ +//! `EbuyAccess` — typed response from `GET /api/ebuy/access/`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// Whether the caller can read GSA eBuy requests. +/// +/// Returned by [`Client::get_ebuy_access`](crate::Client::get_ebuy_access). +/// Without access, [`Client::list_ebuy_requests`](crate::Client::list_ebuy_requests) returns an empty page rather than an error, so this is how to tell "no access" from "no matches". +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct EbuyAccess { + /// `true` when the caller can read eBuy requests. + #[serde(default)] + pub enabled: bool, + + /// Why access is off: `"tier_required"` (below the Pro plan) or `"no_contract_grant"` (no GSA schedule contract linked to the account). `None` when [`enabled`](Self::enabled) is true. `"tier_required"` wins when both apply. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + + /// The caller's own linked GSA schedule contract numbers, sorted. + #[serde(default)] + pub contracts: Vec, + + /// Forward-compatible bucket for any unrecognized fields the server adds. + #[serde(flatten)] + pub extra: HashMap, +} diff --git a/crates/tango/src/models/mod.rs b/crates/tango/src/models/mod.rs index aab3203..72ef33e 100644 --- a/crates/tango/src/models/mod.rs +++ b/crates/tango/src/models/mod.rs @@ -14,6 +14,7 @@ pub mod agency; pub mod contract_appeal; +pub mod ebuy; pub mod protest; pub mod resolve; pub mod validate; @@ -21,6 +22,7 @@ pub mod webhook; pub use agency::AgencyRecord; pub use contract_appeal::ContractAppealRecord; +pub use ebuy::EbuyAccess; pub use protest::ProtestRecord; pub use resolve::{ResolveCandidate, ResolveInput, ResolveResult, ResolveTargetType}; pub use validate::{ValidateInput, ValidateInputType, ValidateResult}; diff --git a/crates/tango/src/resources/ebuy.rs b/crates/tango/src/resources/ebuy.rs new file mode 100644 index 0000000..e172e08 --- /dev/null +++ b/crates/tango/src/resources/ebuy.rs @@ -0,0 +1,353 @@ +//! `GET /api/ebuy/requests/` — GSA eBuy requests (RFQs, RFPs and RFIs) posted under your own GSA schedule contracts. +//! +//! 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. +//! A caller with no linked contract gets an empty list, not an error — [`Client::get_ebuy_access`] tells "no access" apart from "no matches". +//! A request outside your scope 404s on lookup, the same answer as an id that never existed. +//! +//! `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. +//! 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. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt}; +use crate::models::EbuyAccess; +use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; +use crate::Record; +use bon::Builder; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Options for [`Client::list_ebuy_requests`] and [`Client::iterate_ebuy_requests`]. +/// +/// Every string filter except the date bounds accepts `|` for OR. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListEbuyRequestsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_EBUY_REQUESTS_MINIMAL`](crate::SHAPE_EBUY_REQUESTS_MINIMAL) or roll your own; `organization(*)` and `attachments(*)` are the expands. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// 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. + #[builder(into)] + pub search: Option, + /// eBuy request id, exact (e.g. `RFQ1835158`). + #[builder(into)] + pub rfq_id: Option, + /// The buyer's own solicitation or reference number. Dash-insensitive. + #[builder(into)] + pub reference_number: Option, + /// Request type: `RFQ`, `RFP` or `RFI`. + #[builder(into)] + pub request_type: Option, + /// Status as last seen: `Open` or `Cancelled`. Frozen, not a currency signal — an `Open` request may have closed since; read `last_seen` for staleness. + #[builder(into)] + pub status: Option, + /// Special Item Number the request was posted under. + #[builder(into)] + pub sin: Option, + /// GSA schedule the request was posted under. + #[builder(into)] + pub schedule: Option, + /// Buying department name, exactly as published (free text). + #[builder(into)] + pub buyer_agency: Option, + /// Agency name, abbreviation or code, matched against the buyer's resolved organization including every sub-agency and office beneath it. + #[builder(into)] + pub agency: Option, + /// Narrow to one of your own linked contracts. A contract you are not linked to returns nothing rather than an error. + #[builder(into)] + pub contract_number: Option, + + /// Lower bound on `issue_date` (ISO `YYYY-MM-DD`, inclusive). + #[builder(into)] + pub issue_date_after: Option, + /// Upper bound on `issue_date` (inclusive of that whole day). + #[builder(into)] + pub issue_date_before: Option, + /// Lower bound on `close_date` (inclusive). + #[builder(into)] + pub close_date_after: Option, + /// Upper bound on `close_date` (inclusive of that whole day). + #[builder(into)] + pub close_date_before: Option, + + /// Sort key: `issue_date`, `close_date`, `last_seen` or `modified`, prefixed with `-` for descending. The server default is `-issue_date`. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListEbuyRequestsOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + apply_pagination( + &mut q, + self.page, + self.limit, + self.cursor.as_deref(), + self.shape.as_deref(), + self.flat, + self.flat_lists, + ); + push_opt(&mut q, "search", self.search.as_deref()); + push_opt(&mut q, "rfq_id", self.rfq_id.as_deref()); + push_opt(&mut q, "reference_number", self.reference_number.as_deref()); + push_opt(&mut q, "request_type", self.request_type.as_deref()); + push_opt(&mut q, "status", self.status.as_deref()); + push_opt(&mut q, "sin", self.sin.as_deref()); + push_opt(&mut q, "schedule", self.schedule.as_deref()); + push_opt(&mut q, "buyer_agency", self.buyer_agency.as_deref()); + push_opt(&mut q, "agency", self.agency.as_deref()); + push_opt(&mut q, "contract_number", self.contract_number.as_deref()); + push_opt(&mut q, "issue_date_after", self.issue_date_after.as_deref()); + push_opt( + &mut q, + "issue_date_before", + self.issue_date_before.as_deref(), + ); + push_opt(&mut q, "close_date_after", self.close_date_after.as_deref()); + push_opt( + &mut q, + "close_date_before", + self.close_date_before.as_deref(), + ); + push_opt(&mut q, "ordering", self.ordering.as_deref()); + for (k, v) in &self.extra { + if !v.is_empty() { + q.push((k.clone(), v.clone())); + } + } + q + } +} + +/// Options for [`Client::get_ebuy_request`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetEbuyRequestOptions { + /// Shape selector. When empty, the server returns every field plus `organization(*)` and `attachments(*)`. + #[builder(into)] + pub shape: Option, + /// Flatten nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When `flat=true`, also flatten list-valued nested fields. + #[builder(default)] + pub flat_lists: bool, +} + +impl GetEbuyRequestOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + push_opt(&mut q, "shape", self.shape.as_deref()); + if self.flat { + q.push(("flat".into(), "true".into())); + } + if self.flat_lists { + q.push(("flat_lists".into(), "true".into())); + } + q + } +} + +impl Client { + /// `GET /api/ebuy/requests/` — one page of the eBuy requests visible to your account. + /// + /// 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. + pub async fn list_ebuy_requests(&self, opts: ListEbuyRequestsOptions) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/ebuy/requests/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/ebuy/requests/{rfq_id}/` — one eBuy request. + /// + /// 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. + /// A request outside your scope returns [`Error::NotFound`], the same as an unknown id. + pub async fn get_ebuy_request( + &self, + rfq_id: &str, + opts: Option, + ) -> Result { + if rfq_id.is_empty() { + return Err(Error::Validation { + message: "get_ebuy_request: rfq_id is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/ebuy/requests/{}/", urlencoding(rfq_id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/ebuy/requests/{rfq_id}/attachments/{doc_seq_num}/download/` — a short-lived download URL for one stored attachment. + /// + /// 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. + /// + /// 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. + pub async fn get_ebuy_attachment_url(&self, rfq_id: &str, doc_seq_num: u32) -> Result { + if rfq_id.is_empty() { + return Err(Error::Validation { + message: "get_ebuy_attachment_url: rfq_id is required".into(), + response: None, + }); + } + let path = format!( + "/api/ebuy/requests/{}/attachments/{doc_seq_num}/download/", + urlencoding(rfq_id) + ); + match self.get_redirect_location(&path, &[]).await { + Err(Error::Validation { + message, + response: Some(body), + }) => { + let link = body + .raw + .as_ref() + .and_then(|v| v.get("url")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + match link { + Some(url) => Err(Error::ExternalLink { + url, + response: Some(body), + }), + None => Err(Error::Validation { + message, + response: Some(body), + }), + } + } + other => other, + } + } + + /// `GET /api/ebuy/access/` — whether your account can read eBuy requests, and why not when it can't. + pub async fn get_ebuy_access(&self) -> Result { + self.get_json::("/api/ebuy/access/", &[]).await + } + + /// Stream every eBuy request matching `opts`. + pub fn iterate_ebuy_requests(&self, opts: ListEbuyRequestsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_ebuy_requests(next).await }) + }); + PageStream::new(self.clone(), fetch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_q(q: &[(String, String)], k: &str) -> Option { + q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) + } + + #[test] + fn list_ebuy_requests_all_filters_emit() { + let opts = ListEbuyRequestsOptions::builder() + .search("cybersecurity") + .rfq_id("RFQ1835158") + .reference_number("W912DY-26-Q-0012") + .request_type("RFQ|RFI") + .status("Open") + .sin("54151S") + .schedule("MAS") + .buyer_agency("Department of the Army") + .agency("DOD") + .contract_number("C-1") + .issue_date_after("2026-01-01") + .issue_date_before("2026-06-30") + .close_date_after("2026-02-01") + .close_date_before("2026-07-31") + .ordering("-close_date") + .build(); + let q = opts.to_query(); + for (k, v) in [ + ("search", "cybersecurity"), + ("rfq_id", "RFQ1835158"), + ("reference_number", "W912DY-26-Q-0012"), + ("request_type", "RFQ|RFI"), + ("status", "Open"), + ("sin", "54151S"), + ("schedule", "MAS"), + ("buyer_agency", "Department of the Army"), + ("agency", "DOD"), + ("contract_number", "C-1"), + ("issue_date_after", "2026-01-01"), + ("issue_date_before", "2026-06-30"), + ("close_date_after", "2026-02-01"), + ("close_date_before", "2026-07-31"), + ("ordering", "-close_date"), + ] { + assert_eq!(get_q(&q, k).as_deref(), Some(v), "{k}"); + } + assert_eq!(q.len(), 15); + } + + #[test] + fn list_ebuy_requests_zero_value_omitted() { + assert!(ListEbuyRequestsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn get_ebuy_request_options_emit() { + let q = GetEbuyRequestOptions::builder() + .shape("rfq_id,attachments(*)") + .flat(true) + .build() + .to_query(); + assert_eq!(get_q(&q, "shape").as_deref(), Some("rfq_id,attachments(*)")); + assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); + assert_eq!(get_q(&q, "flat_lists"), None); + } + + #[test] + fn ebuy_access_decodes() { + let access: EbuyAccess = serde_json::from_value(serde_json::json!({ + "enabled": false, + "reason": "no_contract_grant", + "contracts": [] + })) + .expect("decode"); + assert!(!access.enabled); + assert_eq!(access.reason.as_deref(), Some("no_contract_grant")); + assert!(access.contracts.is_empty()); + } + + #[tokio::test] + async fn empty_rfq_id_is_rejected_before_any_request() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_ebuy_request("", None).await.unwrap_err(); + assert!(matches!(err, Error::Validation { .. })); + let err = client.get_ebuy_attachment_url("", 1).await.unwrap_err(); + assert!(matches!(err, Error::Validation { .. })); + } +} diff --git a/crates/tango/src/resources/mod.rs b/crates/tango/src/resources/mod.rs index 3d68b4e..632bf58 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod budget; pub(crate) mod contract_appeals; pub(crate) mod contracts; pub(crate) mod dibbs; +pub(crate) mod ebuy; pub(crate) mod entities; pub(crate) mod entity_subresources; pub(crate) mod exclusions; @@ -40,6 +41,7 @@ pub use contracts::ListContractsOptions; pub use dibbs::{ GetDibbsOptions, ListDibbsAwardsOptions, ListDibbsRfpsOptions, ListDibbsRfqsOptions, }; +pub use ebuy::{GetEbuyRequestOptions, ListEbuyRequestsOptions}; pub use entities::{GetEntityOptions, ListEntitiesOptions}; pub use entity_subresources::{EntityBudgetFlowsOptions, EntitySubresourceOptions}; pub use exclusions::{GetExclusionOptions, ListExclusionsOptions}; diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index 8444fc6..3b548e4 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -257,3 +257,12 @@ pub const SHAPE_SBIR_SOLICITATIONS_MINIMAL: &str = concat!( "solicitation_id,solicitation_number,title,program,activity,", "cycle_name,solicitation_status,year,start_date,end_date", ); + +/// Suggested list shape for [`Client::list_ebuy_requests`](crate::Client::list_ebuy_requests). +/// +/// Mirrors the API's default list shape. `description` is detail-only; `attachment_count` says whether a detail fetch is worth it. +pub const SHAPE_EBUY_REQUESTS_MINIMAL: &str = concat!( + "rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency,", + "buyer_agency_code,reference_number,issue_date,close_date,attachment_count,", + "link_count,last_seen", +); diff --git a/crates/tango/src/transport.rs b/crates/tango/src/transport.rs index c2478ee..1e134b1 100644 --- a/crates/tango/src/transport.rs +++ b/crates/tango/src/transport.rs @@ -131,21 +131,39 @@ pub(crate) async fn send_with_retries( url: reqwest::Url, body: Body<'_>, ) -> Result> { - let max_attempts = inner.retries.saturating_add(1); - let mut attempt: u32 = 0; - // Pre-serialize the JSON body once; we re-use the bytes across attempts. let body_bytes = match body { Body::None => None, Body::Json(v) => Some(serde_json::to_vec(v)?), }; + retry(inner, || { + attempt_once(inner, method.clone(), url.clone(), body_bytes.as_deref()) + }) + .await +} + +/// GET `url` on the no-redirect client and return the resolved `Location` of +/// its 3xx response, under the same retry policy as every other request. +pub(crate) async fn redirect_location_with_retries( + inner: &crate::client::ClientInner, + url: reqwest::Url, +) -> Result { + retry(inner, || attempt_redirect(inner, url.clone())).await +} + +async fn retry(inner: &crate::client::ClientInner, mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let max_attempts = inner.retries.saturating_add(1); + let mut attempt: u32 = 0; loop { - let err = - match attempt_once(inner, method.clone(), url.clone(), body_bytes.as_deref()).await { - Ok(bytes) => return Ok(bytes), - Err(e) => e, - }; + let err = match op().await { + Ok(v) => return Ok(v), + Err(e) => e, + }; if !err.is_retryable() || attempt + 1 >= max_attempts { return Err(err); @@ -172,13 +190,14 @@ fn backoff_for(base: Duration, attempt: u32) -> Duration { base.saturating_mul(mult).min(MAX_BACKOFF) } -async fn attempt_once( +fn build_request( inner: &crate::client::ClientInner, + http: &reqwest::Client, method: Method, url: reqwest::Url, body_bytes: Option<&[u8]>, -) -> Result> { - let mut req: RequestBuilder = inner.http.request(method, url); +) -> RequestBuilder { + let mut req: RequestBuilder = http.request(method, url); req = req.header(reqwest::header::ACCEPT, "application/json"); if !inner.api_key.is_empty() { req = req.header(API_KEY_HEADER, &inner.api_key); @@ -194,9 +213,16 @@ async fn attempt_once( if !inner.timeout.is_zero() { req = req.timeout(inner.timeout); } + req +} - let resp_result = req.send().await; - let resp: Response = match resp_result { +/// Send `req` and return its status, headers and body, recording the headers +/// for [`Client::rate_limit_info`](crate::Client::rate_limit_info). +async fn send_raw( + inner: &crate::client::ClientInner, + req: RequestBuilder, +) -> Result<(StatusCode, HeaderMap, Vec)> { + let resp: Response = match req.send().await { Ok(r) => r, Err(e) => { if e.is_timeout() { @@ -217,11 +243,54 @@ async fn attempt_once( Ok(b) => b.to_vec(), Err(e) => return Err(Error::Transport(e)), }; + Ok((status, headers, bytes)) +} +async fn attempt_once( + inner: &crate::client::ClientInner, + method: Method, + url: reqwest::Url, + body_bytes: Option<&[u8]>, +) -> Result> { + let req = build_request(inner, &inner.http, method, url, body_bytes); + let (status, headers, bytes) = send_raw(inner, req).await?; if status.is_success() { return Ok(bytes); } + Err(decode_error(status, &headers, &bytes)) +} +async fn attempt_redirect(inner: &crate::client::ClientInner, url: reqwest::Url) -> Result { + let req = build_request( + inner, + &inner.http_no_redirect, + Method::GET, + url.clone(), + None, + ); + let (status, headers, bytes) = send_raw(inner, req).await?; + if status.is_redirection() { + let location = headers + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| Error::Api { + status: status.as_u16(), + message: "redirect response carried no Location header".into(), + response: None, + })?; + return url + .join(location) + .map(String::from) + .map_err(|e| Error::Build(format!("parse redirect location {location}: {e}"))); + } + if status.is_success() { + return Err(Error::Api { + status: status.as_u16(), + message: format!("expected a redirect, got status {}", status.as_u16()), + response: None, + }); + } Err(decode_error(status, &headers, &bytes)) } diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index 6ca1033..b53230e 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -5,9 +5,9 @@ use serde_json::json; use std::time::Duration; use tango::{ BudgetAccountQuartersOptions, BudgetAccountRecipientsOptions, Client, EntityBudgetFlowsOptions, - GetDibbsOptions, GetExclusionOptions, GetSbirOptions, ListDibbsAwardsOptions, - ListDibbsRfpsOptions, ListDibbsRfqsOptions, ListExclusionsOptions, - ListSbirSolicitationsOptions, ListSbirTopicsOptions, + Error, GetDibbsOptions, GetEbuyRequestOptions, GetExclusionOptions, GetSbirOptions, + ListDibbsAwardsOptions, ListDibbsRfpsOptions, ListDibbsRfqsOptions, ListEbuyRequestsOptions, + ListExclusionsOptions, ListSbirSolicitationsOptions, ListSbirTopicsOptions, }; fn make_client(server: &MockServer) -> Client { @@ -366,3 +366,159 @@ async fn contract_sub_routes() { subs.assert_async().await; txns.assert_async().await; } + +#[tokio::test] +async fn ebuy_routes() { + let server = MockServer::start_async().await; + let list = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/ebuy/requests/") + .query_param("status", "Open") + .query_param("sin", "54151S") + .query_param("ordering", "-close_date"); + then.status(200) + .json_body(page(json!([{"rfq_id": "RFQ1835158"}]))); + }) + .await; + let get = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/ebuy/requests/RFQ1835158/") + .query_param("shape", "rfq_id,attachments(*)"); + then.status(200).json_body(json!({ + "rfq_id": "RFQ1835158", + "attachments": [{"doc_seq_num": 1, "is_link": false}] + })); + }) + .await; + let access = server + .mock_async(|when, then| { + when.method(GET).path("/api/ebuy/access/"); + then.status(200).json_body(json!({ + "enabled": false, + "reason": "tier_required", + "contracts": [] + })); + }) + .await; + let c = make_client(&server); + let p = c + .list_ebuy_requests( + ListEbuyRequestsOptions::builder() + .status("Open") + .sin("54151S") + .ordering("-close_date") + .build(), + ) + .await + .expect("list"); + assert_eq!(p.results.len(), 1); + let rec = c + .get_ebuy_request( + "RFQ1835158", + Some( + GetEbuyRequestOptions::builder() + .shape("rfq_id,attachments(*)") + .build(), + ), + ) + .await + .expect("get"); + assert_eq!( + rec.get("rfq_id").and_then(serde_json::Value::as_str), + Some("RFQ1835158") + ); + let a = c.get_ebuy_access().await.expect("access"); + assert!(!a.enabled); + assert_eq!(a.reason.as_deref(), Some("tier_required")); + list.assert_async().await; + get.assert_async().await; + access.assert_async().await; +} + +#[tokio::test] +async fn ebuy_attachment_url_returns_redirect_target_without_following_it() { + let server = MockServer::start_async().await; + let target = server.url("/presigned/doc.pdf?X-Amz-Signature=abc"); + let download = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/ebuy/requests/RFQ1835158/attachments/2/download/") + .header("X-API-KEY", "test-key"); + then.status(302).header("Location", target.as_str()); + }) + .await; + let presigned = server + .mock_async(|when, then| { + when.method(GET).path("/presigned/doc.pdf"); + then.status(200).body("%PDF"); + }) + .await; + let url = make_client(&server) + .get_ebuy_attachment_url("RFQ1835158", 2) + .await + .expect("url"); + assert_eq!(url, target); + download.assert_async().await; + presigned.assert_hits_async(0).await; +} + +#[tokio::test] +async fn ebuy_attachment_url_resolves_a_relative_location() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/ebuy/requests/RFQ1/attachments/1/download/"); + then.status(302).header("Location", "/files/doc.pdf"); + }) + .await; + let url = make_client(&server) + .get_ebuy_attachment_url("RFQ1", 1) + .await + .expect("url"); + assert_eq!(url, server.url("/files/doc.pdf")); +} + +#[tokio::test] +async fn ebuy_attachment_url_surfaces_the_link_of_a_link_entry() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/ebuy/requests/RFQ1/attachments/3/download/"); + then.status(400).json_body(json!({ + "detail": "This entry is an external link, not a stored document.", + "url": "https://example.test/spec" + })); + }) + .await; + let err = make_client(&server) + .get_ebuy_attachment_url("RFQ1", 3) + .await + .unwrap_err(); + match err { + Error::ExternalLink { url, .. } => assert_eq!(url, "https://example.test/spec"), + other => panic!("expected ExternalLink, got {other:?}"), + } +} + +#[tokio::test] +async fn ebuy_attachment_url_maps_uncaptured_document_to_not_found() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/ebuy/requests/RFQ1/attachments/4/download/"); + then.status(404).json_body(json!({ + "detail": "The document for this attachment has not been captured yet." + })); + }) + .await; + let err = make_client(&server) + .get_ebuy_attachment_url("RFQ1", 4) + .await + .unwrap_err(); + assert!(matches!(err, Error::NotFound { .. }), "{err:?}"); +} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c3c3d89..63ad02a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -195,6 +195,23 @@ Options: `ListDibbsRfqsOptions`, `ListDibbsRfpsOptions`, `ListDibbsAwardsOptions - **An award row is one line item, and `total_contract_price` is the order total repeated on every line.** Never sum it across rows; deduplicate on `award_number` + `delivery_order_number` first. - `entity` matches only awards whose CAGE code resolved to a registered entity; `awardee_cage` reaches every award. +### GSA eBuy (`ebuy.rs`) + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_ebuy_requests(opts)` / `iterate_ebuy_requests(opts)` | `GET /api/ebuy/requests/` | `Page` / `PageStream` | +| `get_ebuy_request(rfq_id, opts)` | `GET /api/ebuy/requests/{rfq_id}/` | `Record` | +| `get_ebuy_attachment_url(rfq_id, doc_seq_num)` | `GET /api/ebuy/requests/{rfq_id}/attachments/{doc_seq_num}/download/` | `String` (the redirect target) | +| `get_ebuy_access()` | `GET /api/ebuy/access/` | **`EbuyAccess`** (typed) | + +Options: `ListEbuyRequestsOptions` (`search`, `rfq_id`, `reference_number`, `request_type`, `status`, `sin`, `schedule`, `buyer_agency`, `agency`, `contract_number`, `issue_date_after` / `_before`, `close_date_after` / `_before`, `ordering` over `issue_date`, `close_date`, `last_seen` and `modified`), `GetEbuyRequestOptions`. Shape: `SHAPE_EBUY_REQUESTS_MINIMAL`, which mirrors the API's default list shape. Available from Tango API 5.1.0. + +**Requires the Pro plan, and is scoped to your own GSA schedule contracts.** You see a request only while your account is linked to a contract it was posted under. With no linked contract the list is **empty rather than an error**, so call `get_ebuy_access` to tell "no access" from "no matches": it returns `enabled`, a `reason` (`tier_required` or `no_contract_grant`; `tier_required` wins when both apply) and your linked `contracts`. A request outside your scope 404s on lookup, the same as an unknown id. `contract_number` narrows to one of your own contracts; a contract you are not linked to returns nothing. + +**`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". Read `last_seen` for staleness. 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. + +**`get_ebuy_attachment_url` does not follow the redirect.** The API answers with a redirect to a presigned URL valid for about five minutes; the method returns that URL, so fetch it promptly. It runs on a client with redirects disabled (override it with the builder's `no_redirect_http_client` if your proxy or TLS setup must apply). An attachment with `is_link = true` has no stored document: the call returns `Error::ExternalLink { url, .. }` carrying the link. A document not yet captured returns `Error::NotFound`. + ### Exclusions (`exclusions.rs`) SAM.gov exclusions: debarments, suspensions and other ineligibility records. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66217eb..d2a3df0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,6 +24,7 @@ tango-rust/ (workspace root) │ │ ├── version.rs # VERSION const │ │ ├── models/ # Typed input/output structs │ │ │ ├── agency.rs +│ │ │ ├── ebuy.rs │ │ │ ├── protest.rs │ │ │ ├── resolve.rs │ │ │ ├── validate.rs @@ -42,6 +43,7 @@ tango-rust/ (workspace root) │ │ ├── subawards.rs │ │ ├── gsa.rs │ │ ├── protests.rs +│ │ ├── ebuy.rs # GSA eBuy requests + attachment URL + access │ │ ├── itdashboard.rs │ │ ├── lcats.rs # dispatcher (entity vs IDV) │ │ ├── lookups.rs # organizations, NAICS, PSC, business types, @@ -79,6 +81,7 @@ A small hand-picked set of methods return typed structs where the sibling SDKs a | ------ | ----------- | | `get_agency` | `AgencyRecord` | | `get_protest` | `ProtestRecord` | +| `get_ebuy_access` | `EbuyAccess` | | `resolve` | `ResolveResult` (with `Vec`) | | `validate` | `ValidateResult` | | `list_webhook_endpoints` / `get_*` / `create_*` / `update_*` | `Page` / `WebhookEndpoint` | diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 5e2379e..72dc0df 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -108,6 +108,7 @@ All 34 constants live in `shapes.rs` and are re-exported at the crate root. They | `SHAPE_SLED_FORECASTS_MINIMAL` | `list_sled_forecasts` | forecast identity, agency, advertisement estimate, value band | | `SHAPE_SLED_FORECASTS_COMPREHENSIVE` | `get_sled_forecast` | the above + description, organization, contact | | `SHAPE_BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts` | the API's default: identity + lifecycle dollars + capped ratios | +| `SHAPE_EBUY_REQUESTS_MINIMAL` | `list_ebuy_requests` | the API's default: request identity, schedule/SIN, frozen status, buyer, dates, attachment and link counts, last_seen | | `SHAPE_DIBBS_RFQS_MINIMAL` | `list_dibbs_rfqs` | uuid, solicitation, NSN, part number, nomenclature, quantity, dates, is_open | | `SHAPE_DIBBS_RFPS_MINIMAL` | `list_dibbs_rfps` | uuid, solicitation, NSN, part number, nomenclature, dates, is_open | | `SHAPE_DIBBS_AWARDS_MINIMAL` | `list_dibbs_awards` | uuid, award number, solicitation, NSN, part, awardee CAGE, award date, order total |