diff --git a/CHANGELOG.md b/CHANGELOG.md index 73fd992..b2803e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,14 @@ This project follows [Semantic Versioning](https://semver.org/). ### 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`) - Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs `makegov/tango#1275`) ### Changed - HTTP client now supports PATCH/PUT/DELETE for non-GET endpoints. +- `joiner` is now respected when unflattening `flat=true` responses on supported endpoints. ## [0.1.0] - 2025-11-21 diff --git a/README.md b/README.md index 301af48..281dbbb 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, grants, and webhooks. +- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, vehicles, IDVs, forecasts, opportunities, notices, grants, and webhooks. - **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 e0a0ea9..df5eff3 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 334d6b7..00a7130 100644 --- a/src/client.ts +++ b/src/client.ts @@ -112,6 +112,21 @@ export interface ListWebhookSubscriptionsOptions { pageSize?: number; } +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; @@ -416,6 +431,278 @@ 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); + } + // --------------------------------------------------------------------------- // Webhooks (v2) // --------------------------------------------------------------------------- @@ -516,14 +803,14 @@ export class TangoClient { 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 d7f3e17..2068879 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -8,6 +8,8 @@ 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"; export type { WebhookEndpoint, WebhookEventType, 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 817e9e9..d34685a 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -292,6 +292,264 @@ describe("TangoClient", () => { 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); + }); + it("supports webhooks v2 endpoints (event types, subscriptions, test delivery, sample payload)", async () => { const calls: { url: string; init: RequestInit }[] = []; @@ -482,7 +740,7 @@ describe("TangoClient", () => { expect(endpoints.results[0].name).toBe("yoni"); const created = await client.createWebhookEndpoint({ callbackUrl: "https://example.com/tango/webhooks" }); - expect(created.secret).toBe("secret"); + expect((created as any).secret).toBe("secret"); const updated = await client.updateWebhookEndpoint(created.id, { isActive: false }); expect(updated.is_active).toBe(false);