Skip to content
Merged
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

Pre-1.0 (SemVer 0.x): the change under **Breaking** ships without a deprecation cycle.

### `makegov-tango`

#### Breaking

- **`Page<T>` is now `#[non_exhaustive]` and gains `count_type` and `meta`.** Code that reads a page is unaffected; code that built a `Page` with a struct literal or destructured it without `..` no longer compiles. The SDK builds pages and callers read them, so this closes the door on the next field addition being breaking too.

#### 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`.
Expand All @@ -24,16 +30,37 @@ This project follows [Semantic Versioning](https://semver.org/).
- **`account_category` and `account_category_in` on `ListBudgetAccountsOptions`** (sent as `account_category` and `account_category__in`). Values are `budgetary` or `credit_financing` today; credit financing accounts have null `enacted_ba` and are left out of organization budget totals.
- **Vehicle company counts** (Tango API 5.8.0): `SHAPE_VEHICLES_MINIMAL` and `SHAPE_VEHICLES_COMPREHENSIVE` now request `holder_count` (distinct companies holding one of the vehicle's IDVs) and `order_winner_count` (distinct companies that have won a task order under it), next to `awardee_count`.

- **`Page::count_type`**: `"exact"` or `"approximate"`, read from the `X-Results-CountType` header of each list response, `None` when the endpoint does not send it. Past 1,000 matches the contract, IDV, opportunity and notice lists may report a query-planner estimate as `count`, which can be well off; this is how to tell. It is taken from each response itself, so concurrent calls on one client never see each other's value the way `last_response_headers` can.
- **`Page::meta`**: the response's `meta` object, passed through as the API sends it. The SDK dropped it before, so agency-filter diagnostics and upgrade hints were unreachable. Each entry of `meta["resolved_filters"][<filter name>]` that resolved now says how its token matched in `matched_by` (Tango API 5.9.0): `key` (an organization UUID), `code` (a 3-digit CGAC or 4-digit FPDS code), `name` (the organization's name, including a department's everyday name, a spelling variant or a rename), `alias` (an abbreviation or the organization's own alias) or `fuzzy` (a looser text match, worth checking against the resolved name). An entry that did not resolve has no `matched_by`.
- **Federal Register documents** (Tango API 5.3.0). `resources/federal_register.rs` adds `list_federal_register_documents` / `iterate_federal_register_documents` and `get_federal_register_document`, with `ListFederalRegisterDocumentsOptions`, `GetFederalRegisterDocumentOptions`, `SHAPE_FEDERAL_REGISTER_MINIMAL` and `SHAPE_FEDERAL_REGISTER_COMPREHENSIVE`. These are the rules, proposed rules, notices and presidential documents published in the Federal Register since 1994. All seventeen of the API's filters are named fields, including `comments_open`, `cfr_title` / `cfr_part`, `rin`, and both `agency` (a Tango organization, including its sub-agencies) and `fr_agency` (the Federal Register's own agency slug); the document type filter is `r#type`. `get_federal_register_document` takes the document's `uuid`, not its `document_number`, because a document number is not unique on its own: the Federal Register reused some before 2016. Look a document up by number with `document_number` on the list. `full_text` is served on the detail method only when named in `shape`, and neither shape constant includes it, since it can run to several MB. `ordering = "rank"` requires a non-empty `search`, matching the API.
- **`federal_register` and `ebuy_request` alert query types** (`alerts.federal_register.match`, `alerts.ebuy_request.match`). The SDK models `query_type` and event types as strings, so `create_webhook_alert` already accepted them; `docs/WEBHOOKS.md` now names them, and that an eBuy alert sees only the requests your account can read.
- **`agency` on `ListBudgetAccountsOptions`, `ListExclusionsOptions` and `ListItdashboardOptions`** (Tango API 5.3.0). It takes a Tango agency name, abbreviation, code or organization key (e.g. `EPA`) and matches the whole organization subtree, so a department includes its sub-agencies; OR several with `|`. On exclusions it scopes by the excluding agency. On IT Dashboard it is available at every plan.
- **The `organization(...)` expand on budget accounts, exclusions, SBIR topics and SBIR solicitations, and `organization_id` on budget accounts and exclusions** (Tango API 5.3.0), carrying `agency_code`, `agency_name`, `department_code`, `department_name`, `office_code`, `office_name` and `organization_id`. This SDK passes `shape` through, so it needed no code change; `docs/API_REFERENCE.md` documents it. The default shapes are unchanged.
- **`cycle_name` on `ListSbirTopicsOptions`** (Tango API 5.3.1) filters topics by their exact DoD DSIP solicitation cycle (e.g. `DOD_SBIR_2026_P1_CBZ`); `cycle_name` is also a topic field.
- **`awarded` and `awardee_uei` on `ListOpportunitiesOptions`** (Tango API 5.5.0). Both search every opportunity rather than only active ones. `get_opportunity`, `get_notice` and `docs/API_REFERENCE.md` document the award leaves (`awarded`, `award_date`, `award_amount`, `awardee`, `awardee_uei`, `award_count`, `solicitation_opportunity_id`) and the `awards(...)` expand; none is in a default shape.
- **File and link counts on opportunities and notices** (Tango API 5.9.0), documented on `get_opportunity`, `get_notice` and `get_vehicle`: `meta.files_count` and `meta.links_count` on opportunities, `file_count` and `link_count` on notices, and `opportunity(meta(files_count,links_count))` on vehicles. The existing totals still count every attachment, links included, so files plus links need not equal them. The new fields are null until a record has been counted, so treat null as unknown, not zero. No default shape names them.
- **`commercial_item_acquisition_procedures` on `ListContractsOptions` and `ListIDVsOptions`** (Tango API 5.10.0). Filters by the FPDS commercial item acquisition procedures code: `A` (commercial products/services), `B` (products or services pursuant to FAR 12.102(f)), `C` (services pursuant to FAR 12.102(g)), `D` (commercial procedures not used) or `E` (DoD Section 803 Commercial Solutions Opening). Codes are case-insensitive and pipe-separated for OR, e.g. `"A|E"`; an unknown or multi-character code matches nothing. The API does not offer it on OTAs or OT IDVs, so their options do not take it.
- **`obligations_by_fiscal_year(fiscal_year, obligated)` on contracts, IDVs, OTAs and OT IDVs** (Tango API 5.11.0). The shape expand returns the award's obligations per federal fiscal year, oldest first, each rounded to cents; `models::FiscalYearObligation::from_record` decodes it from a record (`fiscal_year` as `i64`, `obligated` as `f64`), returning `Ok(None)` when it is null or not in the shape. It is always null on IDVs and OT IDVs, whose transactions report a running lifetime total.
- **`attachments(extraction_status)` on opportunities and notices** (Tango API 5.11.0), documented on `get_opportunity` and `get_notice`: `pending`, `extracting`, `completed`, `failed` or `unsupported` for each attachment, so you can tell why `extracted_text` is missing. It is returned only when named, at every plan.
- **Filters tango-python already exposed:** `has_awards` on `ListPscOptions` (only codes with contract award history), `search` on `ListVehicleAwardeesOptions` and `ordering` on `ListVehicleOrdersOptions` (`award_date`, the default, `obligated` or `total_contract_value`).

#### 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.
- **`SHAPE_BUDGET_ACCOUNTS_MINIMAL` matches the API's default shape again.** It adds `data_through_period` (right after `fiscal_year`), `account_category` and `source_anomalies`, plus `attribution_status`, `attribution_confidence` and `contract_obligated_estimated`, which the API's default already carried.
- **The API's default budget-account ordering now puts null `enacted_ba` last** within each fiscal year, with `id` breaking ties so pages are stable. No SDK change is needed; the `ordering` field documents it.
- `uei` / `recipient_uei` on `ListContractsOptions`, and `uei` on `ListIDVsOptions`, `ListOTAsOptions` and `ListOTIDVsOptions`, document that several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). The SDK already passed the value through unchanged.

#### Deprecated

- **`awardee_count` on vehicles** is deprecated by the Tango API as an alias of `order_winner_count`, and is removed at the API's next major version. The vehicle shapes still request it so existing readers keep working; read `order_winner_count` instead.

#### Fixed

- **`ListItdashboardOptions` documents the API's current filter tiers.** `agency_name` is available at every plan, not Business+ only, and so are `agency` and `previous_uii`.
- **The SBIR topic `agency` filter is documented as organization-resolved.** It rolls up the hierarchy (`HHS` includes NIH topics) and falls back to the raw agency text only for a token that does not resolve; the docs said it never resolved.
- **API errors now carry the server's reason when it is nested under `detail`.** A 403 or other non-2xx response the SDK had no dedicated variant for kept a top-level `detail`, `message` or `error` string, but a structured body such as a plan gate's `{"detail": {"error": "...", "code": "tier_required"}}` left `Error::Api`'s `message` at `API request failed with status 403`, so a caller refused for a plan-gated filter couldn't tell why. The message is now the API's own explanation (`tango: API error (status 403): The 'psc' filter requires a Small plan or higher.`), and the parsed body stays on `response`. When a body carries several of these keys, `error` now wins over `message` and `detail`, matching the Python SDK. Non-JSON bodies, such as a proxy's 502 page, keep the old message.

## [0.2.0] — 2026-09-23

Pre-1.0 (SemVer 0.x): the removals under **Breaking** ship without a deprecation cycle.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,34 @@ let next_page = client.list_idvs(
# Ok(()) }
```

`page.count` is the total across every page. Past 1,000 matches the contract, IDV, opportunity and notice lists may report a query-planner estimate there instead of an exact count, and `page.count_type` says which you got: `Some("exact")`, `Some("approximate")`, or `None` when the endpoint does not say. It is read from that page's own response, so concurrent calls on one client never see each other's value.

`page.meta` is the response's `meta` object as the API sends it. On a list filtered by agency it carries the filter diagnostics: each entry of `meta["resolved_filters"][<filter name>]` that resolved says how its token matched in `matched_by` (Tango API 5.9.0+): `key`, `code`, `name`, `alias` or `fuzzy`. A `fuzzy` match is worth checking against the resolved name.

```rust,no_run
# use tango::{Client, ListContractsOptions};
# async fn run() -> tango::Result<()> {
# let client = Client::builder().api_key("x").build()?;
let page = client
.list_contracts(ListContractsOptions::builder().awarding_agency("Navy").build())
.await?;
if page.count_type.as_deref() == Some("approximate") {
println!("about {} matches", page.count);
}
if let Some(entries) = page
.meta
.as_ref()
.and_then(|m| m["resolved_filters"]["awarding_agency"].as_array())
{
for entry in entries {
if entry["matched_by"] == "fuzzy" {
println!("check this match: {entry}");
}
}
}
# Ok(()) }
```

Or just iterate via `PageStream<T>`:

```rust,no_run
Expand Down Expand Up @@ -262,6 +290,7 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs
| 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` |
| Federal Register | `list_federal_register_documents` | `get_federal_register_document` | `iterate_federal_register_documents` |
| 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_*` |
| Budget accounts | `list_budget_accounts` | `get_budget_account` | `iterate_budget_accounts` |
Expand Down
15 changes: 11 additions & 4 deletions crates/tango/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,21 @@ impl Client {
transport::decode_json(&bytes)
}

/// Internal: GET `path` with `query`, return raw bytes.
pub(crate) async fn get_bytes(
/// Internal: GET a list endpoint and decode it as a [`Page`](crate::Page), reading `count_type` from the response headers.
pub(crate) async fn get_page<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(String, String)],
) -> Result<Vec<u8>> {
) -> Result<crate::Page<T>> {
let url = self.build_url(path, query)?;
transport::send_with_retries(&self.inner, reqwest::Method::GET, url, Body::None).await
let (bytes, headers) = transport::send_with_retries_and_headers(
&self.inner,
reqwest::Method::GET,
url,
Body::None,
)
.await?;
crate::Page::decode_response(&bytes, &headers)
}

/// Internal: GET `path` without following redirects and return the
Expand Down
7 changes: 2 additions & 5 deletions crates/tango/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,8 @@ use std::time::Duration;
/// - `{"message": "..."}` or `{"error": "..."}` (generic envelope)
/// - `{"<field>": ["..."]}` (DRF field-error array)
///
/// The raw decoded JSON is preserved in [`ErrorBody::raw`] for callers that
/// need to inspect a custom shape. [`ErrorBody::message`] is the SDK's best
/// guess at a human-readable line — extracted by walking envelope keys
/// (`detail` / `message` / `error`) first, then sorted-key iteration over the
/// remaining keys.
/// The raw decoded JSON is preserved in [`ErrorBody::raw`] for callers that need to inspect a custom shape.
/// [`ErrorBody::message`] is the server's own explanation: the first non-empty string among the envelope keys `error`, `message` and `detail`, then the same keys inside a `detail` object (a plan gate's structured 403).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorBody {
/// The human-readable message the SDK extracted from the body, when one
Expand Down
11 changes: 6 additions & 5 deletions crates/tango/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ 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_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_ENTITIES_MINIMAL, SHAPE_EXCLUSIONS_MINIMAL, SHAPE_FEDERAL_REGISTER_COMPREHENSIVE,
SHAPE_FEDERAL_REGISTER_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
90 changes: 90 additions & 0 deletions crates/tango/src/models/awards.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! `FiscalYearObligation` — typed view of the `obligations_by_fiscal_year` shape expand on contracts, IDVs, OTAs and OT IDVs.

use crate::Record;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// One federal fiscal year of an award's obligations, from the `obligations_by_fiscal_year(fiscal_year,obligated)` shape expand (Tango API 5.11.0).
///
/// The expand sums the award's transactions per federal fiscal year (October 1 starts the next fiscal year), oldest year first, each rounded to cents.
/// A de-obligation nets against its own year, so a year can be zero or negative.
/// It is returned only when named in `shape`: no default shape includes it.
/// Decode it from a contract, IDV, OTA or OT IDV record with [`FiscalYearObligation::from_record`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FiscalYearObligation {
/// The federal fiscal year, e.g. `2025` for October 2024 through September 2025.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fiscal_year: Option<i64>,

/// Net obligations in that fiscal year, in dollars, rounded to cents.
#[serde(
default,
deserialize_with = "crate::models::budget::number_or_numeric_string",
skip_serializing_if = "Option::is_none"
)]
pub obligated: Option<f64>,

/// Forward-compat catch-all for server-side fields the SDK does not yet model.
#[serde(flatten, default)]
pub extra: HashMap<String, Value>,
}

impl FiscalYearObligation {
/// Decode the `obligations_by_fiscal_year` field of an award [`Record`].
///
/// Returns `Ok(None)` when the field is absent (not in the requested shape) or null.
/// It is always null on IDVs and OT IDVs, whose transactions report a running lifetime total rather than each action's obligation, and null on a contract or OTA with no dated transactions.
/// Returns an error when the field is present but not a list of year objects.
pub fn from_record(record: &Record) -> serde_json::Result<Option<Vec<Self>>> {
match record.get("obligations_by_fiscal_year") {
None | Some(Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn record(v: Value) -> Record {
v.as_object().cloned().expect("object")
}

#[test]
fn decodes_years_in_order_including_a_negative_year() {
let r = record(json!({
"key": "CONT_AWD_1",
"obligations_by_fiscal_year": [
{"fiscal_year": 2024, "obligated": 125_000.5},
{"fiscal_year": 2025, "obligated": -1_200.0},
{"fiscal_year": 2026, "obligated": "300.25"}
]
}));
let years = FiscalYearObligation::from_record(&r)
.expect("decode")
.expect("present");
assert_eq!(years.len(), 3);
assert_eq!(years[0].fiscal_year, Some(2024));
assert_eq!(years[0].obligated, Some(125_000.5));
assert_eq!(years[1].obligated, Some(-1_200.0));
assert_eq!(years[2].obligated, Some(300.25));
}

#[test]
fn null_and_absent_are_none() {
for v in [json!({"obligations_by_fiscal_year": null}), json!({})] {
assert!(FiscalYearObligation::from_record(&record(v))
.expect("decode")
.is_none());
}
}

#[test]
fn non_list_is_an_error() {
let r = record(json!({"obligations_by_fiscal_year": {"fiscal_year": 2025}}));
assert!(FiscalYearObligation::from_record(&r).is_err());
}
}
Loading
Loading