From e3595f2338f39c0f3d52561d6cb181648c7728b3 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 13:54:33 -0500 Subject: [PATCH 1/9] feat(pagination): count_type and meta on every list page Page gains count_type, read from each response's own X-Results-CountType header, and meta, the response's meta object passed through (agency-filter diagnostics including matched_by, upgrade hints). Page is now #[non_exhaustive]. Co-Authored-By: Claude Opus 5.5 --- README.md | 28 ++++++++ crates/tango/src/client.rs | 15 ++-- crates/tango/src/pagination.rs | 70 +++++++++++++++++++ crates/tango/src/resources/agencies.rs | 6 +- crates/tango/src/resources/budget.rs | 9 +-- .../tango/src/resources/contract_appeals.rs | 3 +- crates/tango/src/resources/contracts.rs | 9 +-- crates/tango/src/resources/dibbs.rs | 9 +-- crates/tango/src/resources/ebuy.rs | 3 +- crates/tango/src/resources/entities.rs | 3 +- .../src/resources/entity_subresources.rs | 6 +- crates/tango/src/resources/exclusions.rs | 3 +- crates/tango/src/resources/gsa.rs | 3 +- .../tango/src/resources/idv_subresources.rs | 3 +- crates/tango/src/resources/idvs.rs | 3 +- crates/tango/src/resources/itdashboard.rs | 3 +- crates/tango/src/resources/lookups.rs | 24 +++---- crates/tango/src/resources/opportunities.rs | 12 ++-- crates/tango/src/resources/otas.rs | 9 +-- crates/tango/src/resources/protests.rs | 3 +- crates/tango/src/resources/sbir.rs | 6 +- crates/tango/src/resources/sled.rs | 9 +-- crates/tango/src/resources/subawards.rs | 3 +- .../src/resources/vehicle_subresources.rs | 6 +- crates/tango/src/resources/vehicles.rs | 3 +- crates/tango/src/resources/webhooks_api.rs | 6 +- crates/tango/src/transport.rs | 16 ++++- crates/tango/tests/routes.rs | 45 ++++++++++++ docs/ARCHITECTURE.md | 3 + 29 files changed, 219 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 2d42d34..d5eb886 100644 --- a/README.md +++ b/README.md @@ -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"][]` 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`: ```rust,no_run diff --git a/crates/tango/src/client.rs b/crates/tango/src/client.rs index c16caff..60410db 100644 --- a/crates/tango/src/client.rs +++ b/crates/tango/src/client.rs @@ -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( &self, path: &str, query: &[(String, String)], - ) -> Result> { + ) -> Result> { 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 diff --git a/crates/tango/src/pagination.rs b/crates/tango/src/pagination.rs index 71dafac..02171a5 100644 --- a/crates/tango/src/pagination.rs +++ b/crates/tango/src/pagination.rs @@ -32,7 +32,10 @@ use std::task::{Context, Poll}; /// `next`/`previous` are the server-supplied URLs to the adjacent pages; /// `cursor` is the keyset cursor extracted from `next` for convenience (empty /// on offset-paginated endpoints). +/// +/// The struct is `#[non_exhaustive]`: the SDK builds pages, callers read them. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct Page { /// Total number of items across all pages, when the server reports it. /// For some endpoints this matches `results.len()`. @@ -41,6 +44,16 @@ pub struct Page { pub next: Option, /// URL of the previous page, or `None` when on the first page. pub previous: Option, + /// `"exact"` or `"approximate"`, from the response's `X-Results-CountType` header; `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`](Self::count), which can be well off; this says which one you got. + /// It is read from this page's own response, so concurrent calls on one client never see each other's value the way [`Client::last_response_headers`](crate::Client::last_response_headers) can. + pub count_type: Option, + /// The response's `meta` object, passed through as the API sends it: agency-filter diagnostics (`resolved_filters`, `agency_warnings`, `unresolved_agency_tokens`, `resolved_agencies`), `upgrade_hints` and the like. + /// + /// Each entry of `meta["resolved_filters"][]` that resolved 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`. + pub meta: Option, /// Opaque page metadata for keyset endpoints. pub page_metadata: Option, /// Cursor extracted from `next` on cursor-paginated endpoints. Pass it @@ -60,6 +73,8 @@ struct RawPage { #[serde(default)] previous: Option, #[serde(default)] + meta: Option, + #[serde(default)] page_metadata: Option, #[serde(default = "Vec::new")] results: Vec, @@ -82,6 +97,8 @@ impl Page { count, next: raw.next.filter(|s| !s.is_empty()), previous: raw.previous.filter(|s| !s.is_empty()), + count_type: None, + meta: raw.meta.filter(|m| !m.is_null()), page_metadata: raw.page_metadata, cursor, results: raw.results, @@ -91,8 +108,29 @@ impl Page { } Ok(out) } + + /// Decode a list response body and read [`count_type`](Self::count_type) from its headers. + pub(crate) fn decode_response( + bytes: &[u8], + headers: &reqwest::header::HeaderMap, + ) -> Result + where + T: for<'de> Deserialize<'de>, + { + let mut page = Self::decode(bytes)?; + page.count_type = headers + .get(COUNT_TYPE_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_ascii_lowercase); + Ok(page) + } } +/// Response header that says whether a list's `count` is exact or a query-planner estimate. +const COUNT_TYPE_HEADER: &str = "x-results-counttype"; + fn extract_cursor(url: &str) -> Option { let parsed = reqwest::Url::parse(url).ok()?; parsed @@ -313,6 +351,38 @@ mod tests { assert_eq!(page.count, 3); } + #[test] + fn decode_passes_meta_through() { + let body = br#"{ + "count": 1, + "results": [{"a": 1}], + "meta": {"resolved_filters": {"agency": [{"token": "dod", "matched_by": "alias"}]}} + }"#; + let page: Page = Page::decode(body).expect("decode"); + let meta = page.meta.expect("meta"); + assert_eq!( + meta["resolved_filters"]["agency"][0]["matched_by"], + serde_json::json!("alias") + ); + assert!(page.count_type.is_none()); + } + + #[test] + fn decode_response_reads_count_type_header() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("X-Results-CountType", "Approximate".parse().unwrap()); + let page: Page = + Page::decode_response(br#"{"count": 10000, "results": []}"#, &headers).expect("decode"); + assert_eq!(page.count_type.as_deref(), Some("approximate")); + let page: Page = Page::decode_response( + br#"{"count": 1, "results": []}"#, + &reqwest::header::HeaderMap::new(), + ) + .expect("decode"); + assert!(page.count_type.is_none()); + assert!(page.meta.is_none()); + } + #[test] fn extract_page_parses_query_param() { assert_eq!( diff --git a/crates/tango/src/resources/agencies.rs b/crates/tango/src/resources/agencies.rs index cdb8152..1fa85b4 100644 --- a/crates/tango/src/resources/agencies.rs +++ b/crates/tango/src/resources/agencies.rs @@ -133,8 +133,7 @@ impl Client { /// `GET /api/agencies/` — list federal agencies. pub async fn list_agencies(&self, opts: ListAgenciesOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/agencies/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/agencies/", &q).await } /// `GET /api/agencies/{code}/` — fetch a single agency. @@ -211,8 +210,7 @@ async fn list_agency_contracts( } let q = opts.to_query(); let path = format!("/api/agencies/{}/contracts/{which}/", urlencoding(code)); - let bytes = client.get_bytes(&path, &q).await?; - Page::decode(&bytes) + client.get_page(&path, &q).await } fn iterate_agency_contracts( diff --git a/crates/tango/src/resources/budget.rs b/crates/tango/src/resources/budget.rs index 0fd103b..9e08a9a 100644 --- a/crates/tango/src/resources/budget.rs +++ b/crates/tango/src/resources/budget.rs @@ -239,8 +239,7 @@ impl Client { opts: ListBudgetAccountsOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/budget/accounts/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/budget/accounts/", &q).await } /// Stream every budget-account rollup matching `opts`. @@ -286,8 +285,7 @@ impl Client { } let q = opts.unwrap_or_default().to_query(); let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id)); - let bytes = self.get_bytes(&path, &q).await?; - Page::decode(&bytes) + self.get_page(&path, &q).await } /// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient contract flows for a single account-year, largest `contract_obligated` first. @@ -307,8 +305,7 @@ impl Client { } let q = opts.unwrap_or_default().to_query(); let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id)); - let bytes = self.get_bytes(&path, &q).await?; - Page::decode(&bytes) + self.get_page(&path, &q).await } } diff --git a/crates/tango/src/resources/contract_appeals.rs b/crates/tango/src/resources/contract_appeals.rs index 62d4610..105331e 100644 --- a/crates/tango/src/resources/contract_appeals.rs +++ b/crates/tango/src/resources/contract_appeals.rs @@ -163,8 +163,7 @@ impl Client { opts: ListContractAppealsOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/contract_appeals/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/contract_appeals/", &q).await } /// `GET /api/contract_appeals/{uuid}/` — fetch a single decision by UUID. diff --git a/crates/tango/src/resources/contracts.rs b/crates/tango/src/resources/contracts.rs index 507bf1c..f6a4313 100644 --- a/crates/tango/src/resources/contracts.rs +++ b/crates/tango/src/resources/contracts.rs @@ -260,8 +260,7 @@ impl Client { /// `GET /api/contracts/` — one page of federal contract records. pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/contracts/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/contracts/", &q).await } /// `GET /api/contracts/{key}/` — a single federal contract record. @@ -293,8 +292,7 @@ impl Client { } 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) + self.get_page(&path, &q).await } /// `GET /api/contracts/{key}/transactions/` — raw transaction history @@ -312,8 +310,7 @@ impl Client { } 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) + self.get_page(&path, &q).await } /// Stream every federal contract record matching `opts`. The stream follows diff --git a/crates/tango/src/resources/dibbs.rs b/crates/tango/src/resources/dibbs.rs index 766738d..9693197 100644 --- a/crates/tango/src/resources/dibbs.rs +++ b/crates/tango/src/resources/dibbs.rs @@ -428,8 +428,7 @@ impl Client { /// `GET /api/dibbs/rfqs/` — one page of DIBBS request-for-quote line records. pub async fn list_dibbs_rfqs(&self, opts: ListDibbsRfqsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/dibbs/rfqs/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/dibbs/rfqs/", &q).await } /// `GET /api/dibbs/rfqs/{uuid}/` — a single DIBBS request-for-quote line. @@ -460,8 +459,7 @@ impl Client { /// `GET /api/dibbs/rfps/` — one page of DIBBS request-for-proposal records. pub async fn list_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/dibbs/rfps/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/dibbs/rfps/", &q).await } /// `GET /api/dibbs/rfps/{uuid}/` — a single DIBBS request-for-proposal. @@ -492,8 +490,7 @@ impl Client { /// `GET /api/dibbs/awards/` — one page of DIBBS award line item records. pub async fn list_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/dibbs/awards/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/dibbs/awards/", &q).await } /// `GET /api/dibbs/awards/{uuid}/` — a single DIBBS award line item. diff --git a/crates/tango/src/resources/ebuy.rs b/crates/tango/src/resources/ebuy.rs index 50502b8..efdf276 100644 --- a/crates/tango/src/resources/ebuy.rs +++ b/crates/tango/src/resources/ebuy.rs @@ -179,8 +179,7 @@ impl Client { /// 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) + self.get_page("/api/ebuy/requests/", &q).await } /// `GET /api/ebuy/requests/{rfq_id}/` — one eBuy request. diff --git a/crates/tango/src/resources/entities.rs b/crates/tango/src/resources/entities.rs index b86a173..487ff9f 100644 --- a/crates/tango/src/resources/entities.rs +++ b/crates/tango/src/resources/entities.rs @@ -163,8 +163,7 @@ impl Client { /// `GET /api/entities/` — one page of entity records. pub async fn list_entities(&self, opts: ListEntitiesOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/entities/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/entities/", &q).await } /// `GET /api/entities/{uei}/` — fetch a single entity by UEI or CAGE code. diff --git a/crates/tango/src/resources/entity_subresources.rs b/crates/tango/src/resources/entity_subresources.rs index 08f84c3..b398610 100644 --- a/crates/tango/src/resources/entity_subresources.rs +++ b/crates/tango/src/resources/entity_subresources.rs @@ -241,8 +241,7 @@ impl Client { } let q = opts.unwrap_or_default().to_query(); let path = format!("/api/entities/{}/budget-flows/", urlencoding(uei)); - let bytes = self.get_bytes(&path, &q).await?; - Page::decode(&bytes) + self.get_page(&path, &q).await } /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling @@ -299,8 +298,7 @@ async fn list_entity_subresource( } let q = opts.to_query(); let path = format!("/api/entities/{}/{segment}/", urlencoding(uei)); - let bytes = client.get_bytes(&path, &q).await?; - Page::decode(&bytes) + client.get_page(&path, &q).await } fn iterate_entity_subresource( diff --git a/crates/tango/src/resources/exclusions.rs b/crates/tango/src/resources/exclusions.rs index f06ce64..74edf84 100644 --- a/crates/tango/src/resources/exclusions.rs +++ b/crates/tango/src/resources/exclusions.rs @@ -210,8 +210,7 @@ impl Client { /// `GET /api/exclusions/` — one page of exclusion records. pub async fn list_exclusions(&self, opts: ListExclusionsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/exclusions/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/exclusions/", &q).await } /// `GET /api/exclusions/{exclusion_key}/` — a single exclusion. diff --git a/crates/tango/src/resources/gsa.rs b/crates/tango/src/resources/gsa.rs index 6ee7d13..4f50604 100644 --- a/crates/tango/src/resources/gsa.rs +++ b/crates/tango/src/resources/gsa.rs @@ -131,8 +131,7 @@ impl Client { opts: ListGsaElibraryContractsOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/gsa_elibrary_contracts/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/gsa_elibrary_contracts/", &q).await } /// `GET /api/gsa_elibrary_contracts/{uuid}/` — fetch a single GSA eLibrary diff --git a/crates/tango/src/resources/idv_subresources.rs b/crates/tango/src/resources/idv_subresources.rs index 4120ce2..41332a0 100644 --- a/crates/tango/src/resources/idv_subresources.rs +++ b/crates/tango/src/resources/idv_subresources.rs @@ -168,8 +168,7 @@ async fn list_idv_subresource( } let q = opts.to_query(); let path = format!("/api/idvs/{}/{segment}/", urlencoding(key)); - let bytes = client.get_bytes(&path, &q).await?; - Page::decode(&bytes) + client.get_page(&path, &q).await } fn iterate_idv_subresource( diff --git a/crates/tango/src/resources/idvs.rs b/crates/tango/src/resources/idvs.rs index fea3f6a..35de685 100644 --- a/crates/tango/src/resources/idvs.rs +++ b/crates/tango/src/resources/idvs.rs @@ -224,8 +224,7 @@ impl Client { /// `GET /api/idvs/` — one page of Indefinite Delivery Vehicle records. pub async fn list_idvs(&self, opts: ListIDVsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/idvs/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/idvs/", &q).await } /// `GET /api/idvs/{key}/` — fetch a single IDV by key. diff --git a/crates/tango/src/resources/itdashboard.rs b/crates/tango/src/resources/itdashboard.rs index 01b1578..875ddf9 100644 --- a/crates/tango/src/resources/itdashboard.rs +++ b/crates/tango/src/resources/itdashboard.rs @@ -163,8 +163,7 @@ impl Client { /// `GET /api/itdashboard/` — one page of federal IT investments. pub async fn list_itdashboard(&self, opts: ListItdashboardOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/itdashboard/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/itdashboard/", &q).await } /// `GET /api/itdashboard/{uii}/` — fetch a single investment diff --git a/crates/tango/src/resources/lookups.rs b/crates/tango/src/resources/lookups.rs index cc1b30e..62a7dc9 100644 --- a/crates/tango/src/resources/lookups.rs +++ b/crates/tango/src/resources/lookups.rs @@ -552,8 +552,7 @@ impl Client { /// `GET /api/organizations/` — one page of organization records. pub async fn list_organizations(&self, opts: ListOrganizationsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/organizations/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/organizations/", &q).await } /// Stream every organization record matching `opts`. @@ -585,8 +584,7 @@ impl Client { /// `GET /api/naics/` — one page of NAICS records. pub async fn list_naics(&self, opts: ListNaicsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/naics/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/naics/", &q).await } /// Stream every NAICS record matching `opts`. @@ -618,8 +616,7 @@ impl Client { /// `GET /api/psc/` — one page of PSC (Product/Service Code) records. pub async fn list_psc(&self, opts: ListPscOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/psc/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/psc/", &q).await } /// Stream every PSC record matching `opts`. @@ -651,8 +648,7 @@ impl Client { /// `GET /api/mas_sins/` — one page of GSA MAS Special Item Number records. pub async fn list_mas_sins(&self, opts: ListMasSinsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/mas_sins/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/mas_sins/", &q).await } /// Stream every MAS SIN record matching `opts`. @@ -687,8 +683,7 @@ impl Client { opts: ListAssistanceListingsOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/assistance_listings/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/assistance_listings/", &q).await } /// Stream every Assistance Listing record matching `opts`. @@ -728,8 +723,7 @@ impl Client { opts: ListBusinessTypesOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/business_types/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/business_types/", &q).await } /// Stream every business-type record matching `opts`. @@ -762,8 +756,7 @@ impl Client { /// `GET /api/offices/` — one page of federal contracting office records. pub async fn list_offices(&self, opts: ListOfficesOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/offices/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/offices/", &q).await } /// Stream every office record matching `opts`. @@ -800,8 +793,7 @@ impl Client { /// compatibility. pub async fn list_departments(&self, opts: ListDepartmentsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/departments/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/departments/", &q).await } /// Stream every department record matching `opts`. diff --git a/crates/tango/src/resources/opportunities.rs b/crates/tango/src/resources/opportunities.rs index 670e91e..03a1d0d 100644 --- a/crates/tango/src/resources/opportunities.rs +++ b/crates/tango/src/resources/opportunities.rs @@ -581,8 +581,7 @@ impl Client { /// `GET /api/opportunities/` — one page of opportunity records. pub async fn list_opportunities(&self, opts: ListOpportunitiesOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/opportunities/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/opportunities/", &q).await } /// Stream every opportunity matching `opts`. @@ -600,8 +599,7 @@ impl Client { /// `GET /api/notices/` — one page of notice records. pub async fn list_notices(&self, opts: ListNoticesOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/notices/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/notices/", &q).await } /// Stream every notice matching `opts`. @@ -619,8 +617,7 @@ impl Client { /// `GET /api/forecasts/` — one page of forecast records. pub async fn list_forecasts(&self, opts: ListForecastsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/forecasts/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/forecasts/", &q).await } /// Stream every forecast matching `opts`. @@ -638,8 +635,7 @@ impl Client { /// `GET /api/grants/` — one page of grant records. pub async fn list_grants(&self, opts: ListGrantsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/grants/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/grants/", &q).await } /// Stream every grant matching `opts`. diff --git a/crates/tango/src/resources/otas.rs b/crates/tango/src/resources/otas.rs index 0234bfc..eddf1c1 100644 --- a/crates/tango/src/resources/otas.rs +++ b/crates/tango/src/resources/otas.rs @@ -564,8 +564,7 @@ impl Client { /// `GET /api/otas/` — one page of Other Transaction Authority award actions. pub async fn list_otas(&self, opts: ListOTAsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/otas/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/otas/", &q).await } /// `GET /api/otas/{key}/` — fetch a single OTA by key. @@ -596,8 +595,7 @@ impl Client { /// `GET /api/otidvs/` — one page of Other Transaction IDV parent records. pub async fn list_otidvs(&self, opts: ListOTIDVsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/otidvs/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/otidvs/", &q).await } /// `GET /api/otidvs/{key}/` — fetch a single OTIDV by key. @@ -639,8 +637,7 @@ impl Client { } let q = opts.to_query(); let path = format!("/api/otidvs/{}/awards/", urlencoding(key)); - let bytes = self.get_bytes(&path, &q).await?; - Page::decode(&bytes) + self.get_page(&path, &q).await } /// Stream every child award under the given OTIDV parent. diff --git a/crates/tango/src/resources/protests.rs b/crates/tango/src/resources/protests.rs index e50b2f7..a7244dd 100644 --- a/crates/tango/src/resources/protests.rs +++ b/crates/tango/src/resources/protests.rs @@ -174,8 +174,7 @@ impl Client { /// `GET /api/protests/` — one page of bid-protest records. pub async fn list_protests(&self, opts: ListProtestsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/protests/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/protests/", &q).await } /// `GET /api/protests/{case_id}/` — fetch a single protest by case ID. diff --git a/crates/tango/src/resources/sbir.rs b/crates/tango/src/resources/sbir.rs index 40bfa05..91c9023 100644 --- a/crates/tango/src/resources/sbir.rs +++ b/crates/tango/src/resources/sbir.rs @@ -280,8 +280,7 @@ impl Client { /// `GET /api/sbir/topics/` — one page of SBIR/STTR topic records. pub async fn list_sbir_topics(&self, opts: ListSbirTopicsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/sbir/topics/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/sbir/topics/", &q).await } /// `GET /api/sbir/topics/{topic_id}/` — a single SBIR/STTR topic. @@ -319,8 +318,7 @@ impl Client { opts: ListSbirSolicitationsOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/sbir/solicitations/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/sbir/solicitations/", &q).await } /// `GET /api/sbir/solicitations/{solicitation_id}/` — a single DoD DSIP solicitation cycle. diff --git a/crates/tango/src/resources/sled.rs b/crates/tango/src/resources/sled.rs index c49aade..009c7a8 100644 --- a/crates/tango/src/resources/sled.rs +++ b/crates/tango/src/resources/sled.rs @@ -517,8 +517,7 @@ impl Client { opts: ListSledOpportunitiesOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/sled/opportunities/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/sled/opportunities/", &q).await } /// `GET /api/sled/opportunities/{opportunity_id}/` — one solicitation, @@ -582,8 +581,7 @@ impl Client { "/api/sled/opportunities/{}/revisions/", urlencoding(opportunity_id) ); - let bytes = self.get_bytes(&path, &q).await?; - Page::decode(&bytes) + self.get_page(&path, &q).await } /// `GET /api/sled/opportunities/coverage/` — the per-state coverage rollup. @@ -607,8 +605,7 @@ impl Client { opts: ListSledForecastsOptions, ) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/sled/forecasts/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/sled/forecasts/", &q).await } /// `GET /api/sled/forecasts/{forecast_id}/` — one forecast. diff --git a/crates/tango/src/resources/subawards.rs b/crates/tango/src/resources/subawards.rs index e8bbcb0..2a67aae 100644 --- a/crates/tango/src/resources/subawards.rs +++ b/crates/tango/src/resources/subawards.rs @@ -109,8 +109,7 @@ impl Client { /// `GET /api/subawards/` — one page of subaward records. pub async fn list_subawards(&self, opts: ListSubawardsOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/subawards/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/subawards/", &q).await } /// `GET /api/subawards/{key}/` — a single subaward record. diff --git a/crates/tango/src/resources/vehicle_subresources.rs b/crates/tango/src/resources/vehicle_subresources.rs index 7e62c92..1309145 100644 --- a/crates/tango/src/resources/vehicle_subresources.rs +++ b/crates/tango/src/resources/vehicle_subresources.rs @@ -167,8 +167,7 @@ async fn list_vehicle_awardees_inner( } let q = opts.to_query(); let path = format!("/api/vehicles/{}/awardees/", urlencoding(uuid)); - let bytes = client.get_bytes(&path, &q).await?; - Page::decode(&bytes) + client.get_page(&path, &q).await } async fn list_vehicle_orders_inner( @@ -184,8 +183,7 @@ async fn list_vehicle_orders_inner( } let q = opts.to_query(); let path = format!("/api/vehicles/{}/orders/", urlencoding(uuid)); - let bytes = client.get_bytes(&path, &q).await?; - Page::decode(&bytes) + client.get_page(&path, &q).await } #[cfg(test)] diff --git a/crates/tango/src/resources/vehicles.rs b/crates/tango/src/resources/vehicles.rs index 956b97d..bb98543 100644 --- a/crates/tango/src/resources/vehicles.rs +++ b/crates/tango/src/resources/vehicles.rs @@ -218,8 +218,7 @@ impl Client { /// `GET /api/vehicles/` — one page of contracting-vehicle records. pub async fn list_vehicles(&self, opts: ListVehiclesOptions) -> Result> { let q = opts.to_query(); - let bytes = self.get_bytes("/api/vehicles/", &q).await?; - Page::decode(&bytes) + self.get_page("/api/vehicles/", &q).await } /// `GET /api/vehicles/{uuid}/` — fetch a single contracting vehicle. diff --git a/crates/tango/src/resources/webhooks_api.rs b/crates/tango/src/resources/webhooks_api.rs index ad33fb2..cb03bdd 100644 --- a/crates/tango/src/resources/webhooks_api.rs +++ b/crates/tango/src/resources/webhooks_api.rs @@ -39,8 +39,7 @@ impl Client { pub async fn list_webhook_endpoints(&self, opts: ListOptions) -> Result> { let mut q = Vec::new(); opts.apply(&mut q); - let bytes = self.get_bytes("/api/webhooks/endpoints/", &q).await?; - Page::::decode(&bytes) + self.get_page("/api/webhooks/endpoints/", &q).await } /// `GET /api/webhooks/endpoints/{id}/` — fetch a single endpoint. @@ -156,8 +155,7 @@ impl Client { pub async fn list_webhook_alerts(&self, opts: ListOptions) -> Result> { let mut q = Vec::new(); opts.apply(&mut q); - let bytes = self.get_bytes("/api/webhooks/alerts/", &q).await?; - Page::::decode(&bytes) + self.get_page("/api/webhooks/alerts/", &q).await } /// `GET /api/webhooks/alerts/{id}/` — fetch a single alert. diff --git a/crates/tango/src/transport.rs b/crates/tango/src/transport.rs index 1e134b1..fbc2bb8 100644 --- a/crates/tango/src/transport.rs +++ b/crates/tango/src/transport.rs @@ -131,6 +131,18 @@ pub(crate) async fn send_with_retries( url: reqwest::Url, body: Body<'_>, ) -> Result> { + send_with_retries_and_headers(inner, method, url, body) + .await + .map(|(bytes, _)| bytes) +} + +/// Like [`send_with_retries`], but also return the successful response's headers. +pub(crate) async fn send_with_retries_and_headers( + inner: &crate::client::ClientInner, + method: Method, + url: reqwest::Url, + body: Body<'_>, +) -> Result<(Vec, HeaderMap)> { // Pre-serialize the JSON body once; we re-use the bytes across attempts. let body_bytes = match body { Body::None => None, @@ -251,11 +263,11 @@ async fn attempt_once( method: Method, url: reqwest::Url, body_bytes: Option<&[u8]>, -) -> Result> { +) -> Result<(Vec, HeaderMap)> { 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); + return Ok((bytes, headers)); } Err(decode_error(status, &headers, &bytes)) } diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index 40aab61..4e981db 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -633,3 +633,48 @@ async fn ebuy_attachment_url_maps_uncaptured_document_to_not_found() { .unwrap_err(); assert!(matches!(err, Error::NotFound { .. }), "{err:?}"); } + +#[tokio::test] +async fn list_pages_carry_their_own_count_type_and_meta() { + let server = MockServer::start_async().await; + let contracts = server + .mock_async(|when, then| { + when.method(GET).path("/api/contracts/"); + then.status(200) + .header("X-Results-CountType", "approximate") + .json_body(json!({ + "count": 125_000, + "next": null, + "previous": null, + "results": [{"key": "K1"}], + "meta": {"resolved_filters": {"awarding_agency": [ + {"token": "DOD", "matched_by": "alias"}, + {"token": "nope"} + ]}} + })); + }) + .await; + let naics = server + .mock_async(|when, then| { + when.method(GET).path("/api/naics/"); + then.status(200) + .header("X-Results-CountType", "exact") + .json_body(page(json!([{"code": "541512"}]))); + }) + .await; + let c = make_client(&server); + let (p_contracts, p_naics) = tokio::join!( + c.list_contracts(tango::ListContractsOptions::builder().build()), + c.list_naics(tango::ListNaicsOptions::builder().build()), + ); + let p_contracts = p_contracts.expect("contracts"); + let p_naics = p_naics.expect("naics"); + contracts.assert_async().await; + naics.assert_async().await; + assert_eq!(p_contracts.count_type.as_deref(), Some("approximate")); + assert_eq!(p_naics.count_type.as_deref(), Some("exact")); + let resolved = &p_contracts.meta.as_ref().expect("meta")["resolved_filters"]["awarding_agency"]; + assert_eq!(resolved[0]["matched_by"], json!("alias")); + assert!(resolved[1].get("matched_by").is_none()); + assert!(p_naics.meta.is_none()); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d2a3df0..18350f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -150,10 +150,13 @@ Validation-message extraction (`extract_validation_message`) walks the parsed bo `Page` is the decoded envelope returned by `list_*` methods: ```rust +#[non_exhaustive] pub struct Page { pub count: u64, pub next: Option, pub previous: Option, + pub count_type: Option, // from this response's X-Results-CountType header + pub meta: Option, // the response's `meta`, passed through pub page_metadata: Option, pub cursor: Option, // extracted from `next` for keyset endpoints pub results: Vec, From 2dfe14b5ef1bbb3c738f0e6f97738757d6a567f0 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 13:57:28 -0500 Subject: [PATCH 2/9] feat(federal-register): Federal Register documents list_federal_register_documents / iterate_federal_register_documents and get_federal_register_document (by uuid), with ListFederalRegisterDocumentsOptions naming all seventeen of the API's filters plus ordering, GetFederalRegisterDocumentOptions, and SHAPE_FEDERAL_REGISTER_MINIMAL / _COMPREHENSIVE (Tango API 5.3.0). Co-Authored-By: Claude Opus 5.5 --- crates/tango/src/lib.rs | 11 +- .../tango/src/resources/federal_register.rs | 307 ++++++++++++++++++ crates/tango/src/resources/mod.rs | 4 + crates/tango/src/shapes.rs | 21 ++ crates/tango/tests/routes.rs | 63 ++++ 5 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 crates/tango/src/resources/federal_register.rs diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index bae2fda..4c9b2a3 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -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, diff --git a/crates/tango/src/resources/federal_register.rs b/crates/tango/src/resources/federal_register.rs new file mode 100644 index 0000000..696a7e9 --- /dev/null +++ b/crates/tango/src/resources/federal_register.rs @@ -0,0 +1,307 @@ +//! `GET /api/federal_register/` — Federal Register documents: rules, proposed rules, notices and presidential documents published since 1994 (Tango API 5.3.0). +//! +//! The detail route takes a document's `uuid`, not its `document_number`: the Federal Register reused some document numbers before 2016, so a number is not unique on its own. +//! Look a document up by number with [`ListFederalRegisterDocumentsOptions::document_number`]. +//! +//! `full_text` can run to several MB. It is served on [`Client::get_federal_register_document`] only when named in `shape`, and neither default shape includes it. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool}; +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_federal_register_documents`] and [`Client::iterate_federal_register_documents`]. +/// +/// All seventeen of the API's filters are named fields, plus `ordering`. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListFederalRegisterDocumentsOptions { + /// 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_FEDERAL_REGISTER_MINIMAL`](crate::SHAPE_FEDERAL_REGISTER_MINIMAL) or roll your own. `full_text` is not available on the list. + #[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, + + /// Ranked full-text search over the title, abstract and action. Wrap in double quotes for a phrase. + #[builder(into)] + pub search: Option, + /// Exact FR document number, e.g. `2016-31922`. OR several with `|`. A number the Federal Register reused before 2016 matches every document that carries it. + #[builder(into)] + pub document_number: Option, + /// Document type, sent as `type`: `Notice`, `Rule`, `Proposed Rule`, `Presidential Document`, `Correction`, `Sunshine Act Document` or `Uncategorized Document` (case-insensitive). OR several with `|`. Documents published before 2008 are mostly `Uncategorized Document`. + #[builder(into)] + pub document_type: Option, + /// A Tango agency name, abbreviation, code or organization key, e.g. `EPA`. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies. OR several with `|`. + #[builder(into)] + pub agency: Option, + /// The Federal Register's own agency slug, e.g. `environmental-protection-agency`. OR several with `|`. + #[builder(into)] + pub fr_agency: Option, + /// Published on or after (`YYYY-MM-DD`). + #[builder(into)] + pub publication_date_after: Option, + /// Published on or before (`YYYY-MM-DD`). + #[builder(into)] + pub publication_date_before: Option, + /// Effective on or after (`YYYY-MM-DD`). + #[builder(into)] + pub effective_on_after: Option, + /// Effective on or before (`YYYY-MM-DD`). + #[builder(into)] + pub effective_on_before: Option, + /// Comment period closes on or after (`YYYY-MM-DD`). + #[builder(into)] + pub comments_close_on_after: Option, + /// Comment period closes on or before (`YYYY-MM-DD`). + #[builder(into)] + pub comments_close_on_before: Option, + /// `Some(true)` for documents whose comment period closes today or later, `Some(false)` for those already closed. Documents with no comment deadline match neither. + pub comments_open: Option, + /// A CFR title number, e.g. `40`. + #[builder(into)] + pub cfr_title: Option, + /// A CFR part number, e.g. `52`. Requires [`cfr_title`](Self::cfr_title), and both must match the same CFR reference. + #[builder(into)] + pub cfr_part: Option, + /// Significant under Executive Order 12866. + pub significant: Option, + /// A Regulation Identifier Number, e.g. `2060-AV16`. OR several with `|`. + #[builder(into)] + pub rin: Option, + /// Exact executive order number. + #[builder(into)] + pub executive_order_number: Option, + + /// Sort key: `publication_date` (the server default, as `-publication_date`), `effective_on`, `comments_close_on`, `document_number` or `rank`, prefixed with `-` for descending. `rank` requires a non-empty [`search`](Self::search). + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListFederalRegisterDocumentsOptions { + 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, "document_number", self.document_number.as_deref()); + push_opt(&mut q, "type", self.document_type.as_deref()); + push_opt(&mut q, "agency", self.agency.as_deref()); + push_opt(&mut q, "fr_agency", self.fr_agency.as_deref()); + for (key, value) in [ + ("publication_date_after", &self.publication_date_after), + ("publication_date_before", &self.publication_date_before), + ("effective_on_after", &self.effective_on_after), + ("effective_on_before", &self.effective_on_before), + ("comments_close_on_after", &self.comments_close_on_after), + ("comments_close_on_before", &self.comments_close_on_before), + ] { + push_opt(&mut q, key, value.as_deref()); + } + push_opt_bool(&mut q, "comments_open", self.comments_open); + push_opt(&mut q, "cfr_title", self.cfr_title.as_deref()); + push_opt(&mut q, "cfr_part", self.cfr_part.as_deref()); + push_opt_bool(&mut q, "significant", self.significant); + push_opt(&mut q, "rin", self.rin.as_deref()); + push_opt( + &mut q, + "executive_order_number", + self.executive_order_number.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_federal_register_document`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetFederalRegisterDocumentOptions { + /// Shape selector. When empty, the server returns its detail default, which [`SHAPE_FEDERAL_REGISTER_COMPREHENSIVE`](crate::SHAPE_FEDERAL_REGISTER_COMPREHENSIVE) mirrors. Name `full_text` to get the document's plain text, which can run to several MB. + #[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 GetFederalRegisterDocumentOptions { + 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/federal_register/` — one page of Federal Register documents, newest publication first by default. + pub async fn list_federal_register_documents( + &self, + opts: ListFederalRegisterDocumentsOptions, + ) -> Result> { + let q = opts.to_query(); + self.get_page("/api/federal_register/", &q).await + } + + /// `GET /api/federal_register/{uuid}/` — one Federal Register document by its `uuid`. + /// + /// The route takes the `uuid`, not the `document_number`, which is not unique on its own; find a document by number with [`list_federal_register_documents`](Self::list_federal_register_documents) and [`ListFederalRegisterDocumentsOptions::document_number`]. + /// `full_text` is returned only when named in `shape`. + pub async fn get_federal_register_document( + &self, + document_uuid: &str, + opts: Option, + ) -> Result { + if document_uuid.is_empty() { + return Err(Error::Validation { + message: "get_federal_register_document: document_uuid is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/federal_register/{}/", urlencoding(document_uuid)); + self.get_json::(&path, &q).await + } + + /// Stream every Federal Register document matching `opts`. + pub fn iterate_federal_register_documents( + &self, + opts: ListFederalRegisterDocumentsOptions, + ) -> 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_federal_register_documents(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_federal_register_documents_all_filters_emit() { + let opts = ListFederalRegisterDocumentsOptions::builder() + .search("\"air quality\"") + .document_number("2016-31922") + .document_type("Rule|Proposed Rule") + .agency("EPA") + .fr_agency("environmental-protection-agency") + .publication_date_after("2024-01-01") + .publication_date_before("2024-12-31") + .effective_on_after("2024-02-01") + .effective_on_before("2024-11-30") + .comments_close_on_after("2024-03-01") + .comments_close_on_before("2024-10-31") + .comments_open(false) + .cfr_title("40") + .cfr_part("52") + .significant(true) + .rin("2060-AV16") + .executive_order_number("14028") + .ordering("rank") + .build(); + let q = opts.to_query(); + for (k, v) in [ + ("search", "\"air quality\""), + ("document_number", "2016-31922"), + ("type", "Rule|Proposed Rule"), + ("agency", "EPA"), + ("fr_agency", "environmental-protection-agency"), + ("publication_date_after", "2024-01-01"), + ("publication_date_before", "2024-12-31"), + ("effective_on_after", "2024-02-01"), + ("effective_on_before", "2024-11-30"), + ("comments_close_on_after", "2024-03-01"), + ("comments_close_on_before", "2024-10-31"), + ("comments_open", "false"), + ("cfr_title", "40"), + ("cfr_part", "52"), + ("significant", "true"), + ("rin", "2060-AV16"), + ("executive_order_number", "14028"), + ("ordering", "rank"), + ] { + assert_eq!(get_q(&q, k).as_deref(), Some(v), "{k}"); + } + assert_eq!(q.len(), 18); + } + + #[test] + fn list_federal_register_documents_zero_value_omitted() { + assert!(ListFederalRegisterDocumentsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn default_shapes_never_name_full_text() { + for shape in [ + crate::SHAPE_FEDERAL_REGISTER_MINIMAL, + crate::SHAPE_FEDERAL_REGISTER_COMPREHENSIVE, + ] { + assert!(!shape.split(',').any(|f| f == "full_text"), "{shape}"); + } + } + + #[tokio::test] + async fn empty_uuid_is_rejected_before_any_request() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client + .get_federal_register_document("", None) + .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 632bf58..eb567ea 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod ebuy; pub(crate) mod entities; pub(crate) mod entity_subresources; pub(crate) mod exclusions; +pub(crate) mod federal_register; pub(crate) mod gsa; pub(crate) mod idv_subresources; pub(crate) mod idvs; @@ -45,6 +46,9 @@ pub use ebuy::{GetEbuyRequestOptions, ListEbuyRequestsOptions}; pub use entities::{GetEntityOptions, ListEntitiesOptions}; pub use entity_subresources::{EntityBudgetFlowsOptions, EntitySubresourceOptions}; pub use exclusions::{GetExclusionOptions, ListExclusionsOptions}; +pub use federal_register::{ + GetFederalRegisterDocumentOptions, ListFederalRegisterDocumentsOptions, +}; pub use gsa::{GetGsaElibraryContractOptions, ListGsaElibraryContractsOptions}; pub use idv_subresources::IdvSubresourceOptions; pub use idvs::{GetIDVOptions, ListIDVsOptions}; diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index b44f681..5161c3a 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -261,6 +261,27 @@ pub const SHAPE_SBIR_SOLICITATIONS_MINIMAL: &str = concat!( "cycle_name,solicitation_status,year,start_date,end_date", ); +/// Suggested list shape for [`Client::list_federal_register_documents`](crate::Client::list_federal_register_documents). +/// +/// Mirrors the API's default list shape. `full_text` is never on the list. +pub const SHAPE_FEDERAL_REGISTER_MINIMAL: &str = concat!( + "uuid,document_number,publication_date,type,subtype,title,abstract,action,", + "agencies,cfr_references,citation,significant,comments_close_on,effective_on,", + "html_url,pdf_url", +); + +/// Suggested detail shape for [`Client::get_federal_register_document`](crate::Client::get_federal_register_document). +/// +/// Mirrors the API's default detail shape. `full_text` is left out on purpose: it can run to several MB, so name it when you want it. +pub const SHAPE_FEDERAL_REGISTER_COMPREHENSIVE: &str = concat!( + "uuid,document_number,publication_date,type,subtype,title,abstract,action,", + "agencies,cfr_references,citation,significant,comments_close_on,effective_on,", + "html_url,pdf_url,dates,signing_date,start_page,end_page,volume,docket_ids,", + "dockets,regulation_id_numbers,topics,correction_of,corrections,", + "executive_order_number,presidential_document_number,proclamation_number,", + "comment_url,regulations_dot_gov_url,raw_text_url,body_html_url", +); + /// 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. diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index 4e981db..aaa6850 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -678,3 +678,66 @@ async fn list_pages_carry_their_own_count_type_and_meta() { assert!(resolved[1].get("matched_by").is_none()); assert!(p_naics.meta.is_none()); } + +#[tokio::test] +async fn federal_register_routes() { + let server = MockServer::start_async().await; + let list = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/federal_register/") + .query_param("document_number", "2016-31922") + .query_param("type", "Rule") + .query_param("comments_open", "false"); + then.status(200).json_body(page(json!([{ + "uuid": "0b6c7f0e-7e0a-4c43-9c8a-1f3f6c1d2e3a", + "document_number": "2016-31922" + }]))); + }) + .await; + let get = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/federal_register/0b6c7f0e-7e0a-4c43-9c8a-1f3f6c1d2e3a/") + .query_param("shape", "uuid,title,full_text"); + then.status(200).json_body(json!({ + "uuid": "0b6c7f0e-7e0a-4c43-9c8a-1f3f6c1d2e3a", + "title": "Air Plan Approval", + "full_text": "ENVIRONMENTAL PROTECTION AGENCY ..." + })); + }) + .await; + let c = make_client(&server); + let p = c + .list_federal_register_documents( + tango::ListFederalRegisterDocumentsOptions::builder() + .document_number("2016-31922") + .document_type("Rule") + .comments_open(false) + .build(), + ) + .await + .expect("list"); + let uuid = p.results[0] + .get("uuid") + .and_then(serde_json::Value::as_str) + .expect("uuid") + .to_string(); + let doc = c + .get_federal_register_document( + &uuid, + Some( + tango::GetFederalRegisterDocumentOptions::builder() + .shape("uuid,title,full_text") + .build(), + ), + ) + .await + .expect("get"); + list.assert_async().await; + get.assert_async().await; + assert_eq!( + doc.get("title").and_then(serde_json::Value::as_str), + Some("Air Plan Approval") + ); +} From 55b2883766db5d4dfc16908c8d86e1ac33404db9 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 13:57:28 -0500 Subject: [PATCH 3/9] feat: agency filter on budget accounts, exclusions and IT Dashboard; cycle_name on SBIR topics agency takes a Tango agency name, abbreviation, code or organization key and matches the whole organization subtree (Tango API 5.3.0). cycle_name filters SBIR topics by DSIP cycle (Tango API 5.3.1). The IT Dashboard tier docs now match the API: agency_name, agency and previous_uii are available at every plan. The SBIR topic agency docs now say the filter is organization-resolved. Co-Authored-By: Claude Opus 5.5 --- crates/tango/src/resources/budget.rs | 13 +++++++++++++ crates/tango/src/resources/exclusions.rs | 13 +++++++++++++ crates/tango/src/resources/itdashboard.rs | 23 +++++++++++++++++------ crates/tango/src/resources/sbir.rs | 18 +++++++++++++++++- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/tango/src/resources/budget.rs b/crates/tango/src/resources/budget.rs index 9e08a9a..5fcd964 100644 --- a/crates/tango/src/resources/budget.rs +++ b/crates/tango/src/resources/budget.rs @@ -74,6 +74,9 @@ pub struct ListBudgetAccountsOptions { pub data_through_period_lte: Option, /// `Some(true)` keeps only rows with no File A data (null `data_through_period`), `Some(false)` only rows with it (sent as `data_through_period__isnull`). pub data_through_period_isnull: Option, + /// A Tango agency name, abbreviation, code or organization key (e.g. `EPA`), matched against the account's resolved organization. Matches by name, abbreviation, or code (CGAC, FPDS, Federal Hierarchy key or AAC) and includes every sub-agency and office beneath it; OR several with `|`. A token that does not resolve falls back to an exact match on `agency_code`. + #[builder(into)] + pub agency: Option, /// Agency code filter (exact). #[builder(into)] pub agency_code: Option, @@ -153,6 +156,7 @@ impl ListBudgetAccountsOptions { "data_through_period__isnull", self.data_through_period_isnull, ); + push_opt(&mut q, "agency", self.agency.as_deref()); push_opt(&mut q, "agency_code", self.agency_code.as_deref()); push_opt(&mut q, "bureau_name", self.bureau_name.as_deref()); push_opt( @@ -484,4 +488,13 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn list_budget_accounts_agency_filter_emits() { + let q = ListBudgetAccountsOptions::builder() + .agency("EPA") + .build() + .to_query(); + assert_eq!(get_q(&q, "agency").as_deref(), Some("EPA")); + } } diff --git a/crates/tango/src/resources/exclusions.rs b/crates/tango/src/resources/exclusions.rs index 74edf84..a31e883 100644 --- a/crates/tango/src/resources/exclusions.rs +++ b/crates/tango/src/resources/exclusions.rs @@ -49,6 +49,9 @@ pub struct ListExclusionsOptions { /// Exclusion program (e.g. `Reciprocal`). #[builder(into)] pub exclusion_program: Option, + /// The excluding agency as a Tango agency name, abbreviation, code or organization key (e.g. `DOD`). Matches by name, abbreviation, or code (CGAC, FPDS, Federal Hierarchy key or AAC) and includes every sub-agency and office beneath it; OR several with `|`. A token that does not resolve falls back to an exact match on `excluding_agency_code`. + #[builder(into)] + pub agency: Option, /// Code of the agency that issued the exclusion. #[builder(into)] pub excluding_agency_code: Option, @@ -122,6 +125,7 @@ impl ListExclusionsOptions { "exclusion_program", self.exclusion_program.as_deref(), ); + push_opt(&mut q, "agency", self.agency.as_deref()); push_opt( &mut q, "excluding_agency_code", @@ -395,4 +399,13 @@ mod tests { assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true")); } + + #[test] + fn list_exclusions_agency_filter_emits() { + let q = ListExclusionsOptions::builder() + .agency("DOD|HHS") + .build() + .to_query(); + assert_eq!(get_q(&q, "agency").as_deref(), Some("DOD|HHS")); + } } diff --git a/crates/tango/src/resources/itdashboard.rs b/crates/tango/src/resources/itdashboard.rs index 875ddf9..f8027d7 100644 --- a/crates/tango/src/resources/itdashboard.rs +++ b/crates/tango/src/resources/itdashboard.rs @@ -14,11 +14,9 @@ use std::sync::Arc; /// Options for [`Client::list_itdashboard`] and [`Client::iterate_itdashboard`]. /// /// Filters are tier-gated by the API: -/// - **Free:** [`search`](Self::search) -/// - **Pro:** [`agency_code`](Self::agency_code), [`type_of_investment`](Self::type_of_investment), -/// [`updated_time_after`](Self::updated_time_after), [`updated_time_before`](Self::updated_time_before) -/// - **Business+:** [`agency_name`](Self::agency_name), [`cio_rating`](Self::cio_rating), -/// [`cio_rating_max`](Self::cio_rating_max), [`performance_risk`](Self::performance_risk) +/// - **Free:** [`search`](Self::search), [`agency_name`](Self::agency_name), [`agency`](Self::agency) and [`previous_uii`](Self::previous_uii) +/// - **Pro:** [`agency_code`](Self::agency_code), [`type_of_investment`](Self::type_of_investment), [`updated_time_after`](Self::updated_time_after), [`updated_time_before`](Self::updated_time_before) +/// - **Business+:** [`cio_rating`](Self::cio_rating), [`cio_rating_max`](Self::cio_rating_max), [`performance_risk`](Self::performance_risk) /// /// Hitting a gated filter on a lower tier returns a 403. CIO ratings: /// 1=High Risk, 2=Moderately High, 3=Medium, 4=Moderately Low, 5=Low. @@ -52,9 +50,12 @@ pub struct ListItdashboardOptions { /// Agency code filter (Pro tier). #[builder(into)] pub agency_code: Option, - /// Agency name filter (Business+ tier). + /// Agency name filter: a partial text match on `agency_name` (Free tier). #[builder(into)] pub agency_name: Option, + /// A Tango agency name, abbreviation, code or organization key (e.g. `DOT`), matched against the investment's resolved organization (Free tier). Matches by name, abbreviation, or code (CGAC, FPDS, Federal Hierarchy key or AAC) and includes every sub-agency and office beneath it; OR several with `|`. A token that does not resolve falls back to a partial match on `agency_name`. + #[builder(into)] + pub agency: Option, /// Investment type filter (Pro tier). #[builder(into)] pub type_of_investment: Option, @@ -103,6 +104,7 @@ impl ListItdashboardOptions { push_opt(&mut q, "previous_uii", self.previous_uii.as_deref()); push_opt(&mut q, "agency_code", self.agency_code.as_deref()); push_opt(&mut q, "agency_name", self.agency_name.as_deref()); + push_opt(&mut q, "agency", self.agency.as_deref()); push_opt( &mut q, "type_of_investment", @@ -298,4 +300,13 @@ mod tests { .to_query(); assert_eq!(get_q(&q, "previous_uii").as_deref(), Some("015-000000100")); } + + #[test] + fn list_itdashboard_agency_filter_emits() { + let q = ListItdashboardOptions::builder() + .agency("DOT") + .build() + .to_query(); + assert_eq!(get_q(&q, "agency").as_deref(), Some("DOT")); + } } diff --git a/crates/tango/src/resources/sbir.rs b/crates/tango/src/resources/sbir.rs index 91c9023..4ca8771 100644 --- a/crates/tango/src/resources/sbir.rs +++ b/crates/tango/src/resources/sbir.rs @@ -38,12 +38,15 @@ pub struct ListSbirTopicsOptions { /// Whether the topic is accepting proposals today: `open`, `closed`, or `unknown` (no close date and no parent solicitation to inherit one from). #[builder(into)] pub activity: Option, - /// Partial, case-insensitive match on the raw agency text (e.g. `DOD`, `NIH`). Not organization-resolved, so variant spellings do not normalize. + /// Agency name, abbreviation or code (e.g. `HHS`), matched against the topic's resolved organization and rolled up the hierarchy, so `HHS` includes NIH topics. OR several with `|`. A token that does not resolve falls back to a partial, case-insensitive match on the raw agency text. #[builder(into)] pub agency: Option, /// Topic number. #[builder(into)] pub topic_number: Option, + /// Exact DoD DSIP cycle name, e.g. `DOD_SBIR_2026_P1_CBZ` (Tango API 5.3.1). Only topics fed from DSIP carry one. + #[builder(into)] + pub cycle_name: Option, /// Parent solicitation number. #[builder(into)] pub solicitation_number: Option, @@ -98,6 +101,7 @@ impl ListSbirTopicsOptions { push_opt(&mut q, "activity", self.activity.as_deref()); push_opt(&mut q, "agency", self.agency.as_deref()); push_opt(&mut q, "topic_number", self.topic_number.as_deref()); + push_opt(&mut q, "cycle_name", self.cycle_name.as_deref()); push_opt( &mut q, "solicitation_number", @@ -588,4 +592,16 @@ mod tests { assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true")); } + + #[test] + fn list_sbir_topics_cycle_name_filter_emits() { + let q = ListSbirTopicsOptions::builder() + .cycle_name("DOD_SBIR_2026_P1_CBZ") + .build() + .to_query(); + assert_eq!( + get_q(&q, "cycle_name").as_deref(), + Some("DOD_SBIR_2026_P1_CBZ") + ); + } } From 5dce2bb3654244654c70a235843b721945478038 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 13:59:05 -0500 Subject: [PATCH 4/9] feat(opportunities): awarded and awardee_uei filters; document award, attachment-count and extraction_status leaves ListOpportunitiesOptions gains awarded (Option) and awardee_uei (Tango API 5.5.0). get_opportunity / get_notice document the award leaves and awards(...) expand, the file and link counts (Tango API 5.9.0) and attachments(extraction_status) (Tango API 5.11.0); get_vehicle documents opportunity(meta(files_count,links_count)). Co-Authored-By: Claude Opus 5.5 --- crates/tango/src/resources/opportunities.rs | 50 +++++++++++++++++++++ crates/tango/src/resources/vehicles.rs | 2 + crates/tango/tests/routes.rs | 45 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/crates/tango/src/resources/opportunities.rs b/crates/tango/src/resources/opportunities.rs index 03a1d0d..5f34cda 100644 --- a/crates/tango/src/resources/opportunities.rs +++ b/crates/tango/src/resources/opportunities.rs @@ -45,6 +45,11 @@ pub struct ListOpportunitiesOptions { /// Tri-state: `Some(true)`/`Some(false)` to filter; `None` to omit. #[builder(into)] pub active: Option, + /// Whether the opportunity has an award, either posted on it or linked to it from a separate award notice (Tango API 5.5.0). Searches every opportunity rather than only active ones, so pair it with [`active`](Self::active) to narrow to open opportunities. `Some(false)` reaches the server as a filter value. + pub awarded: Option, + /// The awardee's UEI, case-insensitive; OR several with `|` (Tango API 5.5.0). Searches every opportunity rather than only active ones. + #[builder(into)] + pub awardee_uei: Option, /// Awarding agency CGAC code. #[builder(into)] pub agency: Option, @@ -113,6 +118,8 @@ impl ListOpportunitiesOptions { self.flat_lists, ); push_opt_bool(&mut q, "active", self.active); + push_opt_bool(&mut q, "awarded", self.awarded); + push_opt(&mut q, "awardee_uei", self.awardee_uei.as_deref()); push_opt(&mut q, "agency", self.agency.as_deref()); push_opt( &mut q, @@ -579,6 +586,8 @@ impl ListGrantsOptions { impl Client { /// `GET /api/opportunities/` — one page of opportunity records. + /// + /// The shape leaves documented on [`get_opportunity`](Self::get_opportunity) (award fields, attachment counts, `attachments(extraction_status)`) are accepted on the list too. pub async fn list_opportunities(&self, opts: ListOpportunitiesOptions) -> Result> { let q = opts.to_query(); self.get_page("/api/opportunities/", &q).await @@ -597,6 +606,8 @@ impl Client { } /// `GET /api/notices/` — one page of notice records. + /// + /// The shape leaves documented on [`get_notice`](Self::get_notice) are accepted on the list too. pub async fn list_notices(&self, opts: ListNoticesOptions) -> Result> { let q = opts.to_query(); self.get_page("/api/notices/", &q).await @@ -651,6 +662,23 @@ impl Client { } /// `GET /api/opportunities/{opportunity_id}/` — a single opportunity. + /// + /// None of the fields below is in the SDK's default shape, so name the ones you want. + /// + /// **Award fields** (Tango API 5.5.0): `awarded`, `award_date`, `award_amount`, `awardee`, `awardee_uei`, `award_count`, `solicitation_opportunity_id` and the `awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)` expand. + /// `award_date`, `award_amount`, `awardee` and `awardee_uei` are filled only where SAM.gov posted an award notice, and `award_amount` is the text SAM.gov published, not a number. + /// When an award is posted as its own opportunity and references its solicitation, the award's `solicitation_opportunity_id` points back to the solicitation, whose `awards(...)` lists up to ten of the most recent linked awards; `award_count` is the full count. + /// `awarded` is true when the opportunity has an award of its own or has linked awards. + /// + /// **Attachment counts** (Tango API 5.9.0): `meta(attachments_count,files_count,links_count)`. + /// A `file` is a document attached to the notice (only files have extracted text) and a `link` is a URL the notice lists; `meta.attachments_count` still counts every attachment, so files plus links need not equal it. + /// `files_count` and `links_count` are null until the record has been counted: treat null as unknown, not zero. + /// + /// **`attachments(extraction_status)`** (Tango API 5.11.0) reports `pending`, `extracting`, `completed`, `failed` or `unsupported` for each attachment, which says why `extracted_text` is missing. It is available on every plan and returned only when named; `attachments(*)` does not carry it. + /// + /// ```text + /// opportunity_id,title,awarded,award_count,awards(notice_id,award_date,awardee_uei),meta(files_count,links_count),attachments(name,extraction_status) + /// ``` pub async fn get_opportunity( &self, opportunity_id: &str, @@ -669,6 +697,12 @@ impl Client { } /// `GET /api/notices/{notice_id}/` — a single notice. + /// + /// None of the fields below is in the SDK's default shape, so name the ones you want. + /// + /// - **Award fields** (Tango API 5.5.0): `award_date`, `award_amount`, `awardee` and `awardee_uei`, filled only where the notice posted an award. + /// - **Attachment counts** (Tango API 5.9.0): `file_count` and `link_count` beside `attachment_count`, which still counts every attachment, links included. The new counts are null until the record has been counted: treat null as unknown, not zero. + /// - **`attachments(extraction_status)`** (Tango API 5.11.0): `pending`, `extracting`, `completed`, `failed` or `unsupported` per attachment, on every plan, returned only when named. pub async fn get_notice(&self, notice_id: &str, opts: Option) -> Result { if notice_id.is_empty() { return Err(Error::Validation { @@ -768,6 +802,22 @@ mod tests { assert_eq!(get_q(&q, "active").as_deref(), Some("false")); } + #[test] + fn opportunities_award_filters_emit() { + let q = ListOpportunitiesOptions::builder() + .awarded(false) + .awardee_uei("ABCDEF123456|ZYXWVU654321") + .build() + .to_query(); + assert_eq!(get_q(&q, "awarded").as_deref(), Some("false")); + assert_eq!( + get_q(&q, "awardee_uei").as_deref(), + Some("ABCDEF123456|ZYXWVU654321") + ); + let q = ListOpportunitiesOptions::default().to_query(); + assert!(!q.iter().any(|(k, _)| k == "awarded" || k == "awardee_uei")); + } + #[test] fn opportunities_active_none_omits() { let opts = ListOpportunitiesOptions::default(); diff --git a/crates/tango/src/resources/vehicles.rs b/crates/tango/src/resources/vehicles.rs index bb98543..a9073f1 100644 --- a/crates/tango/src/resources/vehicles.rs +++ b/crates/tango/src/resources/vehicles.rs @@ -222,6 +222,8 @@ impl Client { } /// `GET /api/vehicles/{uuid}/` — fetch a single contracting vehicle. + /// + /// The embedded solicitation accepts the opportunity attachment counts (Tango API 5.9.0): `opportunity(meta(files_count,links_count))`. Both are null until counted, so treat null as unknown, not zero. pub async fn get_vehicle(&self, uuid: &str, opts: Option) -> Result { if uuid.is_empty() { return Err(Error::Validation { diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index aaa6850..c9565bc 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -741,3 +741,48 @@ async fn federal_register_routes() { Some("Air Plan Approval") ); } + +#[tokio::test] +async fn opportunity_award_fields_and_attachment_status_pass_through() { + let shape = "opportunity_id,awarded,award_count,awards(notice_id,awardee_uei),meta(files_count,links_count),attachments(name,extraction_status)"; + let server = MockServer::start_async().await; + let m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/opportunities/") + .query_param("awarded", "true") + .query_param("awardee_uei", "ABCDEF123456") + .query_param("shape", shape); + then.status(200).json_body(page(json!([{ + "opportunity_id": "o-1", + "awarded": true, + "award_count": 12, + "awards": [{"notice_id": "n-9", "awardee_uei": "ABCDEF123456"}], + "meta": {"files_count": 2, "links_count": null}, + "attachments": [ + {"name": "sow.pdf", "extraction_status": "completed"}, + {"name": "scan.tif", "extraction_status": "unsupported"} + ] + }]))); + }) + .await; + let p = make_client(&server) + .list_opportunities( + tango::ListOpportunitiesOptions::builder() + .awarded(true) + .awardee_uei("ABCDEF123456") + .shape(shape) + .build(), + ) + .await + .expect("list"); + m.assert_async().await; + let row = &p.results[0]; + assert_eq!(row["awards"].as_array().map(Vec::len), Some(1)); + assert_eq!(row["meta"]["files_count"], json!(2)); + assert!(row["meta"]["links_count"].is_null()); + assert_eq!( + row["attachments"][1]["extraction_status"], + json!("unsupported") + ); +} From 9e8de9031220117fe997d968eeda4e836768eba1 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 14:02:00 -0500 Subject: [PATCH 5/9] feat(awards): commercial_item_acquisition_procedures filter and obligations_by_fiscal_year decoding ListContractsOptions and ListIDVsOptions gain commercial_item_acquisition_procedures (Tango API 5.10.0); the API does not offer it on OTAs or OT IDVs. models::FiscalYearObligation decodes the obligations_by_fiscal_year(fiscal_year,obligated) expand (Tango API 5.11.0), which is always null on IDVs and OT IDVs. The uei / recipient_uei docs on the contract, IDV, OTA and OT IDV lists say several UEIs joined by | match any of them (Tango API 5.11.0). Co-Authored-By: Claude Opus 5.5 --- crates/tango/src/models/awards.rs | 90 +++++++++++++++++++++++++ crates/tango/src/models/budget.rs | 2 +- crates/tango/src/models/mod.rs | 2 + crates/tango/src/resources/contracts.rs | 27 +++++++- crates/tango/src/resources/idvs.rs | 22 +++++- crates/tango/src/resources/otas.rs | 4 +- crates/tango/tests/routes.rs | 38 +++++++++++ 7 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 crates/tango/src/models/awards.rs diff --git a/crates/tango/src/models/awards.rs b/crates/tango/src/models/awards.rs new file mode 100644 index 0000000..974a2e4 --- /dev/null +++ b/crates/tango/src/models/awards.rs @@ -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, + + /// 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, + + /// Forward-compat catch-all for server-side fields the SDK does not yet model. + #[serde(flatten, default)] + pub extra: HashMap, +} + +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>> { + 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()); + } +} diff --git a/crates/tango/src/models/budget.rs b/crates/tango/src/models/budget.rs index bb23404..a48ec69 100644 --- a/crates/tango/src/models/budget.rs +++ b/crates/tango/src/models/budget.rs @@ -81,7 +81,7 @@ pub fn budget_data_through_period(record: &Record) -> Option { } /// Decode an amount sent as a JSON number or as a numeric string, since the API passes these values through as the source data carries them. -fn number_or_numeric_string<'de, D>(deserializer: D) -> Result, D::Error> +pub(crate) fn number_or_numeric_string<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { diff --git a/crates/tango/src/models/mod.rs b/crates/tango/src/models/mod.rs index eedf504..4f7e243 100644 --- a/crates/tango/src/models/mod.rs +++ b/crates/tango/src/models/mod.rs @@ -13,6 +13,7 @@ //! through `serde_json::to_value` will re-emit those extras. pub mod agency; +pub mod awards; pub mod budget; pub mod contract_appeal; pub mod ebuy; @@ -22,6 +23,7 @@ pub mod validate; pub mod webhook; pub use agency::AgencyRecord; +pub use awards::FiscalYearObligation; pub use budget::{ budget_data_through_period, BudgetSourceAnomaly, BudgetSourceAnomalyRow, BudgetSourceAnomalySource, diff --git a/crates/tango/src/resources/contracts.rs b/crates/tango/src/resources/contracts.rs index f6a4313..c984a0a 100644 --- a/crates/tango/src/resources/contracts.rs +++ b/crates/tango/src/resources/contracts.rs @@ -112,9 +112,12 @@ pub struct ListContractsOptions { /// Recipient name filter (canonical name). #[builder(into)] pub recipient: Option, - /// Recipient UEI filter (canonical name). + /// Recipient UEI filter (canonical name). Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). #[builder(into)] pub uei: Option, + /// FPDS commercial item acquisition procedures code (Tango API 5.10.0), case-insensitive: `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). OR several with `|`, e.g. `A|E`; an unknown or multi-character code matches nothing. The API does not offer it on OTAs or OT IDVs. + #[builder(into)] + pub commercial_item_acquisition_procedures: Option, /// Set-aside filter (canonical name). #[builder(into)] pub set_aside: Option, @@ -129,7 +132,7 @@ pub struct ListContractsOptions { /// SDK-friendly alias for [`recipient`](Self::recipient). #[builder(into)] pub recipient_name: Option, - /// SDK-friendly alias for [`uei`](Self::uei). + /// SDK-friendly alias for [`uei`](Self::uei). Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). #[builder(into)] pub recipient_uei: Option, /// SDK-friendly alias for [`set_aside`](Self::set_aside). @@ -228,6 +231,11 @@ impl ListContractsOptions { "uei", first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]), ); + push_opt( + &mut q, + "commercial_item_acquisition_procedures", + self.commercial_item_acquisition_procedures.as_deref(), + ); push_opt( &mut q, "set_aside", @@ -258,6 +266,9 @@ impl ListContractsOptions { impl Client { /// `GET /api/contracts/` — one page of federal contract records. + /// + /// Name `obligations_by_fiscal_year(fiscal_year,obligated)` in `shape` for the award's obligations per federal fiscal year (Tango API 5.11.0), and decode it with [`FiscalYearObligation::from_record`](crate::models::FiscalYearObligation::from_record). + /// The same expand works on the IDV, OTA and OT IDV lists and every award `get_*`, but it is always null on IDVs and OT IDVs. pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result> { let q = opts.to_query(); self.get_page("/api/contracts/", &q).await @@ -449,4 +460,16 @@ mod tests { .to_query(); assert_eq!(get_q(&q, "key").as_deref(), Some("K1|K2")); } + + #[test] + fn list_contracts_commercial_item_acquisition_procedures_emits() { + let q = ListContractsOptions::builder() + .commercial_item_acquisition_procedures("A|E") + .build() + .to_query(); + assert_eq!( + get_q(&q, "commercial_item_acquisition_procedures").as_deref(), + Some("A|E") + ); + } } diff --git a/crates/tango/src/resources/idvs.rs b/crates/tango/src/resources/idvs.rs index 35de685..9da657a 100644 --- a/crates/tango/src/resources/idvs.rs +++ b/crates/tango/src/resources/idvs.rs @@ -109,9 +109,12 @@ pub struct ListIDVsOptions { /// Solicitation identifier filter. #[builder(into)] pub solicitation_identifier: Option, - /// Recipient UEI filter. + /// Recipient UEI filter. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). #[builder(into)] pub uei: Option, + /// FPDS commercial item acquisition procedures code (Tango API 5.10.0), case-insensitive: `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). OR several with `|`, e.g. `A|E`; an unknown or multi-character code matches nothing. The API does not offer it on OTAs or OT IDVs. + #[builder(into)] + pub commercial_item_acquisition_procedures: Option, /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`. #[builder(into)] @@ -180,6 +183,11 @@ impl ListIDVsOptions { self.solicitation_identifier.as_deref(), ); push_opt(&mut q, "uei", self.uei.as_deref()); + push_opt( + &mut q, + "commercial_item_acquisition_procedures", + self.commercial_item_acquisition_procedures.as_deref(), + ); for (k, v) in &self.extra { if !v.is_empty() { @@ -358,4 +366,16 @@ mod tests { let q = ListIDVsOptions::builder().key("K1").build().to_query(); assert_eq!(get_q(&q, "key").as_deref(), Some("K1")); } + + #[test] + fn list_idvs_commercial_item_acquisition_procedures_emits() { + let q = ListIDVsOptions::builder() + .commercial_item_acquisition_procedures("A|E") + .build() + .to_query(); + assert_eq!( + get_q(&q, "commercial_item_acquisition_procedures").as_deref(), + Some("A|E") + ); + } } diff --git a/crates/tango/src/resources/otas.rs b/crates/tango/src/resources/otas.rs index eddf1c1..0a43ca5 100644 --- a/crates/tango/src/resources/otas.rs +++ b/crates/tango/src/resources/otas.rs @@ -62,7 +62,7 @@ pub struct ListOTAsOptions { /// Recipient name filter. #[builder(into)] pub recipient: Option, - /// Recipient UEI. + /// Recipient UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). #[builder(into)] pub uei: Option, @@ -254,7 +254,7 @@ pub struct ListOTIDVsOptions { /// Recipient name filter. #[builder(into)] pub recipient: Option, - /// Recipient UEI. + /// Recipient UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). #[builder(into)] pub uei: Option, diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index c9565bc..f5dd8fb 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -786,3 +786,41 @@ async fn opportunity_award_fields_and_attachment_status_pass_through() { json!("unsupported") ); } + +#[tokio::test] +async fn contract_obligations_by_fiscal_year_decode_from_a_list_row() { + let shape = "key,obligations_by_fiscal_year(fiscal_year,obligated)"; + let server = MockServer::start_async().await; + let m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/contracts/") + .query_param("uei", "UEI1|UEI2") + .query_param("commercial_item_acquisition_procedures", "A|E") + .query_param("shape", shape); + then.status(200).json_body(page(json!([{ + "key": "CONT_AWD_1", + "obligations_by_fiscal_year": [ + {"fiscal_year": 2025, "obligated": 1000.0}, + {"fiscal_year": 2026, "obligated": -50.25} + ] + }]))); + }) + .await; + let p = make_client(&server) + .list_contracts( + tango::ListContractsOptions::builder() + .recipient_uei("UEI1|UEI2") + .commercial_item_acquisition_procedures("A|E") + .shape(shape) + .build(), + ) + .await + .expect("list"); + m.assert_async().await; + let years = tango::models::FiscalYearObligation::from_record(&p.results[0]) + .expect("decode") + .expect("present"); + assert_eq!(years[1].fiscal_year, Some(2026)); + assert_eq!(years[1].obligated, Some(-50.25)); +} From eaabab5c5d18c473ec52404cb06ff7c94eb6969b Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 14:06:19 -0500 Subject: [PATCH 6/9] refactor(federal-register): name the type filter r#type, as ListOrganizationsOptions does Co-Authored-By: Claude Opus 5.5 --- crates/tango/src/resources/federal_register.rs | 8 ++++---- crates/tango/tests/routes.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/tango/src/resources/federal_register.rs b/crates/tango/src/resources/federal_register.rs index 696a7e9..3f6dbf2 100644 --- a/crates/tango/src/resources/federal_register.rs +++ b/crates/tango/src/resources/federal_register.rs @@ -46,9 +46,9 @@ pub struct ListFederalRegisterDocumentsOptions { /// Exact FR document number, e.g. `2016-31922`. OR several with `|`. A number the Federal Register reused before 2016 matches every document that carries it. #[builder(into)] pub document_number: Option, - /// Document type, sent as `type`: `Notice`, `Rule`, `Proposed Rule`, `Presidential Document`, `Correction`, `Sunshine Act Document` or `Uncategorized Document` (case-insensitive). OR several with `|`. Documents published before 2008 are mostly `Uncategorized Document`. + /// Document type: `Notice`, `Rule`, `Proposed Rule`, `Presidential Document`, `Correction`, `Sunshine Act Document` or `Uncategorized Document` (case-insensitive). OR several with `|`. Documents published before 2008 are mostly `Uncategorized Document`. #[builder(into)] - pub document_type: Option, + pub r#type: Option, /// A Tango agency name, abbreviation, code or organization key, e.g. `EPA`. Matches a document when any agency it lists falls within that organization, so a department includes its sub-agencies. OR several with `|`. #[builder(into)] pub agency: Option, @@ -113,7 +113,7 @@ impl ListFederalRegisterDocumentsOptions { ); push_opt(&mut q, "search", self.search.as_deref()); push_opt(&mut q, "document_number", self.document_number.as_deref()); - push_opt(&mut q, "type", self.document_type.as_deref()); + push_opt(&mut q, "type", self.r#type.as_deref()); push_opt(&mut q, "agency", self.agency.as_deref()); push_opt(&mut q, "fr_agency", self.fr_agency.as_deref()); for (key, value) in [ @@ -234,7 +234,7 @@ mod tests { let opts = ListFederalRegisterDocumentsOptions::builder() .search("\"air quality\"") .document_number("2016-31922") - .document_type("Rule|Proposed Rule") + .r#type("Rule|Proposed Rule") .agency("EPA") .fr_agency("environmental-protection-agency") .publication_date_after("2024-01-01") diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index f5dd8fb..28b06c1 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -712,7 +712,7 @@ async fn federal_register_routes() { .list_federal_register_documents( tango::ListFederalRegisterDocumentsOptions::builder() .document_number("2016-31922") - .document_type("Rule") + .r#type("Rule") .comments_open(false) .build(), ) From f14f1b72ab9e8e690681d6edb4774a0f595cba9e Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 14:06:20 -0500 Subject: [PATCH 7/9] feat: has_awards on PSC, search on vehicle awardees, ordering on vehicle orders Filters tango-python already exposes and the API accepts: ListPscOptions::has_awards, ListVehicleAwardeesOptions::search and ListVehicleOrdersOptions::ordering (award_date, obligated or total_contract_value). Co-Authored-By: Claude Opus 5.5 --- crates/tango/src/resources/lookups.rs | 13 ++++++++ .../src/resources/vehicle_subresources.rs | 30 +++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/tango/src/resources/lookups.rs b/crates/tango/src/resources/lookups.rs index 62a7dc9..d6dd121 100644 --- a/crates/tango/src/resources/lookups.rs +++ b/crates/tango/src/resources/lookups.rs @@ -220,6 +220,8 @@ pub struct ListPscOptions { /// Free-text search filter. #[builder(into)] pub search: Option, + /// `Some(true)` returns only codes with contract award history. `Some(false)` is the same as unset: the server applies no filter. + pub has_awards: Option, /// Escape hatch for filter keys not first-classed here. #[builder(default)] @@ -239,6 +241,7 @@ impl ListPscOptions { self.flat_lists, ); push_opt(&mut q, "search", self.search.as_deref()); + push_opt_bool(&mut q, "has_awards", self.has_awards); for (k, v) in &self.extra { if !v.is_empty() { q.push((k.clone(), v.clone())); @@ -832,6 +835,16 @@ mod tests { q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) } + #[test] + fn psc_has_awards_emits() { + let q = ListPscOptions::builder() + .has_awards(true) + .build() + .to_query(); + assert_eq!(get_q(&q, "has_awards").as_deref(), Some("true")); + assert!(ListPscOptions::builder().build().to_query().is_empty()); + } + // ----- Organizations ----- #[test] diff --git a/crates/tango/src/resources/vehicle_subresources.rs b/crates/tango/src/resources/vehicle_subresources.rs index 1309145..48574e7 100644 --- a/crates/tango/src/resources/vehicle_subresources.rs +++ b/crates/tango/src/resources/vehicle_subresources.rs @@ -4,7 +4,7 @@ use crate::client::Client; use crate::error::{Error, Result}; -use crate::internal::apply_pagination; +use crate::internal::{apply_pagination, push_opt}; use crate::pagination::{FetchFn, Page, PageStream}; use crate::resources::agencies::urlencoding; use crate::Record; @@ -12,8 +12,7 @@ use bon::Builder; use std::sync::Arc; /// Options for [`Client::list_vehicle_awardees`] and -/// [`Client::iterate_vehicle_awardees`]. The endpoint only accepts the -/// shared pagination + shaping fields. +/// [`Client::iterate_vehicle_awardees`]. #[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] #[non_exhaustive] pub struct ListVehicleAwardeesOptions { @@ -36,6 +35,9 @@ pub struct ListVehicleAwardeesOptions { /// When [`flat`](Self::flat) is also true, flatten list-valued fields. #[builder(default)] pub flat_lists: bool, + /// Entity-aware full-text search across the awardees' IDV fields. + #[builder(into)] + pub search: Option, } impl ListVehicleAwardeesOptions { @@ -50,13 +52,13 @@ impl ListVehicleAwardeesOptions { self.flat, self.flat_lists, ); + push_opt(&mut q, "search", self.search.as_deref()); q } } /// Options for [`Client::list_vehicle_orders`] and -/// [`Client::iterate_vehicle_orders`]. The endpoint only accepts the -/// shared pagination + shaping fields. +/// [`Client::iterate_vehicle_orders`]. #[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] #[non_exhaustive] pub struct ListVehicleOrdersOptions { @@ -78,6 +80,9 @@ pub struct ListVehicleOrdersOptions { /// When [`flat`](Self::flat) is also true, flatten list-valued fields. #[builder(default)] pub flat_lists: bool, + /// Server-side sort: `award_date` (the default, as `-award_date`), `obligated` or `total_contract_value`, prefixed with `-` for descending. + #[builder(into)] + pub ordering: Option, } impl ListVehicleOrdersOptions { @@ -92,6 +97,7 @@ impl ListVehicleOrdersOptions { self.flat, self.flat_lists, ); + push_opt(&mut q, "ordering", self.ordering.as_deref()); q } } @@ -203,6 +209,20 @@ mod tests { assert!(q.contains(&("shape".into(), "vehicle_awardees(minimal)".into()))); } + #[test] + fn awardees_search_and_orders_ordering_emit() { + let q = ListVehicleAwardeesOptions::builder() + .search("cyber") + .build() + .to_query(); + assert!(q.contains(&("search".into(), "cyber".into()))); + let q = ListVehicleOrdersOptions::builder() + .ordering("-obligated") + .build() + .to_query(); + assert!(q.contains(&("ordering".into(), "-obligated".into()))); + } + #[test] fn orders_opts_emit_flat() { let opts = ListVehicleOrdersOptions::builder() From bd49c51d253745fa800c886858274f2f4dde5b8e Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 14:10:01 -0500 Subject: [PATCH 8/9] docs: document the Tango API 5.11.0 catch-up and add the changelog entries API reference sections for Federal Register, the new filters and shape leaves, count_type and meta on Page, the corrected IT Dashboard tiers and SBIR agency semantics; the federal_register and ebuy_request alert query types in WEBHOOKS.md; the two new shape constants in SHAPES.md; README pagination notes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 26 ++++++++++++++++++++++++ README.md | 1 + docs/API_REFERENCE.md | 47 +++++++++++++++++++++++++++++++++++++++---- docs/ARCHITECTURE.md | 3 +++ docs/SHAPES.md | 4 +++- docs/WEBHOOKS.md | 1 + 6 files changed, 77 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c13f49..28a29f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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`. @@ -24,16 +30,36 @@ 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"][]` 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. + ## [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 d5eb886..7cc12d5 100644 --- a/README.md +++ b/README.md @@ -290,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` | diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a8b0090..4b010fe 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -12,6 +12,7 @@ The canonical, always-up-to-date reference is the auto-generated rustdoc at **` carries `count_type` (`"exact"` or `"approximate"`, from that response's `X-Results-CountType` header; `None` when the endpoint does not send it) and `meta` (the response's `meta` object as sent). Past 1,000 matches the contract, IDV, opportunity and notice lists may report a query-planner estimate as `count`. On an agency-filtered list, each resolved entry of `meta["resolved_filters"][]` says how its token matched in `matched_by` (`key`, `code`, `name`, `alias` or `fuzzy`; Tango API 5.9.0+), and an unresolved entry has none. ## Resources @@ -37,6 +38,10 @@ Options: `ListAgenciesOptions`, `GetAgencyOptions`, `AgencyContractsOptions` (al Options: `ListContractsOptions` (list), `ListOptions` (`get_contract`), `EntitySubresourceOptions` (sub-routes; `/transactions/` honours only pagination and `ordering`). 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. +- **`uei` / `recipient_uei` accept several UEIs joined by `|`** and match awards to any of them, up to 500 (Tango API 5.11.0). The same holds for `uei` on the IDV, OTA and OT IDV lists. +- **`commercial_item_acquisition_procedures`** (Tango API 5.10.0) filters by the FPDS code, case-insensitive: `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). OR several with `|`, e.g. `A|E`; an unknown or multi-character code matches nothing. It is on `ListContractsOptions` and `ListIDVsOptions` only: the API does not offer it on OTAs or OT IDVs. +- **`obligations_by_fiscal_year(fiscal_year,obligated)`** (Tango API 5.11.0) is a shape expand on contracts, IDVs, OTAs and OT IDVs, list and detail. It sums the award's transactions per federal fiscal year (October 1 starts the next one), oldest 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, and it is always null on IDVs and OT IDVs, whose transactions report a running lifetime total. Decode it with `models::FiscalYearObligation::from_record(&row)`, which returns `Ok(None)` when the field is null or not in the shape. + ### IDVs (`idvs.rs`, `idv_subresources.rs`) | Method | Endpoint | Returns | @@ -76,7 +81,10 @@ Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` ( | `list_vehicle_awardees(uuid, opts)` / `iterate_*` | `GET /api/vehicles/{uuid}/awardees/` | `Page` / `PageStream` | | `list_vehicle_orders(uuid, opts)` / `iterate_*` | `GET /api/vehicles/{uuid}/orders/` | `Page` / `PageStream` | -Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions`, `ListVehicleOrdersOptions`. +Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions` (adds `search`), `ListVehicleOrdersOptions` (adds `ordering` over `award_date`, the default, `obligated` and `total_contract_value`). + +- The default vehicle shapes 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). `awardee_count` is a deprecated alias of `order_winner_count`. +- The embedded solicitation accepts the opportunity attachment counts: `opportunity(meta(files_count,links_count))`. ### Opportunities / Notices / Forecasts / Grants (`opportunities.rs`) @@ -93,6 +101,14 @@ Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`. The singleton `get_*` methods take `Option`. `ListGrantsOptions` exposes a typed `grant_id` filter; `ListNoticesOptions` adds `notice_id`, `department` and `office`; `ListOpportunitiesOptions` adds `opportunity_id`; `ListForecastsOptions` adds `id`. +`ListOpportunitiesOptions` also takes `awarded` (`Option`) and `awardee_uei` (case-insensitive, `|` for several), Tango API 5.5.0. Both search every opportunity rather than only active ones, so set `active` too to narrow to open opportunities. + +**Award fields** (Tango API 5.5.0) are not in the default shape, so name them: `awarded`, `award_date`, `award_amount`, `awardee`, `awardee_uei`, `award_count`, `solicitation_opportunity_id` and `awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)`. `award_date`, `award_amount`, `awardee` and `awardee_uei` are filled only where SAM.gov posted an award notice, and `award_amount` is the text SAM.gov published, not a number. When an award posted as its own opportunity references its solicitation, its `solicitation_opportunity_id` points back, and the solicitation's `awards(...)` lists up to ten of the most recent linked awards (`award_count` is the full count). Notices accept `award_date`, `award_amount`, `awardee` and `awardee_uei`. + +**Attachment counts** (Tango API 5.9.0): an attachment's `type` is `file` (a document attached to the notice; only files have extracted text) or `link` (a URL the notice lists). Opportunities carry `meta(attachments_count,files_count,links_count)` and notices `attachment_count`, `file_count` and `link_count`. The total still counts every attachment, so files plus links need not equal it, and the file and link counts are null until the record has been counted: **treat null as unknown, not zero**. No default shape names them. + +**`attachments(extraction_status)`** (Tango API 5.11.0) reports `pending`, `extracting`, `completed`, `failed` or `unsupported` for each attachment on an opportunity or notice, which says why `extracted_text` is missing. It is available on every plan and returned only when named: `attachments(*)` does not carry it. + **There is no attachment-search method.** The API retired `/api/opportunities/attachment-search/` (it returns 404 for every query). `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. ### OTAs / OTIDVs (`otas.rs`) @@ -136,6 +152,7 @@ Options: `ListBudgetAccountsOptions` (list), `ListOptions` (`get_budget_account` - **`{id}` is the row's numeric `id`**, not the federal account symbol. - **`quarters` covers FY2021 onward**; an earlier account-year returns an empty page. - **`recipients` is contract flows only.** Each row carries the resolved `funding_office` and `recipient` plus a capped `contracts` list; a row that hits the cap sets `contracts_truncated`. +- **`agency`** (Tango API 5.3.0) takes a Tango agency name, abbreviation, code or organization key (e.g. `EPA`) and matches the account's resolved organization, including every sub-agency beneath it; `|` for several. A token that does not resolve falls back to an exact `agency_code` match. The `organization(...)` expand (`agency_code`, `agency_name`, `department_code`, `department_name`, `office_code`, `office_name`, `organization_id`) is accepted in shapes, as is `organization_id`; the default shape is unchanged. - The `quarters` and `recipients` envelopes also carry `federal_account_symbol` and `fiscal_year`, which `Page` does not surface. ### GSA eLibrary (`gsa.rs`) @@ -217,6 +234,23 @@ Options: `ListEbuyRequestsOptions` (`search`, `rfq_id`, `reference_number`, `req **The document text is `attachments(extracted_text)`** (API 5.9.1+). It must be **named** — neither the default shape nor `attachments(*)` carries it, and `SHAPE_EBUY_REQUESTS_MINIMAL` does not include it. Ask for it with a shape such as `rfq_id,attachments(doc_seq_num,doc_name,extracted_text)`. Its key is **absent rather than null** on an attachment with no text: a link, an empty scan, or a document not yet extracted. +### Federal Register (`federal_register.rs`) + +Rules, proposed rules, notices and presidential documents published in the Federal Register since 1994 (Tango API 5.3.0). + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_federal_register_documents(opts)` / `iterate_federal_register_documents(opts)` | `GET /api/federal_register/` | `Page` / `PageStream` | +| `get_federal_register_document(uuid, opts)` | `GET /api/federal_register/{uuid}/` | `Record` | + +Options: `ListFederalRegisterDocumentsOptions` (`search`, `document_number`, `r#type`, `agency`, `fr_agency`, `publication_date_after` / `_before`, `effective_on_after` / `_before`, `comments_close_on_after` / `_before`, `comments_open`, `cfr_title`, `cfr_part`, `significant`, `rin`, `executive_order_number`, and `ordering` over `publication_date`, `effective_on`, `comments_close_on`, `document_number` and `rank`), `GetFederalRegisterDocumentOptions`. Shapes: `SHAPE_FEDERAL_REGISTER_MINIMAL` and `SHAPE_FEDERAL_REGISTER_COMPREHENSIVE`, which mirror the API's list and detail defaults. + +- **The detail route takes the `uuid`, not the `document_number`.** The Federal Register reused some document numbers before 2016, so a number is not unique on its own; look one up with `document_number` on the list, which matches every document carrying it. +- **`full_text` is detail-only and must be named.** It can run to several MB, so neither default shape includes it. +- `agency` is a Tango organization and matches a document when any agency it lists falls within it (a department includes its sub-agencies); `fr_agency` is the Federal Register's own agency slug, e.g. `environmental-protection-agency`. +- `comments_open = Some(true)` keeps documents whose comment period closes today or later and `Some(false)` those already closed; a document with no comment deadline matches neither. `cfr_part` requires `cfr_title`, and both must match the same CFR reference. `ordering = "rank"` requires a non-empty `search`. +- Documents published before 2008 are mostly typed `Uncategorized Document`. + ### Exclusions (`exclusions.rs`) SAM.gov exclusions: debarments, suspensions and other ineligibility records. @@ -231,6 +265,7 @@ Options: `ListExclusionsOptions`, `GetExclusionOptions`. Shape: `SHAPE_EXCLUSION - **Whether an exclusion is in force is derived at query time**: not delisted, already activated, not yet terminated. Filter with `active` rather than trusting a stored flag. - **`delisted` is not expiry.** It means SAM lifted or withdrew the record; a natural expiration leaves the record listed. - Most exclusions are individuals and carry no UEI. `entity_uei` is set only when the record's UEI matches a registered entity. +- **`agency`** (Tango API 5.3.0) scopes by the excluding agency: a Tango agency name, abbreviation, code or organization key, including every component of it; `|` for several. A token that does not resolve falls back to an exact `excluding_agency_code` match. The `organization(...)` expand (`agency_code`, `agency_name`, `department_code`, `department_name`, `office_code`, `office_name`, `organization_id`) is accepted in shapes, as is `organization_id`. ### SBIR / STTR (`sbir.rs`) @@ -246,7 +281,9 @@ SBIR/STTR topics and DoD DSIP solicitation cycles. Options: `ListSbirTopicsOptions`, `ListSbirSolicitationsOptions`, `GetSbirOptions`. Shapes: `SHAPE_SBIR_TOPICS_MINIMAL`, `SHAPE_SBIR_SOLICITATIONS_MINIMAL`. - **`activity` is open/closed, derived at query time** from the close or end date. A topic with no close date and no parent solicitation to inherit one from is `unknown`. -- The topic `agency` filter is a partial, case-insensitive match on the raw agency text, not organization-resolved, so variant spellings do not normalize. +- The topic `agency` filter is organization-resolved and rolls up the hierarchy (`HHS` includes NIH topics); a token that does not resolve falls back to a partial match on the raw agency text. +- `ListSbirTopicsOptions::cycle_name` filters topics by their exact DoD DSIP cycle (e.g. `DOD_SBIR_2026_P1_CBZ`, Tango API 5.3.1); only topics fed from DSIP carry one. `cycle_name` is also a topic field. +- The `organization(...)` expand (`agency_code`, `agency_name`, `department_code`, `department_name`, `office_code`, `office_name`, `organization_id`) is accepted in shapes on topics and solicitations. ### State & Local — SLED (`sled.rs`) — **Beta** @@ -288,7 +325,9 @@ Forecasts carry **no liveness at all**: no deadline to have passed, so no `statu | `list_itdashboard(opts)` / `iterate_itdashboard(opts)` | `GET /api/itdashboard/` | `Page` / `PageStream` | | `get_itdashboard(uii, opts)` | `GET /api/itdashboard/{uii}/` | `Record` | -Options: `ListItdashboardOptions`, `GetItdashboardOptions`. `previous_uii` finds the investment(s) that superseded a retired UII. Some filters are tier-gated by the server (free vs. pro vs. business+); see the rustdoc on the options struct. +Options: `ListItdashboardOptions`, `GetItdashboardOptions`. `previous_uii` finds the investment(s) that superseded a retired UII. `agency` (Tango API 5.3.0) matches the investment's resolved organization, including every sub-agency and office beneath it, falling back to a partial `agency_name` match. + +Some filters are tier-gated by the server: `search`, `agency_name`, `agency` and `previous_uii` are available at every plan; `agency_code`, `type_of_investment` and the `updated_time` bounds need Pro; `cio_rating`, `cio_rating_max` and `performance_risk` need Business or above. A gated filter below its plan returns a 403. ### LCATs (`lcats.rs`) @@ -313,7 +352,7 @@ Options: `ListItdashboardOptions`, `GetItdashboardOptions`. `previous_uii` finds Paths: `/api/organizations/`, `/api/naics/`, `/api/psc/`, `/api/mas_sins/`, `/api/assistance_listings/`, `/api/business_types/`, `/api/offices/`, `/api/departments/`. (The underscore/hyphen split is the server's; the SDK matches it.) -Options: `ListOrganizationsOptions`, `ListNaicsOptions`, `ListPscOptions`, `ListMasSinsOptions`, `ListAssistanceListingsOptions`, `ListBusinessTypesOptions`, `ListOfficesOptions`, `ListDepartmentsOptions`. +Options: `ListOrganizationsOptions`, `ListNaicsOptions`, `ListPscOptions` (`has_awards = Some(true)` keeps only codes with contract award history), `ListMasSinsOptions`, `ListAssistanceListingsOptions`, `ListBusinessTypesOptions`, `ListOfficesOptions`, `ListDepartmentsOptions`. A department's `code` is an integer (`97` for DoD), returned as a JSON number; `get_department` takes it as a string (`"97"`). An agency's own `code` is a string (`"9700"`), and its nested `department.code` is the department's integer. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 18350f2..a9a0225 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,6 +24,8 @@ tango-rust/ (workspace root) │ │ ├── version.rs # VERSION const │ │ ├── models/ # Typed input/output structs │ │ │ ├── agency.rs +│ │ │ ├── awards.rs # FiscalYearObligation +│ │ │ ├── budget.rs # BudgetSourceAnomaly │ │ │ ├── ebuy.rs │ │ │ ├── protest.rs │ │ │ ├── resolve.rs @@ -44,6 +46,7 @@ tango-rust/ (workspace root) │ │ ├── gsa.rs │ │ ├── protests.rs │ │ ├── ebuy.rs # GSA eBuy requests + attachment URL + access +│ │ ├── federal_register.rs # Federal Register documents │ │ ├── itdashboard.rs │ │ ├── lcats.rs # dispatcher (entity vs IDV) │ │ ├── lookups.rs # organizations, NAICS, PSC, business types, diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 4dec2ca..94089cd 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -76,7 +76,7 @@ let page = client.list_contracts( ## Shape preset constants -All 34 constants live in `shapes.rs` and are re-exported at the crate root. They mirror the `ShapeConfig.*` enums in `tango-node` and `tango-python` exactly — same names, same field selectors, same intent. +All 37 constants live in `shapes.rs` and are re-exported at the crate root. They mirror the `ShapeConfig.*` enums in `tango-node` and `tango-python` exactly — same names, same field selectors, same intent. | Constant | Intended use | Notes | | -------- | ------------ | ----- | @@ -108,6 +108,8 @@ 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 (including `data_through_period` and `account_category`) + lifecycle dollars + attribution status + capped ratios + `source_anomalies` | +| `SHAPE_FEDERAL_REGISTER_MINIMAL` | `list_federal_register_documents` | the API's default: uuid, document number, publication date, type, title, abstract, action, agencies, CFR references, citation, significance, comment and effective dates, links | +| `SHAPE_FEDERAL_REGISTER_COMPREHENSIVE` | `get_federal_register_document` | the API's detail default: the above + dates, pages, dockets, RINs, topics, corrections, presidential document numbers, comment and text URLs (never `full_text`, which can run to several MB and must be named) | | `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 (never the attachment text: name `attachments(extracted_text)`, which `attachments(*)` does not carry and which is absent on an attachment with no text) | | `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 | diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index ce86107..f9f154a 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -202,6 +202,7 @@ Client-side validation rejects empty `name` or `callback_url` on `create_webhook - `filters` must be a non-empty JSON object. Client-side validation rejects `null` or `{}`. - `endpoint` is required when the account has multiple endpoints; single-endpoint accounts can omit it (the server auto-resolves). - `query_type` and `filters` are **read-only after creation**; `update_webhook_alert` only allows `name`, `frequency`, `cron_expression`, `is_active`. +- **`federal_register` and `ebuy_request` are query types**, emitting `alerts.federal_register.match` and `alerts.ebuy_request.match`. An `ebuy_request` alert sees only the requests your own account can read, so it needs the Pro plan and a linked GSA schedule contract (`get_ebuy_access` says whether you have both). - **`contract_appeal` is its own query type**, emitting `alerts.contract_appeal.match`. It overlaps neither `contract` (an award) nor the protest corpus (a challenge to an award): a board decision is a dispute under a contract already awarded, so an alert on one of the three never stands in for another. - **A record merely reaching its date fires nothing — except `sled_opportunity`.** An exclusion passing its termination date, or a DIBBS solicitation passing its close date, emits no event: open/closed and in-force are derived at query time, so no stored row changes. `sled_opportunity` is the one exception — a state solicitation's liveness is a stored `status` column Tango recomputes every fifteen minutes rather than deriving per request, so a deadline passing **is** a write and `alerts.sled_opportunity.match` can follow it. There is no `sled_forecast` query type (a forecast has no deadline, so nothing transitions), and SLED revisions and attachments are not separately alertable: subscribe to `sled_opportunity` and filter on `change_seen_after` or `revision_kind`. From 2c2fa45c74dce2d85ea6b9c40feff3cabebb621d Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 14:35:20 -0500 Subject: [PATCH 9/9] fix: keep the server's reason on API errors when it is nested under detail A structured error body such as a plan gate's `{"detail": {"error": "...", "code": "tier_required"}}` left `Error::Api`'s message at the bare status line. The reason is now read from the envelope keys `error`, `message` and `detail`, then the same keys inside a `detail` object, matching the Python SDK; the parsed body stays on `response`, and non-JSON bodies keep the old message. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + crates/tango/src/error.rs | 7 +-- crates/tango/src/transport.rs | 93 +++++++++++++++++++++++++++++++---- docs/CLIENT.md | 2 +- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a29f1..e8957b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Pre-1.0 (SemVer 0.x): the change under **Breaking** ships without a deprecation - **`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 diff --git a/crates/tango/src/error.rs b/crates/tango/src/error.rs index 6010de5..66f1a49 100644 --- a/crates/tango/src/error.rs +++ b/crates/tango/src/error.rs @@ -20,11 +20,8 @@ use std::time::Duration; /// - `{"message": "..."}` or `{"error": "..."}` (generic envelope) /// - `{"": ["..."]}` (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 diff --git a/crates/tango/src/transport.rs b/crates/tango/src/transport.rs index fbc2bb8..0e5e1d3 100644 --- a/crates/tango/src/transport.rs +++ b/crates/tango/src/transport.rs @@ -312,7 +312,7 @@ fn decode_error(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> Error { } else { serde_json::from_slice(body).ok() }; - let body_message = parsed_value.as_ref().and_then(extract_top_level_message); + let body_message = parsed_value.as_ref().and_then(extract_reason); let response = parsed_value.as_ref().map(|v| ErrorBody { message: body_message.clone().unwrap_or_default(), raw: Some(v.clone()), @@ -344,16 +344,23 @@ fn decode_error(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> Error { } } -fn extract_top_level_message(v: &Value) -> Option { +/// The server's own explanation from an error body, e.g. why a 403 refused the request. +/// +/// The API sends either `{"detail": "..."}` or a structured body such as a tier gate's `{"error": "...", "code": "tier_required", ...}`, sometimes nested under `detail`. +fn extract_reason(v: &Value) -> Option { let Value::Object(map) = v else { return None }; - for key in ["detail", "message", "error"] { - if let Some(Value::String(s)) = map.get(key) { - if !s.is_empty() { - return Some(s.clone()); - } - } - } - None + let envelope_reason = |map: &serde_json::Map| { + ["error", "message", "detail"] + .iter() + .find_map(|key| match map.get(*key) { + Some(Value::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + }) + }; + envelope_reason(map).or_else(|| match map.get("detail") { + Some(Value::Object(nested)) => envelope_reason(nested), + _ => None, + }) } /// Decode a JSON byte slice into a typed `T`, wrapping decode errors so they @@ -444,6 +451,72 @@ mod tests { ); } + fn api_error(status: u16, body: &[u8]) -> (u16, String, Option, String) { + let err = decode_error( + StatusCode::from_u16(status).unwrap(), + &HeaderMap::new(), + body, + ); + let display = err.to_string(); + match err { + Error::Api { + status, + message, + response, + } => (status, message, response, display), + other => panic!("expected Error::Api, got {other:?}"), + } + } + + #[test] + fn api_error_carries_the_server_reason() { + let body = json!({"detail": "These shape fields are not allowed for your account."}); + let (status, message, response, display) = api_error(403, body.to_string().as_bytes()); + assert_eq!(status, 403); + assert_eq!( + message, + "These shape fields are not allowed for your account." + ); + assert_eq!( + display, + "tango: API error (status 403): These shape fields are not allowed for your account." + ); + let response = response.unwrap(); + assert_eq!(response.message, message); + assert_eq!(response.raw, Some(body)); + } + + #[test] + fn api_error_tier_gate_reason_flat_or_nested() { + let reason = "The 'psc' filter requires a Small plan or higher."; + for body in [ + json!({"error": reason, "code": "tier_required"}), + json!({"detail": {"error": reason, "code": "tier_required"}}), + ] { + let (_, message, response, _) = api_error(403, body.to_string().as_bytes()); + assert_eq!(message, reason, "body: {body}"); + assert_eq!(response.unwrap().raw, Some(body)); + } + } + + #[test] + fn api_error_json_body_without_a_reason_keeps_the_body() { + let body = json!({"code": "forbidden"}); + let (_, message, response, _) = api_error(403, body.to_string().as_bytes()); + assert_eq!(message, "API request failed with status 403"); + let response = response.unwrap(); + assert_eq!(response.message, ""); + assert_eq!(response.raw, Some(body)); + } + + #[test] + fn api_error_non_json_body_keeps_the_bare_message() { + let (status, message, response, _) = api_error(502, b"Bad Gateway"); + assert_eq!(status, 502); + assert_eq!(message, "API request failed with status 502"); + assert_eq!(response, None); + } + #[test] fn backoff_doubles_and_caps() { let base = Duration::from_millis(250); diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 2ee37ac..a104084 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -179,7 +179,7 @@ Helpers: The `response` payload on each variant exposes: -- `response.message` — the SDK's best guess at a human-readable line extracted from the body (envelope keys `detail` / `message` / `error` first, then sorted-key iteration over the remaining keys). +- `response.message` — the server's own explanation, extracted from the body: 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). Empty when the body has none. `Error::Api`'s `message` is this reason, or `API request failed with status ` when there is none or the body was not JSON. - `response.raw` — the full decoded JSON `Value`, in case you want to introspect a custom shape. ## Custom HTTP client