diff --git a/CHANGELOG.md b/CHANGELOG.md index f0155c7..badcd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index e209d5f..7e901f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,7 +1173,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "makegov-tango" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bon", "futures", @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "makegov-tango-webhooks" -version = "0.1.0" +version = "0.2.0" dependencies = [ "hex", "hmac", diff --git a/Cargo.toml b/Cargo.toml index 397d50b..8419be0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index b3b0eac..6862e3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index f1029f5..15b0135 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -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; diff --git a/crates/tango/src/resources/budget.rs b/crates/tango/src/resources/budget.rs new file mode 100644 index 0000000..9c48318 --- /dev/null +++ b/crates/tango/src/resources/budget.rs @@ -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, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use + /// [`SHAPE_BUDGET_ACCOUNTS_MINIMAL`](crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL) + /// or roll your own. + #[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, + + // ----- Resource filters ----- + /// Federal account symbol filter (exact, e.g. `"097-0100"`). + #[builder(into)] + pub federal_account_symbol: Option, + /// `fiscal_year` filter (exact). + #[builder(into)] + pub fiscal_year: Option, + /// Lower bound for `fiscal_year` (inclusive). + #[builder(into)] + pub fiscal_year_gte: Option, + /// Upper bound for `fiscal_year` (inclusive). + #[builder(into)] + pub fiscal_year_lte: Option, + /// Awarding/funding agency CGAC code filter (exact). + #[builder(into)] + pub agency_code: Option, + /// Bureau of Economic Analysis category filter (exact). + #[builder(into)] + pub bea_category: Option, + /// On/off-budget flag filter (exact). + #[builder(into)] + pub on_off_budget: Option, + /// Free-text search filter. + #[builder(into)] + pub search: Option, + /// Server-side sort spec (prefix `-` for descending). + #[builder(into)] + pub ordering: Option, + + /// 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, +} + +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> { + 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 { + 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_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) -> Result { + 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::(&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, + ) -> Result> { + 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, + ) -> Result> { + 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 { + 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:?}"), + } + } +} diff --git a/crates/tango/src/resources/contracts.rs b/crates/tango/src/resources/contracts.rs index 05224eb..c07e761 100644 --- a/crates/tango/src/resources/contracts.rs +++ b/crates/tango/src/resources/contracts.rs @@ -1,9 +1,11 @@ //! `GET /api/contracts/` — list and stream federal contract records. use crate::client::Client; -use crate::error::Result; -use crate::internal::{apply_pagination, first_non_empty, push_opt}; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, first_non_empty, push_opt, ListOptions}; use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; +use crate::resources::entity_subresources::EntitySubresourceOptions; use crate::Record; use bon::Builder; use std::collections::BTreeMap; @@ -257,6 +259,58 @@ impl Client { Page::decode(&bytes) } + /// `GET /api/contracts/{key}/` — a single federal contract record. + pub async fn get_contract(&self, key: &str, opts: Option) -> Result { + if key.is_empty() { + return Err(Error::Validation { + message: "get_contract: key is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/contracts/{}/", urlencoding(key)); + self.get_json::(&path, &q).await + } + + /// `GET /api/contracts/{key}/subawards/` — subawards reported against a + /// single prime contract. + pub async fn list_contract_subawards( + &self, + key: &str, + opts: Option, + ) -> Result> { + if key.is_empty() { + return Err(Error::Validation { + message: "list_contract_subawards: key is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/contracts/{}/subawards/", urlencoding(key)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/contracts/{key}/transactions/` — raw transaction history + /// backing a single contract. + pub async fn list_contract_transactions( + &self, + key: &str, + opts: Option, + ) -> Result> { + if key.is_empty() { + return Err(Error::Validation { + message: "list_contract_transactions: key is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/contracts/{}/transactions/", urlencoding(key)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) + } + /// Stream every federal contract record matching `opts`. The stream follows /// `?cursor=` (or `?page=` fallback) on the server's `next` URL. /// @@ -348,4 +402,40 @@ mod tests { Some(crate::SHAPE_CONTRACTS_MINIMAL) ); } + + #[tokio::test] + async fn get_contract_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_contract("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn list_contract_subawards_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .list_contract_subawards("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn list_contract_transactions_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .list_contract_transactions("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } } diff --git a/crates/tango/src/resources/entities.rs b/crates/tango/src/resources/entities.rs index af182c0..8b69159 100644 --- a/crates/tango/src/resources/entities.rs +++ b/crates/tango/src/resources/entities.rs @@ -43,6 +43,10 @@ pub struct ListEntitiesOptions { /// CAGE code filter. #[builder(into)] pub cage_code: Option, + /// CAGE filter. Distinct API filter from [`cage_code`](Self::cage_code); + /// the server rejects setting both — use one or the other. + #[builder(into)] + pub cage: Option, /// NAICS code filter. #[builder(into)] pub naics: Option, @@ -93,6 +97,7 @@ impl ListEntitiesOptions { ); push_opt(&mut q, "search", self.search.as_deref()); push_opt(&mut q, "cage_code", self.cage_code.as_deref()); + push_opt(&mut q, "cage", self.cage.as_deref()); push_opt(&mut q, "naics", self.naics.as_deref()); push_opt(&mut q, "name", self.name.as_deref()); push_opt(&mut q, "psc", self.psc.as_deref()); @@ -201,6 +206,7 @@ mod tests { let opts = ListEntitiesOptions::builder() .search("Acme") .cage_code("1ABC5") + .cage("1ABC5") .naics("541512") .name("Acme Corp") .psc("D302") @@ -215,6 +221,7 @@ mod tests { let q = opts.to_query(); assert_eq!(get_q(&q, "search").as_deref(), Some("Acme")); assert_eq!(get_q(&q, "cage_code").as_deref(), Some("1ABC5")); + assert_eq!(get_q(&q, "cage").as_deref(), Some("1ABC5")); assert_eq!(get_q(&q, "naics").as_deref(), Some("541512")); assert_eq!(get_q(&q, "name").as_deref(), Some("Acme Corp")); assert_eq!(get_q(&q, "psc").as_deref(), Some("D302")); diff --git a/crates/tango/src/resources/entity_subresources.rs b/crates/tango/src/resources/entity_subresources.rs index eca4c7c..35d3162 100644 --- a/crates/tango/src/resources/entity_subresources.rs +++ b/crates/tango/src/resources/entity_subresources.rs @@ -200,6 +200,17 @@ impl Client { iterate_entity_subresource(self, uei.to_string(), "lcats", opts) } + /// `GET /api/entities/{uei}/budget-flows/` — funding-account budget flows + /// attributed to this entity. Returns a paginated list of funding-account + /// rows. + pub async fn get_entity_budget_flows( + &self, + uei: &str, + opts: Option, + ) -> Result> { + list_entity_subresource(self, uei, "budget-flows", opts.unwrap_or_default()).await + } + /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling /// windowed metrics for this entity. Mirrors the signature of the sibling /// SDKs (Node / Python / Go). @@ -351,6 +362,19 @@ mod tests { } } + #[tokio::test] + async fn get_entity_budget_flows_empty_uei_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_entity_budget_flows("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("uei")), + other => panic!("expected Validation, got {other:?}"), + } + } + #[tokio::test] async fn get_entity_metrics_empty_uei_returns_validation() { let client = Client::builder().api_key("x").build().expect("build"); diff --git a/crates/tango/src/resources/idv_subresources.rs b/crates/tango/src/resources/idv_subresources.rs index 9dae917..4120ce2 100644 --- a/crates/tango/src/resources/idv_subresources.rs +++ b/crates/tango/src/resources/idv_subresources.rs @@ -1,8 +1,8 @@ -//! IDV sub-resources: awards, child IDVs, transactions, summary, LCATs. +//! IDV sub-resources: awards, child IDVs, transactions, LCATs. //! //! Endpoints under `/api/idvs/{key}/…/` that share a common parameter shape. //! The Go SDK uses a mix of `ListIDVsOptions` (awards/child-idvs), -//! `ListOptions` (transactions/summary-awards), and `EntityLcatsOptions` +//! `ListOptions` (transactions), and `EntityLcatsOptions` //! (lcats); the Rust port consolidates these behind a single //! [`IdvSubresourceOptions`] (pagination + shape + ordering + search + joiner) //! since the surface server-side params for these endpoints are identical at @@ -20,7 +20,7 @@ use std::sync::Arc; /// Options shared by every IDV sub-resource list endpoint /// (`/api/idvs/{key}/awards/`, `/child-idvs/`, `/transactions/`, -/// `/summary/awards/`, `/lcats/`). +/// `/lcats/`). /// /// Carries pagination + shape + ordering + search + joiner. Use the `extra` /// field to forward filters not yet first-classed on this struct. @@ -139,37 +139,6 @@ impl Client { iterate_idv_subresource(self, key.to_string(), "transactions", opts) } - /// `GET /api/idvs/{identifier}/summary/` — summary roll-up for an IDV. - /// - /// Deprecated: the v1.0.0 server returns 404 for this endpoint. Retained - /// for parity with the other SDKs; migrate to [`Client::get_idv`] with the - /// comprehensive shape. - #[deprecated(note = "Deprecated upstream; use get_idv with the comprehensive shape")] - pub async fn get_idv_summary(&self, key: &str) -> Result { - if key.is_empty() { - return Err(Error::Validation { - message: "get_idv_summary: key is required".into(), - response: None, - }); - } - let path = format!("/api/idvs/{}/summary/", urlencoding(key)); - self.get_json::(&path, &[]).await - } - - /// `GET /api/idvs/{identifier}/summary/awards/` — awards belonging to an - /// IDV summary. - /// - /// Deprecated: the v1.0.0 server returns 404 for this endpoint. Retained - /// for parity with the other SDKs; migrate to [`Client::list_idv_awards`]. - #[deprecated(note = "Deprecated upstream; use list_idv_awards")] - pub async fn list_idv_summary_awards( - &self, - key: &str, - opts: IdvSubresourceOptions, - ) -> Result> { - list_idv_subresource(self, key, "summary/awards", opts).await - } - /// `GET /api/idvs/{key}/lcats/` — Labor Categories (LCATs) under an IDV. pub async fn list_idv_lcats( &self, @@ -292,15 +261,4 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } - - #[tokio::test] - #[allow(deprecated)] - async fn get_idv_summary_empty_key_returns_validation() { - let client = Client::builder().api_key("x").build().expect("build"); - let err = client.get_idv_summary("").await.expect_err("must error"); - match err { - Error::Validation { message, .. } => assert!(message.contains("key")), - other => panic!("expected Validation, got {other:?}"), - } - } } diff --git a/crates/tango/src/resources/mod.rs b/crates/tango/src/resources/mod.rs index d8f073f..6754f99 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -2,6 +2,7 @@ //! methods on [`Client`](crate::Client) and exports its `*Options` builders. pub(crate) mod agencies; +pub(crate) mod budget; pub(crate) mod contracts; pub(crate) mod entities; pub(crate) mod entity_subresources; @@ -26,6 +27,7 @@ pub use agencies::{ AgencyContractsOptions, GetAgencyOptions, ListAgenciesOptions, ListAgencyAwardingContractsOptions, ListAgencyFundingContractsOptions, }; +pub use budget::ListBudgetAccountsOptions; pub use contracts::ListContractsOptions; pub use entities::{GetEntityOptions, ListEntitiesOptions}; pub use entity_subresources::EntitySubresourceOptions; diff --git a/crates/tango/src/resources/opportunities.rs b/crates/tango/src/resources/opportunities.rs index c8eeb2c..d931164 100644 --- a/crates/tango/src/resources/opportunities.rs +++ b/crates/tango/src/resources/opportunities.rs @@ -3,8 +3,9 @@ use crate::client::Client; use crate::error::{Error, Result}; -use crate::internal::{apply_pagination, push_opt, push_opt_bool, push_opt_u32}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool, push_opt_u32, ListOptions}; use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; use crate::Record; use bon::Builder; use std::collections::BTreeMap; @@ -445,6 +446,9 @@ pub struct ListGrantsOptions { /// CFDA number filter. #[builder(into)] pub cfda_number: Option, + /// Grant identifier filter (exact match on `grant_id`). + #[builder(into)] + pub grant_id: Option, /// Funding-categories filter (CSV). #[builder(into)] pub funding_categories: Option, @@ -496,6 +500,7 @@ impl ListGrantsOptions { push_opt(&mut q, "agency", self.agency.as_deref()); push_opt(&mut q, "applicant_types", self.applicant_types.as_deref()); push_opt(&mut q, "cfda_number", self.cfda_number.as_deref()); + push_opt(&mut q, "grant_id", self.grant_id.as_deref()); push_opt( &mut q, "funding_categories", @@ -661,6 +666,66 @@ impl Client { PageStream::new(self.clone(), fetch) } + /// `GET /api/opportunities/{opportunity_id}/` — a single opportunity. + pub async fn get_opportunity( + &self, + opportunity_id: &str, + opts: Option, + ) -> Result { + if opportunity_id.is_empty() { + return Err(Error::Validation { + message: "get_opportunity: opportunity_id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/opportunities/{}/", urlencoding(opportunity_id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/notices/{notice_id}/` — a single notice. + pub async fn get_notice(&self, notice_id: &str, opts: Option) -> Result { + if notice_id.is_empty() { + return Err(Error::Validation { + message: "get_notice: notice_id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/notices/{}/", urlencoding(notice_id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/forecasts/{id}/` — a single procurement forecast. + pub async fn get_forecast(&self, id: &str, opts: Option) -> Result { + if id.is_empty() { + return Err(Error::Validation { + message: "get_forecast: id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/forecasts/{}/", urlencoding(id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/grants/{grant_id}/` — a single grant opportunity. + pub async fn get_grant(&self, grant_id: &str, opts: Option) -> Result { + if grant_id.is_empty() { + return Err(Error::Validation { + message: "get_grant: grant_id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/grants/{}/", urlencoding(grant_id)); + self.get_json::(&path, &q).await + } + /// `GET /api/opportunities/attachment-search/` — semantic search over /// the extracted text of opportunity attachments (SOWs, PWSs, J&As). /// @@ -790,6 +855,7 @@ mod tests { .agency("9700") .applicant_types("11") .cfda_number("10.001") + .grant_id("GRANT-123") .funding_categories("AR") .funding_instruments("G") .opportunity_number("OPP-001") @@ -802,12 +868,56 @@ mod tests { let q = opts.to_query(); assert_eq!(get_q(&q, "applicant_types").as_deref(), Some("11")); assert_eq!(get_q(&q, "cfda_number").as_deref(), Some("10.001")); + assert_eq!(get_q(&q, "grant_id").as_deref(), Some("GRANT-123")); assert_eq!(get_q(&q, "funding_categories").as_deref(), Some("AR")); assert_eq!(get_q(&q, "funding_instruments").as_deref(), Some("G")); assert_eq!(get_q(&q, "opportunity_number").as_deref(), Some("OPP-001")); assert_eq!(get_q(&q, "status").as_deref(), Some("posted")); } + #[tokio::test] + async fn get_grant_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_grant("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("grant_id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_opportunity_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_opportunity("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("opportunity_id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_forecast_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_forecast("", 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_notice_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_notice("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("notice_id")), + other => panic!("expected Validation, got {other:?}"), + } + } + #[test] fn attachment_search_emits_all_flags() { let opts = SearchOpportunityAttachmentsOptions::builder() diff --git a/crates/tango/src/resources/subawards.rs b/crates/tango/src/resources/subawards.rs index ae56cec..e8bbcb0 100644 --- a/crates/tango/src/resources/subawards.rs +++ b/crates/tango/src/resources/subawards.rs @@ -1,9 +1,10 @@ //! `GET /api/subawards/` — list and stream subaward records. use crate::client::Client; -use crate::error::Result; -use crate::internal::{apply_pagination, push_opt}; +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; @@ -112,6 +113,20 @@ impl Client { Page::decode(&bytes) } + /// `GET /api/subawards/{key}/` — a single subaward record. + pub async fn get_subaward(&self, key: &str, opts: Option) -> Result { + if key.is_empty() { + return Err(Error::Validation { + message: "get_subaward: key is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/subawards/{}/", urlencoding(key)); + self.get_json::(&path, &q).await + } + /// Stream every subaward matching `opts`. pub fn iterate_subawards(&self, opts: ListSubawardsOptions) -> PageStream { let opts = Arc::new(opts); @@ -187,4 +202,14 @@ mod tests { let q = opts.to_query(); assert_eq!(get_q(&q, "x").as_deref(), Some("y")); } + + #[tokio::test] + async fn get_subaward_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_subaward("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } } diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index a86414b..ff1f537 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -14,6 +14,13 @@ pub const DEFAULT_BASE_URL: &str = "https://tango.makegov.com"; pub const SHAPE_CONTRACTS_MINIMAL: &str = "key,piid,award_date,recipient(display_name),description,total_contract_value"; +/// Default shape for +/// [`Client::list_budget_accounts`](crate::Client::list_budget_accounts). +pub const SHAPE_BUDGET_ACCOUNTS_MINIMAL: &str = concat!( + "federal_account_symbol,fiscal_year,agency_name,enacted_ba,", + "obligated_total,contract_obligated,contract_share_of_obligated_capped" +); + /// Default shape for [`Client::list_entities`](crate::Client::list_entities). pub const SHAPE_ENTITIES_MINIMAL: &str = "uei,legal_business_name,cage_code,business_types"; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 1db1958..700adcf 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -31,8 +31,11 @@ Options: `ListAgenciesOptions`, `GetAgencyOptions`, `AgencyContractsOptions` (al | Method | Endpoint | Returns | | ------ | -------- | ------- | | `list_contracts(opts)` / `iterate_contracts(opts)` | `GET /api/contracts/` | `Page` / `PageStream` | +| `get_contract(key, opts)` | `GET /api/contracts/{key}/` | `Record` | +| `list_contract_subawards(key, opts)` | `GET /api/contracts/{key}/subawards/` | `Page` | +| `list_contract_transactions(key, opts)` | `GET /api/contracts/{key}/transactions/` | `Page` | -Options: `ListContractsOptions`. SDK-friendly filter aliases (`naics_code`, `psc_code`, `recipient_name`, `recipient_uei`, `set_aside_type`, `keyword`) map onto canonical API names. When both are set, the SDK alias wins (mirrors Node/Python). `sort`+`order` combine into `ordering` with `-` prefix for descending. +Options: `ListContractsOptions` (list), `ListOptions` (`get_contract`), `EntitySubresourceOptions` (sub-routes). SDK-friendly filter aliases (`naics_code`, `psc_code`, `recipient_name`, `recipient_uei`, `set_aside_type`, `keyword`) map onto canonical API names. When both are set, the SDK alias wins (mirrors Node/Python). `sort`+`order` combine into `ordering` with `-` prefix for descending. ### IDVs (`idvs.rs`, `idv_subresources.rs`) @@ -44,8 +47,6 @@ Options: `ListContractsOptions`. SDK-friendly filter aliases (`naics_code`, `psc | `list_idv_child_idvs(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/child-idvs/` | `Page` / `PageStream` | | `list_idv_transactions(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/transactions/` | `Page` / `PageStream` | | `list_idv_lcats(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/lcats/` | `Page` / `PageStream` | -| `get_idv_summary(key)` *(deprecated)* | `GET /api/idvs/{key}/summary/` | `Record` | -| `list_idv_summary_awards(key, opts)` *(deprecated)* | `GET /api/idvs/{key}/summary/awards/` | `Page` | Options: `ListIDVsOptions`, `GetIDVOptions`, `IdvSubresourceOptions` (shared across the sub-resource list endpoints). @@ -61,9 +62,10 @@ Options: `ListIDVsOptions`, `GetIDVOptions`, `IdvSubresourceOptions` (shared acr | `list_entity_otidvs(uei, opts)` / `iterate_*` | `GET /api/entities/{uei}/otidvs/` | `Page` / `PageStream` | | `list_entity_subawards(uei, opts)` / `iterate_*` | `GET /api/entities/{uei}/subawards/` | `Page` / `PageStream` | | `list_entity_lcats(uei, opts)` / `iterate_*` | `GET /api/entities/{uei}/lcats/` | `Page` / `PageStream` | +| `get_entity_budget_flows(uei, opts)` | `GET /api/entities/{uei}/budget-flows/` | `Page` | | `get_entity_metrics(uei, months, period_grouping)` | `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` | `Record` | -Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` (shared across sub-resource list endpoints). +Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` (shared across sub-resource list endpoints). `ListEntitiesOptions` exposes both `cage` and `cage_code` as distinct typed filters; the server rejects setting both. ### Vehicles (`vehicles.rs`, `vehicle_subresources.rs`) @@ -81,12 +83,16 @@ Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions | Method | Endpoint | Returns | | ------ | -------- | ------- | | `list_opportunities(opts)` / `iterate_*` | `GET /api/opportunities/` | `Page` / `PageStream` | +| `get_opportunity(opportunity_id, opts)` | `GET /api/opportunities/{opportunity_id}/` | `Record` | | `list_notices(opts)` / `iterate_*` | `GET /api/notices/` | `Page` / `PageStream` | +| `get_notice(notice_id, opts)` | `GET /api/notices/{notice_id}/` | `Record` | | `list_forecasts(opts)` / `iterate_*` | `GET /api/forecasts/` | `Page` / `PageStream` | +| `get_forecast(id, opts)` | `GET /api/forecasts/{id}/` | `Record` | | `list_grants(opts)` / `iterate_*` | `GET /api/grants/` | `Page` / `PageStream` | +| `get_grant(grant_id, opts)` | `GET /api/grants/{grant_id}/` | `Record` | | `search_opportunity_attachments(opts)` | `GET /api/opportunities/attachment-search/` | `Page` | -Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The attachment-search method validates `q` non-empty client-side. +Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The singleton `get_*` methods take `Option`. The attachment-search method validates `q` non-empty client-side. `ListGrantsOptions` exposes a typed `grant_id` filter. ### OTAs / OTIDVs (`otas.rs`) @@ -105,8 +111,20 @@ Options: `ListOTAsOptions`, `GetOTAOptions`, `ListOTIDVsOptions`, `GetOTIDVOptio | Method | Endpoint | Returns | | ------ | -------- | ------- | | `list_subawards(opts)` / `iterate_subawards(opts)` | `GET /api/subawards/` | `Page` / `PageStream` | +| `get_subaward(key, opts)` | `GET /api/subawards/{key}/` | `Record` | -Options: `ListSubawardsOptions`. **Note**: the server rejects `id` and `amount` in subaward shapes; use `SHAPE_SUBAWARDS_MINIMAL` or a custom shape that avoids them. +Options: `ListSubawardsOptions` (list), `ListOptions` (`get_subaward`). **Note**: the server rejects `id` and `amount` in subaward shapes; use `SHAPE_SUBAWARDS_MINIMAL` or a custom shape that avoids them. + +### Budget (`budget.rs`) + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_budget_accounts(opts)` / `iterate_budget_accounts(opts)` | `GET /api/budget/accounts/` | `Page` / `PageStream` | +| `get_budget_account(id, opts)` | `GET /api/budget/accounts/{id}/` | `Record` | +| `get_budget_account_quarters(id, opts)` | `GET /api/budget/accounts/{id}/quarters/` | `Page` | +| `get_budget_account_recipients(id, opts)` | `GET /api/budget/accounts/{id}/recipients/` | `Page` | + +Options: `ListBudgetAccountsOptions` (list), `ListOptions` (`get_*`). The `BudgetAccount` schema is wide (~63 fields) and shape-driven; use `SHAPE_BUDGET_ACCOUNTS_MINIMAL` for a compact default. The full `__gte` / `__lte` numeric-range filters are reachable via the `extra` map. The `recipients` envelope carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside the pagination fields. ### GSA eLibrary (`gsa.rs`)