Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_*` |
Expand All @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions crates/tango/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,6 +130,14 @@ impl Client {
/// supplied, its built-in timeout is ignored — per-request
/// deadlines are applied here.
http_client: Option<reqwest::Client>,
/// 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<reqwest::Client>,
) -> Result<Self> {
let api_key = match api_key.filter(|s| !s.is_empty()) {
Some(k) => k,
Expand All @@ -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,
Expand Down Expand Up @@ -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<String> {
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<B: Serialize, T: serde::de::DeserializeOwned>(
&self,
Expand Down
22 changes: 21 additions & 1 deletion crates/tango/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ pub enum Error {
response: Option<ErrorBody>,
},

/// 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<ErrorBody>,
},

/// HTTP 429 — the caller exceeded a rate limit.
///
/// `retry_after` is populated from the `Retry-After` header when present
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -154,6 +167,7 @@ impl Error {
Self::Auth { .. }
| Self::NotFound { .. }
| Self::Validation { .. }
| Self::ExternalLink { .. }
| Self::Decode(_)
| Self::Build(_) => false,
}
Expand All @@ -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,
Expand Down Expand Up @@ -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());
}
}
12 changes: 6 additions & 6 deletions crates/tango/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions crates/tango/src/models/ebuy.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

/// The caller's own linked GSA schedule contract numbers, sorted.
#[serde(default)]
pub contracts: Vec<String>,

/// Forward-compatible bucket for any unrecognized fields the server adds.
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
2 changes: 2 additions & 0 deletions crates/tango/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@

pub mod agency;
pub mod contract_appeal;
pub mod ebuy;
pub mod protest;
pub mod resolve;
pub mod validate;
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};
Expand Down
Loading
Loading