Skip to content
Open
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to the `makegov-tango` and `makegov-tango-webhooks` crates a

This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

Sync to Tango API v4.6.9. Pre-1.0 (SemVer 0.x): the removals below are breaking but ship without a deprecation cycle.

### Added

- **Budget surface** (`budget.rs`): `list_budget_accounts` / `iterate_budget_accounts` (`GET /api/budget/accounts/`), `get_budget_account` (`GET /api/budget/accounts/{id}/`), `get_budget_account_quarters` (`GET /api/budget/accounts/{id}/quarters/`), `get_budget_account_recipients` (`GET /api/budget/accounts/{id}/recipients/`). New `ListBudgetAccountsOptions` builder and `SHAPE_BUDGET_ACCOUNTS_MINIMAL` shape constant.
- Singleton detail GETs: `get_contract` (`GET /api/contracts/{key}/`), `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`.
- Contract sub-routes: `list_contract_subawards` (`GET /api/contracts/{key}/subawards/`), `list_contract_transactions` (`GET /api/contracts/{key}/transactions/`).
- `get_entity_budget_flows` (`GET /api/entities/{uei}/budget-flows/`).
- `grant_id` typed filter on `ListGrantsOptions`.
- `cage` typed filter on `ListEntitiesOptions` (distinct from the existing `cage_code`; the server rejects setting both).

### Removed

- **Breaking**: `get_idv_summary` and `list_idv_summary_awards`. These hit `/api/idvs/{key}/summary/` and `/api/idvs/{key}/summary/awards/`, which have never existed in the Tango API (the server returns 404). Use `get_idv` with a comprehensive shape and `list_idv_awards` respectively.

## [0.1.0] — 2026-05-15

First public release of the Tango Rust SDK.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
exclude = ["examples"]

[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
Expand Down
11 changes: 10 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ This roadmap tracks the Rust SDK only. The goal is to stay closely aligned with
- [X] Sub-resource walks for IDVs, entities, agencies, vehicles.
- [X] OTAs / OTIDVs, GSA eLibrary, IT Dashboard, protests, LCATs.

## Next (0.2)
## 0.2 (API sync to v4.6.9)

- [X] Budget surface: `list_budget_accounts` / `iterate_budget_accounts`, `get_budget_account`, `get_budget_account_quarters`, `get_budget_account_recipients` (`/api/budget/accounts/`).
- [X] Singleton detail GETs: `get_contract`, `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`.
- [X] Contract sub-routes: `list_contract_subawards`, `list_contract_transactions`.
- [X] `get_entity_budget_flows` (`/api/entities/{uei}/budget-flows/`).
- [X] `grant_id` filter on grants; `cage` filter on entities.
- [X] Removed fabricated `get_idv_summary` / `list_idv_summary_awards` (paths never existed upstream).

## Next

- [ ] Edition 2024 migration once MSRV catches up (re-evaluate `rust-version` floor).
- [ ] Optional `blocking` feature (sync facade over the async client, gated behind a Cargo feature).
Expand Down
16 changes: 8 additions & 8 deletions crates/tango/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ pub use error::{Error, ErrorBody, Result};
pub use internal::ListOptions;
pub use pagination::{Page, PageStream};
pub use shapes::{
DEFAULT_BASE_URL, SHAPE_CONTRACTS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE,
SHAPE_ENTITIES_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_SUBAWARDS_MINIMAL,
SHAPE_VEHICLES_COMPREHENSIVE, SHAPE_VEHICLES_MINIMAL, SHAPE_VEHICLE_AWARDEES_MINIMAL,
SHAPE_VEHICLE_ORDERS_MINIMAL,
DEFAULT_BASE_URL, SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL,
SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_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_SUBAWARDS_MINIMAL, SHAPE_VEHICLES_COMPREHENSIVE, SHAPE_VEHICLES_MINIMAL,
SHAPE_VEHICLE_AWARDEES_MINIMAL, SHAPE_VEHICLE_ORDERS_MINIMAL,
};
pub use transport::RateLimitInfo;
pub use version::VERSION;
Expand Down
300 changes: 300 additions & 0 deletions crates/tango/src/resources/budget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
//! `/api/budget/accounts/` — federal-account x fiscal-year budget rollups.
//!
//! One row per `(federal_account_symbol, fiscal_year)` covering the full
//! budget lifecycle (requested → enacted → apportioned → obligated →
//! outlayed), pre-computed ratios + trends, the contract/assistance/unlinked
//! breakdown, and request-vs-actual contract spend. The schema is wide
//! (~63 fields) and shape-driven, so every method returns the untyped
//! [`Record`] map like the other resource families.

use crate::client::Client;
use crate::error::{Error, Result};
use crate::internal::{apply_pagination, push_opt, ListOptions};
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_budget_accounts`] and
/// [`Client::iterate_budget_accounts`]. Mirrors `ListBudgetAccountsOptions`
/// in the Go SDK.
///
/// The full `__gte` / `__lte` range-filter set (the 26 numeric metrics on the
/// underlying FilterSet) is reachable via the [`extra`](Self::extra) map.
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListBudgetAccountsOptions {
// ----- Pagination + shape -----
/// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
#[builder(into)]
pub page: Option<u32>,
/// Page size (server caps at 100).
#[builder(into)]
pub limit: Option<u32>,
/// Keyset cursor.
#[builder(into)]
pub cursor: Option<String>,
/// Comma-separated field selector. Use
/// [`SHAPE_BUDGET_ACCOUNTS_MINIMAL`](crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
/// or roll your own.
#[builder(into)]
pub shape: Option<String>,
/// 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,

// ----- Resource filters -----
/// Federal account symbol filter (exact, e.g. `"097-0100"`).
#[builder(into)]
pub federal_account_symbol: Option<String>,
/// `fiscal_year` filter (exact).
#[builder(into)]
pub fiscal_year: Option<String>,
/// Lower bound for `fiscal_year` (inclusive).
#[builder(into)]
pub fiscal_year_gte: Option<String>,
/// Upper bound for `fiscal_year` (inclusive).
#[builder(into)]
pub fiscal_year_lte: Option<String>,
/// Awarding/funding agency CGAC code filter (exact).
#[builder(into)]
pub agency_code: Option<String>,
/// Bureau of Economic Analysis category filter (exact).
#[builder(into)]
pub bea_category: Option<String>,
/// On/off-budget flag filter (exact).
#[builder(into)]
pub on_off_budget: Option<String>,
/// Free-text search filter.
#[builder(into)]
pub search: Option<String>,
/// Server-side sort spec (prefix `-` for descending).
#[builder(into)]
pub ordering: Option<String>,

/// Escape hatch for filter keys not yet first-classed on this struct
/// (e.g. the `*_gte` / `*_lte` range filters on the numeric metrics).
#[builder(default)]
pub extra: BTreeMap<String, String>,
}

impl ListBudgetAccountsOptions {
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,
"federal_account_symbol",
self.federal_account_symbol.as_deref(),
);
push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
push_opt(&mut q, "fiscal_year__gte", self.fiscal_year_gte.as_deref());
push_opt(&mut q, "fiscal_year__lte", self.fiscal_year_lte.as_deref());
push_opt(&mut q, "agency_code", self.agency_code.as_deref());
push_opt(&mut q, "bea_category", self.bea_category.as_deref());
push_opt(&mut q, "on_off_budget", self.on_off_budget.as_deref());
push_opt(&mut q, "search", self.search.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
}
}

impl Client {
/// `GET /api/budget/accounts/` — one page of budget-account rollups.
pub async fn list_budget_accounts(
&self,
opts: ListBudgetAccountsOptions,
) -> Result<Page<Record>> {
let q = opts.to_query();
let bytes = self.get_bytes("/api/budget/accounts/", &q).await?;
Page::decode(&bytes)
}

/// Stream every budget-account rollup matching `opts`.
pub fn iterate_budget_accounts(&self, opts: ListBudgetAccountsOptions) -> PageStream<Record> {
let opts = Arc::new(opts);
let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
let mut next = (*opts).clone();
next.page = page;
next.cursor = cursor;
Box::pin(async move { client.list_budget_accounts(next).await })
});
PageStream::new(self.clone(), fetch)
}

/// `GET /api/budget/accounts/{id}/` — a single budget-account rollup.
pub async fn get_budget_account(&self, id: &str, opts: Option<ListOptions>) -> Result<Record> {
if id.is_empty() {
return Err(Error::Validation {
message: "get_budget_account: id is required".into(),
response: None,
});
}
let mut q = Vec::new();
opts.unwrap_or_default().apply(&mut q);
let path = format!("/api/budget/accounts/{}/", urlencoding(id));
self.get_json::<Record>(&path, &q).await
}

/// `GET /api/budget/accounts/{id}/quarters/` — quarterly lifecycle detail
/// for a single account-year.
pub async fn get_budget_account_quarters(
&self,
id: &str,
opts: Option<ListOptions>,
) -> Result<Page<Record>> {
if id.is_empty() {
return Err(Error::Validation {
message: "get_budget_account_quarters: id is required".into(),
response: None,
});
}
let mut q = Vec::new();
opts.unwrap_or_default().apply(&mut q);
let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id));
let bytes = self.get_bytes(&path, &q).await?;
Page::decode(&bytes)
}

/// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient
/// contract-flow detail for a single account-year. The response envelope
/// carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside
/// the standard pagination fields, so callers should navigate the returned
/// [`Record`] structure directly.
pub async fn get_budget_account_recipients(
&self,
id: &str,
opts: Option<ListOptions>,
) -> Result<Page<Record>> {
if id.is_empty() {
return Err(Error::Validation {
message: "get_budget_account_recipients: id is required".into(),
response: None,
});
}
let mut q = Vec::new();
opts.unwrap_or_default().apply(&mut q);
let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id));
let bytes = self.get_bytes(&path, &q).await?;
Page::decode(&bytes)
}
}

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

fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
}

#[test]
fn options_emit_all_filters() {
let opts = ListBudgetAccountsOptions::builder()
.federal_account_symbol("097-0100")
.fiscal_year("2024")
.fiscal_year_gte("2020")
.fiscal_year_lte("2025")
.agency_code("9700")
.bea_category("discretionary")
.on_off_budget("on")
.search("operations")
.ordering("-enacted_ba")
.build();
let q = opts.to_query();
assert_eq!(
get_q(&q, "federal_account_symbol").as_deref(),
Some("097-0100")
);
assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
assert_eq!(get_q(&q, "fiscal_year__gte").as_deref(), Some("2020"));
assert_eq!(get_q(&q, "fiscal_year__lte").as_deref(), Some("2025"));
assert_eq!(get_q(&q, "agency_code").as_deref(), Some("9700"));
assert_eq!(get_q(&q, "bea_category").as_deref(), Some("discretionary"));
assert_eq!(get_q(&q, "on_off_budget").as_deref(), Some("on"));
assert_eq!(get_q(&q, "search").as_deref(), Some("operations"));
assert_eq!(get_q(&q, "ordering").as_deref(), Some("-enacted_ba"));
}

#[test]
fn pagination_and_shape_emit() {
let opts = ListBudgetAccountsOptions::builder()
.page(2u32)
.limit(50u32)
.shape(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
assert_eq!(
get_q(&q, "shape").as_deref(),
Some(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
);
}

#[test]
fn extra_forwards_range_filters() {
let mut extra = BTreeMap::new();
extra.insert("enacted_ba__gte".to_string(), "1000000".to_string());
let opts = ListBudgetAccountsOptions::builder().extra(extra).build();
let q = opts.to_query();
assert_eq!(get_q(&q, "enacted_ba__gte").as_deref(), Some("1000000"));
}

#[tokio::test]
async fn get_budget_account_empty_id_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_budget_account("", None)
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("id")),
other => panic!("expected Validation, got {other:?}"),
}
}

#[tokio::test]
async fn get_budget_account_quarters_empty_id_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_budget_account_quarters("", None)
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("id")),
other => panic!("expected Validation, got {other:?}"),
}
}

#[tokio::test]
async fn get_budget_account_recipients_empty_id_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_budget_account_recipients("", None)
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("id")),
other => panic!("expected Validation, got {other:?}"),
}
}
}
Loading
Loading