diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 210e61c..766403a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,10 +3,9 @@ name: Tests # Go test matrix. Runs on push to main and on all PRs. # # Note on Go versions: per locked decision D-03 the minimum supported Go -# version is 1.23. go.mod currently still says `go 1.22` — that bump lands -# in a follow-up wave. The workflow targets 1.23+ now so the matrix is -# correct on day one of v1.0.0; "stable" tracks the current Go release -# (1.24+ when GitHub Actions picks it up). +# version is 1.23, which matches `go 1.23` in go.mod. The workflow targets +# 1.23+ now so the matrix is correct on day one of v1.0.0; "stable" tracks +# the current Go release (1.24+ when GitHub Actions picks it up). on: push: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e14515..0e0a9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to `github.com/makegov/tango-go` will be documented in this This project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +Sync to Tango API v4.6.9. Pre-1.0 (SemVer 0.x): the removals below are breaking but ship without a deprecation cycle. + +### Added + +- **Budget surface** (`budget.go`): `ListBudgetAccounts` / `IterateBudgetAccounts` (`GET /api/budget/accounts/`), `GetBudgetAccount` (`GET /api/budget/accounts/{id}/`), `GetBudgetAccountQuarters` (`GET /api/budget/accounts/{id}/quarters/`), `GetBudgetAccountRecipients` (`GET /api/budget/accounts/{id}/recipients/`). New `ListBudgetAccountsOptions` and `ShapeBudgetAccountsMinimal` shape constant. +- Singleton detail GETs: `GetContract` (`GET /api/contracts/{key}/`), `GetOpportunity`, `GetNotice`, `GetForecast`, `GetGrant`, `GetSubaward`. +- Contract sub-routes: `ListContractSubawards` (`GET /api/contracts/{key}/subawards/`), `ListContractTransactions` (`GET /api/contracts/{key}/transactions/`). +- `GetEntityBudgetFlows` (`GET /api/entities/{uei}/budget-flows/`). +- `GrantID` typed filter on `ListGrantsOptions`. +- `Cage` typed filter on `ListEntitiesOptions` (distinct from the existing `CageCode`; the server rejects setting both). + +### Removed + +- **Breaking**: `GetIDVSummary` and `ListIDVSummaryAwards`. These hit `/api/idvs/{id}/summary/` and `/api/idvs/{id}/summary/awards/`, which have never existed in the Tango API (the server returns 404). Use `GetIDV` with a richer shape and `ListIDVAwards` respectively. + ## [0.1.0] - 2026-05-15 First public release of the Tango Go SDK. diff --git a/ROADMAP.md b/ROADMAP.md index fcea84e..37809aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,15 @@ This roadmap tracks the Go SDK only. The goal is to stay closely aligned with th - [X] Sub-resource walks for IDVs, entities, agencies, vehicles. - [X] OTAs / OTIDVs, GSA eLibrary, IT Dashboard, protests, LCATs. +## 0.2 (API sync to v4.6.9) + +- [X] Budget surface: `ListBudgetAccounts` / `IterateBudgetAccounts`, `GetBudgetAccount`, `GetBudgetAccountQuarters`, `GetBudgetAccountRecipients` (`/api/budget/accounts/`). +- [X] Singleton detail GETs: `GetContract`, `GetOpportunity`, `GetNotice`, `GetForecast`, `GetGrant`, `GetSubaward`. +- [X] Contract sub-routes: `ListContractSubawards`, `ListContractTransactions`. +- [X] `GetEntityBudgetFlows` (`/api/entities/{uei}/budget-flows/`). +- [X] `GrantID` filter on grants; `Cage` filter on entities. +- [X] Removed fabricated `GetIDVSummary` / `ListIDVSummaryAwards` (paths never existed upstream). + ## Next - [ ] Comprehensive integration tests against the live Tango API. diff --git a/budget.go b/budget.go new file mode 100644 index 0000000..19500fa --- /dev/null +++ b/budget.go @@ -0,0 +1,121 @@ +package tango + +import ( + "context" + "net/url" +) + +// ListBudgetAccountsOptions filters /api/budget/accounts/ — federal-account x +// fiscal-year budget rollups. Embed ListOptions for pagination + shape +// control. The BudgetAccount schema is wide (~63 fields) and shape-driven; +// use ShapeBudgetAccountsMinimal for a compact default. The full __gte / __lte +// numeric-range filter set is reachable via Extra. +type ListBudgetAccountsOptions struct { + ListOptions + + // FederalAccountSymbol is an exact filter (e.g. "097-0100"). + FederalAccountSymbol string + // FiscalYear is an exact filter. + FiscalYear string + // FiscalYearGte is the inclusive lower bound for fiscal_year. + FiscalYearGte string + // FiscalYearLte is the inclusive upper bound for fiscal_year. + FiscalYearLte string + // AgencyCode is the awarding/funding agency CGAC code (exact). + AgencyCode string + // BEACategory is the Bureau of Economic Analysis category (exact). + BEACategory string + // OnOffBudget is the on/off-budget flag (exact). + OnOffBudget string + // Search is a free-text search filter. + Search string + // Ordering is the server-side sort spec; prefix "-" for descending. + Ordering string + + // Extra is an escape hatch for filter keys not yet first-classed on this + // struct (e.g. the *_gte / *_lte range filters on the numeric metrics). + Extra map[string]any +} + +func (o *ListBudgetAccountsOptions) toQuery() url.Values { + q := url.Values{} + if o == nil { + return q + } + o.ListOptions.applyTo(q) + setIfNotEmpty(q, "federal_account_symbol", o.FederalAccountSymbol) + setIfNotEmpty(q, "fiscal_year", o.FiscalYear) + setIfNotEmpty(q, "fiscal_year__gte", o.FiscalYearGte) + setIfNotEmpty(q, "fiscal_year__lte", o.FiscalYearLte) + setIfNotEmpty(q, "agency_code", o.AgencyCode) + setIfNotEmpty(q, "bea_category", o.BEACategory) + setIfNotEmpty(q, "on_off_budget", o.OnOffBudget) + setIfNotEmpty(q, "search", o.Search) + setIfNotEmpty(q, "ordering", o.Ordering) + for k, v := range o.Extra { + q.Set(k, valueToString(v)) + } + return q +} + +// ListBudgetAccounts queries /api/budget/accounts/. +func (c *Client) ListBudgetAccounts(ctx context.Context, opts *ListBudgetAccountsOptions) (*PaginatedResponse[Record], error) { + q := url.Values{} + if opts != nil { + q = opts.toQuery() + } + return listGeneric[Record](ctx, c, "/api/budget/accounts/", q) +} + +// IterateBudgetAccounts returns an Iterator that walks every budget-account +// rollup matching opts. The iterator follows ?page= or ?cursor= on the +// server's next URL automatically. +func (c *Client) IterateBudgetAccounts(ctx context.Context, opts *ListBudgetAccountsOptions) *Iterator[Record] { + if opts == nil { + opts = &ListBudgetAccountsOptions{} + } + return &Iterator[Record]{ + ctx: ctx, + fetch: func(ctx context.Context, page int, cursor string) (*PaginatedResponse[Record], error) { + next := *opts + next.Page = page + next.Cursor = cursor + return c.ListBudgetAccounts(ctx, &next) + }, + } +} + +// GetBudgetAccount fetches a single budget-account rollup by its id +// (/api/budget/accounts/{id}/). +func (c *Client) GetBudgetAccount(ctx context.Context, id string, opts *ListOptions) (Record, error) { + if id == "" { + return nil, &ValidationError{&APIError{Message: "budget account id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/budget/accounts/"+pathEscape(id)+"/", q) +} + +// GetBudgetAccountQuarters fetches the quarterly lifecycle detail for a single +// account-year (/api/budget/accounts/{id}/quarters/). +func (c *Client) GetBudgetAccountQuarters(ctx context.Context, id string, opts *ListOptions) (*PaginatedResponse[Record], error) { + if id == "" { + return nil, &ValidationError{&APIError{Message: "budget account id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return listGeneric[Record](ctx, c, "/api/budget/accounts/"+pathEscape(id)+"/quarters/", q) +} + +// GetBudgetAccountRecipients fetches the funding-office x recipient contract- +// flow detail for a single account-year (/api/budget/accounts/{id}/recipients/). +// The response envelope carries extra keys (federal_account_symbol, +// fiscal_year) alongside the standard pagination fields. +func (c *Client) GetBudgetAccountRecipients(ctx context.Context, id string, opts *ListOptions) (*PaginatedResponse[Record], error) { + if id == "" { + return nil, &ValidationError{&APIError{Message: "budget account id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return listGeneric[Record](ctx, c, "/api/budget/accounts/"+pathEscape(id)+"/recipients/", q) +} diff --git a/budget_test.go b/budget_test.go new file mode 100644 index 0000000..4e511d0 --- /dev/null +++ b/budget_test.go @@ -0,0 +1,142 @@ +package tango + +import ( + "context" + "errors" + "net/http" + "testing" +) + +func TestListBudgetAccountsFilterMapping(t *testing.T) { + cases := []struct { + name string + opts *ListBudgetAccountsOptions + wantQS map[string]string + notInQS []string + }{ + { + name: "nil opts produces empty query", + opts: nil, + wantQS: map[string]string{}, + notInQS: []string{"federal_account_symbol", "fiscal_year", "search"}, + }, + { + name: "all typed filters", + opts: &ListBudgetAccountsOptions{ + FederalAccountSymbol: "097-0100", + FiscalYear: "2024", + FiscalYearGte: "2020", + FiscalYearLte: "2025", + AgencyCode: "9700", + BEACategory: "discretionary", + OnOffBudget: "on", + Search: "operations", + Ordering: "-enacted_ba", + }, + wantQS: map[string]string{ + "federal_account_symbol": "097-0100", + "fiscal_year": "2024", + "fiscal_year__gte": "2020", + "fiscal_year__lte": "2025", + "agency_code": "9700", + "bea_category": "discretionary", + "on_off_budget": "on", + "search": "operations", + "ordering": "-enacted_ba", + }, + }, + { + name: "pagination and shape", + opts: &ListBudgetAccountsOptions{ + ListOptions: ListOptions{Page: 2, Limit: 50, Shape: ShapeBudgetAccountsMinimal}, + }, + wantQS: map[string]string{ + "page": "2", + "limit": "50", + "shape": ShapeBudgetAccountsMinimal, + }, + }, + { + name: "extra forwards range filters", + opts: &ListBudgetAccountsOptions{Extra: map[string]any{"enacted_ba__gte": "1000000"}}, + wantQS: map[string]string{"enacted_ba__gte": "1000000"}, + }, + { + name: "zero values omitted", + opts: &ListBudgetAccountsOptions{}, + notInQS: []string{"federal_account_symbol", "fiscal_year", "agency_code", "ordering"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.ListBudgetAccounts(context.Background(), tc.opts) + assertQueryContains(t, capturedURL, tc.wantQS, tc.notInQS) + }) + } +} + +func TestListBudgetAccountsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.ListBudgetAccounts(context.Background(), nil) + assertPathContains(t, capturedURL, "/api/budget/accounts/") +} + +func TestIterateBudgetAccountsBuildsIterator(t *testing.T) { + c, _ := newTestClient(t, http.HandlerFunc(emptyListHandler)) + it := c.IterateBudgetAccounts(context.Background(), nil) + if it == nil { + t.Fatal("expected non-nil iterator") + } +} + +func TestGetBudgetAccountRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetBudgetAccount(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetBudgetAccountBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetBudgetAccount(context.Background(), "097-0100-2024", nil) + assertPathContains(t, capturedURL, "/api/budget/accounts/097-0100-2024/") +} + +func TestGetBudgetAccountQuartersRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetBudgetAccountQuarters(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetBudgetAccountQuartersBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.GetBudgetAccountQuarters(context.Background(), "acct-1", nil) + assertPathContains(t, capturedURL, "/api/budget/accounts/acct-1/quarters/") +} + +func TestGetBudgetAccountRecipientsRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetBudgetAccountRecipients(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetBudgetAccountRecipientsBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.GetBudgetAccountRecipients(context.Background(), "acct-1", nil) + assertPathContains(t, capturedURL, "/api/budget/accounts/acct-1/recipients/") +} diff --git a/contracts.go b/contracts.go index 5971f11..b6c3fb3 100644 --- a/contracts.go +++ b/contracts.go @@ -169,6 +169,43 @@ func (c *Client) IterateContracts(ctx context.Context, opts *ListContractsOption } } +// GetContract fetches a single federal contract record by its key +// (/api/contracts/{key}/). +func (c *Client) GetContract(ctx context.Context, key string, opts *ListOptions) (Record, error) { + if key == "" { + return nil, &ValidationError{&APIError{Message: "contract key is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/contracts/"+pathEscape(key)+"/", q) +} + +// ListContractSubawards lists subawards reported against a single prime +// contract (/api/contracts/{key}/subawards/). +func (c *Client) ListContractSubawards(ctx context.Context, key string, opts *EntitySubresourceOptions) (*PaginatedResponse[Record], error) { + if key == "" { + return nil, &ValidationError{&APIError{Message: "contract key is required"}} + } + q := url.Values{} + if opts != nil { + q = opts.toQuery() + } + return listGeneric[Record](ctx, c, "/api/contracts/"+pathEscape(key)+"/subawards/", q) +} + +// ListContractTransactions lists the raw transaction history backing a single +// contract (/api/contracts/{key}/transactions/). +func (c *Client) ListContractTransactions(ctx context.Context, key string, opts *EntitySubresourceOptions) (*PaginatedResponse[Record], error) { + if key == "" { + return nil, &ValidationError{&APIError{Message: "contract key is required"}} + } + q := url.Values{} + if opts != nil { + q = opts.toQuery() + } + return listGeneric[Record](ctx, c, "/api/contracts/"+pathEscape(key)+"/transactions/", q) +} + func valueToString(v any) string { switch t := v.(type) { case string: diff --git a/contracts_test.go b/contracts_test.go index 18c5542..4b53bbb 100644 --- a/contracts_test.go +++ b/contracts_test.go @@ -242,6 +242,54 @@ func TestListContractsAwardType(t *testing.T) { assertQueryContains(t, capturedURL, map[string]string{"award_type": "A"}, nil) } +func TestGetContractRequiresKey(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetContract(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetContractBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetContract(context.Background(), "KEY-1", nil) + assertPathContains(t, capturedURL, "/api/contracts/KEY-1/") +} + +func TestListContractSubawardsRequiresKey(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.ListContractSubawards(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestListContractSubawardsBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.ListContractSubawards(context.Background(), "KEY-1", nil) + assertPathContains(t, capturedURL, "/api/contracts/KEY-1/subawards/") +} + +func TestListContractTransactionsRequiresKey(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.ListContractTransactions(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestListContractTransactionsBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.ListContractTransactions(context.Background(), "KEY-1", nil) + assertPathContains(t, capturedURL, "/api/contracts/KEY-1/transactions/") +} + func TestListContractsServerError(t *testing.T) { c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 87fa7cb..f919112 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -16,6 +16,7 @@ client := tango.NewClient(tango.WithAPIKey(os.Getenv("TANGO_API_KEY"))) - [Organizations / Offices / Departments](#organizations--offices--departments) - [Business types](#business-types) - [Contracts](#contracts) +- [Budget](#budget) - [IDVs](#idvs) (+ sub-resources) - [OTAs / OTIDVs](#otas--otidvs) - [Subawards](#subawards) @@ -176,6 +177,44 @@ for c, err := range client.IterateContracts(ctx, opts).Seq() { } ``` +### `GetContract(ctx, key string, *ListOptions) (Record, error)` + +`GET /api/contracts/{key}/`. Fetches a single contract record. Validates `key` non-empty client-side. + +### `ListContractSubawards(ctx, key string, *EntitySubresourceOptions) (*PaginatedResponse[Record], error)` + +`GET /api/contracts/{key}/subawards/`. Subawards reported against a single prime contract. + +### `ListContractTransactions(ctx, key string, *EntitySubresourceOptions) (*PaginatedResponse[Record], error)` + +`GET /api/contracts/{key}/transactions/`. Raw transaction history backing a single contract. + +--- + +## Budget + +Federal-account x fiscal-year budget rollups. The `BudgetAccount` schema is wide (~63 fields) and shape-driven; use `ShapeBudgetAccountsMinimal` for a compact default. The full `__gte` / `__lte` numeric-range filters are reachable via `Extra`. + +### `ListBudgetAccounts(ctx, *ListBudgetAccountsOptions) (*PaginatedResponse[Record], error)` + +`GET /api/budget/accounts/`. Lists budget-account rollups. Typed filters: `FederalAccountSymbol`, `FiscalYear` (+ `FiscalYearGte` / `FiscalYearLte`), `AgencyCode`, `BEACategory`, `OnOffBudget`, `Search`, `Ordering`. + +### `IterateBudgetAccounts(ctx, *ListBudgetAccountsOptions) *Iterator[Record]` + +Walks every budget-account rollup matching opts. + +### `GetBudgetAccount(ctx, id string, *ListOptions) (Record, error)` + +`GET /api/budget/accounts/{id}/`. A single budget-account rollup. + +### `GetBudgetAccountQuarters(ctx, id string, *ListOptions) (*PaginatedResponse[Record], error)` + +`GET /api/budget/accounts/{id}/quarters/`. Quarterly lifecycle detail for an account-year. + +### `GetBudgetAccountRecipients(ctx, id string, *ListOptions) (*PaginatedResponse[Record], error)` + +`GET /api/budget/accounts/{id}/recipients/`. Funding-office x recipient contract-flow detail. The envelope carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside the standard pagination fields. + --- ## IDVs @@ -204,14 +243,6 @@ IDVs (indefinite delivery vehicles) are parent "vehicle award" records that can `GET /api/idvs/{key}/transactions/`. Raw transaction history backing an IDV. Only accepts pagination params (no filters). -### `GetIDVSummary(ctx, identifier string) (Record, error)` - -> **Deprecated.** `GET /api/idvs/{identifier}/summary/`. The current server returns `404` for this endpoint. Retained for parity with the Node SDK. Migrate to `GetIDV` with a richer `Shape`. - -### `ListIDVSummaryAwards(ctx, identifier string, *ListOptions) (*PaginatedResponse[Record], error)` - -> **Deprecated.** `GET /api/idvs/{identifier}/summary/awards/`. Server returns `404`. Migrate to `ListIDVAwards`. - ### `ListIDVLcats(ctx, key string, *EntityLcatsOptions) (*PaginatedResponse[Record], error)` `GET /api/idvs/{key}/lcats/`. Labor Categories attached to an IDV. Re-uses `EntityLcatsOptions` because the entity and IDV lcats endpoints share a parameter shape. @@ -260,6 +291,10 @@ OTAs (Other Transaction Authority awards) and OTIDVs (umbrella OT agreements wit > **Shape constraints.** Use `ShapeSubawardsMinimal` — the server rejects `id` and `amount` in subaward shapes. +### `GetSubaward(ctx, key string, *ListOptions) (Record, error)` + +`GET /api/subawards/{key}/`. A single subaward record. Validates `key` non-empty client-side. + --- ## Vehicles @@ -294,7 +329,9 @@ Vehicles provide a solicitation-centric grouping of related IDVs. ### `ListEntities(ctx, *ListEntitiesOptions) (*PaginatedResponse[Record], error)` -`GET /api/entities/`. Federal vendors / recipients. Filters: `Search`, `CageCode`, `NAICS`, `Name`, `PSC`, `PurposeOfRegistrationCode`, `Socioeconomic`, `State`, `TotalAwardsObligated[Gte/Lte]`, `UEI`, `ZipCode`. +`GET /api/entities/`. Federal vendors / recipients. Filters: `Search`, `CageCode`, `Cage`, `NAICS`, `Name`, `PSC`, `PurposeOfRegistrationCode`, `Socioeconomic`, `State`, `TotalAwardsObligated[Gte/Lte]`, `UEI`, `ZipCode`. + +> `Cage` and `CageCode` are distinct API filters; the server rejects setting both — use one or the other. ### `GetEntity(ctx, key string, *GetEntityOptions) (Record, error)` @@ -314,6 +351,7 @@ All take a UEI plus `*EntitySubresourceOptions` (embeds `ListOptions` + `Joiner` | `ListEntityOTIDVs(ctx, uei, *EntitySubresourceOptions)` | `GET /api/entities/{uei}/otidvs/` | | `ListEntitySubawards(ctx, uei, *EntitySubawardsOptions)` | `GET /api/entities/{uei}/subawards/` | | `ListEntityLcats(ctx, uei, *EntityLcatsOptions)` | `GET /api/entities/{uei}/lcats/` | +| `GetEntityBudgetFlows(ctx, uei, *EntitySubresourceOptions)` | `GET /api/entities/{uei}/budget-flows/` | All return `*PaginatedResponse[Record]`. Empty UEI is rejected client-side as `*ValidationError`. @@ -331,6 +369,10 @@ All return `*PaginatedResponse[Record]`. Empty UEI is rejected client-side as `* ### `IterateOpportunities(ctx, *ListOpportunitiesOptions) *Iterator[Record]` +### `GetOpportunity(ctx, opportunityID string, *ListOptions) (Record, error)` + +`GET /api/opportunities/{opportunity_id}/`. A single opportunity. Validates `opportunityID` non-empty client-side. + ### `SearchOpportunityAttachments(ctx, SearchOpportunityAttachmentsOptions) (Record, error)` `GET /api/opportunities/attachment-search/`. Semantic search over the extracted text of opportunity attachments (SOWs, PWSs, J&As, etc.). @@ -353,18 +395,30 @@ res, err := client.SearchOpportunityAttachments(ctx, tango.SearchOpportunityAtta ### `IterateNotices(ctx, *ListNoticesOptions) *Iterator[Record]` +### `GetNotice(ctx, noticeID string, *ListOptions) (Record, error)` + +`GET /api/notices/{notice_id}/`. A single notice. Validates `noticeID` non-empty client-side. + ### `ListForecasts(ctx, *ListForecastsOptions) (*PaginatedResponse[Record], error)` `GET /api/forecasts/`. Filters: `Agency`, `AwardDate[After/Before]`, `FiscalYear[Gte/Lte]`, `Modified[After/Before]`, `NAICSCode`, `NAICSStartsWith`, `Ordering`, `Search`, `SourceSystem`, `Status`. ### `IterateForecasts(ctx, *ListForecastsOptions) *Iterator[Record]` +### `GetForecast(ctx, id string, *ListOptions) (Record, error)` + +`GET /api/forecasts/{id}/`. A single procurement forecast. Validates `id` non-empty client-side. + ### `ListGrants(ctx, *ListGrantsOptions) (*PaginatedResponse[Record], error)` -`GET /api/grants/`. Filters: `Agency`, `ApplicantTypes`, `CFDANumber`, `FundingCategories`, `FundingInstruments`, `OpportunityNumber`, `Ordering`, `PostedDate[After/Before]`, `ResponseDate[After/Before]`, `Search`, `Status`. +`GET /api/grants/`. Filters: `Agency`, `ApplicantTypes`, `CFDANumber`, `GrantID`, `FundingCategories`, `FundingInstruments`, `OpportunityNumber`, `Ordering`, `PostedDate[After/Before]`, `ResponseDate[After/Before]`, `Search`, `Status`. ### `IterateGrants(ctx, *ListGrantsOptions) *Iterator[Record]` +### `GetGrant(ctx, grantID string, *ListOptions) (Record, error)` + +`GET /api/grants/{grant_id}/`. A single grant opportunity. Validates `grantID` non-empty client-side. + --- ## Protests diff --git a/entities.go b/entities.go index e0aa5c6..9655306 100644 --- a/entities.go +++ b/entities.go @@ -9,8 +9,11 @@ import ( type ListEntitiesOptions struct { ListOptions - Search string - CageCode string + Search string + CageCode string + // Cage is a distinct API filter from CageCode; the server rejects + // setting both — use one or the other. + Cage string NAICS string Name string PSC string @@ -33,6 +36,7 @@ func (o *ListEntitiesOptions) toQuery() url.Values { o.ListOptions.applyTo(q) setIfNotEmpty(q, "search", o.Search) setIfNotEmpty(q, "cage_code", o.CageCode) + setIfNotEmpty(q, "cage", o.Cage) setIfNotEmpty(q, "naics", o.NAICS) setIfNotEmpty(q, "name", o.Name) setIfNotEmpty(q, "psc", o.PSC) diff --git a/entities_test.go b/entities_test.go index 8f557e4..e44a2ce 100644 --- a/entities_test.go +++ b/entities_test.go @@ -25,6 +25,7 @@ func TestListEntitiesFilterMapping(t *testing.T) { opts: &ListEntitiesOptions{ Search: "Acme", CageCode: "1ABC5", + Cage: "1ABC5", NAICS: "541512", Name: "Acme Corp", PSC: "D302", @@ -39,6 +40,7 @@ func TestListEntitiesFilterMapping(t *testing.T) { wantQS: map[string]string{ "search": "Acme", "cage_code": "1ABC5", + "cage": "1ABC5", "naics": "541512", "name": "Acme Corp", "psc": "D302", diff --git a/entity_subresources.go b/entity_subresources.go index 2a5b93f..ff67254 100644 --- a/entity_subresources.go +++ b/entity_subresources.go @@ -164,3 +164,10 @@ func (c *Client) ListEntityLcats(ctx context.Context, uei string, opts *EntityLc } return listGeneric[Record](ctx, c, "/api/entities/"+pathEscape(uei)+"/lcats/", q) } + +// GetEntityBudgetFlows lists funding-account budget flows attributed to an +// entity (/api/entities/{uei}/budget-flows/). Returns a paginated list of +// funding-account rows. +func (c *Client) GetEntityBudgetFlows(ctx context.Context, uei string, opts *EntitySubresourceOptions) (*PaginatedResponse[Record], error) { + return c.listEntitySubresource(ctx, uei, "budget-flows", opts) +} diff --git a/entity_subresources_test.go b/entity_subresources_test.go index 68a4a1a..c7f22c9 100644 --- a/entity_subresources_test.go +++ b/entity_subresources_test.go @@ -158,6 +158,22 @@ func TestListEntityLcatsBuildsPath(t *testing.T) { assertPathContains(t, capturedURL, "/api/entities/UEI12345/lcats/") } +func TestGetEntityBudgetFlowsRequiresUEI(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetEntityBudgetFlows(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetEntityBudgetFlowsBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLHandler(&capturedURL)) + _, _ = c.GetEntityBudgetFlows(context.Background(), "UEI12345", nil) + assertPathContains(t, capturedURL, "/api/entities/UEI12345/budget-flows/") +} + func TestEntityLcatsOptionsFilterMapping(t *testing.T) { var capturedURL string c, _ := newTestClient(t, captureURLHandler(&capturedURL)) diff --git a/idv_subresources.go b/idv_subresources.go index 03fb3a2..3a24f64 100644 --- a/idv_subresources.go +++ b/idv_subresources.go @@ -46,36 +46,6 @@ func (c *Client) ListIDVTransactions(ctx context.Context, key string, opts *List return listGeneric[Record](ctx, c, "/api/idvs/"+pathEscape(key)+"/transactions/", q) } -// GetIDVSummary fetches the summary roll-up for an IDV by its -// solicitation identifier (/api/idvs/{identifier}/summary/). -// -// Deprecated: the v1.0.0 server returns 404 for this endpoint per the -// upstream Node SDK CHANGELOG. The method is retained for parity; callers -// should migrate to GetIDV with a richer shape. -func (c *Client) GetIDVSummary(ctx context.Context, identifier string) (Record, error) { - if identifier == "" { - return nil, &ValidationError{&APIError{Message: "IDV solicitation identifier is required"}} - } - return getGeneric[Record](ctx, c, "/api/idvs/"+pathEscape(identifier)+"/summary/", nil) -} - -// ListIDVSummaryAwards lists awards belonging to an IDV summary -// (/api/idvs/{identifier}/summary/awards/). -// -// Deprecated: the v1.0.0 server returns 404 for this endpoint per the -// upstream Node SDK CHANGELOG. The method is retained for parity; callers -// should migrate to ListIDVAwards. -func (c *Client) ListIDVSummaryAwards(ctx context.Context, identifier string, opts *ListOptions) (*PaginatedResponse[Record], error) { - if identifier == "" { - return nil, &ValidationError{&APIError{Message: "IDV solicitation identifier is required"}} - } - q := url.Values{} - if opts != nil { - opts.applyTo(q) - } - return listGeneric[Record](ctx, c, "/api/idvs/"+pathEscape(identifier)+"/summary/awards/", q) -} - // ListIDVLcats lists Labor Categories (LCATs) under an IDV // (/api/idvs/{key}/lcats/). Re-uses EntityLcatsOptions because the server // accepts the same parameter shape on both /entities/{uei}/lcats/ and diff --git a/idv_subresources_test.go b/idv_subresources_test.go index a88aa6e..74b079b 100644 --- a/idv_subresources_test.go +++ b/idv_subresources_test.go @@ -79,38 +79,6 @@ func TestListIDVTransactionsWithPagination(t *testing.T) { }, nil) } -func TestGetIDVSummaryRequiresIdentifier(t *testing.T) { - c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) - _, err := c.GetIDVSummary(context.Background(), "") - var ve *ValidationError - if !errors.As(err, &ve) { - t.Fatalf("expected *ValidationError, got %T: %v", err, err) - } -} - -func TestGetIDVSummaryBuildsPath(t *testing.T) { - var capturedURL string - c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) - _, _ = c.GetIDVSummary(context.Background(), "SOL-001") - assertPathContains(t, capturedURL, "/api/idvs/SOL-001/summary/") -} - -func TestListIDVSummaryAwardsRequiresIdentifier(t *testing.T) { - c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) - _, err := c.ListIDVSummaryAwards(context.Background(), "", nil) - var ve *ValidationError - if !errors.As(err, &ve) { - t.Fatalf("expected *ValidationError, got %T: %v", err, err) - } -} - -func TestListIDVSummaryAwardsBuildsPath(t *testing.T) { - var capturedURL string - c, _ := newTestClient(t, captureURLHandler(&capturedURL)) - _, _ = c.ListIDVSummaryAwards(context.Background(), "SOL-001", nil) - assertPathContains(t, capturedURL, "/api/idvs/SOL-001/summary/awards/") -} - func TestListIDVLcatsRequiresKey(t *testing.T) { c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) _, err := c.ListIDVLcats(context.Background(), "", nil) diff --git a/lookups.go b/lookups.go index 2ec1bbe..d4671a1 100644 --- a/lookups.go +++ b/lookups.go @@ -166,6 +166,17 @@ func (c *Client) ListSubawards(ctx context.Context, opts *ListSubawardsOptions) return listGeneric[Record](ctx, c, "/api/subawards/", q) } +// GetSubaward fetches a single subaward record by its key +// (/api/subawards/{key}/). +func (c *Client) GetSubaward(ctx context.Context, key string, opts *ListOptions) (Record, error) { + if key == "" { + return nil, &ValidationError{&APIError{Message: "subaward key is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/subawards/"+pathEscape(key)+"/", q) +} + // GetVersion returns the API version metadata. func (c *Client) GetVersion(ctx context.Context) (Record, error) { return getGeneric[Record](ctx, c, "/api/version/", nil) diff --git a/lookups_test.go b/lookups_test.go index b6e45d1..65447f9 100644 --- a/lookups_test.go +++ b/lookups_test.go @@ -286,3 +286,19 @@ func TestGetVersionBuildsPath(t *testing.T) { _, _ = c.GetVersion(context.Background()) assertPathContains(t, capturedURL, "/api/version/") } + +func TestGetSubawardRequiresKey(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetSubaward(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetSubawardBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetSubaward(context.Background(), "SUB-1", nil) + assertPathContains(t, capturedURL, "/api/subawards/SUB-1/") +} diff --git a/misc_coverage_test.go b/misc_coverage_test.go index 9a533c1..a172474 100644 --- a/misc_coverage_test.go +++ b/misc_coverage_test.go @@ -227,17 +227,6 @@ func TestListIDVChildIDVsNilOpts(t *testing.T) { assertQueryContains(t, capturedURL, nil, []string{"ordering", "naics"}) } -// --------------------------------------------------------------------------- -// ListIDVSummaryAwards nil opts -// --------------------------------------------------------------------------- - -func TestListIDVSummaryAwardsNilOpts(t *testing.T) { - var capturedURL string - c, _ := newTestClient(t, captureURLHandler(&capturedURL)) - _, _ = c.ListIDVSummaryAwards(context.Background(), "SOL-001", nil) - assertPathContains(t, capturedURL, "/api/idvs/SOL-001/summary/awards/") -} - // --------------------------------------------------------------------------- // ListVehicleOrders nil opts // --------------------------------------------------------------------------- diff --git a/opportunities.go b/opportunities.go index 14071c5..b14c5bb 100644 --- a/opportunities.go +++ b/opportunities.go @@ -200,6 +200,7 @@ type ListGrantsOptions struct { Agency string ApplicantTypes string CFDANumber string + GrantID string FundingCategories string FundingInstruments string OpportunityNumber string @@ -223,6 +224,7 @@ func (o *ListGrantsOptions) toQuery() url.Values { setIfNotEmpty(q, "agency", o.Agency) setIfNotEmpty(q, "applicant_types", o.ApplicantTypes) setIfNotEmpty(q, "cfda_number", o.CFDANumber) + setIfNotEmpty(q, "grant_id", o.GrantID) setIfNotEmpty(q, "funding_categories", o.FundingCategories) setIfNotEmpty(q, "funding_instruments", o.FundingInstruments) setIfNotEmpty(q, "opportunity_number", o.OpportunityNumber) @@ -295,3 +297,47 @@ func (c *Client) IterateGrants(ctx context.Context, opts *ListGrantsOptions) *It }, } } + +// GetOpportunity fetches a single opportunity by its identifier +// (/api/opportunities/{opportunity_id}/). +func (c *Client) GetOpportunity(ctx context.Context, opportunityID string, opts *ListOptions) (Record, error) { + if opportunityID == "" { + return nil, &ValidationError{&APIError{Message: "opportunity_id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/opportunities/"+pathEscape(opportunityID)+"/", q) +} + +// GetNotice fetches a single notice by its identifier +// (/api/notices/{notice_id}/). +func (c *Client) GetNotice(ctx context.Context, noticeID string, opts *ListOptions) (Record, error) { + if noticeID == "" { + return nil, &ValidationError{&APIError{Message: "notice_id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/notices/"+pathEscape(noticeID)+"/", q) +} + +// GetForecast fetches a single procurement forecast by its identifier +// (/api/forecasts/{id}/). +func (c *Client) GetForecast(ctx context.Context, id string, opts *ListOptions) (Record, error) { + if id == "" { + return nil, &ValidationError{&APIError{Message: "forecast id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/forecasts/"+pathEscape(id)+"/", q) +} + +// GetGrant fetches a single grant opportunity by its identifier +// (/api/grants/{grant_id}/). +func (c *Client) GetGrant(ctx context.Context, grantID string, opts *ListOptions) (Record, error) { + if grantID == "" { + return nil, &ValidationError{&APIError{Message: "grant_id is required"}} + } + q := url.Values{} + opts.applyTo(q) + return getGeneric[Record](ctx, c, "/api/grants/"+pathEscape(grantID)+"/", q) +} diff --git a/opportunities_test.go b/opportunities_test.go index 374f8ed..ae042a0 100644 --- a/opportunities_test.go +++ b/opportunities_test.go @@ -2,6 +2,7 @@ package tango import ( "context" + "errors" "testing" ) @@ -219,6 +220,7 @@ func TestListGrantsFilterMapping(t *testing.T) { Agency: "9700", ApplicantTypes: "11", CFDANumber: "10.001", + GrantID: "GRANT-123", FundingCategories: "AR", FundingInstruments: "G", OpportunityNumber: "OPP-001", @@ -234,6 +236,7 @@ func TestListGrantsFilterMapping(t *testing.T) { "agency": "9700", "applicant_types": "11", "cfda_number": "10.001", + "grant_id": "GRANT-123", "funding_categories": "AR", "funding_instruments": "G", "opportunity_number": "OPP-001", @@ -289,3 +292,67 @@ func TestIterateGrantsNilOpts(t *testing.T) { t.Fatal("expected non-nil iterator") } } + +func TestGetOpportunityRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetOpportunity(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetOpportunityBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetOpportunity(context.Background(), "OPP-1", nil) + assertPathContains(t, capturedURL, "/api/opportunities/OPP-1/") +} + +func TestGetNoticeRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetNotice(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetNoticeBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetNotice(context.Background(), "NOT-1", nil) + assertPathContains(t, capturedURL, "/api/notices/NOT-1/") +} + +func TestGetForecastRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetForecast(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetForecastBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetForecast(context.Background(), "FC-1", nil) + assertPathContains(t, capturedURL, "/api/forecasts/FC-1/") +} + +func TestGetGrantRequiresID(t *testing.T) { + c := NewClient(WithAPIKey("k"), WithBaseURL("http://localhost:0"), WithRetries(0)) + _, err := c.GetGrant(context.Background(), "", nil) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T: %v", err, err) + } +} + +func TestGetGrantBuildsPath(t *testing.T) { + var capturedURL string + c, _ := newTestClient(t, captureURLRecordHandler(&capturedURL)) + _, _ = c.GetGrant(context.Background(), "GR-1", nil) + assertPathContains(t, capturedURL, "/api/grants/GR-1/") +} diff --git a/shapes.go b/shapes.go index 70d33c7..21fc821 100644 --- a/shapes.go +++ b/shapes.go @@ -10,6 +10,10 @@ const ( // ShapeContractsMinimal — default for ListContracts. ShapeContractsMinimal = "key,piid,award_date,recipient(display_name),description,total_contract_value" + // ShapeBudgetAccountsMinimal — default for ListBudgetAccounts. + ShapeBudgetAccountsMinimal = "federal_account_symbol,fiscal_year,agency_name,enacted_ba," + + "obligated_total,contract_obligated,contract_share_of_obligated_capped" + // ShapeEntitiesMinimal — default for ListEntities. ShapeEntitiesMinimal = "uei,legal_business_name,cage_code,business_types" diff --git a/version.go b/version.go index 9c915c9..891ebec 100644 --- a/version.go +++ b/version.go @@ -2,4 +2,4 @@ package tango // Version is the tango-go SDK version. Keep in sync with CHANGELOG.md // and the git tag at release time. -const Version = "0.1.0" +const Version = "0.2.0"