From 7f41c2e4b4a67164329f43d8cdefd9c470f70b46 Mon Sep 17 00:00:00 2001 From: infinityplusone Date: Tue, 20 Jan 2026 18:05:27 -0500 Subject: [PATCH] Implement vehicles and IDV endpoints with comprehensive support for list, detail, and awardees retrieval. Update README and CHANGELOG to reflect new features and changes. Enhance type definitions and schemas for vehicles and IDVs. --- .gitignore | 3 + CHANGELOG.md | 9 + README.md | 2 +- docs/API_REFERENCE.md | 100 +++++ src/client.ts | 295 ++++++++++++++- src/config.ts | 26 ++ src/models/IDV.ts | 17 + src/models/Vehicle.ts | 31 ++ src/models/index.ts | 2 + src/shapes/explicitSchemas.ts | 674 ++++++++++++++++++++++++++++++++++ tests/unit/client.test.ts | 258 +++++++++++++ 11 files changed, 1412 insertions(+), 5 deletions(-) create mode 100644 src/models/IDV.ts create mode 100644 src/models/Vehicle.ts diff --git a/.gitignore b/.gitignore index 385df2a..81d701d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ dist/ coverage/ .build/ .tmp/ +.npm-cache/ +node_modules/ # Editor / OS .DS_Store @@ -26,3 +28,4 @@ coverage/ # Other ROADMAP.md +/yoni diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd4c84..52d6e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to `@makegov/tango-node` will be documented in this file. This project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- Vehicles endpoints: `listVehicles`, `getVehicle`, and `listVehicleAwardees` (supports shaping + flattening). (refs `makegov/tango#1327`) +- IDV endpoints: `listIdvs`, `getIdv`, `listIdvAwards`, `listIdvChildIdvs`, `listIdvTransactions`, `getIdvSummary`, `listIdvSummaryAwards`. (refs `makegov/tango#1327`) + +### Changed +- `joiner` is now respected when unflattening `flat=true` responses on supported endpoints. + ## [0.1.0] - 2025-11-21 - Initial Node.js port of the Tango Python SDK. diff --git a/README.md b/README.md index 6b64e4a..65b803d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A modern Node.js SDK for the [Tango API](https://tango.makegov.com), featuring d - **Dynamic Response Shaping** – Ask Tango for exactly the fields you want using a simple shape syntax. - **Type-Safe by Design** – Shape strings are validated against Tango schemas and mapped to generated TypeScript types. -- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, and grants. +- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, vehicles, forecasts, opportunities, notices, and grants. - **Flexible Data Access** – Plain JavaScript objects backed by runtime validation and parsing, materialized via the dynamic model pipeline. - **Modern Node** – Built for Node 18+ with native `fetch` and ESM-first design. - **Tested Against the Real API** – Integration tests (mirroring the Python SDK) keep behavior aligned. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 0c1aca1..afcd910 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -115,6 +115,106 @@ limit: number --- +## Vehicles + +Vehicles provide a solicitation-centric grouping of related IDVs. + +### `listVehicles(options)` + +```ts +const resp = await client.listVehicles({ + search: "GSA schedule", + shape: ShapeConfig.VEHICLES_MINIMAL, + page: 1, + limit: 25, +}); +``` + +Supported parameters: + +- `search` (vehicle-level full-text search) +- `page`, `limit` (max 100) +- `shape`, `flat`, `flatLists` + +### `getVehicle(uuid, options?)` + +```ts +const vehicle = await client.getVehicle("00000000-0000-0000-0000-000000000001", { + shape: ShapeConfig.VEHICLES_COMPREHENSIVE, +}); +``` + +Notes: + +- On vehicle detail, `search` filters expanded `awardees(...)` when included in your `shape` (it does not filter the vehicle itself). +- When using `flat: true`, you can override the joiner with `joiner` (default `"."`). + +### `listVehicleAwardees(uuid, options?)` + +```ts +const awardees = await client.listVehicleAwardees("00000000-0000-0000-0000-000000000001", { + shape: ShapeConfig.VEHICLE_AWARDEES_MINIMAL, +}); +``` + +--- + +## IDVs + +IDVs (indefinite delivery vehicles) are the parent “vehicle award” records that can have child awards/orders under them. + +### `listIdvs(options)` + +```ts +const idvs = await client.listIdvs({ + limit: 25, + cursor: null, + shape: ShapeConfig.IDVS_MINIMAL, + awarding_agency: "4700", +}); +``` + +Notes: + +- This endpoint uses **keyset pagination** (`cursor` + `limit`) rather than `page`. + +### `getIdv(key, options?)` + +```ts +const idv = await client.getIdv("SOME_IDV_KEY", { + shape: ShapeConfig.IDVS_COMPREHENSIVE, +}); +``` + +### `listIdvAwards(key, options?)` + +Lists child awards (contracts) under an IDV. + +```ts +const awards = await client.listIdvAwards("SOME_IDV_KEY", { limit: 25 }); +``` + +### `listIdvChildIdvs({ key, ...options })` + +```ts +const children = await client.listIdvChildIdvs({ key: "SOME_IDV_KEY", limit: 25 }); +``` + +### `listIdvTransactions(key, options?)` + +```ts +const tx = await client.listIdvTransactions("SOME_IDV_KEY", { limit: 100 }); +``` + +### `getIdvSummary(identifier)` / `listIdvSummaryAwards(identifier, options?)` + +```ts +const summary = await client.getIdvSummary("SOLICITATION_IDENTIFIER"); +const awards = await client.listIdvSummaryAwards("SOLICITATION_IDENTIFIER", { limit: 25 }); +``` + +--- + ## Entities ### `listEntities(options)` diff --git a/src/client.ts b/src/client.ts index 6fef460..10c392a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,6 +99,21 @@ export interface ListEntitiesOptions extends ListOptionsBase { [key: string]: unknown; } +export interface ListVehiclesOptions extends ListOptionsBase { + search?: string; + [key: string]: unknown; +} + +export interface ListIdvsOptions { + limit?: number; + cursor?: string | null; + shape?: string | null; + flat?: boolean; + flatLists?: boolean; + joiner?: string; + [key: string]: unknown; +} + export class TangoClient { private readonly http: HttpClient; private readonly shapeParser: ShapeParser; @@ -403,19 +418,291 @@ export class TangoClient { return paginated; } + // --------------------------------------------------------------------------- + // Vehicles (Awards) + // --------------------------------------------------------------------------- + + async listVehicles(options: ListVehiclesOptions = {}): Promise>> { + const { page = 1, limit = 25, shape, flat = false, flatLists = false, search, ...filters } = options; + + const params: AnyRecord = { + page, + limit: Math.min(limit, 100), + }; + + const shapeToUse = shape ?? ShapeConfig.VEHICLES_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) params.flat = "true"; + if (flatLists) params.flat_lists = "true"; + } + + if (search) { + params.search = search; + } + + // Vehicles list currently supports `search` + pagination + shaping. We allow extra keys for forward compatibility. + Object.assign(params, filters); + + const data = await this.http.get("/api/vehicles/", params); + const rawResults = Array.isArray(data?.results) ? (data.results as AnyRecord[]) : []; + + const results = this.materializeList("Vehicle", shapeSpec, rawResults, flat); + + return buildPaginatedResponse({ ...data, results } as AnyRecord); + } + + async getVehicle( + uuid: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string; search?: string } = {}, + ): Promise> { + if (!uuid) { + throw new TangoValidationError("Vehicle uuid is required"); + } + + const { shape, flat = false, flatLists = false, joiner = ".", search } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.VEHICLES_COMPREHENSIVE; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + // On vehicle detail, `search` filters expanded awardees when shaping includes `awardees(...)`. + if (search) { + params.search = search; + } + + const data = await this.http.get(`/api/vehicles/${encodeURIComponent(uuid)}/`, params); + + const result = this.materializeOne("Vehicle", shapeSpec, data, flat, joiner); + return result as Record; + } + + async listVehicleAwardees( + uuid: string, + options: { page?: number; limit?: number; shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise>> { + if (!uuid) { + throw new TangoValidationError("Vehicle uuid is required"); + } + + const { page = 1, limit = 25, shape, flat = false, flatLists = false, joiner = "." } = options; + + const params: AnyRecord = { + page, + limit: Math.min(limit, 100), + }; + + const shapeToUse = shape ?? ShapeConfig.VEHICLE_AWARDEES_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/vehicles/${encodeURIComponent(uuid)}/awardees/`, params); + const rawResults = Array.isArray(data?.results) ? (data.results as AnyRecord[]) : []; + + const results = this.materializeList("IDV", shapeSpec, rawResults, flat, joiner); + + return buildPaginatedResponse({ ...data, results } as AnyRecord); + } + + // --------------------------------------------------------------------------- + // IDVs (Awards) + // --------------------------------------------------------------------------- + + async listIdvs(options: ListIdvsOptions = {}): Promise>> { + const { limit = 25, cursor = null, shape, flat = false, flatLists = false, joiner = ".", ...filters } = options; + + const params: AnyRecord = { + limit: Math.min(limit, 100), + }; + if (cursor) params.cursor = cursor; + + const shapeToUse = shape ?? ShapeConfig.IDVS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + Object.assign(params, filters); + + const data = await this.http.get("/api/idvs/", params); + const rawResults = Array.isArray(data?.results) ? (data.results as AnyRecord[]) : []; + + const results = this.materializeList("IDV", shapeSpec, rawResults, flat, joiner); + return buildPaginatedResponse({ ...data, results } as AnyRecord); + } + + async getIdv( + key: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!key) { + throw new TangoValidationError("IDV key is required"); + } + + const { shape, flat = false, flatLists = false, joiner = "." } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.IDVS_COMPREHENSIVE; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/idvs/${encodeURIComponent(key)}/`, params); + + const result = this.materializeOne("IDV", shapeSpec, data, flat, joiner); + return result as Record; + } + + async listIdvAwards( + key: string, + options: ListContractsOptions & { cursor?: string | null; joiner?: string } = {}, + ): Promise>> { + if (!key) { + throw new TangoValidationError("IDV key is required"); + } + + const { limit = 25, cursor = null, shape, flat = false, flatLists = false, joiner = ".", filters = {}, ...restFilters } = options; + + const params: AnyRecord = { + limit: Math.min(limit, 100), + }; + if (cursor) params.cursor = cursor; + + const shapeToUse = shape ?? ShapeConfig.CONTRACTS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const mergedFilters: AnyRecord = { ...(filters ?? {}), ...restFilters }; + const apiFilterParams = buildContractFilterParams(mergedFilters); + Object.assign(params, apiFilterParams); + + const data = await this.http.get(`/api/idvs/${encodeURIComponent(key)}/awards/`, params); + const rawResults = Array.isArray(data?.results) ? (data.results as AnyRecord[]) : []; + + const results = this.materializeList("Contract", shapeSpec, rawResults, flat, joiner); + return buildPaginatedResponse({ ...data, results } as AnyRecord); + } + + async listIdvChildIdvs(options: { key: string } & ListIdvsOptions): Promise>> { + const { key, ...rest } = options; + if (!key) { + throw new TangoValidationError("IDV key is required"); + } + + const { limit = 25, cursor = null, shape, flat = false, flatLists = false, joiner = ".", ...filters } = rest; + + const params: AnyRecord = { + limit: Math.min(limit, 100), + }; + if (cursor) params.cursor = cursor; + + const shapeToUse = shape ?? ShapeConfig.IDVS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + Object.assign(params, filters); + + const data = await this.http.get(`/api/idvs/${encodeURIComponent(key)}/idvs/`, params); + const rawResults = Array.isArray(data?.results) ? (data.results as AnyRecord[]) : []; + + const results = this.materializeList("IDV", shapeSpec, rawResults, flat, joiner); + return buildPaginatedResponse({ ...data, results } as AnyRecord); + } + + async listIdvTransactions( + key: string, + options: { limit?: number; cursor?: string | null } = {}, + ): Promise>> { + if (!key) { + throw new TangoValidationError("IDV key is required"); + } + + const { limit = 100, cursor = null } = options; + const params: AnyRecord = { limit: Math.min(limit, 500) }; + if (cursor) params.cursor = cursor; + + const data = await this.http.get(`/api/idvs/${encodeURIComponent(key)}/transactions/`, params); + return buildPaginatedResponse>(data); + } + + async getIdvSummary(identifier: string): Promise> { + if (!identifier) { + throw new TangoValidationError("IDV solicitation identifier is required"); + } + return await this.http.get(`/api/idvs/${encodeURIComponent(identifier)}/summary/`); + } + + async listIdvSummaryAwards( + identifier: string, + options: { limit?: number; cursor?: string | null; ordering?: string } = {}, + ): Promise>> { + if (!identifier) { + throw new TangoValidationError("IDV solicitation identifier is required"); + } + + const { limit = 25, cursor = null, ordering } = options; + const params: AnyRecord = { limit: Math.min(limit, 100) }; + if (cursor) params.cursor = cursor; + if (ordering) params.ordering = ordering; + + const data = await this.http.get(`/api/idvs/${encodeURIComponent(identifier)}/summary/awards/`, params); + return buildPaginatedResponse>(data); + } + private parseShape(shape: string | null | undefined, flat: boolean, flatLists: boolean): ShapeSpec | null { if (!shape) return null; return this.shapeParser.parseWithFlags(shape, flat, flatLists); } - private materializeList(baseModel: string, shapeSpec: ShapeSpec | null, rawItems: AnyRecord[], flat: boolean): AnyRecord[] { - const prepared = flat ? rawItems.map((item) => unflattenResponse(item)) : rawItems; + private materializeList(baseModel: string, shapeSpec: ShapeSpec | null, rawItems: AnyRecord[], flat: boolean, joiner = "."): AnyRecord[] { + const prepared = flat ? rawItems.map((item) => unflattenResponse(item, joiner)) : rawItems; if (!shapeSpec) return prepared; return this.modelFactory.createList(baseModel, shapeSpec, prepared); } - private materializeOne(baseModel: string, shapeSpec: ShapeSpec | null, rawItem: AnyRecord, flat: boolean): AnyRecord { - const prepared = flat ? unflattenResponse(rawItem) : rawItem; + private materializeOne(baseModel: string, shapeSpec: ShapeSpec | null, rawItem: AnyRecord, flat: boolean, joiner = "."): AnyRecord { + const prepared = flat ? unflattenResponse(rawItem, joiner) : rawItem; if (!shapeSpec) return prepared; return this.modelFactory.createOne(baseModel, shapeSpec, prepared); } diff --git a/src/config.ts b/src/config.ts index 803501d..265e019 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,4 +25,30 @@ export const ShapeConfig = { // Default for listGrants() GRANTS_MINIMAL: "grant_id,opportunity_number,title,status(*),agency_code", + + // Default for listIdvs() + IDVS_MINIMAL: "key,piid,award_date,recipient(display_name,uei),description,total_contract_value,obligated,idv_type", + + // Default for getIdv() + IDVS_COMPREHENSIVE: + "key,piid,award_date,description,fiscal_year,total_contract_value,base_and_exercised_options_value,obligated," + + "idv_type,multiple_or_single_award_idv,type_of_idc,period_of_performance(start_date,last_date_to_order)," + + "recipient(display_name,legal_business_name,uei,cage_code)," + + "awarding_office(*),funding_office(*),place_of_performance(*),parent_award(key,piid)," + + "competition(*),legislative_mandates(*),transactions(*),subawards_summary(*)", + + // Default for listVehicles() + VEHICLES_MINIMAL: + "uuid,solicitation_identifier,organization_id,awardee_count,order_count," + + "vehicle_obligations,vehicle_contracts_value,solicitation_title,solicitation_date", + + // Default for getVehicle() + VEHICLES_COMPREHENSIVE: + "uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,who_can_use," + + "solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside," + + "fiscal_year,award_date,last_date_to_order,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value," + + "type_of_idc,contract_type,competition_details(*)", + + // Default for listVehicleAwardees() + VEHICLE_AWARDEES_MINIMAL: "uuid,key,piid,award_date,title,order_count,idv_obligations,idv_contracts_value,recipient(display_name,uei)", } as const; diff --git a/src/models/IDV.ts b/src/models/IDV.ts new file mode 100644 index 0000000..5ac4192 --- /dev/null +++ b/src/models/IDV.ts @@ -0,0 +1,17 @@ +import { RecipientProfile } from "./RecipientProfile.js"; + +export interface IDV { + uuid: string; + key: string; + piid?: string | null; + award_date?: string | null; + description?: string | null; + + recipient?: RecipientProfile | null; + + // Vehicle membership rollups (present on `/api/vehicles/{uuid}/awardees/`). + title?: string | null; + order_count?: number | null; + idv_obligations?: string | null; + idv_contracts_value?: string | null; +} diff --git a/src/models/Vehicle.ts b/src/models/Vehicle.ts new file mode 100644 index 0000000..b5f217a --- /dev/null +++ b/src/models/Vehicle.ts @@ -0,0 +1,31 @@ +export interface Vehicle { + uuid: string; + solicitation_identifier: string; + agency_id?: string | null; + organization_id?: string | null; + + // Choice fields are returned as {code, description} objects when shaped. + vehicle_type?: Record | null; + who_can_use?: Record | null; + type_of_idc?: Record | null; + contract_type?: Record | null; + + agency_details?: Record | null; + descriptions?: string[] | null; + fiscal_year?: number | null; + + solicitation_title?: string | null; + solicitation_description?: string | null; + solicitation_date?: string | null; + naics_code?: number | null; + psc_code?: string | null; + set_aside?: string | null; + + award_date?: string | null; + last_date_to_order?: string | null; + + awardee_count?: number | null; + order_count?: number | null; + vehicle_obligations?: string | null; + vehicle_contracts_value?: string | null; +} diff --git a/src/models/index.ts b/src/models/index.ts index 8301670..1acf687 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -8,3 +8,5 @@ export type { Location } from "./Location.js"; export type { Notice } from "./Notice.js"; export type { Opportunity } from "./Opportunity.js"; export type { RecipientProfile } from "./RecipientProfile.js"; +export type { Vehicle } from "./Vehicle.js"; +export type { IDV } from "./IDV.js"; diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index 0a6cd1d..a163be2 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -433,6 +433,155 @@ export const RECIPIENT_PROFILE_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: "Location", }, + cage: { + name: "cage", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + duns: { + name: "duns", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +export const AWARD_OFFICE_SCHEMA: FieldSchemaMap = { + office_code: { + name: "office_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + office_name: { + name: "office_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + agency_code: { + name: "agency_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + agency_name: { + name: "agency_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + department_code: { + name: "department_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + department_name: { + name: "department_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +export const IDV_PERIOD_OF_PERFORMANCE_SCHEMA: FieldSchemaMap = { + start_date: { + name: "start_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + last_date_to_order: { + name: "last_date_to_order", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +export const OFFICERS_SCHEMA: FieldSchemaMap = { + highly_compensated_officer_1_name: { + name: "highly_compensated_officer_1_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_1_amount: { + name: "highly_compensated_officer_1_amount", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_2_name: { + name: "highly_compensated_officer_2_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_2_amount: { + name: "highly_compensated_officer_2_amount", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_3_name: { + name: "highly_compensated_officer_3_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_3_amount: { + name: "highly_compensated_officer_3_amount", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_4_name: { + name: "highly_compensated_officer_4_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_4_amount: { + name: "highly_compensated_officer_4_amount", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_5_name: { + name: "highly_compensated_officer_5_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + highly_compensated_officer_5_amount: { + name: "highly_compensated_officer_5_amount", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, }; export const CONTRACT_SCHEMA: FieldSchemaMap = { @@ -1816,6 +1965,525 @@ export const GRANT_SCHEMA: FieldSchemaMap = { }, }; +// --------------------------------------------------------------------------- +// Vehicles (Awards) +// --------------------------------------------------------------------------- + +export const VEHICLE_COMPETITION_DETAILS_SCHEMA: FieldSchemaMap = { + commercial_item_acquisition_procedures: { + name: "commercial_item_acquisition_procedures", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + evaluated_preference: { + name: "evaluated_preference", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + extent_competed: { + name: "extent_competed", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + most_recent_solicitation_date: { + name: "most_recent_solicitation_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + number_of_offers_received: { + name: "number_of_offers_received", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + original_solicitation_date: { + name: "original_solicitation_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + other_than_full_and_open_competition: { + name: "other_than_full_and_open_competition", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + set_aside: { + name: "set_aside", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + simplified_procedures_for_certain_commercial_items: { + name: "simplified_procedures_for_certain_commercial_items", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + small_business_competitiveness_demonstration_program: { + name: "small_business_competitiveness_demonstration_program", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + solicitation_identifier: { + name: "solicitation_identifier", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + solicitation_procedures: { + name: "solicitation_procedures", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +export const IDV_SCHEMA: FieldSchemaMap = { + uuid: { + name: "uuid", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + key: { + name: "key", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + piid: { + name: "piid", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + award_date: { + name: "award_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + naics_code: { + name: "naics_code", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + psc_code: { + name: "psc_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + total_contract_value: { + name: "total_contract_value", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + base_and_exercised_options_value: { + name: "base_and_exercised_options_value", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + fiscal_year: { + name: "fiscal_year", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + obligated: { + name: "obligated", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + idv_type: { + name: "idv_type", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + multiple_or_single_award_idv: { + name: "multiple_or_single_award_idv", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + type_of_idc: { + name: "type_of_idc", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + description: { + name: "description", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + recipient: { + name: "recipient", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "RecipientProfile", + }, + place_of_performance: { + name: "place_of_performance", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "PlaceOfPerformance", + }, + awarding_office: { + name: "awarding_office", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "AwardOffice", + }, + funding_office: { + name: "funding_office", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "AwardOffice", + }, + parent_award: { + name: "parent_award", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "ParentAward", + }, + officers: { + name: "officers", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "Officers", + }, + legislative_mandates: { + name: "legislative_mandates", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "LegislativeMandates", + }, + set_aside: { + name: "set_aside", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "CodeDescription", + }, + period_of_performance: { + name: "period_of_performance", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "IDVPeriodOfPerformance", + }, + transactions: { + name: "transactions", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "Transaction", + }, + subawards_summary: { + name: "subawards_summary", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "SubawardsSummary", + }, + competition: { + name: "competition", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "Competition", + }, + awards: { + name: "awards", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "Contract", + }, + naics: { + name: "naics", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "CodeDescription", + }, + psc: { + name: "psc", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "CodeDescription", + }, + // Alias expansion used in vehicle shaping: orders(...) == IDV child awards/contracts. + orders: { + name: "orders", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "Contract", + }, + // Vehicle membership rollups (present on `/api/vehicles/{uuid}/awardees/`). + title: { + name: "title", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + order_count: { + name: "order_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + idv_obligations: { + name: "idv_obligations", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + idv_contracts_value: { + name: "idv_contracts_value", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +export const VEHICLE_SCHEMA: FieldSchemaMap = { + uuid: { + name: "uuid", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + solicitation_identifier: { + name: "solicitation_identifier", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + agency_id: { + name: "agency_id", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + organization_id: { + name: "organization_id", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + vehicle_type: { + name: "vehicle_type", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + who_can_use: { + name: "who_can_use", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + type_of_idc: { + name: "type_of_idc", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + contract_type: { + name: "contract_type", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + agency_details: { + name: "agency_details", + type: "dict", + isOptional: true, + isList: false, + nestedModel: null, + }, + descriptions: { + name: "descriptions", + type: "str", + isOptional: true, + isList: true, + nestedModel: null, + }, + fiscal_year: { + name: "fiscal_year", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + award_date: { + name: "award_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + last_date_to_order: { + name: "last_date_to_order", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + awardee_count: { + name: "awardee_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + order_count: { + name: "order_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + vehicle_obligations: { + name: "vehicle_obligations", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + vehicle_contracts_value: { + name: "vehicle_contracts_value", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + solicitation_title: { + name: "solicitation_title", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + solicitation_description: { + name: "solicitation_description", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + solicitation_date: { + name: "solicitation_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + naics_code: { + name: "naics_code", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + psc_code: { + name: "psc_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + set_aside: { + name: "set_aside", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + awardees: { + name: "awardees", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "IDV", + }, + opportunity: { + name: "opportunity", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "Opportunity", + }, + competition_details: { + name: "competition_details", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "VehicleCompetitionDetails", + }, +}; + export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Office: OFFICE_SCHEMA, Location: LOCATION_SCHEMA, @@ -1827,6 +2495,9 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Transaction: TRANSACTION_SCHEMA, Department: DEPARTMENT_SCHEMA, Contact: CONTACT_SCHEMA, + AwardOffice: AWARD_OFFICE_SCHEMA, + IDVPeriodOfPerformance: IDV_PERIOD_OF_PERFORMANCE_SCHEMA, + Officers: OFFICERS_SCHEMA, RecipientProfile: RECIPIENT_PROFILE_SCHEMA, Contract: CONTRACT_SCHEMA, Entity: ENTITY_SCHEMA, @@ -1835,6 +2506,9 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Notice: NOTICE_SCHEMA, Agency: AGENCY_SCHEMA, Grant: GRANT_SCHEMA, + Vehicle: VEHICLE_SCHEMA, + IDV: IDV_SCHEMA, + VehicleCompetitionDetails: VEHICLE_COMPETITION_DETAILS_SCHEMA, CFDANumber: CFDA_NUMBER_SCHEMA, CodeDescription: CODE_DESCRIPTION_SCHEMA, GrantAttachment: GRANT_ATTACHMENT_SCHEMA, diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index bfbac8d..d90bd0b 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -291,4 +291,262 @@ describe("TangoClient", () => { expect(contract.recipient?.display_name).toBe("Acme"); expect((contract as any).total_contract_value).toBe("123.45"); }); + + it("supports vehicles list + detail + awardees endpoints", async () => { + const calls: { url: string; init: RequestInit }[] = []; + + const fetchImpl = async (url: string | URL, init?: RequestInit): Promise => { + calls.push({ url: String(url), init: init ?? {} }); + + const parsed = new URL(String(url)); + + if (parsed.pathname.endsWith("/awardees/")) { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + results: [ + { + uuid: "00000000-0000-0000-0000-000000000002", + key: "IDV-KEY", + award_date: "2024-01-01", + idv_obligations: 100.0, + recipient: { display_name: "Acme", uei: "UEI123" }, + }, + ], + }); + }, + }; + } + + if (parsed.pathname === "/api/vehicles/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + results: [ + { + uuid: "00000000-0000-0000-0000-000000000001", + solicitation_identifier: "47QSWA20D0001", + solicitation_date: "2024-01-15", + vehicle_obligations: 123.45, + }, + ], + }); + }, + }; + } + + // getVehicle (detail) response with custom joiner and flat response + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + uuid: "00000000-0000-0000-0000-000000000001", + opportunity__title: "Test Opportunity", + }); + }, + }; + }; + + const client = new TangoClient({ + apiKey: "test", + baseUrl: "https://example.test", + fetchImpl, + }); + + const vehicles = await client.listVehicles({ search: "GSA" }); + expect(vehicles.results[0]).toMatchObject({ + uuid: "00000000-0000-0000-0000-000000000001", + solicitation_identifier: "47QSWA20D0001", + }); + expect((vehicles.results[0] as any).solicitation_date).toBeInstanceOf(Date); + expect((vehicles.results[0] as any).vehicle_obligations).toBe("123.45"); + + const vehicle = await client.getVehicle("00000000-0000-0000-0000-000000000001", { + shape: "uuid,opportunity(title)", + flat: true, + joiner: "__", + flatLists: true, + }); + expect((vehicle as any).opportunity.title).toBe("Test Opportunity"); + + const awardees = await client.listVehicleAwardees("00000000-0000-0000-0000-000000000001"); + expect((awardees.results[0] as any).award_date).toBeInstanceOf(Date); + expect((awardees.results[0] as any).idv_obligations).toBe("100"); + + // Verify query params were sent for each call + expect(calls).toHaveLength(3); + const urls = calls.map((c) => new URL(c.url)); + + // listVehicles + expect(urls[0].pathname).toBe("/api/vehicles/"); + expect(urls[0].searchParams.get("shape")).toBe(ShapeConfig.VEHICLES_MINIMAL); + expect(urls[0].searchParams.get("search")).toBe("GSA"); + + // getVehicle + expect(urls[1].pathname).toBe("/api/vehicles/00000000-0000-0000-0000-000000000001/"); + expect(urls[1].searchParams.get("shape")).toBe("uuid,opportunity(title)"); + expect(urls[1].searchParams.get("flat")).toBe("true"); + expect(urls[1].searchParams.get("joiner")).toBe("__"); + expect(urls[1].searchParams.get("flat_lists")).toBe("true"); + + // listVehicleAwardees + expect(urls[2].pathname).toBe("/api/vehicles/00000000-0000-0000-0000-000000000001/awardees/"); + expect(urls[2].searchParams.get("shape")).toBe(ShapeConfig.VEHICLE_AWARDEES_MINIMAL); + }); + + it("validates required arguments for getVehicle", async () => { + const client = new TangoClient({ + apiKey: "test", + baseUrl: "https://example.test", + fetchImpl: async () => { + throw new Error("should not be called"); + }, + }); + + // @ts-expect-error + await expect(client.getVehicle("")).rejects.toBeInstanceOf(TangoValidationError); + }); + + it("supports idv endpoints (list + retrieve + child endpoints)", async () => { + const calls: string[] = []; + + const fetchImpl = async (url: string | URL): Promise => { + calls.push(String(url)); + const parsed = new URL(String(url)); + + if (parsed.pathname === "/api/idvs/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + next: "https://example.test/api/idvs/?cursor=next", + results: [ + { + key: "IDV-KEY", + piid: "47QSWA20D0001", + award_date: "2024-01-01", + obligated: 10.0, + idv_type: { code: "A", description: "GWAC" }, + recipient: { display_name: "Acme", uei: "UEI123" }, + }, + ], + }); + }, + }; + } + + if (parsed.pathname === "/api/idvs/IDV-KEY/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + key: "IDV-KEY", + award_date: "2024-01-01", + obligated: 10.0, + }); + }, + }; + } + + if (parsed.pathname === "/api/idvs/IDV-KEY/awards/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + results: [{ key: "C-1", award_date: "2024-01-02", total_contract_value: 123.45 }], + }); + }, + }; + } + + if (parsed.pathname === "/api/idvs/IDV-KEY/idvs/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 0, + results: [], + }); + }, + }; + } + + if (parsed.pathname === "/api/idvs/IDV-KEY/transactions/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + count: 1, + results: [{ modification_number: "0", obligated: 1.23, transaction_date: "2024-01-03" }], + }); + }, + }; + } + + if (parsed.pathname === "/api/idvs/SOL/summary/") { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ solicitation_identifier: "SOL", awardee_count: 2 }); + }, + }; + } + + // /summary/awards/ + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ count: 0, results: [] }); + }, + }; + }; + + const client = new TangoClient({ + apiKey: "test", + baseUrl: "https://example.test", + fetchImpl, + }); + + const idvs = await client.listIdvs({ limit: 10, cursor: "abc", awarding_agency: "4700" }); + expect(idvs.results).toHaveLength(1); + expect((idvs.results[0] as any).award_date).toBeInstanceOf(Date); + expect((idvs.results[0] as any).obligated).toBe("10"); + + const idv = await client.getIdv("IDV-KEY"); + expect((idv as any).key).toBe("IDV-KEY"); + + const awards = await client.listIdvAwards("IDV-KEY", { limit: 5 }); + expect((awards.results[0] as any).award_date).toBeInstanceOf(Date); + expect((awards.results[0] as any).total_contract_value).toBe("123.45"); + + await client.listIdvChildIdvs({ key: "IDV-KEY", limit: 5 }); + await client.listIdvTransactions("IDV-KEY", { limit: 50 }); + await client.getIdvSummary("SOL"); + await client.listIdvSummaryAwards("SOL", { limit: 25, ordering: "-award_date" }); + + const parsedCalls = calls.map((u) => new URL(u)); + + // listIdvs + expect(parsedCalls[0].pathname).toBe("/api/idvs/"); + expect(parsedCalls[0].searchParams.get("limit")).toBe("10"); + expect(parsedCalls[0].searchParams.get("cursor")).toBe("abc"); + expect(parsedCalls[0].searchParams.get("awarding_agency")).toBe("4700"); + expect(parsedCalls[0].searchParams.get("shape")).toBe(ShapeConfig.IDVS_MINIMAL); + }); });