Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions budget.go
Original file line number Diff line number Diff line change
@@ -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)
}
142 changes: 142 additions & 0 deletions budget_test.go
Original file line number Diff line number Diff line change
@@ -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/")
}
37 changes: 37 additions & 0 deletions contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading