diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c23a62..be9b4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **`obligations_by_fiscal_year(fiscal_year, obligated)` shape expand on contracts, IDVs, OTAs and OT IDVs** (Tango API 5.11.0; parity with tango-python). Returns the award's obligations per federal fiscal year, oldest first, each rounded to cents. The new `FiscalYearObligation` model type and schema carry `fiscal_year` as an integer and `obligated` as a decimal, which the shaped response normalizes to a string like every money field. The API always sends `null` on IDVs and OT IDVs, whose transactions report a running lifetime total; the shaped contract and IDV methods read a `null` nested list as `[]`, as they do for every nested list. + +- **`attachments(extraction_status)` shape field on opportunities and notices** (Tango API 5.11.0; parity with tango-python). Reports `pending`, `extracting`, `completed`, `failed` or `unsupported` for each attachment, so you can tell why `extracted_text` is missing. `OpportunityAttachment` gains the field, typed as an open string. + +- **`commercial_item_acquisition_procedures` filter on `listContracts()` and `listIdvs()`** (Tango API 5.10.0; parity with tango-python), and on `listIdvAwards()` and `listIdvChildIdvs()`, which share those options. 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 `listOtas()` and `listOtidvs()` do not type it. + +- **`PaginatedResponse.countType`** (parity with tango-python). `"exact"` or `"approximate"`, read from the API's `X-Results-CountType` header on every list method (`null` when the API doesn't send it). Past 1,000 matches the contract, IDV, opportunity and notice lists may return a query-planner estimate as `count`, which can be well off; this is how a caller tells the two apart. It is taken from each response itself, so concurrent calls on one client can't see each other's value the way `lastResponseHeaders` can. + - **Attachment file and link counts on opportunities and notices** (Tango API 5.9.0; parity with tango-python). Opportunities accept `meta(files_count,links_count)` beside `meta(attachments_count)`, and notices accept `file_count` and `link_count` beside `attachment_count`. An attachment's `type` is `file` (a document; only a file has extracted text) or `link` (a URL the notice lists). The new fields count each, and the existing total still counts every attachment, links included. An attachment of any other type counts only in the total, so files plus links need not equal it. The new fields are `null` until a record has been counted, so read `null` as unknown, not zero. `Opportunity` gains a typed `meta` (the new `OpportunityMeta`), `Notice` gains the three counts, and `docs/API_REFERENCE.md` documents the rules. The SDK's default shapes are unchanged, so name the fields in `shape`. - **Federal Register documents** (Tango API 5.3.0; parity with tango-python). `listFederalRegisterDocuments(options)` and `getFederalRegisterDocument(uuid, options)` over `/api/federal_register/`, plus a `FederalRegisterDocument` model interface, a registered shape schema, and the `FEDERAL_REGISTER_MINIMAL` / `FEDERAL_REGISTER_COMPREHENSIVE` defaults. 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 typed options on `ListFederalRegisterDocumentsOptions`, 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). @@ -58,6 +66,8 @@ This project follows [Semantic Versioning](https://semver.org/). ### Changed +- Re-vendored `contracts/filter_shape_contract.json` for Tango API 5.11.0 and regenerated the overlay. +- Documented that `recipient_uei` on `listContracts()` (and `uei` on `listIdvs()`, `listOtas()` and `listOtidvs()`) accepts several UEIs joined by `|`, up to 500 (Tango API 5.11.0); the SDK already passed the value through unchanged. - Re-vendored the contract for Tango API 5.9.0 (51 resources) and regenerated the overlay: 385 fields across 28 containers, 76 nested schemas. `contracts/observed_shape_types.json` is re-vendored from tango-python as well, so the new fields carry the types the API returns (`awards` is a list, `awarded` a boolean, `data_through_period` an integer) instead of types guessed from their names. - Re-vendored `contracts/filter_shape_contract.json` (schema_version 2, 48 resources) and regenerated `src/shapes/generatedOverlay.ts` from it — 359 fields across 25 containers, 73 nested schemas. - Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. @@ -70,6 +80,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Fixed +- **API errors now carry the server's reason** (parity with tango-python). A 403 or other non-2xx response the SDK had no dedicated error class for threw `TangoAPIError("API request failed with status 403")`, so a caller refused for a G2X-only shape field or a plan-gated filter couldn't tell why without digging into `responseData`. The message now ends with the API's own explanation (`API request failed with status 403: G2X shape fields are not allowed for your account.`), and the parsed body stays on `responseData`. Non-JSON bodies, such as a proxy's 502 page, keep the old message. - **`attachments(name,doc_role,doc_role_alt)` no longer throws `ShapeValidationError`** on `getOpportunity`, `listOpportunities`, `getNotice` and `listNotices`; the API serves both fields on the Pro plan and above when they are named. New `OpportunityAttachment` and `AttachmentDocRole` types cover the six roles and are used on `Opportunity` and `Notice`. - **SLED solicitations accept `delisted_at` and `meta(jurisdiction_declared)`**, which were rejected client-side. `SledOpportunity` and `SledMetaPayload` carry the new fields, and `status_reason` now documents `delisted`. - **`ProtestRecord` types `agency` and `protester` as the strings the API returns**, and gains `title`, `docket_url`, `decision_url`, `organization`, `dockets` and `decisions`. The never-served `docket` property is deprecated in favor of `dockets`. @@ -77,6 +88,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Documentation +- `docs/API_REFERENCE.md` covers `obligations_by_fiscal_year`, `commercial_item_acquisition_procedures`, pipe-joined UEIs, `attachments(extraction_status)` and `countType`. `docs/WEBHOOKS.md` notes that `alerts.federal_register.match` and `alerts.ebuy_request.match` are alertable, with `query_type: "federal_register"` and `"ebuy_request"`. - New **Federal Register** section in `docs/API_REFERENCE.md` with both methods and the full filter table, and both new constants in the `docs/SHAPES.md` preset table. `README.md`'s method list gained both methods. - New **GSA eBuy** section in `docs/API_REFERENCE.md` covering all four methods, the full filter table, and the data caveats (the frozen `status`, the empty-list-not-error scoping, the short-lived attachment URL, the named-only `attachments(extracted_text)`). `README.md`'s method and error lists gained the new methods and error. - New **Contract Appeals** section in `docs/API_REFERENCE.md` covering both methods, the full filter table, and the two properties that catch people out (the core-subset default and the tier-gated, absent-rather-than-null `decision_text`). `README.md`'s method list gained both methods. diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index e888e74..fed5961 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.9.0", + "api_version": "5.11.0", "description": "Canonical API filter/shape contract. Downstream consumers (SDK, MCP) should validate their conformance against this manifest.", "generated_from": "scripts/filter_shape_conformance.py", "schema_version": 2 @@ -95,6 +95,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -150,6 +151,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -429,6 +434,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -718,6 +730,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -837,6 +852,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -892,6 +908,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -1171,6 +1191,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -1460,6 +1487,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -2701,6 +2731,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -2756,6 +2787,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -3035,6 +3070,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -3324,6 +3366,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -3434,6 +3479,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "cursor", "expiring_gte", "expiring_lte", @@ -4339,6 +4385,7 @@ "doc_seq_num", "doc_session_date", "doc_type", + "extracted_text", "is_link" ] }, @@ -4414,6 +4461,7 @@ "attachments.doc_seq_num", "attachments.doc_session_date", "attachments.doc_type", + "attachments.extracted_text", "attachments.is_link", "award_method", "buyer_agency", @@ -4934,6 +4982,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -4989,6 +5038,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -5268,6 +5321,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -5557,6 +5617,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -5675,6 +5738,7 @@ "award_date_gte", "award_date_lte", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -5714,6 +5778,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -5940,6 +6008,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -6187,6 +6262,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -6643,6 +6721,13 @@ "organization_id" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "parent_award": { "expands": {}, "fields": [ @@ -6760,6 +6845,9 @@ "non_governmental_dollars", "non_traditional_government_contractor_participation", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "parent_award", "parent_award.key", "parent_award.piid", @@ -6979,6 +7067,13 @@ "organization_id" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "period_of_performance": { "expands": {}, "fields": [ @@ -7086,6 +7181,9 @@ "non_governmental_dollars", "non_traditional_government_contractor_participation", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "period_of_performance", "period_of_performance.current_end_date", "period_of_performance.start_date", @@ -8627,6 +8725,7 @@ "award_date_gte", "award_date_lte", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -8666,6 +8765,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -8892,6 +8995,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -9139,6 +9249,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -9218,6 +9331,7 @@ "award_date_gte", "award_date_lte", "awarding_agency", + "commercial_item_acquisition_procedures", "cursor", "expiring_gte", "expiring_lte", @@ -9258,6 +9372,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -9313,6 +9428,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -9592,6 +9711,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -9881,6 +10007,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -9999,6 +10128,7 @@ "award_date_gte", "award_date_lte", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -10038,6 +10168,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -10264,6 +10398,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -10511,6 +10652,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", @@ -11407,6 +11551,7 @@ "doc_role", "doc_role_alt", "extracted_text", + "extraction_status", "file_size", "mime_type", "name", @@ -11537,6 +11682,7 @@ "attachments.doc_role", "attachments.doc_role_alt", "attachments.extracted_text", + "attachments.extraction_status", "attachments.file_size", "attachments.mime_type", "attachments.name", @@ -11866,6 +12012,7 @@ "doc_role", "doc_role_alt", "extracted_text", + "extraction_status", "file_size", "mime_type", "name", @@ -12040,6 +12187,7 @@ "attachments.doc_role", "attachments.doc_role_alt", "attachments.extracted_text", + "attachments.extraction_status", "attachments.file_size", "attachments.mime_type", "attachments.name", @@ -12630,6 +12778,13 @@ "organization_id" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "parent_award": { "expands": {}, "fields": [ @@ -12747,6 +12902,9 @@ "non_governmental_dollars", "non_traditional_government_contractor_participation", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "parent_award", "parent_award.key", "parent_award.piid", @@ -12974,6 +13132,13 @@ "organization_id" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "period_of_performance": { "expands": {}, "fields": [ @@ -13081,6 +13246,9 @@ "non_governmental_dollars", "non_traditional_government_contractor_participation", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "period_of_performance", "period_of_performance.current_end_date", "period_of_performance.start_date", @@ -13311,6 +13479,13 @@ "organization_id" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "parent_award": { "expands": {}, "fields": [ @@ -13428,6 +13603,9 @@ "non_governmental_dollars", "non_traditional_government_contractor_participation", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "parent_award", "parent_award.key", "parent_award.piid", @@ -15355,6 +15533,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -15533,6 +15718,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -15911,6 +16103,7 @@ "expands": {}, "fields": [ "attachment_id", + "extraction_status", "file_size", "mime_type", "name", @@ -16228,6 +16421,9 @@ "awardees.number_of_actions", "awardees.number_of_offers_source", "awardees.obligated", + "awardees.obligations_by_fiscal_year", + "awardees.obligations_by_fiscal_year.fiscal_year", + "awardees.obligations_by_fiscal_year.obligated", "awardees.officers", "awardees.officers.highly_compensated_officer_1_amount", "awardees.officers.highly_compensated_officer_1_name", @@ -16337,6 +16533,9 @@ "awardees.orders.number_of_actions", "awardees.orders.number_of_offers_source", "awardees.orders.obligated", + "awardees.orders.obligations_by_fiscal_year", + "awardees.orders.obligations_by_fiscal_year.fiscal_year", + "awardees.orders.obligations_by_fiscal_year.obligated", "awardees.orders.officers", "awardees.orders.officers.highly_compensated_officer_1_amount", "awardees.orders.officers.highly_compensated_officer_1_name", @@ -16540,6 +16739,7 @@ "opportunity.archive_date", "opportunity.attachments", "opportunity.attachments.attachment_id", + "opportunity.attachments.extraction_status", "opportunity.attachments.file_size", "opportunity.attachments.mime_type", "opportunity.attachments.name", @@ -16715,6 +16915,7 @@ "award_date_lte", "award_type", "awarding_agency", + "commercial_item_acquisition_procedures", "expiring_gte", "expiring_lte", "fiscal_year", @@ -16770,6 +16971,10 @@ "filter_class": "PerformantAgencyFilter", "type": "string" }, + "commercial_item_acquisition_procedures": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, "expiring_gte": { "filter_class": "DateFilter", "lookup": "gte", @@ -17049,6 +17254,13 @@ "description" ] }, + "obligations_by_fiscal_year": { + "expands": {}, + "fields": [ + "fiscal_year", + "obligated" + ] + }, "officers": { "expands": {}, "fields": [ @@ -17338,6 +17550,9 @@ "number_of_actions", "number_of_offers_source", "obligated", + "obligations_by_fiscal_year", + "obligations_by_fiscal_year.fiscal_year", + "obligations_by_fiscal_year.obligated", "officers", "officers.highly_compensated_officer_1_amount", "officers.highly_compensated_officer_1_name", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 6b0cc9a..deed046 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -83,6 +83,32 @@ These mirror the Python SDK: `key` is also a typed filter — pass a contract key (or several separated by `|`) to fetch specific records through the list endpoint. The same `key` filter exists on `listIdvs`, `listOtas`, and `listOtidvs`. +`recipient_uei` (and `uei` on `listIdvs`, `listOtas` and `listOtidvs`) accepts several UEIs joined by `|`, matching awards to any of them, up to 500 (Tango API 5.11.0). +The SDK passes the value through unchanged. + +`commercial_item_acquisition_procedures` filters by the FPDS commercial item acquisition procedures code (Tango API 5.10.0): `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. +It is also typed on `listIdvs`, `listIdvAwards` and `listIdvChildIdvs`, but the API does not offer it on OTAs or OT IDVs. + +**Obligations by fiscal year — `obligations_by_fiscal_year(fiscal_year, obligated)`** (Tango API 5.11.0): + +```ts +import type { FiscalYearObligation } from "@makegov/tango-node/models"; + +const resp = await client.listContracts({ + piid: "SOME_PIID", + shape: "key,piid,obligated,obligations_by_fiscal_year(fiscal_year,obligated)", +}); +for (const year of (resp.results[0].obligations_by_fiscal_year ?? []) as FiscalYearObligation[]) { + console.log(year.fiscal_year, year.obligated); // number, decimal string +} +``` + +- 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. +- Returned only when named: no default shape includes it. +- The same expand works on `listIdvs()`, `listOtas()`, `listOtidvs()` and the `get*` methods. On IDVs and OT IDVs the API always sends `null`, because their transactions report a running lifetime total rather than each action's obligation. +- The API also sends `null` for a contract or OTA with no dated transactions. On the shaped methods (`listContracts`, `getContract`, `listIdvs`, `getIdv` and the IDV child lists) the client reads a `null` nested list as `[]`, as it does for every nested list; `listOtas()` and `listOtidvs()` return the API's JSON as-is. + Sorting: ```ts @@ -265,6 +291,9 @@ const page = await client.listOpportunities({ }); ``` +**Extraction status.** `attachments(extraction_status)` reports where text extraction stands for each attachment: `pending`, `extracting`, `completed`, `failed` or `unsupported`, so you can tell why `extracted_text` is missing (Tango API 5.11.0). +It works on notices too, and like every attachment field it must be named, e.g. `shape: "opportunity_id,attachments(name,extracted_text,extraction_status)"`. + --- ## Notices @@ -1299,6 +1328,7 @@ All list endpoints return: ```ts interface PaginatedResponse { count: number; + countType: "exact" | "approximate" | null; next: string | null; previous: string | null; pageMetadata: Record | null; @@ -1313,6 +1343,10 @@ interface PaginatedResponse { You can follow `next` / `previous` manually, pass `cursor` back on keyset-paginated endpoints, or use the `iterate*` helpers. +`countType` says whether `count` is `"exact"` or `"approximate"`, read from the API's `X-Results-CountType` header on every list method; it is `null` when the API does not send it. +Past 1,000 matches the contract, IDV, opportunity and notice lists may return a query-planner estimate as `count`, which can be well off; this is how to tell the two apart. +It is taken from each response itself, so concurrent calls on one client cannot see each other's value the way `client.lastResponseHeaders` can. + ### Response `meta` and agency-filter diagnostics `meta` carries any response-level metadata the API attached to the page — currently agency-filter resolution diagnostics. diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index fc7b61e..6904e41 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -79,6 +79,9 @@ console.log(info.event_types); // [{ event_type: "alerts.contract.match", description: "...", schema_version: 1 }, ...] ``` +The list is served by the API, so the SDK hardcodes no event types and a new one needs no SDK release. +`alerts.federal_register.match` and `alerts.ebuy_request.match` are among them, so `createWebhookAlert({ query_type: "federal_register", ... })` and `query_type: "ebuy_request"` work. + ### 2. See what a payload looks like ```ts diff --git a/src/client.ts b/src/client.ts index 7d1a893..c7187e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ import { ModelFactory } from "./shapes/factory.js"; import { ShapeParser } from "./shapes/parser.js"; import type { ShapeSpec } from "./shapes/types.js"; import { isRecord } from "./utils/guards.js"; -import { HttpClient } from "./utils/http.js"; +import { COUNT_TYPE_KEY, HttpClient } from "./utils/http.js"; import { unflattenResponse } from "./utils/unflatten.js"; import { AgencyRecord, @@ -111,9 +111,12 @@ function buildPaginatedResponse(raw: AnyRecord): PaginatedRespons const pageMetadata = isRecord(pageMetadataVal) ? pageMetadataVal : null; const meta = isRecord(metaVal) ? metaVal : null; const cursor = extractCursorFromUrl(next); + const countTypeVal = (raw as Record | null)?.[COUNT_TYPE_KEY]; + const countType = typeof countTypeVal === "string" ? countTypeVal : null; return { count, + countType, next, previous, pageMetadata, @@ -218,10 +221,14 @@ export interface ListContractsOptions extends ListOptionsBase { psc_code?: string; // SDK-friendly alias mapped to `psc` recipient?: string; recipient_name?: string; // SDK-friendly alias mapped to `recipient` + /** Vendor UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). */ uei?: string; - recipient_uei?: string; // SDK-friendly alias mapped to `uei` + /** SDK-friendly alias mapped to `uei`. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). */ + recipient_uei?: string; set_aside?: string; set_aside_type?: string; // SDK-friendly alias mapped to `set_aside` + /** FPDS commercial item acquisition procedures 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). Multi-value OR via `|`, e.g. `"A|E"`; an unknown or multi-character code matches nothing. Not offered on OTAs or OT IDVs. Requires Tango API 5.10.0. */ + commercial_item_acquisition_procedures?: string; // Search + ordering search?: string; @@ -313,7 +320,10 @@ export interface ListIdvsOptions { search?: string; set_aside?: string; solicitation_identifier?: string; + /** Vendor UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). */ uei?: string; + /** FPDS commercial item acquisition procedures 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). Multi-value OR via `|`, e.g. `"A|E"`; an unknown or multi-character code matches nothing. Not offered on OTAs or OT IDVs. Requires Tango API 5.10.0. */ + commercial_item_acquisition_procedures?: string; [key: string]: unknown; } @@ -889,6 +899,7 @@ export interface ListDepartmentsOptions extends ListOptionsBase { export interface ListOtasOptions extends ListOptionsBase { cursor?: string | null; joiner?: string; + /** Awardee UEI. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0). */ uei?: string; /** Exact award key (the detail-endpoint identifier). */ key?: string; diff --git a/src/models/Contract.ts b/src/models/Contract.ts index 950c37e..9043a9e 100644 --- a/src/models/Contract.ts +++ b/src/models/Contract.ts @@ -51,6 +51,16 @@ export interface ParentAwardReference { piid?: string | null; } +/** + * One entry of an award's `obligations_by_fiscal_year` expand (Tango API 5.11.0): the obligations dated in one federal fiscal year, rounded to cents. + * + * `obligated` arrives as a JSON number; the client's shaped response parses it to a decimal string, as it does every money field. A de-obligation nets against its own year, so a year can be zero or negative. + */ +export interface FiscalYearObligation { + fiscal_year: number; + obligated: string | number; +} + /** * Contract list/detail item. * @@ -80,6 +90,8 @@ export interface Contract { legislative_mandates?: LegislativeMandates; place_of_performance?: PlaceOfPerformance; subawards_summary?: SubawardsSummary; + /** Obligations per federal fiscal year, oldest first. Returned only when named in `shape`. The API sends null for a contract with no dated transactions, which the client's shaped response reads as `[]`, as it does for every nested list. Requires Tango API 5.11.0. */ + obligations_by_fiscal_year?: FiscalYearObligation[] | null; /** @deprecated Never returned by the API; removed in v2.0.0. */ id?: string; diff --git a/src/models/IDV.ts b/src/models/IDV.ts index 5ac4192..2d94cf8 100644 --- a/src/models/IDV.ts +++ b/src/models/IDV.ts @@ -1,3 +1,4 @@ +import type { FiscalYearObligation } from "./Contract.js"; import { RecipientProfile } from "./RecipientProfile.js"; export interface IDV { @@ -9,6 +10,9 @@ export interface IDV { recipient?: RecipientProfile | null; + /** The API always sends null on an IDV (read as `[]` by the client's shaped response), because an IDV's transactions report a running lifetime total rather than each action's obligation. Accepted in `shape` for parity with contracts. Requires Tango API 5.11.0. */ + obligations_by_fiscal_year?: FiscalYearObligation[] | null; + // Vehicle membership rollups (present on `/api/vehicles/{uuid}/awardees/`). title?: string | null; order_count?: number | null; diff --git a/src/models/Opportunity.ts b/src/models/Opportunity.ts index d5975e1..32371f4 100644 --- a/src/models/Opportunity.ts +++ b/src/models/Opportunity.ts @@ -13,6 +13,8 @@ export interface OpportunityAttachment { posted_date?: string | null; url?: string | null; extracted_text?: string | null; + /** Where text extraction stands for this attachment: `pending`, `extracting`, `completed`, `failed` or `unsupported`, so you can tell why `extracted_text` is missing. Treat it as an open string. Requires Tango API 5.11.0. */ + extraction_status?: "pending" | "extracting" | "completed" | "failed" | "unsupported" | (string & {}) | null; /** * The document's role in the package. Requires a Pro plan or above and must be named explicitly — `attachments(*)` does not carry it. * diff --git a/src/models/index.ts b/src/models/index.ts index 7022439..affb17b 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,6 +2,7 @@ export type { Agency } from "./Agency.js"; export type { BudgetAccount, BudgetSourceAnomaly, BudgetSourceAnomalyRow, BudgetSourceAnomalySource } from "./BudgetAccount.js"; export type { Contract, + FiscalYearObligation, OrganizationOfficePayload, PlaceOfPerformance, SubawardsSummary, diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index cf087ba..bb8d22c 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -314,6 +314,24 @@ export const TRANSACTION_SCHEMA: FieldSchemaMap = { }, }; +// One entry of an award's `obligations_by_fiscal_year`: the obligations dated in one federal fiscal year, rounded to cents. +export const FISCAL_YEAR_OBLIGATION_SCHEMA: FieldSchemaMap = { + fiscal_year: { + name: "fiscal_year", + type: "int", + isOptional: false, + isList: false, + nestedModel: null, + }, + obligated: { + name: "obligated", + type: "Decimal", + isOptional: false, + isList: false, + nestedModel: null, + }, +}; + export const DEPARTMENT_SCHEMA: FieldSchemaMap = { abbreviation: { name: "abbreviation", @@ -996,6 +1014,14 @@ export const CONTRACT_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: null, }, + /** Obligations per federal fiscal year, oldest first, each rounded to cents. Always null on IDVs and OT IDVs. Requires Tango API 5.11.0. */ + obligations_by_fiscal_year: { + name: "obligations_by_fiscal_year", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "FiscalYearObligation", + }, }; export const ENTITY_SCHEMA: FieldSchemaMap = { @@ -2356,6 +2382,14 @@ export const IDV_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: null, }, + /** Obligations per federal fiscal year, oldest first, each rounded to cents. Always null on IDVs and OT IDVs. Requires Tango API 5.11.0. */ + obligations_by_fiscal_year: { + name: "obligations_by_fiscal_year", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "FiscalYearObligation", + }, }; export const VEHICLE_SCHEMA: FieldSchemaMap = { @@ -2876,6 +2910,14 @@ export const OTA_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: "RecipientProfile", }, + /** Obligations per federal fiscal year, oldest first, each rounded to cents. Always null on IDVs and OT IDVs. Requires Tango API 5.11.0. */ + obligations_by_fiscal_year: { + name: "obligations_by_fiscal_year", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "FiscalYearObligation", + }, }; // OTIDV (Other Transaction IDV) - IDV-like @@ -2936,6 +2978,14 @@ export const OTIDV_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: "RecipientProfile", }, + /** Obligations per federal fiscal year, oldest first, each rounded to cents. Always null on IDVs and OT IDVs. Requires Tango API 5.11.0. */ + obligations_by_fiscal_year: { + name: "obligations_by_fiscal_year", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "FiscalYearObligation", + }, }; // Subaward (prime/sub awards) @@ -6464,6 +6514,7 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { LegislativeMandates: LEGISLATIVE_MANDATES_SCHEMA, SubawardsSummary: SUBAWARDS_SUMMARY_SCHEMA, Transaction: TRANSACTION_SCHEMA, + FiscalYearObligation: FISCAL_YEAR_OBLIGATION_SCHEMA, Department: DEPARTMENT_SCHEMA, Contact: CONTACT_SCHEMA, AwardOffice: AWARD_OFFICE_SCHEMA, diff --git a/src/shapes/generatedOverlay.ts b/src/shapes/generatedOverlay.ts index f38588c..e948696 100644 --- a/src/shapes/generatedOverlay.ts +++ b/src/shapes/generatedOverlay.ts @@ -62,6 +62,7 @@ export const GENERATED_NESTED: Record = { doc_role: f("doc_role", "str"), doc_role_alt: f("doc_role_alt", "str"), extracted_text: f("extracted_text", "str"), + extraction_status: f("extraction_status", "str"), file_size: f("file_size", "int"), mime_type: f("mime_type", "str"), name: f("name", "str"), @@ -75,6 +76,7 @@ export const GENERATED_NESTED: Record = { doc_role: f("doc_role", "str"), doc_role_alt: f("doc_role_alt", "str"), extracted_text: f("extracted_text", "str"), + extraction_status: f("extraction_status", "str"), file_size: f("file_size", "int"), mime_type: f("mime_type", "str"), name: f("name", "str"), @@ -85,6 +87,7 @@ export const GENERATED_NESTED: Record = { }, Attachments3: { attachment_id: f("attachment_id", "str"), + extraction_status: f("extraction_status", "str"), file_size: f("file_size", "str"), mime_type: f("mime_type", "str"), name: f("name", "str"), @@ -98,6 +101,7 @@ export const GENERATED_NESTED: Record = { doc_role: f("doc_role", "str"), doc_role_alt: f("doc_role_alt", "str"), extracted_text: f("extracted_text", "str"), + extraction_status: f("extraction_status", "str"), file_size: f("file_size", "str"), mime_type: f("mime_type", "str"), name: f("name", "str"), @@ -432,6 +436,10 @@ export const GENERATED_NESTED: Record = { code: f("code", "str"), type: f("type", "str"), }, + ObligationsByFiscalYear: { + fiscal_year: f("fiscal_year", "str"), + obligated: f("obligated", "str"), + }, Officers2: { highly_compensated_officer_1_amount: f("highly_compensated_officer_1_amount", "Decimal"), highly_compensated_officer_1_name: f("highly_compensated_officer_1_name", "str"), diff --git a/src/types.ts b/src/types.ts index 0a6ec6c..3c928ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,13 @@ export interface TangoClientOptions { export interface PaginatedResponse { count: number; + /** + * Whether `count` is `"exact"` or `"approximate"`, read from this response's `X-Results-CountType` header; `null` when the API does not send it. + * Past 1,000 matches the contract, IDV, opportunity and notice lists may return a query-planner estimate as `count`, which can be well off; this is how to tell the two apart. + * It is taken from each response itself, so concurrent calls on one client cannot see each other's value the way `lastResponseHeaders` can. + * Optional so pre-existing code constructing a `PaginatedResponse` still compiles; responses built by the client always populate it. + */ + countType?: "exact" | "approximate" | (string & {}) | null; next: string | null; previous: string | null; pageMetadata: Record | null; diff --git a/src/utils/http.ts b/src/utils/http.ts index edc8726..455e31c 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -65,6 +65,28 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** Where a list response body carries its `X-Results-CountType` header value. */ +export const COUNT_TYPE_KEY: unique symbol = Symbol("tango.countType"); + +/** + * 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`. + */ +function errorReason(data: unknown): string | null { + if (!isRecord(data)) return null; + for (const body of [data, data.detail]) { + if (typeof body === "string" && body) return body; + if (isRecord(body)) { + for (const key of ["error", "message", "detail"]) { + const value = body[key]; + if (typeof value === "string" && value) return value; + } + } + } + return null; +} + /** * Parse a `Retry-After` header. Accepts both a delta-seconds form and an * HTTP-date form. Returns milliseconds, or `null` if header is missing/invalid. @@ -319,7 +341,9 @@ export class HttpClient { } if (!res.ok) { - const e = new TangoAPIError(`API request failed with status ${res.status}`, res.status, data); + const reason = errorReason(data); + const message = `API request failed with status ${res.status}`; + const e = new TangoAPIError(reason ? `${message}: ${reason}` : message, res.status, data); if (isRetryableStatus(res.status)) { (e as unknown as Record).__retryable = true; if (retryAfterMs !== null) { @@ -334,6 +358,12 @@ export class HttpClient { throw new TangoAPIError(data.error, res.status, data); } + // Stamp a list body with its own count type so each `PaginatedResponse` reads this response's header, not the shared `lastResponseHeaders`. A symbol key survives object spread and stays out of JSON. + const countType = getHeader(res.headers, "x-results-counttype"); + if (countType && isRecord(data) && "count" in data) { + (data as Record)[COUNT_TYPE_KEY] = countType.toLowerCase(); + } + return (data ?? {}) as T; } diff --git a/tests/unit/client.count-type.test.ts b/tests/unit/client.count-type.test.ts new file mode 100644 index 0000000..b1edfa2 --- /dev/null +++ b/tests/unit/client.count-type.test.ts @@ -0,0 +1,79 @@ +/** + * `PaginatedResponse.countType`, read from each list response's own `X-Results-CountType` header. + */ + +import { TangoClient } from "../../src/client.js"; + +type Reply = { headers?: Record; body?: unknown }; + +function makeClient(replies: Reply[]): TangoClient { + let i = 0; + const fetchImpl = (async () => { + const reply = replies[Math.min(i++, replies.length - 1)]; + return { + ok: true, + status: 200, + headers: new Headers(reply.headers ?? {}), + async text() { + return JSON.stringify(reply.body ?? { count: 1500, next: null, previous: null, results: [] }); + }, + }; + }) as unknown as typeof fetch; + return new TangoClient({ apiKey: "k", baseUrl: "http://localhost:8000", fetchImpl, retries: 0 }); +} + +describe("PaginatedResponse.countType", () => { + it.each([ + ["exact", "exact"], + ["approximate", "approximate"], + ["Approximate", "approximate"], + ])("reads X-Results-CountType %s as %s", async (header, expected) => { + const client = makeClient([{ headers: { "X-Results-CountType": header } }]); + const res = await client.listContracts(); + expect(res.countType).toBe(expected); + }); + + it("is null when the API does not send the header", async () => { + const client = makeClient([{}]); + const res = await client.listOpportunities(); + expect(res.countType).toBeNull(); + }); + + it("keeps each response's own value when two calls share a client", async () => { + const client = makeClient([{ headers: { "X-Results-CountType": "approximate" } }, { headers: { "X-Results-CountType": "exact" } }]); + const first = await client.listIdvs(); + const second = await client.listNotices(); + expect(first.countType).toBe("approximate"); + expect(second.countType).toBe("exact"); + }); + + it("covers the generic list path too", async () => { + const client = makeClient([{ headers: { "X-Results-CountType": "exact" } }]); + const res = await client.listOtas(); + expect(res.countType).toBe("exact"); + }); + + it("does not leak into the results or a JSON round-trip of the page", async () => { + const client = makeClient([ + { headers: { "X-Results-CountType": "exact" }, body: { count: 1, next: null, previous: null, results: [{ key: "K1" }] } }, + ]); + const res = await client.listOtas(); + expect(Object.keys(res.results[0])).toEqual(["key"]); + expect(JSON.parse(JSON.stringify(res)).countType).toBe("exact"); + }); +}); + +describe("IDV list methods return the pagination cursor", () => { + const NEXT = "http://localhost:8000/api/idvs/?cursor=cur-2&limit=25"; + const body = { count: 30, next: NEXT, previous: null, results: [] }; + + it.each([ + ["listIdvs", (c: TangoClient) => c.listIdvs()], + ["listIdvAwards", (c: TangoClient) => c.listIdvAwards("IDV-KEY")], + ["listIdvChildIdvs", (c: TangoClient) => c.listIdvChildIdvs({ key: "IDV-KEY" })], + ["listIdvTransactions", (c: TangoClient) => c.listIdvTransactions("IDV-KEY")], + ] as const)("%s", async (_name, call) => { + const res = await call(makeClient([{ body }])); + expect(res.cursor).toBe("cur-2"); + }); +}); diff --git a/tests/unit/client.obligations-by-fiscal-year.test.ts b/tests/unit/client.obligations-by-fiscal-year.test.ts new file mode 100644 index 0000000..2bcbb25 --- /dev/null +++ b/tests/unit/client.obligations-by-fiscal-year.test.ts @@ -0,0 +1,85 @@ +/** + * The `obligations_by_fiscal_year(...)` expand on contracts, IDVs, OTAs and OT IDVs (Tango API 5.11.0). + */ + +import { TangoClient } from "../../src/client.js"; +import { ShapeValidationError } from "../../src/errors.js"; +import { SchemaRegistry } from "../../src/shapes/schema.js"; + +const SHAPE = "key,obligations_by_fiscal_year(fiscal_year,obligated)"; + +function makeClient(body: unknown): { client: TangoClient; urls: string[] } { + const urls: string[] = []; + const fetchImpl = (async (url: string | URL) => { + urls.push(String(url)); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify(body); + }, + }; + }) as unknown as typeof fetch; + const client = new TangoClient({ apiKey: "k", baseUrl: "http://localhost:8000", fetchImpl, retries: 0 }); + return { client, urls }; +} + +function page(rows: Record[]): Record { + return { count: rows.length, next: null, previous: null, results: rows }; +} + +describe("obligations_by_fiscal_year schema", () => { + it.each(["Contract", "IDV", "OTA", "OTIDV"])("%s declares it as a list of FiscalYearObligation", (model) => { + const field = new SchemaRegistry().getField(model, "obligations_by_fiscal_year"); + expect(field).toMatchObject({ isList: true, isOptional: true, nestedModel: "FiscalYearObligation" }); + }); + + it("FiscalYearObligation carries fiscal_year as int and obligated as Decimal", () => { + const schema = new SchemaRegistry().getSchema("FiscalYearObligation"); + expect(schema.fields.fiscal_year.type).toBe("int"); + expect(schema.fields.obligated.type).toBe("Decimal"); + }); +}); + +describe("obligations_by_fiscal_year responses", () => { + it("listContracts sends the shape and parses each year", async () => { + const { client, urls } = makeClient( + page([ + { + key: "K1", + obligations_by_fiscal_year: [ + { fiscal_year: 2023, obligated: 1000.0 }, + { fiscal_year: 2024, obligated: -250.5 }, + ], + }, + ]), + ); + const res = await client.listContracts({ shape: SHAPE }); + + expect(new URL(urls[0]).searchParams.get("shape")).toBe(SHAPE); + expect(res.results[0].obligations_by_fiscal_year).toEqual([ + { fiscal_year: 2023, obligated: "1000" }, + { fiscal_year: 2024, obligated: "-250.5" }, + ]); + }); + + it.each(["key,obligations_by_fiscal_year(*)", "key,obligations_by_fiscal_year"])("listContracts accepts %s", async (shape) => { + const { client } = makeClient(page([{ key: "K1", obligations_by_fiscal_year: null }])); + const res = await client.listContracts({ shape }); + + expect(res.results[0].key).toBe("K1"); + }); + + it("listIdvs accepts the expand; the API's null reads as an empty list, like every nested list", async () => { + const { client } = makeClient(page([{ key: "IDV1", obligations_by_fiscal_year: null }])); + const res = await client.listIdvs({ shape: SHAPE }); + + expect(res.results[0].obligations_by_fiscal_year).toEqual([]); + }); + + it("listContracts rejects an unknown sub-field", async () => { + const { client } = makeClient(page([{ key: "K1", obligations_by_fiscal_year: [{ fiscal_year: 2024, obligated: 1 }] }])); + + await expect(client.listContracts({ shape: "key,obligations_by_fiscal_year(amount)" })).rejects.toThrow(ShapeValidationError); + }); +}); diff --git a/tests/unit/client.shape-parity.test.ts b/tests/unit/client.shape-parity.test.ts index 5f69e64..79dbaf6 100644 --- a/tests/unit/client.shape-parity.test.ts +++ b/tests/unit/client.shape-parity.test.ts @@ -170,3 +170,35 @@ describe("fields the API added between 5.3.0 and 5.8.0", () => { expect(new URL(calls[0].url).searchParams.get("shape")).toBe(shape); }); }); + +describe("fields the API added in 5.11.0", () => { + const SHAPE = "title,attachments(name,extracted_text,extraction_status)"; + const ATTACHMENTS_WITH_STATUS = [ + { name: "Statement of Work.pdf", extracted_text: "Scope of work", extraction_status: "completed" }, + { name: "Drawings.dwg", extracted_text: null, extraction_status: "unsupported" }, + ]; + + it("getOpportunity shapes attachments(extraction_status)", async () => { + const { client, calls } = makeClient({ title: "Base ops", attachments: ATTACHMENTS_WITH_STATUS }); + const opp = await client.getOpportunity("opp-1", { shape: SHAPE }); + + expect(new URL(calls[0].url).searchParams.get("shape")).toBe(SHAPE); + expect(opp.attachments).toEqual(ATTACHMENTS_WITH_STATUS); + }); + + it("listNotices shapes attachments(extraction_status)", async () => { + const { client } = makeClient(page({ title: "Base ops", attachments: ATTACHMENTS_WITH_STATUS })); + const resp = await client.listNotices({ shape: SHAPE }); + + expect(resp.results[0].attachments).toEqual(ATTACHMENTS_WITH_STATUS); + }); + + it("listVehicles shapes opportunity(meta(files_count,links_count))", async () => { + const shape = "uuid,opportunity(opportunity_id,meta(files_count,links_count))"; + const record = { uuid: "veh-1", opportunity: { opportunity_id: "opp-1", meta: { files_count: 4, links_count: null } } }; + const { client } = makeClient(page(record)); + const resp = await client.listVehicles({ shape }); + + expect(resp.results[0].opportunity).toEqual(record.opportunity); + }); +}); diff --git a/tests/unit/client.typed-filters.test.ts b/tests/unit/client.typed-filters.test.ts index b35112f..4c22586 100644 --- a/tests/unit/client.typed-filters.test.ts +++ b/tests/unit/client.typed-filters.test.ts @@ -89,3 +89,32 @@ describe("typed filters added through Tango API 5.8.0", () => { }); }); }); + +describe("typed filters added in Tango API 5.10.0 and 5.11.0", () => { + it.each([ + ["listContracts", "/api/contracts/"], + ["listIdvs", "/api/idvs/"], + ] as const)("%s sends commercial_item_acquisition_procedures", async (method, expectedPath) => { + const { client, urls } = makeClient(); + await client[method]({ commercial_item_acquisition_procedures: "A|E" }); + const { path, params } = sent(urls); + expect(path).toBe(expectedPath); + expect(params.get("commercial_item_acquisition_procedures")).toBe("A|E"); + }); + + it("listIdvAwards sends commercial_item_acquisition_procedures to the child-award route", async () => { + const { client, urls } = makeClient(); + await client.listIdvAwards("IDV-KEY", { commercial_item_acquisition_procedures: "E" }); + const { path, params } = sent(urls); + expect(path).toBe("/api/idvs/IDV-KEY/awards/"); + expect(params.get("commercial_item_acquisition_procedures")).toBe("E"); + }); + + it("listContracts maps a pipe-joined recipient_uei to uei unchanged", async () => { + const { client, urls } = makeClient(); + await client.listContracts({ recipient_uei: "ABCDEFGHJKL1|ZYXWVUTSRQP9" }); + const { params } = sent(urls); + expect(params.get("uei")).toBe("ABCDEFGHJKL1|ZYXWVUTSRQP9"); + expect(params.has("recipient_uei")).toBe(false); + }); +}); diff --git a/tests/unit/shapes.schema.parity.test.ts b/tests/unit/shapes.schema.parity.test.ts index 1bae326..576cdbb 100644 --- a/tests/unit/shapes.schema.parity.test.ts +++ b/tests/unit/shapes.schema.parity.test.ts @@ -42,8 +42,8 @@ describe("Ported explicit schemas — parity with Python SDK", () => { expect(ORGANIZATION_SCHEMA.level.type).toBe("int"); }); - it("OTA_SCHEMA has 7 fields including recipient expansion", () => { - expect(Object.keys(OTA_SCHEMA)).toHaveLength(7); + it("OTA_SCHEMA has 8 fields including recipient expansion", () => { + expect(Object.keys(OTA_SCHEMA)).toHaveLength(8); expect(OTA_SCHEMA.key.isOptional).toBe(false); expect(OTA_SCHEMA.piid).toBeDefined(); expect(OTA_SCHEMA.total_contract_value.type).toBe("Decimal"); @@ -51,8 +51,8 @@ describe("Ported explicit schemas — parity with Python SDK", () => { expect(OTA_SCHEMA.recipient.nestedModel).toBe("RecipientProfile"); }); - it("OTIDV_SCHEMA has 8 fields including idv_type and recipient", () => { - expect(Object.keys(OTIDV_SCHEMA)).toHaveLength(8); + it("OTIDV_SCHEMA has 9 fields including idv_type and recipient", () => { + expect(Object.keys(OTIDV_SCHEMA)).toHaveLength(9); expect(OTIDV_SCHEMA.key.isOptional).toBe(false); expect(OTIDV_SCHEMA.idv_type).toBeDefined(); expect(OTIDV_SCHEMA.idv_type.type).toBe("dict"); diff --git a/tests/unit/utils.http.test.ts b/tests/unit/utils.http.test.ts index 93ce783..b4c71b2 100644 --- a/tests/unit/utils.http.test.ts +++ b/tests/unit/utils.http.test.ts @@ -345,3 +345,55 @@ describe("HttpClient — structured 400 payloads", () => { expect(validation.availableFields).toEqual({ fields: ["key", "piid"] }); }); }); + +describe("HttpClient — error reason on statuses without a dedicated error", () => { + const rejectionFor = async (status: number, text: string): Promise => { + const client = new HttpClient({ + baseUrl: "https://example.test", + retries: 0, + fetchImpl: async (): Promise => ({ + ok: false, + status, + async text() { + return text; + }, + }), + }); + const err = await client.get("/api/contracts/").catch((e: unknown) => e); + expect(err).toBeInstanceOf(TangoAPIError); + return err as TangoAPIError; + }; + + it("appends the server's reason and keeps the parsed body", async () => { + const body = { detail: "G2X shape fields are not allowed for your account." }; + const err = await rejectionFor(403, JSON.stringify(body)); + + expect(err.message).toBe("API request failed with status 403: G2X shape fields are not allowed for your account."); + expect(err.statusCode).toBe(403); + expect(err.responseData).toEqual(body); + }); + + it.each([ + { error: "The 'psc' filter requires a Small plan or higher.", code: "tier_required" }, + { detail: { error: "The 'psc' filter requires a Small plan or higher.", code: "tier_required" } }, + ])("reads a tier gate's reason flat or nested under detail (%j)", async (body) => { + const err = await rejectionFor(403, JSON.stringify(body)); + + expect(err.message).toBe("API request failed with status 403: The 'psc' filter requires a Small plan or higher."); + }); + + it("keeps the bare message for a JSON body with no reason", async () => { + const body = { code: "forbidden" }; + const err = await rejectionFor(403, JSON.stringify(body)); + + expect(err.message).toBe("API request failed with status 403"); + expect(err.responseData).toEqual(body); + }); + + it("keeps the bare message for a non-JSON body", async () => { + const err = await rejectionFor(502, "Bad Gateway"); + + expect(err.message).toBe("API request failed with status 502"); + expect(err.responseData).toBeNull(); + }); +});