diff --git a/CHANGELOG.md b/CHANGELOG.md index 26596e0..d77f12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### `makegov-tango` + +#### Added + +- **Award announcements** (Tango API 5.14.0). `list_award_announcements`, `iterate_award_announcements` and `get_award_announcement` read `/api/news/award_announcements/`, with `ListAwardAnnouncementsOptions` naming all twenty of the API's filters plus `ordering` (`published_date`, `amount`, `rank`), `GetAwardAnnouncementOptions`, and `SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL` / `SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE` mirroring the API's list and detail defaults. Every row names its `source`: `official_release` rows are the Department of War's daily contracts release, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. +- **`award_announcement` alerts** (Tango API 5.14.0). `create_webhook_alert` accepts the `award_announcement` query type, which emits `alerts.award_announcement.match`. + ## [0.4.0] — 2026-10-29 ### `makegov-tango` diff --git a/README.md b/README.md index dccde24..41896aa 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` | +| Award announcements | `list_award_announcements` | `get_award_announcement` | `iterate_award_announcements` | | 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_*` | @@ -300,6 +301,8 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `get_ebuy_attachment_url` / `get_ebuy_access`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `list_opportunities` with `search` also matches attachment text. Metrics dispatcher: `list_metrics`. +Award announcements (Tango API 5.14.0) name their `source` on every row: `official_release` rows are the Department of War's daily contracts release, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. + See [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) for full signatures, filter fields, and quirks. ## Workspace layout diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index 4c9b2a3..13b63ec 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -119,15 +119,15 @@ pub use error::{Error, ErrorBody, Result}; pub use internal::ListOptions; pub use pagination::{Page, PageStream}; pub use shapes::{ - DEFAULT_BASE_URL, SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL, - SHAPE_CONTRACT_APPEALS_MINIMAL, SHAPE_DIBBS_AWARDS_MINIMAL, SHAPE_DIBBS_RFPS_MINIMAL, - SHAPE_DIBBS_RFQS_MINIMAL, SHAPE_EBUY_REQUESTS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, - 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, + DEFAULT_BASE_URL, SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE, SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL, + 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_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/award_announcements.rs b/crates/tango/src/resources/award_announcements.rs new file mode 100644 index 0000000..8c772cf --- /dev/null +++ b/crates/tango/src/resources/award_announcements.rs @@ -0,0 +1,318 @@ +//! `GET /api/news/award_announcements/` — federal award announcements from two sources, named on every row by `source` (Tango API 5.14.0). +//! +//! `official_release` rows are the awards of $7.5 million or more that the Department of War publishes in its daily contracts release, with the text as published. +//! +//! `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV. +//! They are written by MakeGov from FPDS data with a language model and are **not government text**. +//! A generated announcement is dropped when an official release covers the same award. +//! +//! Lists hide announcements DoD later withdrew unless [`ListAwardAnnouncementsOptions::include_withdrawn`] is `Some(true)`; a withdrawn announcement stays retrievable by its `uuid`. +//! +//! `text` can run past 16,000 characters, so a list carries it only when named in `shape`. + +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_award_announcements`] and [`Client::iterate_award_announcements`]. +/// +/// All twenty of the API's filters are named fields, plus `ordering`. +/// This endpoint pages by `page`, so there is no `cursor` option. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListAwardAnnouncementsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server default 10, cap 100). + #[builder(into)] + pub limit: Option, + /// Comma-separated field selector. Use [`SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL`](crate::SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL) or roll your own. Name `text` to get the announcement body on a 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, + + /// Full-text search over the title and text, at least two characters; a match in the title ranks above a match in the text. Wrap in double quotes for a phrase. + #[builder(into)] + pub search: Option, + /// `official_release` for the Department of War's daily contracts release, or `makegov_generated` for announcements written by MakeGov from FPDS data with a language model, which are not government text. + #[builder(into)] + pub source: Option, + /// A Tango agency name, abbreviation, code or organization key for the awarding organization, e.g. `Navy`. OR several with `|`. + #[builder(into)] + pub agency: Option, + /// The DoD component as printed in the official release's section header, e.g. `NAVY`. Case-insensitive exact match; OR several with `|`. Generated announcements have no component. + #[builder(into)] + pub component: Option, + /// Published on or after (`YYYY-MM-DD`). + #[builder(into)] + pub published_date_after: Option, + /// Published on or before (`YYYY-MM-DD`). + #[builder(into)] + pub published_date_before: Option, + /// Awarded on or after (`YYYY-MM-DD`). + #[builder(into)] + pub award_date_after: Option, + /// Awarded on or before (`YYYY-MM-DD`). + #[builder(into)] + pub award_date_before: Option, + /// Amount at least, in USD. + #[builder(into)] + pub amount_min: Option, + /// Amount at most, in USD. + #[builder(into)] + pub amount_max: Option, + /// A contract number named in the announcement, e.g. `N00019-25-C-0070` or `N0001925C0070`. Dashes and case are ignored; OR several with `|`. + #[builder(into)] + pub piid: Option, + /// Case-insensitive substring match on the awardee names, at least three characters. + #[builder(into)] + pub awardee: Option, + /// An exact UEI of an awardee. Official releases print no UEI, so this matches only where one was resolved. + #[builder(into)] + pub uei: Option, + /// Action type, e.g. `new_award`. OR several with `|`. + #[builder(into)] + pub action_type: Option, + /// Announcement type. OR several with `|`. + #[builder(into)] + pub announcement_type: Option, + /// Announces a civilian award. + pub civilian: Option, + /// `Some(true)` keeps announcements with at least one small-business awardee. `Some(false)` keeps those whose awardees are stated not to be small. Generated announcements state neither, so they match neither. + pub small_business: Option, + /// Announces a modification to an existing contract. + pub is_modification: Option, + /// Names more than one awardee. + pub is_multiple_award: Option, + /// `Some(true)` includes announcements DoD later withdrew, which lists hide otherwise. + pub include_withdrawn: Option, + + /// Sort key: `published_date`, `amount` or `rank`, prefixed with `-` for descending. The server default is `-published_date`, or `-rank` under a [`search`](Self::search). `rank` requires a non-empty `search`, and descending `published_date` and `amount` put rows with no value last. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. The API answers an unknown key with a 400. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListAwardAnnouncementsOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + apply_pagination( + &mut q, + self.page, + self.limit, + None, + self.shape.as_deref(), + self.flat, + self.flat_lists, + ); + for (key, value) in [ + ("search", &self.search), + ("source", &self.source), + ("agency", &self.agency), + ("component", &self.component), + ("published_date_after", &self.published_date_after), + ("published_date_before", &self.published_date_before), + ("award_date_after", &self.award_date_after), + ("award_date_before", &self.award_date_before), + ("amount_min", &self.amount_min), + ("amount_max", &self.amount_max), + ("piid", &self.piid), + ("awardee", &self.awardee), + ("uei", &self.uei), + ("action_type", &self.action_type), + ("announcement_type", &self.announcement_type), + ] { + push_opt(&mut q, key, value.as_deref()); + } + push_opt_bool(&mut q, "civilian", self.civilian); + push_opt_bool(&mut q, "small_business", self.small_business); + push_opt_bool(&mut q, "is_modification", self.is_modification); + push_opt_bool(&mut q, "is_multiple_award", self.is_multiple_award); + push_opt_bool(&mut q, "include_withdrawn", self.include_withdrawn); + 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_award_announcement`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetAwardAnnouncementOptions { + /// Shape selector. When empty, the server returns its detail default, which [`SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE`](crate::SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE) mirrors. + #[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 GetAwardAnnouncementOptions { + 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/news/award_announcements/` — one page of award announcements, newest publication first by default, or best match first under a `search`. + /// + /// `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text; read `source` on each row, or filter on it with [`ListAwardAnnouncementsOptions::source`]. + pub async fn list_award_announcements( + &self, + opts: ListAwardAnnouncementsOptions, + ) -> Result> { + let q = opts.to_query(); + self.get_page("/api/news/award_announcements/", &q).await + } + + /// `GET /api/news/award_announcements/{uuid}/` — one award announcement by its `uuid`, including one DoD later withdrew. + /// + /// `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. + pub async fn get_award_announcement( + &self, + announcement_uuid: &str, + opts: Option, + ) -> Result { + if announcement_uuid.is_empty() { + return Err(Error::Validation { + message: "get_award_announcement: announcement_uuid is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!( + "/api/news/award_announcements/{}/", + urlencoding(announcement_uuid) + ); + self.get_json::(&path, &q).await + } + + /// Stream every award announcement matching `opts`. + pub fn iterate_award_announcements( + &self, + opts: ListAwardAnnouncementsOptions, + ) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, _cursor| { + let mut next = (*opts).clone(); + next.page = page; + Box::pin(async move { client.list_award_announcements(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_award_announcements_all_filters_emit() { + let opts = ListAwardAnnouncementsOptions::builder() + .search("\"hypersonic\"") + .source("official_release") + .agency("Navy|Army") + .component("NAVY") + .published_date_after("2026-09-01") + .published_date_before("2026-09-30") + .award_date_after("2026-08-01") + .award_date_before("2026-08-31") + .amount_min("100000000") + .amount_max("500000000.50") + .piid("N00019-25-C-0070") + .awardee("lockheed") + .uei("ABCDEF123456") + .action_type("new_award") + .announcement_type("award") + .civilian(false) + .small_business(false) + .is_modification(true) + .is_multiple_award(false) + .include_withdrawn(true) + .ordering("-amount") + .build(); + let q = opts.to_query(); + for (k, v) in [ + ("search", "\"hypersonic\""), + ("source", "official_release"), + ("agency", "Navy|Army"), + ("component", "NAVY"), + ("published_date_after", "2026-09-01"), + ("published_date_before", "2026-09-30"), + ("award_date_after", "2026-08-01"), + ("award_date_before", "2026-08-31"), + ("amount_min", "100000000"), + ("amount_max", "500000000.50"), + ("piid", "N00019-25-C-0070"), + ("awardee", "lockheed"), + ("uei", "ABCDEF123456"), + ("action_type", "new_award"), + ("announcement_type", "award"), + ("civilian", "false"), + ("small_business", "false"), + ("is_modification", "true"), + ("is_multiple_award", "false"), + ("include_withdrawn", "true"), + ("ordering", "-amount"), + ] { + assert_eq!(get_q(&q, k).as_deref(), Some(v), "{k}"); + } + assert_eq!(q.len(), 21); + } + + #[test] + fn list_award_announcements_zero_value_omitted() { + assert!(ListAwardAnnouncementsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn only_the_detail_shape_names_text() { + let names_text = |shape: &str| shape.split(',').any(|f| f == "text"); + assert!(!names_text(crate::SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL)); + assert!(names_text(crate::SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE)); + } + + #[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_award_announcement("", 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 eb567ea..3da60ea 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -2,6 +2,7 @@ //! methods on [`Client`](crate::Client) and exports its `*Options` builders. pub(crate) mod agencies; +pub(crate) mod award_announcements; pub(crate) mod budget; pub(crate) mod contract_appeals; pub(crate) mod contracts; @@ -34,6 +35,7 @@ pub use agencies::{ AgencyContractsOptions, GetAgencyOptions, ListAgenciesOptions, ListAgencyAwardingContractsOptions, ListAgencyFundingContractsOptions, }; +pub use award_announcements::{GetAwardAnnouncementOptions, ListAwardAnnouncementsOptions}; pub use budget::{ BudgetAccountQuartersOptions, BudgetAccountRecipientsOptions, ListBudgetAccountsOptions, }; diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index 5161c3a..2c4119c 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -282,6 +282,26 @@ pub const SHAPE_FEDERAL_REGISTER_COMPREHENSIVE: &str = concat!( "comment_url,regulations_dot_gov_url,raw_text_url,body_html_url", ); +/// Suggested list shape for [`Client::list_award_announcements`](crate::Client::list_award_announcements). +/// +/// Mirrors the API's default list shape. `text` is left out: it can run past 16,000 characters, so name it when you want it on a list. +pub const SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL: &str = concat!( + "uuid,source,source_name,source_url,published_date,award_date,title,amount,", + "value_kind,action_type,announcement_type,awardee_count,piids,is_modification,", + "is_multiple_award,is_withdrawn,awardees(*)", +); + +/// Suggested detail shape for [`Client::get_award_announcement`](crate::Client::get_award_announcement). +/// +/// Mirrors the API's default detail shape, including the `awardees(*)` and `organization(*)` expands. +pub const SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE: &str = concat!( + "uuid,source,source_name,source_url,published_date,award_date,title,amount,", + "value_kind,action_type,announcement_type,awardee_count,piids,is_modification,", + "is_multiple_award,is_withdrawn,awardees(*),source_record_id,text,obligated_at_award,award_kind,", + "award_uuids,civilian,component,contracting_activity,corrections,", + "organization(*)", +); + /// 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 4790c1b..d9ffba1 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -1,5 +1,6 @@ //! Wire tests for resource routes: each method hits the path and query the API documents, and decodes the response shape the API actually serves. +use futures::TryStreamExt; use httpmock::prelude::*; use serde_json::json; use std::time::Duration; @@ -752,6 +753,112 @@ async fn federal_register_routes() { ); } +#[tokio::test] +async fn award_announcement_routes() { + let uuid = "7d1e3a5c-2b4f-5a6e-8c0d-9f1b3d5e7a9c"; + let server = MockServer::start_async().await; + let list = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/news/award_announcements/") + .query_param("source", "makegov_generated") + .query_param("piid", "N00019-25-C-0070") + .query_param("small_business", "false") + .query_param("ordering", "-amount"); + then.status(200).json_body(page(json!([{ + "uuid": uuid, + "source": "makegov_generated" + }]))); + }) + .await; + let get = server + .mock_async(|when, then| { + when.method(GET) + .path(format!("/api/news/award_announcements/{uuid}/")) + .query_param("shape", "uuid,awardees(name,uei),organization(agency_name)"); + then.status(200).json_body(json!({ + "uuid": uuid, + "awardees": [{"name": "Example Systems Corp.", "uei": null}], + "organization": {"agency_name": "Department of the Navy"} + })); + }) + .await; + let c = make_client(&server); + let p = c + .list_award_announcements( + tango::ListAwardAnnouncementsOptions::builder() + .source("makegov_generated") + .piid("N00019-25-C-0070") + .small_business(false) + .ordering("-amount") + .build(), + ) + .await + .expect("list"); + let rec = c + .get_award_announcement( + p.results[0] + .get("uuid") + .and_then(serde_json::Value::as_str) + .expect("uuid"), + Some( + tango::GetAwardAnnouncementOptions::builder() + .shape("uuid,awardees(name,uei),organization(agency_name)") + .build(), + ), + ) + .await + .expect("get"); + list.assert_async().await; + get.assert_async().await; + assert_eq!(rec["awardees"][0]["name"], json!("Example Systems Corp.")); + assert_eq!( + rec["organization"]["agency_name"], + json!("Department of the Navy") + ); +} + +#[tokio::test] +async fn iterate_award_announcements_walks_pages_keeping_filters() { + let server = MockServer::start_async().await; + let path = "/api/news/award_announcements/"; + let next = server.url(format!("{path}?source=official_release&page=2")); + let second = server + .mock_async(|when, then| { + when.method(GET) + .path(path) + .query_param("source", "official_release") + .query_param("page", "2"); + then.status(200).json_body(json!({ + "count": 2, "next": null, "previous": null, "results": [{"uuid": "b"}] + })); + }) + .await; + let first = server + .mock_async(|when, then| { + when.method(GET) + .path(path) + .query_param("source", "official_release"); + then.status(200).json_body(json!({ + "count": 2, "next": next, "previous": null, "results": [{"uuid": "a"}] + })); + }) + .await; + let rows: Vec<_> = make_client(&server) + .iterate_award_announcements( + tango::ListAwardAnnouncementsOptions::builder() + .source("official_release") + .build(), + ) + .try_collect() + .await + .expect("walk"); + first.assert_async().await; + second.assert_async().await; + let uuids: Vec<_> = rows.iter().map(|r| r["uuid"].clone()).collect(); + assert_eq!(uuids, vec![json!("a"), json!("b")]); +} + #[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)"; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 4b010fe..5f68468 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -251,6 +251,26 @@ Options: `ListFederalRegisterDocumentsOptions` (`search`, `document_number`, `r# - `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`. +### Award announcements (`award_announcements.rs`) + +Federal award announcements from two sources, named on every row by `source` (Tango API 5.14.0), free at every tier. + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_award_announcements(opts)` / `iterate_award_announcements(opts)` | `GET /api/news/award_announcements/` | `Page` / `PageStream` | +| `get_award_announcement(uuid, opts)` | `GET /api/news/award_announcements/{uuid}/` | `Record` | + +Options: `ListAwardAnnouncementsOptions` (`search`, `source`, `agency`, `component`, `published_date_after` / `_before`, `award_date_after` / `_before`, `amount_min`, `amount_max`, `piid`, `awardee`, `uei`, `action_type`, `announcement_type`, `civilian`, `small_business`, `is_modification`, `is_multiple_award`, `include_withdrawn`, and `ordering` over `published_date`, `amount` and `rank`), `GetAwardAnnouncementOptions`. Shapes: `SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL` and `SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE`, which mirror the API's list and detail defaults. + +- **`source` says who wrote the row.** `official_release` rows are the awards of $7.5 million or more that the Department of War publishes in its daily contracts release, with the text as published. `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV: they are written by MakeGov from FPDS data with a language model and are **not government text**. A generated announcement is dropped when an official release covers the same award. +- **Withdrawn announcements are hidden from lists** unless `include_withdrawn = Some(true)`, and stay retrievable by `uuid`. +- **`text` must be named on a list.** It can run past 16,000 characters, so only the detail default carries it. +- The expands are `awardees(name,uei,location,small_business)` and `organization(organization_id,office_code,office_name,agency_code,agency_name,department_code,department_name)`, and `*` selects every key of either. +- `piid` takes a contract number as printed (`N00019-25-C-0070`) or bare (`N0001925C0070`); dashes and case are ignored. `awardee` is a case-insensitive substring match of at least three characters, and shorter values return a 400. `uei` matches only where one was resolved, since official releases print none. +- `small_business = Some(true)` keeps announcements with at least one small-business awardee and `Some(false)` those whose awardees are stated not to be small; generated announcements state neither, so they match neither. `component` is the release's section header as printed, compared case-insensitively, and generated announcements have none. +- `ordering = "rank"` requires a non-empty `search`, and a `search` with no `ordering` sorts by `-rank`. Otherwise the default is `-published_date`. Descending `published_date` and `amount` put rows with no value last. +- This endpoint pages by `page`, so `ListAwardAnnouncementsOptions` has no `cursor`. + ### Exclusions (`exclusions.rs`) SAM.gov exclusions: debarments, suspensions and other ineligibility records. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a9a0225..c89a6f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,6 +33,7 @@ tango-rust/ (workspace root) │ │ │ └── webhook.rs │ │ └── resources/ # One module per resource family │ │ ├── agencies.rs +│ │ ├── award_announcements.rs # award announcements (official + generated) │ │ ├── contracts.rs │ │ ├── idvs.rs │ │ ├── idv_subresources.rs diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 94089cd..df26028 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -110,6 +110,8 @@ All 37 constants live in `shapes.rs` and are re-exported at the crate root. They | `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_AWARD_ANNOUNCEMENTS_MINIMAL` | `list_award_announcements` | the API's default: uuid, source, publication and award dates, title, amount, value kind, action and announcement types, awardee count, PIIDs, modification, multiple-award and withdrawn flags (never `text`, which can run past 16,000 characters and must be named) | +| `SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE` | `get_award_announcement` | the API's detail default: the above + `text`, source record id, obligated at award, award kind, award UUIDs, civilian flag, component, contracting activity, corrections, `awardees(*)`, `organization(*)` | | `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 fec0b2d..799cfd2 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -204,6 +204,7 @@ Client-side validation rejects empty `name` or `callback_url` on `create_webhook - `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. +- **`award_announcement` is a query type**, emitting `alerts.award_announcement.match` (Tango API 5.14.0). Its filters are the list filters of `list_award_announcements`, so `{"source": "official_release", "amount_min": "100000000"}` alerts only on official releases of $100 million or more. Like the list, an alert skips withdrawn announcements unless its filters set `include_withdrawn`. - **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`. ### Example: create and test an endpoint