From 423540409ca744f84757679613a051f847a9019b Mon Sep 17 00:00:00 2001 From: Kush <69764504+kush2shah@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:54:19 -0400 Subject: [PATCH] feat(api): thread context.Context through the client; cancel on SIGINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Execute() now runs the command tree under signal.NotifyContext, so the first SIGINT/SIGTERM cancels cmd.Context() instead of killing the process. Requester and every api.Client method take a context as their first parameter, requests are built with NewRequestWithContext, and all ~150 call sites pass cmd.Context() (or a threaded ctx) through commands, the sip/tendlc/customerprofile service layers, and the --wait helpers. Every cmdutil.Poll site now sets PollConfig.Context, so the cancellation plumbing added in the 10DLC foundation work finally has a producer: Ctrl-C during --wait aborts the in-flight request and exits through the receipt-emitting paths — tendlc's awaitTerminal prints the accepted resource's bandwidthId instead of dying with nothing on stdout. A second Ctrl-C is untrapped (NotifyContext stops after one signal) and hard-exits. Requester's signatures changed in place rather than growing parallel ...Ctx methods — the call sites are mechanical and this is pre-1.0, per the discussion in #25. Token exchange (auth.TokenManager.GetToken) still runs without a context; that path has its own timeout and is left for the auth-status work. New tests: a client test proving a context deadline aborts an in-flight HTTP request, and a cmd test proving a real SIGINT delivered to the process cancels the command context and Execute returns. AGENTS.md's interim notes about the untrappable Ctrl-C are replaced with the new guarantee. Fixes #25. Fixes #34. --- AGENTS.md | 27 ++++---- cmd/account/register.go | 2 +- cmd/app/assign.go | 2 +- cmd/app/create.go | 4 +- cmd/app/delete.go | 2 +- cmd/app/get.go | 2 +- cmd/app/list.go | 2 +- cmd/app/peers.go | 2 +- cmd/app/update.go | 4 +- cmd/call/create.go | 5 +- cmd/call/get.go | 2 +- cmd/call/hangup.go | 2 +- cmd/call/list.go | 2 +- cmd/call/update.go | 2 +- cmd/customerprofile/create.go | 2 +- cmd/customerprofile/delete.go | 6 +- cmd/customerprofile/get.go | 2 +- cmd/customerprofile/history.go | 6 +- cmd/customerprofile/list.go | 4 +- cmd/customerprofile/update.go | 4 +- cmd/location/create.go | 4 +- cmd/location/list.go | 2 +- cmd/message/get.go | 2 +- cmd/message/list.go | 2 +- cmd/message/media/delete.go | 2 +- cmd/message/media/get.go | 2 +- cmd/message/media/list.go | 2 +- cmd/message/media/upload.go | 2 +- cmd/message/preflight.go | 31 +++++----- cmd/message/send.go | 8 +-- cmd/number/count.go | 2 +- cmd/number/details.go | 2 +- cmd/number/get.go | 2 +- cmd/number/list.go | 13 ++-- cmd/number/nnroutes.go | 2 +- cmd/number/order.go | 5 +- cmd/number/release.go | 2 +- cmd/number/search.go | 2 +- cmd/number/service_activation.go | 12 ++-- cmd/portin/bulk/create.go | 15 +++-- cmd/portin/bulk/get.go | 2 +- cmd/portin/bulk/get_tns.go | 2 + cmd/portin/bulk/list.go | 2 +- cmd/portin/cancel.go | 2 +- cmd/portin/create.go | 11 ++-- cmd/portin/get.go | 2 +- cmd/portin/history.go | 2 +- cmd/portin/list.go | 2 +- cmd/portin/notes.go | 2 + cmd/portin/submit.go | 8 ++- cmd/portin/supp.go | 10 ++- cmd/portin/upload_loa.go | 2 +- cmd/portin/validate_tf.go | 3 + cmd/quickstart/quickstart.go | 58 ++++++++--------- cmd/recording/delete.go | 2 +- cmd/recording/download.go | 2 +- cmd/recording/get.go | 2 +- cmd/recording/list.go | 2 +- cmd/recording/pause.go | 2 +- cmd/recording/resume.go | 2 +- cmd/root.go | 12 +++- cmd/root_test.go | 66 ++++++++++++++++++++ cmd/shortcode/get.go | 2 +- cmd/shortcode/list.go | 2 +- cmd/sip/credential_create.go | 10 +-- cmd/sip/credential_delete.go | 4 +- cmd/sip/credential_get.go | 4 +- cmd/sip/credential_list.go | 4 +- cmd/sip/credential_rotate.go | 6 +- cmd/sip/realm_create.go | 14 +++-- cmd/sip/realm_delete.go | 7 ++- cmd/sip/realm_get.go | 2 +- cmd/sip/realm_list.go | 2 +- cmd/sip/realm_update.go | 2 +- cmd/sip/status.go | 2 +- cmd/site/create.go | 4 +- cmd/site/delete.go | 2 +- cmd/site/get.go | 2 +- cmd/site/list.go | 2 +- cmd/tendlc/async.go | 9 +-- cmd/tendlc/async_test.go | 9 +-- cmd/tendlc/brand_create.go | 8 +-- cmd/tendlc/brand_delete.go | 4 +- cmd/tendlc/brand_get.go | 2 +- cmd/tendlc/brand_history.go | 4 +- cmd/tendlc/brand_identity.go | 4 +- cmd/tendlc/brand_list.go | 4 +- cmd/tendlc/brand_update.go | 4 +- cmd/tendlc/campaign_create.go | 8 +-- cmd/tendlc/campaign_get.go | 2 +- cmd/tendlc/campaign_history.go | 4 +- cmd/tendlc/campaign_lifecycle.go | 6 +- cmd/tendlc/campaign_list.go | 4 +- cmd/tendlc/campaign_phonenumbers.go | 4 +- cmd/tendlc/campaign_update.go | 4 +- cmd/tendlc/number.go | 10 +-- cmd/tendlc/status.go | 2 +- cmd/tendlc/vetting.go | 17 ++--- cmd/tfv/get.go | 2 +- cmd/tfv/submit.go | 2 +- cmd/tnoption/assign.go | 3 + cmd/tnoption/get.go | 2 +- cmd/tnoption/list.go | 2 +- cmd/tollfree/template.go | 2 +- cmd/transcription/create.go | 5 +- cmd/transcription/get.go | 2 +- cmd/vcp/assign.go | 2 +- cmd/vcp/create.go | 4 +- cmd/vcp/delete.go | 2 +- cmd/vcp/get.go | 2 +- cmd/vcp/list.go | 2 +- cmd/vcp/numbers.go | 2 +- cmd/vcp/update.go | 4 +- internal/api/client.go | 79 +++++++++++++----------- internal/api/client_test.go | 56 ++++++++++++----- internal/api/response.go | 5 +- internal/api/response_test.go | 7 ++- internal/customerprofile/service.go | 13 ++-- internal/customerprofile/service_test.go | 15 ++--- internal/customerprofile/write.go | 23 +++---- internal/customerprofile/write_test.go | 25 ++++---- internal/sip/service.go | 57 ++++++++--------- internal/sip/service_test.go | 51 +++++++-------- internal/tendlc/campaignwrite.go | 25 ++++---- internal/tendlc/campaignwrite_test.go | 31 +++++----- internal/tendlc/numbers.go | 13 ++-- internal/tendlc/numbers_test.go | 17 ++--- internal/tendlc/putretry.go | 7 ++- internal/tendlc/putretry_test.go | 21 ++++--- internal/tendlc/service.go | 21 ++++--- internal/tendlc/service_test.go | 19 +++--- internal/tendlc/write.go | 37 +++++------ internal/tendlc/write_test.go | 41 ++++++------ internal/testutil/golden.go | 15 ++--- 134 files changed, 681 insertions(+), 511 deletions(-) create mode 100644 cmd/root_test.go diff --git a/AGENTS.md b/AGENTS.md index 87c3c82..8d376cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1173,17 +1173,15 @@ still-pending. carries `bandwidthId` somewhere in valid JSON — that ID is the one thing that cannot be recovered any other way if the command exits without printing it. -**This guarantee covers every path the command itself takes — it does not -cover an interrupt.** `cmd/root.go`'s `Execute()` runs with no context, and -the CLI does not install a `SIGINT` handler anywhere (no `signal.Notify` / -`signal.NotifyContext` in the codebase), so the cancellation branch in -`awaitTerminal` that exists specifically to emit this receipt can never -actually fire from a real Ctrl-C. Press Ctrl-C during `--wait` after the 202 -has landed and the process dies immediately with no `bandwidthId` on stdout. -If that happens, recover with `band tendlc brand list --customer-profile-id-contains -` to find the brand that was accepted. This applies CLI-wide, not just to -`tendlc` — it is a pre-existing, repo-wide gap, tracked separately from this -PR. +**The guarantee covers a single Ctrl-C too.** `cmd/root.go`'s `Execute()` +runs the command tree under `signal.NotifyContext`, so the first +`SIGINT`/`SIGTERM` cancels `cmd.Context()` rather than killing the process: +the in-flight request aborts, the cancellation branch in `awaitTerminal` +fires, and the receipt (with `bandwidthId`) lands on stdout before the +command exits. A **second** Ctrl-C is not trapped — it hard-kills the +process the Go-default way, with nothing further on stdout. If a receipt was +lost that way, recover with `band tendlc brand list +--customer-profile-id-contains ` to find the brand that was accepted. Without `--wait`, or on a timeout/transport failure with `--wait`, that's the literal synthetic receipt: `{"bandwidthId": "...", "status": "accepted", @@ -1794,9 +1792,10 @@ carries `bandwidthId` somewhere in valid JSON — the one thing that cannot be recovered any other way if the command exits without printing it. This covers `create`, `sync`, and `update` alike, all of which share the same `{bandwidthId, campaignId (if present), status: "accepted", resume}` -receipt shape. As with `brand create`, this guarantee does not extend past a -`SIGINT` — see the equivalent note under [10DLC Brands](#create) for why and -how to recover (`band tendlc campaign list --brand-id-contains `). +receipt shape. As with `brand create`, a single `SIGINT` is covered (the +receipt is emitted before exit); only a second Ctrl-C hard-kills the process +— see the equivalent note under [10DLC Brands](#create), and recover with +`band tendlc campaign list --brand-id-contains `. ### `deactivate`'s honest receipt diff --git a/cmd/account/register.go b/cmd/account/register.go index 7652782..276e757 100644 --- a/cmd/account/register.go +++ b/cmd/account/register.go @@ -94,7 +94,7 @@ func runRegister(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post("/registration", reqBody, &result); err != nil { + if err := client.Post(cmd.Context(), "/registration", reqBody, &result); err != nil { return fmt.Errorf("registering account: %w", err) } diff --git a/cmd/app/assign.go b/cmd/app/assign.go index c06d68d..aa07f09 100644 --- a/cmd/app/assign.go +++ b/cmd/app/assign.go @@ -65,7 +65,7 @@ func runAssign(cmd *cobra.Command, args []string) error { acctID, url.PathEscape(assignSite), url.PathEscape(assignLocation)) var result interface{} - if err := client.Put(path, body, &result); err != nil { + if err := client.Put(cmd.Context(), path, body, &result); err != nil { return fmt.Errorf("assigning application to location: %w", err) } diff --git a/cmd/app/create.go b/cmd/app/create.go index a1f71d3..64c85fc 100644 --- a/cmd/app/create.go +++ b/cmd/app/create.go @@ -95,7 +95,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if createIfNotExists { var listResult interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/applications", acctID), &listResult); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/applications", acctID), &listResult); err != nil { return fmt.Errorf("listing applications: %w", err) } if existing := output.FindByName(listResult, "AppName", createName); existing != nil { @@ -106,7 +106,7 @@ func runCreate(cmd *cobra.Command, args []string) error { bodyData := BuildCreateBody(opts) var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/applications", acctID), api.XMLBody{RootElement: "Application", Data: bodyData}, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/applications", acctID), api.XMLBody{RootElement: "Application", Data: bodyData}, &result); err != nil { if strings.Contains(err.Error(), "HTTP voice feature is required") { return fmt.Errorf("creating voice application: this account requires the HTTP Voice feature to be enabled.\n" + "Contact Bandwidth support to enable it, or check if your account is on the Universal Platform.\n" + diff --git a/cmd/app/delete.go b/cmd/app/delete.go index 86db18d..b45d2ed 100644 --- a/cmd/app/delete.go +++ b/cmd/app/delete.go @@ -31,7 +31,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return err } - if err := client.Delete(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(args[0])), nil); err != nil { + if err := client.Delete(cmd.Context(), fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(args[0])), nil); err != nil { return fmt.Errorf("deleting application: %w", err) } diff --git a/cmd/app/get.go b/cmd/app/get.go index 4c8c7c6..c833a79 100644 --- a/cmd/app/get.go +++ b/cmd/app/get.go @@ -34,7 +34,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(args[0])), &result); err != nil { return fmt.Errorf("getting application: %w", err) } diff --git a/cmd/app/list.go b/cmd/app/list.go index 6a28086..c310b45 100644 --- a/cmd/app/list.go +++ b/cmd/app/list.go @@ -29,7 +29,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/applications", acctID), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/applications", acctID), &result); err != nil { return fmt.Errorf("listing applications: %w", err) } diff --git a/cmd/app/peers.go b/cmd/app/peers.go index 53a02ef..3a4fb91 100644 --- a/cmd/app/peers.go +++ b/cmd/app/peers.go @@ -32,7 +32,7 @@ func runPeers(cmd *cobra.Command, args []string) error { var result interface{} path := fmt.Sprintf("/accounts/%s/applications/%s/associatedsippeers", acctID, url.PathEscape(args[0])) - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return fmt.Errorf("getting application peers: %w", err) } diff --git a/cmd/app/update.go b/cmd/app/update.go index e01b96d..58a2dd3 100644 --- a/cmd/app/update.go +++ b/cmd/app/update.go @@ -50,7 +50,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // First, get the existing app to determine its type var existing interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)), &existing); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)), &existing); err != nil { return fmt.Errorf("getting application: %w", err) } @@ -80,7 +80,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Put(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)), body, &result); err != nil { + if err := client.Put(cmd.Context(), fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)), body, &result); err != nil { return fmt.Errorf("updating application: %w", err) } diff --git a/cmd/call/create.go b/cmd/call/create.go index 332615e..bfc844e 100644 --- a/cmd/call/create.go +++ b/cmd/call/create.go @@ -87,7 +87,7 @@ func runCreate(cmd *cobra.Command, args []string) error { }) var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/calls", acctID), reqBody, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/calls", acctID), reqBody, &result); err != nil { return fmt.Errorf("creating call: %w", err) } @@ -103,11 +103,12 @@ func runCreate(cmd *cobra.Command, args []string) error { } final, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 2 * time.Second, Timeout: createTimeout, Check: func() (bool, interface{}, error) { var callState interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(callID)), &callState); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(callID)), &callState); err != nil { // The Voice API is eventually consistent — a 404 right after // creation means the call record hasn't propagated yet. Retry. var apiErr *api.APIError diff --git a/cmd/call/get.go b/cmd/call/get.go index 9599627..2f3f450 100644 --- a/cmd/call/get.go +++ b/cmd/call/get.go @@ -34,7 +34,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), &result); err != nil { return fmt.Errorf("getting call: %w", err) } diff --git a/cmd/call/hangup.go b/cmd/call/hangup.go index 77859f7..352e294 100644 --- a/cmd/call/hangup.go +++ b/cmd/call/hangup.go @@ -37,7 +37,7 @@ func runHangup(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), reqBody, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), reqBody, &result); err != nil { return fmt.Errorf("hanging up call: %w", err) } diff --git a/cmd/call/list.go b/cmd/call/list.go index 56d60d5..908ce86 100644 --- a/cmd/call/list.go +++ b/cmd/call/list.go @@ -29,7 +29,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/calls", acctID), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/calls", acctID), &result); err != nil { return fmt.Errorf("listing calls: %w", err) } diff --git a/cmd/call/update.go b/cmd/call/update.go index cc4fa78..613997e 100644 --- a/cmd/call/update.go +++ b/cmd/call/update.go @@ -42,7 +42,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), reqBody, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), reqBody, &result); err != nil { return fmt.Errorf("updating call: %w", err) } diff --git a/cmd/customerprofile/create.go b/cmd/customerprofile/create.go index d53fecf..7d918c2 100644 --- a/cmd/customerprofile/create.go +++ b/cmd/customerprofile/create.go @@ -51,7 +51,7 @@ retry — list profiles and reconcile against what you submitted first.`, if err != nil { return err } - env, err := svc.Create(cpsvc.BuildCreateRequest(createOpts)) + env, err := svc.Create(cmd.Context(), cpsvc.BuildCreateRequest(createOpts)) if err != nil { return roleGateError(err) } diff --git a/cmd/customerprofile/delete.go b/cmd/customerprofile/delete.go index 886dd60..3653f58 100644 --- a/cmd/customerprofile/delete.go +++ b/cmd/customerprofile/delete.go @@ -42,7 +42,7 @@ attached.`, if err != nil { return err } - if err := svc.Delete(args[0]); err != nil { + if err := svc.Delete(cmd.Context(), args[0]); err != nil { return roleGateError(err) } format, plain := cmdutil.OutputFlags(cmd) @@ -73,7 +73,7 @@ No --confirm needed: restoring is not destructive.`, if err != nil { return err } - env, err := svc.Get(args[0]) + env, err := svc.Get(cmd.Context(), args[0]) if err != nil { return roleGateError(err) } @@ -85,7 +85,7 @@ No --confirm needed: restoring is not destructive.`, if err != nil { return err } - restored, err := svc.Update(args[0], body) + restored, err := svc.Update(cmd.Context(), args[0], body) if err != nil { return roleGateError(conflictHint(err)) } diff --git a/cmd/customerprofile/get.go b/cmd/customerprofile/get.go index e75a05f..63de1c3 100644 --- a/cmd/customerprofile/get.go +++ b/cmd/customerprofile/get.go @@ -20,7 +20,7 @@ var getCmd = &cobra.Command{ if err != nil { return err } - env, err := svc.Get(args[0]) + env, err := svc.Get(cmd.Context(), args[0]) if err != nil { return roleGateError(err) } diff --git a/cmd/customerprofile/history.go b/cmd/customerprofile/history.go index 6dd64b4..9fa8ef3 100644 --- a/cmd/customerprofile/history.go +++ b/cmd/customerprofile/history.go @@ -53,7 +53,7 @@ CREATED, UPDATED, and DELETED.`, format, plain := cmdutil.OutputFlags(cmd) if !historyAll { - env, err := svc.History(args[0], historyLimit, historyOffset) + env, err := svc.History(cmd.Context(), args[0], historyLimit, historyOffset) if err != nil { return roleGateError(err) } @@ -67,7 +67,7 @@ CREATED, UPDATED, and DELETED.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.History(args[0], limit, offset) + return svc.History(cmd.Context(), args[0], limit, offset) }, historyLimit, func(batch []any) error { all = append(all, batch...) return nil @@ -101,7 +101,7 @@ count: list always returns an array, get always returns an object.`, if err != nil { return err } - env, err := svc.HistoryVersion(args[0], args[1]) + env, err := svc.HistoryVersion(cmd.Context(), args[0], args[1]) if err != nil { return roleGateError(err) } diff --git a/cmd/customerprofile/list.go b/cmd/customerprofile/list.go index c8ad708..00845c9 100644 --- a/cmd/customerprofile/list.go +++ b/cmd/customerprofile/list.go @@ -51,7 +51,7 @@ var listCmd = &cobra.Command{ format, plain := cmdutil.OutputFlags(cmd) if !listAll { - env, err := svc.List(listLimit, listOffset, filters) + env, err := svc.List(cmd.Context(), listLimit, listOffset, filters) if err != nil { return roleGateError(err) } @@ -65,7 +65,7 @@ var listCmd = &cobra.Command{ var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.List(limit, offset, filters) + return svc.List(cmd.Context(), limit, offset, filters) }, listLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/customerprofile/update.go b/cmd/customerprofile/update.go index 774d27c..e6e6505 100644 --- a/cmd/customerprofile/update.go +++ b/cmd/customerprofile/update.go @@ -61,7 +61,7 @@ is rejected by the API's version check — the command exits 4 and you can retry return err } - env, err := svc.Get(args[0]) + env, err := svc.Get(cmd.Context(), args[0]) if err != nil { return roleGateError(err) } @@ -75,7 +75,7 @@ is rejected by the API's version check — the command exits 4 and you can retry return err } - updated, err := svc.Update(args[0], body) + updated, err := svc.Update(cmd.Context(), args[0], body) if err != nil { return roleGateError(conflictHint(err)) } diff --git a/cmd/location/create.go b/cmd/location/create.go index 1f1b980..62147ba 100644 --- a/cmd/location/create.go +++ b/cmd/location/create.go @@ -47,7 +47,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if createIfNotExists { var listResult interface{} listPath := fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, createSiteID) - if err := client.Get(listPath, &listResult); err != nil { + if err := client.Get(cmd.Context(), listPath, &listResult); err != nil { return fmt.Errorf("listing locations: %w", err) } if existing := output.FindByName(listResult, "PeerName", createName); existing != nil { @@ -61,7 +61,7 @@ func runCreate(cmd *cobra.Command, args []string) error { var result interface{} path := fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, createSiteID) - if err := client.Post(path, api.XMLBody{RootElement: "SipPeer", Data: bodyData}, &result); err != nil { + if err := client.Post(cmd.Context(), path, api.XMLBody{RootElement: "SipPeer", Data: bodyData}, &result); err != nil { return fmt.Errorf("creating location: %w", err) } diff --git a/cmd/location/list.go b/cmd/location/list.go index 83f5058..b9df54e 100644 --- a/cmd/location/list.go +++ b/cmd/location/list.go @@ -34,7 +34,7 @@ func runList(cmd *cobra.Command, args []string) error { var result interface{} path := fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, listSiteID) - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return fmt.Errorf("listing locations: %w", err) } diff --git a/cmd/message/get.go b/cmd/message/get.go index 94a6316..76a127d 100644 --- a/cmd/message/get.go +++ b/cmd/message/get.go @@ -34,7 +34,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/users/%s/messages?messageId=%s", acctID, url.QueryEscape(args[0])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/users/%s/messages?messageId=%s", acctID, url.QueryEscape(args[0])), &result); err != nil { return fmt.Errorf("getting message: %w", err) } diff --git a/cmd/message/list.go b/cmd/message/list.go index e67f18b..d7491ee 100644 --- a/cmd/message/list.go +++ b/cmd/message/list.go @@ -67,7 +67,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return fmt.Errorf("listing messages: %w", err) } diff --git a/cmd/message/media/delete.go b/cmd/message/media/delete.go index f5e96bf..498a863 100644 --- a/cmd/message/media/delete.go +++ b/cmd/message/media/delete.go @@ -31,7 +31,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return err } - if err := client.Delete(fmt.Sprintf("/users/%s/media/%s", acctID, args[0]), nil); err != nil { + if err := client.Delete(cmd.Context(), fmt.Sprintf("/users/%s/media/%s", acctID, args[0]), nil); err != nil { return fmt.Errorf("deleting media: %w", err) } diff --git a/cmd/message/media/get.go b/cmd/message/media/get.go index 3764c8a..7cb698c 100644 --- a/cmd/message/media/get.go +++ b/cmd/message/media/get.go @@ -35,7 +35,7 @@ func runGet(cmd *cobra.Command, args []string) error { return err } - data, err := client.GetRaw(fmt.Sprintf("/users/%s/media/%s", acctID, args[0])) + data, err := client.GetRaw(cmd.Context(), fmt.Sprintf("/users/%s/media/%s", acctID, args[0])) if err != nil { return fmt.Errorf("downloading media: %w", err) } diff --git a/cmd/message/media/list.go b/cmd/message/media/list.go index 436002e..d1134fe 100644 --- a/cmd/message/media/list.go +++ b/cmd/message/media/list.go @@ -29,7 +29,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/users/%s/media", acctID), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/users/%s/media", acctID), &result); err != nil { return fmt.Errorf("listing media: %w", err) } diff --git a/cmd/message/media/upload.go b/cmd/message/media/upload.go index 5f26638..87df697 100644 --- a/cmd/message/media/upload.go +++ b/cmd/message/media/upload.go @@ -67,7 +67,7 @@ func runUpload(cmd *cobra.Command, args []string) error { return err } - if err := client.PutRaw(fmt.Sprintf("/users/%s/media/%s", acctID, mediaID), data, ct); err != nil { + if err := client.PutRaw(cmd.Context(), fmt.Sprintf("/users/%s/media/%s", acctID, mediaID), data, ct); err != nil { return fmt.Errorf("uploading media: %w", err) } diff --git a/cmd/message/preflight.go b/cmd/message/preflight.go index 3abc84e..3b9c426 100644 --- a/cmd/message/preflight.go +++ b/cmd/message/preflight.go @@ -1,6 +1,7 @@ package message import ( + "context" "encoding/json" "fmt" "net/url" @@ -20,10 +21,10 @@ type PreflightResult struct { // CheckCallbackURL verifies that the messaging application has a callback URL // that looks like a real server. Without one, delivery confirmations are lost. -func CheckCallbackURL(dashClient *api.Client, acctID, appID string) string { +func CheckCallbackURL(ctx context.Context, dashClient *api.Client, acctID, appID string) string { var result interface{} path := fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)) - if err := dashClient.Get(path, &result); err != nil { + if err := dashClient.Get(ctx, path, &result); err != nil { return "" // can't check, don't warn } @@ -76,11 +77,11 @@ func isPlaceholderURL(u string) bool { // // It checks both the app's associatedsippeers endpoint AND each location's // applicationSettings (the assignment may only be visible from the location side). -func CheckAppAssociation(dashClient *api.Client, acctID, appID string) (bool, string) { +func CheckAppAssociation(ctx context.Context, dashClient *api.Client, acctID, appID string) (bool, string) { // First try the app-level query (fast path) var peersResult interface{} path := fmt.Sprintf("/accounts/%s/applications/%s/associatedsippeers", acctID, url.PathEscape(appID)) - if err := dashClient.Get(path, &peersResult); err == nil { + if err := dashClient.Get(ctx, path, &peersResult); err == nil { peers := extractAssociatedPeers(peersResult) if len(peers) > 0 { return true, "" @@ -90,21 +91,21 @@ func CheckAppAssociation(dashClient *api.Client, acctID, appID string) (bool, st // App-level query found nothing — check from the location side. // List all sites, then check each location's messaging applicationSettings. var sitesResult interface{} - if err := dashClient.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &sitesResult); err != nil { + if err := dashClient.Get(ctx, fmt.Sprintf("/accounts/%s/sites", acctID), &sitesResult); err != nil { return true, "" // can't check, don't block } siteIDs := extractSiteIDs(sitesResult) for _, siteID := range siteIDs { var locsResult interface{} - if err := dashClient.Get(fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), &locsResult); err != nil { + if err := dashClient.Get(ctx, fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), &locsResult); err != nil { continue } peerIDs := extractPeerIDs(locsResult) for _, peerID := range peerIDs { var settings interface{} settingsPath := fmt.Sprintf("/accounts/%s/sites/%s/sippeers/%s/products/messaging/applicationSettings", acctID, siteID, peerID) - if err := dashClient.Get(settingsPath, &settings); err != nil { + if err := dashClient.Get(ctx, settingsPath, &settings); err != nil { continue } if foundAppID := extractAppIDFromSettings(settings); foundAppID == appID { @@ -122,14 +123,14 @@ func CheckAppAssociation(dashClient *api.Client, acctID, appID string) (bool, st // for messaging. For 10DLC numbers, it checks campaign assignment via the // tendlc API. For toll-free and short codes, it returns advisory messages // since those checks require credentials we may not have. -func CheckMessagingReadiness(platClient *api.Client, acctID, fromNumber string) PreflightResult { +func CheckMessagingReadiness(ctx context.Context, platClient *api.Client, acctID, fromNumber string) PreflightResult { nt := cmdutil.ClassifyNumber(fromNumber) switch nt { case cmdutil.NumberType10DLC: - return check10DLC(platClient, acctID, fromNumber) + return check10DLC(ctx, platClient, acctID, fromNumber) case cmdutil.NumberTypeTollFree: - return checkTollFree(platClient, acctID, fromNumber) + return checkTollFree(ctx, platClient, acctID, fromNumber) case cmdutil.NumberTypeShortCode: return PreflightResult{ Ready: true, // we can't check, assume provisioned @@ -143,7 +144,7 @@ func CheckMessagingReadiness(platClient *api.Client, acctID, fromNumber string) // check10DLC iterates the account's 10DLC campaigns and checks if the number // is assigned to any of them with SUCCESS status. -func check10DLC(platClient *api.Client, acctID, number string) PreflightResult { +func check10DLC(ctx context.Context, platClient *api.Client, acctID, number string) PreflightResult { result := PreflightResult{NumberType: cmdutil.NumberType10DLC} // Normalize to E.164 for the filter param @@ -154,7 +155,7 @@ func check10DLC(platClient *api.Client, acctID, number string) PreflightResult { // List all campaigns var campaignsResp interface{} - if err := platClient.Get(fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns", acctID), &campaignsResp); err != nil { + if err := platClient.Get(ctx, fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns", acctID), &campaignsResp); err != nil { // Can't check — don't block the send, just warn result.Ready = true result.Message = "could not verify campaign assignment (API error) — ensure the number is on an approved campaign" @@ -177,7 +178,7 @@ func check10DLC(platClient *api.Client, acctID, number string) PreflightResult { var pnResp interface{} path := fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns/%s/phoneNumbers?phoneNumber=%s", acctID, url.PathEscape(c.id), url.QueryEscape(e164)) - if err := platClient.Get(path, &pnResp); err != nil { + if err := platClient.Get(ctx, path, &pnResp); err != nil { continue } if pn := findPhoneNumberInResponse(pnResp, e164); pn != nil { @@ -204,7 +205,7 @@ func check10DLC(platClient *api.Client, acctID, number string) PreflightResult { return result } -func checkTollFree(platClient *api.Client, acctID, number string) PreflightResult { +func checkTollFree(ctx context.Context, platClient *api.Client, acctID, number string) PreflightResult { result := PreflightResult{NumberType: cmdutil.NumberTypeTollFree} e164 := number @@ -213,7 +214,7 @@ func checkTollFree(platClient *api.Client, acctID, number string) PreflightResul } var tfvResp interface{} - if err := platClient.Get(fmt.Sprintf("/api/v2/accounts/%s/phoneNumbers/%s/tollFreeVerification", acctID, url.PathEscape(e164)), &tfvResp); err != nil { + if err := platClient.Get(ctx, fmt.Sprintf("/api/v2/accounts/%s/phoneNumbers/%s/tollFreeVerification", acctID, url.PathEscape(e164)), &tfvResp); err != nil { // 403 means the credential doesn't have TFV access — don't block, just advise if apiErr, ok := err.(*api.APIError); ok && apiErr.StatusCode == 403 { result.Ready = true diff --git a/cmd/message/send.go b/cmd/message/send.go index 7d3fbc8..8d4b4bd 100644 --- a/cmd/message/send.go +++ b/cmd/message/send.go @@ -148,12 +148,12 @@ func runSend(cmd *cobra.Command, args []string) error { // Preflight: verify the messaging app is linked to a location. dashClient, dashAcctID, dashErr := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) if dashErr == nil { - if ok, msg := CheckAppAssociation(dashClient, dashAcctID, sendAppID); !ok { + if ok, msg := CheckAppAssociation(cmd.Context(), dashClient, dashAcctID, sendAppID); !ok { return fmt.Errorf("preflight check failed: %s", msg) } // Block if the callback URL looks fake/missing — without it, delivery // failures are invisible and you won't know messages aren't arriving. - if warning := CheckCallbackURL(dashClient, dashAcctID, sendAppID); warning != "" { + if warning := CheckCallbackURL(cmd.Context(), dashClient, dashAcctID, sendAppID); warning != "" { return fmt.Errorf("preflight check failed: %s", warning) } } @@ -161,7 +161,7 @@ func runSend(cmd *cobra.Command, args []string) error { // Preflight: verify the from number's provisioning (campaign, TFV, etc.). platClient, platAcctID, platErr := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) if platErr == nil { - check := CheckMessagingReadiness(platClient, platAcctID, sendFrom) + check := CheckMessagingReadiness(cmd.Context(), platClient, platAcctID, sendFrom) if !check.Ready { return fmt.Errorf("preflight check failed: %s", check.Message) } @@ -178,7 +178,7 @@ func runSend(cmd *cobra.Command, args []string) error { reqBody := BuildSendBody(opts) var result interface{} - if err := client.Post(fmt.Sprintf("/users/%s/messages", acctID), reqBody, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/users/%s/messages", acctID), reqBody, &result); err != nil { return fmt.Errorf("sending message: %w", err) } diff --git a/cmd/number/count.go b/cmd/number/count.go index 8ee6eed..90e65c8 100644 --- a/cmd/number/count.go +++ b/cmd/number/count.go @@ -72,7 +72,7 @@ func runCount(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return fmt.Errorf("counting phone numbers: %w", err) } diff --git a/cmd/number/details.go b/cmd/number/details.go index 2ad33d0..20c2acb 100644 --- a/cmd/number/details.go +++ b/cmd/number/details.go @@ -59,7 +59,7 @@ func runDetails(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/tns/%s/tndetails", number), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/tns/%s/tndetails", number), &result); err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { // Keep the APIError wrapped so the 404 still maps to exit 3. diff --git a/cmd/number/get.go b/cmd/number/get.go index 3b57fa9..3a86db0 100644 --- a/cmd/number/get.go +++ b/cmd/number/get.go @@ -41,7 +41,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var raw interface{} - if err := client.Get(path, &raw); err != nil { + if err := client.Get(cmd.Context(), path, &raw); err != nil { return fmt.Errorf("getting phone number details: %w", err) } diff --git a/cmd/number/list.go b/cmd/number/list.go index ae93de0..e044719 100644 --- a/cmd/number/list.go +++ b/cmd/number/list.go @@ -1,6 +1,7 @@ package number import ( + "context" "errors" "fmt" "net/url" @@ -82,9 +83,9 @@ func runList(cmd *cobra.Command, args []string) error { var numbers []string if query := buildListQuery(acctID, opts); query != nil { - numbers, err = fetchPagedNumbers(client, query) + numbers, err = fetchPagedNumbers(cmd.Context(), client, query) } else { - numbers, err = fetchAccountNumbers(client, acctID, listStatus) + numbers, err = fetchAccountNumbers(cmd.Context(), client, acctID, listStatus) } if err != nil { return err @@ -108,7 +109,7 @@ const tnsMaxPages = 100 // FullNumbers formatted as E.164 strings. /tns is preferred over // /accounts/{id}/inserviceNumbers because it's accessible to credentials // without the inservice role. -func fetchAccountNumbers(client *api.Client, acctID, status string) ([]string, error) { +func fetchAccountNumbers(ctx context.Context, client *api.Client, acctID, status string) ([]string, error) { var all []string for page := 1; page <= tnsMaxPages; page++ { q := url.Values{} @@ -118,7 +119,7 @@ func fetchAccountNumbers(client *api.Client, acctID, status string) ([]string, e q.Set("page", strconv.Itoa(page)) var result interface{} - if err := client.Get("/tns?"+q.Encode(), &result); err != nil { + if err := client.Get(ctx, "/tns?"+q.Encode(), &result); err != nil { return nil, wrapTNsError(err, acctID, cmdutil.ActiveBuild()) } @@ -167,7 +168,7 @@ const pagedListSize = 1000 // stopping on a short batch alone would misread a full final page as "more // to come" and issue a needless (and failable) extra request when the match // count is an exact multiple of the page size. -func fetchPagedNumbers(client *api.Client, query *listQuery) ([]string, error) { +func fetchPagedNumbers(ctx context.Context, client *api.Client, query *listQuery) ([]string, error) { var all []string for requests := 0; requests < tnsMaxPages; requests++ { q := url.Values{} @@ -185,7 +186,7 @@ func fetchPagedNumbers(client *api.Client, query *listQuery) ([]string, error) { q.Set("size", strconv.Itoa(pagedListSize)) var result interface{} - if err := client.Get(query.Path+"?"+q.Encode(), &result); err != nil { + if err := client.Get(ctx, query.Path+"?"+q.Encode(), &result); err != nil { return nil, fmt.Errorf("listing phone numbers: %w", err) } diff --git a/cmd/number/nnroutes.go b/cmd/number/nnroutes.go index 6c24d30..1e6df3a 100644 --- a/cmd/number/nnroutes.go +++ b/cmd/number/nnroutes.go @@ -33,7 +33,7 @@ func runNNRoutes(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/tns/%s/availableNnRoutes", number), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/tns/%s/availableNnRoutes", number), &result); err != nil { return fmt.Errorf("listing NN routes: %w", err) } diff --git a/cmd/number/order.go b/cmd/number/order.go index 0833937..63e49dc 100644 --- a/cmd/number/order.go +++ b/cmd/number/order.go @@ -62,7 +62,7 @@ func runOrder(cmd *cobra.Command, args []string) error { bodyData := BuildOrderBody(orderSubaccount, args) var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/orders", acctID), api.XMLBody{RootElement: "Order", Data: bodyData}, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/orders", acctID), api.XMLBody{RootElement: "Order", Data: bodyData}, &result); err != nil { return fmt.Errorf("ordering numbers: %w", err) } @@ -80,10 +80,11 @@ func runOrder(cmd *cobra.Command, args []string) error { } final, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 2 * time.Second, Timeout: orderTimeout, Check: func() (bool, interface{}, error) { - nums, err := fetchAccountNumbers(client, acctID, "Inservice") + nums, err := fetchAccountNumbers(cmd.Context(), client, acctID, "Inservice") if err != nil { return false, nil, fmt.Errorf("polling in-service numbers: %w", err) } diff --git a/cmd/number/release.go b/cmd/number/release.go index 8604c71..cfed511 100644 --- a/cmd/number/release.go +++ b/cmd/number/release.go @@ -41,7 +41,7 @@ func runRelease(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/disconnects", acctID), api.XMLBody{RootElement: "DisconnectTelephoneNumberOrder", Data: bodyData}, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/disconnects", acctID), api.XMLBody{RootElement: "DisconnectTelephoneNumberOrder", Data: bodyData}, &result); err != nil { return fmt.Errorf("releasing number: %w", err) } diff --git a/cmd/number/search.go b/cmd/number/search.go index 3f045be..81b37af 100644 --- a/cmd/number/search.go +++ b/cmd/number/search.go @@ -49,7 +49,7 @@ func runSearch(cmd *cobra.Command, args []string) error { var result interface{} path := fmt.Sprintf("/accounts/%s/availableNumbers?%s", acctID, q.Encode()) - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return cmdutil.Wrap403(err, "searching available numbers", "Numbers") } diff --git a/cmd/number/service_activation.go b/cmd/number/service_activation.go index 558105a..eb2e2fd 100644 --- a/cmd/number/service_activation.go +++ b/cmd/number/service_activation.go @@ -1,6 +1,7 @@ package number import ( + "context" "fmt" "time" @@ -109,7 +110,7 @@ func runServiceActivation(cmd *cobra.Command, action string, args []string) erro body := BuildCheckerBody(args) var result interface{} path := fmt.Sprintf("/api/v2/accounts/%s/serviceActivationChecker", acctID) - if err := client.Post(path, body, &result); err != nil { + if err := client.Post(cmd.Context(), path, body, &result); err != nil { return fmt.Errorf("checking service activation: %w", err) } format, plain := cmdutil.OutputFlags(cmd) @@ -132,7 +133,7 @@ func runServiceActivation(cmd *cobra.Command, action string, args []string) erro var orderResult map[string]interface{} path := fmt.Sprintf("/api/v2/accounts/%s/serviceActivation", acctID) - if err := client.Post(path, body, &orderResult); err != nil { + if err := client.Post(cmd.Context(), path, body, &orderResult); err != nil { return fmt.Errorf("creating service activation order: %w", err) } @@ -146,7 +147,7 @@ func runServiceActivation(cmd *cobra.Command, action string, args []string) erro return fmt.Errorf("service activation order created but no orderId in response") } - final, err := pollServiceActivationOrder(client, acctID, orderID, saTimeout) + final, err := pollServiceActivationOrder(cmd.Context(), client, acctID, orderID, saTimeout) if err != nil { return err } @@ -168,14 +169,15 @@ func extractOrderID(orderResult map[string]interface{}) (string, bool) { // states (RECEIVED / PROCESSING) or the timeout fires. We don't enumerate // terminal states explicitly — anything that's not in-flight is treated // as terminal so the caller can inspect the final response. -func pollServiceActivationOrder(client *api.Client, acctID, orderID string, timeout time.Duration) (interface{}, error) { +func pollServiceActivationOrder(ctx context.Context, client *api.Client, acctID, orderID string, timeout time.Duration) (interface{}, error) { return cmdutil.Poll(cmdutil.PollConfig{ + Context: ctx, Interval: 2 * time.Second, Timeout: timeout, Check: func() (bool, interface{}, error) { var result map[string]interface{} path := fmt.Sprintf("/api/v2/accounts/%s/serviceActivation/%s", acctID, orderID) - if err := client.Get(path, &result); err != nil { + if err := client.Get(ctx, path, &result); err != nil { return false, nil, fmt.Errorf("polling order %s: %w", orderID, err) } data, _ := result["data"].(map[string]interface{}) diff --git a/cmd/portin/bulk/create.go b/cmd/portin/bulk/create.go index 5d5d40b..0d4f109 100644 --- a/cmd/portin/bulk/create.go +++ b/cmd/portin/bulk/create.go @@ -2,6 +2,7 @@ package bulk import ( "bufio" + "context" "errors" "fmt" "net/url" @@ -68,7 +69,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if createCustomerOrderID == "" { return errors.New("--if-not-exists requires --customer-order-id") } - existing, err := findBulkByCustomerOrderID(client, acctID, createCustomerOrderID) + existing, err := findBulkByCustomerOrderID(cmd.Context(), client, acctID, createCustomerOrderID) if err != nil { return err } @@ -79,7 +80,7 @@ func runCreate(cmd *cobra.Command, args []string) error { // returning it as-is. orderID := digString(existing, "OrderId") if orderID != "" { - result, err := putTnList(client, acctID, orderID, tns) + result, err := putTnList(cmd.Context(), client, acctID, orderID, tns) if err != nil { return err } @@ -110,6 +111,7 @@ func runCreate(cmd *cobra.Command, args []string) error { var created interface{} if err := client.Post( + cmd.Context(), fmt.Sprintf("/accounts/%s/bulkPortins", acctID), api.XMLBody{RootElement: "BulkPortin", Data: body}, &created, @@ -125,7 +127,7 @@ func runCreate(cmd *cobra.Command, args []string) error { // Step 2: attach the TN list. If this fails, the template order already // exists — surface its ID so a retry doesn't strand it. Unsubmitted // drafts are auto-removed by the API after 2 days. - result, err := putTnList(client, acctID, orderID, tns) + result, err := putTnList(cmd.Context(), client, acctID, orderID, tns) if err != nil { resume := "re-run the same create to start over (unsubmitted drafts expire after 2 days)" if createCustomerOrderID != "" { @@ -140,9 +142,10 @@ func runCreate(cmd *cobra.Command, args []string) error { // putTnList attaches a TN list to a bulk port-in template order via // PUT /bulkPortins/{orderID}/tnList. -func putTnList(client *api.Client, acctID, orderID string, tns []string) (interface{}, error) { +func putTnList(ctx context.Context, client *api.Client, acctID, orderID string, tns []string) (interface{}, error) { var result interface{} if err := client.Put( + ctx, fmt.Sprintf("/accounts/%s/bulkPortins/%s/tnList", acctID, orderID), api.XMLBody{RootElement: "TnList", Data: map[string]interface{}{"TN": tns}}, &result, @@ -202,7 +205,7 @@ func loadNumbers(numbers []string, numbersFile string) ([]string, error) { // but the endpoint accepts the specific draft-family values even though its // documented enum omits them — both verified against prod. Draft states // first: they're what an idempotent retry most often targets. -func findBulkByCustomerOrderID(client *api.Client, acctID, customerOrderID string) (interface{}, error) { +func findBulkByCustomerOrderID(ctx context.Context, client *api.Client, acctID, customerOrderID string) (interface{}, error) { statuses := []string{ "draft", "validate_draft_tns", @@ -223,7 +226,7 @@ func findBulkByCustomerOrderID(client *api.Client, acctID, customerOrderID strin path := fmt.Sprintf("/accounts/%s/bulkPortins?%s", acctID, q.Encode()) var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(ctx, path, &result); err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { continue diff --git a/cmd/portin/bulk/get.go b/cmd/portin/bulk/get.go index 8f32457..68ba8cf 100644 --- a/cmd/portin/bulk/get.go +++ b/cmd/portin/bulk/get.go @@ -28,7 +28,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/bulkPortins/%s", acctID, args[0]), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/bulkPortins/%s", acctID, args[0]), &result); err != nil { return bulkError(err, "getting bulk port-in") } diff --git a/cmd/portin/bulk/get_tns.go b/cmd/portin/bulk/get_tns.go index 1d32fd3..88655ef 100644 --- a/cmd/portin/bulk/get_tns.go +++ b/cmd/portin/bulk/get_tns.go @@ -42,6 +42,7 @@ func runGetTns(cmd *cobra.Command, args []string) error { get := func() (interface{}, error) { var r interface{} if err := client.Get( + cmd.Context(), fmt.Sprintf("/accounts/%s/bulkPortins/%s/tnList", acctID, orderID), &r, ); err != nil { @@ -58,6 +59,7 @@ func runGetTns(cmd *cobra.Command, args []string) error { } } else { final, perr := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 3 * time.Second, Timeout: getTnsTimeout, Check: func() (bool, interface{}, error) { diff --git a/cmd/portin/bulk/list.go b/cmd/portin/bulk/list.go index f4beac2..c44aaea 100644 --- a/cmd/portin/bulk/list.go +++ b/cmd/portin/bulk/list.go @@ -71,7 +71,7 @@ func runList(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/accounts/%s/bulkPortins?%s", acctID, q.Encode()) var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return bulkError(err, "listing bulk port-in orders") } diff --git a/cmd/portin/cancel.go b/cmd/portin/cancel.go index 4351ea1..eb2739e 100644 --- a/cmd/portin/cancel.go +++ b/cmd/portin/cancel.go @@ -29,7 +29,7 @@ func runCancel(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Delete(fmt.Sprintf("/accounts/%s/portins/%s", acctID, args[0]), &result); err != nil { + if err := client.Delete(cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s", acctID, args[0]), &result); err != nil { return portinError(err, "cancelling port-in order") } diff --git a/cmd/portin/create.go b/cmd/portin/create.go index fd56bd2..b9bc820 100644 --- a/cmd/portin/create.go +++ b/cmd/portin/create.go @@ -1,6 +1,7 @@ package portin import ( + "context" "errors" "fmt" "net/url" @@ -70,7 +71,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if createCustomerOrderID == "" { return errors.New("--if-not-exists requires --customer-order-id") } - existing, err := findByCustomerOrderID(client, acctID, createCustomerOrderID) + existing, err := findByCustomerOrderID(cmd.Context(), client, acctID, createCustomerOrderID) if err != nil { return err } @@ -81,6 +82,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if orderID != "" { var full interface{} if err := client.Get( + cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), &full, ); err == nil { @@ -120,6 +122,7 @@ func runCreate(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Post( + cmd.Context(), fmt.Sprintf("/accounts/%s/portins", acctID), api.XMLBody{RootElement: "LnpOrder", Data: body}, &result, @@ -140,7 +143,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } ct := detectContentType(createLoaPath) path := fmt.Sprintf("/accounts/%s/portins/%s/loas?documentType=LOA", acctID, orderID) - if _, err := client.PostMultipart(path, "loaFile", filepath.Base(createLoaPath), data, ct); err != nil { + if _, err := client.PostMultipart(cmd.Context(), path, "loaFile", filepath.Base(createLoaPath), data, ct); err != nil { return fmt.Errorf("port-in order created (id: %s) but LOA upload failed — retry with: band portin upload-loa %s %s\n underlying error: %w", orderID, orderID, createLoaPath, err) } @@ -160,7 +163,7 @@ func runCreate(cmd *cobra.Command, args []string) error { // // Status names and ordering are deliberate: live/active states first since // those are the most common targets for idempotent retries. -func findByCustomerOrderID(client *api.Client, acctID, customerOrderID string) (interface{}, error) { +func findByCustomerOrderID(ctx context.Context, client *api.Client, acctID, customerOrderID string) (interface{}, error) { statuses := []string{ "submitted", "pending_documents", @@ -189,7 +192,7 @@ func findByCustomerOrderID(client *api.Client, acctID, customerOrderID string) ( path := fmt.Sprintf("/accounts/%s/portins?%s", acctID, q.Encode()) var result interface{} - err := client.Get(path, &result) + err := client.Get(ctx, path, &result) if err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { diff --git a/cmd/portin/get.go b/cmd/portin/get.go index c5f2d17..85b2cc3 100644 --- a/cmd/portin/get.go +++ b/cmd/portin/get.go @@ -28,7 +28,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/portins/%s", acctID, args[0]), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s", acctID, args[0]), &result); err != nil { return portinError(err, "getting port-in order") } diff --git a/cmd/portin/history.go b/cmd/portin/history.go index 04b2029..de7a5a7 100644 --- a/cmd/portin/history.go +++ b/cmd/portin/history.go @@ -28,7 +28,7 @@ func runHistory(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/portins/%s/history", acctID, args[0]), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s/history", acctID, args[0]), &result); err != nil { return portinError(err, "getting port-in history") } diff --git a/cmd/portin/list.go b/cmd/portin/list.go index 240e789..3bb0811 100644 --- a/cmd/portin/list.go +++ b/cmd/portin/list.go @@ -82,7 +82,7 @@ func runList(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/accounts/%s/portins?%s", acctID, params.Encode()) var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return portinError(err, "listing port-in orders") } diff --git a/cmd/portin/notes.go b/cmd/portin/notes.go index 55b236e..fb352f8 100644 --- a/cmd/portin/notes.go +++ b/cmd/portin/notes.go @@ -52,6 +52,7 @@ func runNotesAdd(cmd *cobra.Command, args []string) error { // note's URL in the Location header. Use the Location-aware POST so we // can return the noteId on plain output. location, err := client.PostXMLReturnLocation( + cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s/notes", acctID, args[0]), api.XMLBody{RootElement: "Note", Data: body}, ) @@ -101,6 +102,7 @@ func runNotesList(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Get( + cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s/notes", acctID, args[0]), &result, ); err != nil { diff --git a/cmd/portin/submit.go b/cmd/portin/submit.go index b085b60..3999254 100644 --- a/cmd/portin/submit.go +++ b/cmd/portin/submit.go @@ -1,6 +1,7 @@ package portin import ( + "context" "errors" "fmt" "strings" @@ -46,6 +47,7 @@ func runSubmit(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Put( + cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), api.XMLBody{RootElement: "LnpOrderSupp", Data: body}, &result, @@ -54,7 +56,7 @@ func runSubmit(cmd *cobra.Command, args []string) error { } if submitWait { - final, err := waitForSubmitted(client, acctID, orderID, submitTimeout) + final, err := waitForSubmitted(cmd.Context(), client, acctID, orderID, submitTimeout) if err != nil { return err } @@ -111,15 +113,17 @@ func submitWaitDone(status, prevStatus string) bool { // next steps. On timeout, the submit itself has already been accepted — // the error says so and reports the last-seen status instead of leaving // the user guessing whether the command worked. -func waitForSubmitted(client *api.Client, acctID, orderID string, timeout time.Duration) (interface{}, error) { +func waitForSubmitted(ctx context.Context, client *api.Client, acctID, orderID string, timeout time.Duration) (interface{}, error) { lastStatus := "" prevStatus := "" result, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: ctx, Interval: 3 * time.Second, Timeout: timeout, Check: func() (bool, interface{}, error) { var r interface{} if err := client.Get( + ctx, fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), &r, ); err != nil { diff --git a/cmd/portin/supp.go b/cmd/portin/supp.go index 94e4143..7e5a3a1 100644 --- a/cmd/portin/supp.go +++ b/cmd/portin/supp.go @@ -1,6 +1,7 @@ package portin import ( + "context" "fmt" "time" @@ -70,13 +71,14 @@ func runSupp(cmd *cobra.Command, args []string) error { // Capture the pre-PUT lastModifiedDate so we can detect actual propagation // rather than guessing. var pre interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), &pre); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), &pre); err != nil { return portinError(err, "fetching order before supplement") } preTS := digString(pre, "LastModifiedDate") var putResult interface{} if err := client.Put( + cmd.Context(), fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), api.XMLBody{RootElement: "LnpOrderSupp", Data: body}, &putResult, @@ -84,7 +86,7 @@ func runSupp(cmd *cobra.Command, args []string) error { return portinError(err, "supplementing port-in order") } - verified, err := waitForSuppPropagation(client, acctID, orderID, preTS, suppTimeout) + verified, err := waitForSuppPropagation(cmd.Context(), client, acctID, orderID, preTS, suppTimeout) if err != nil { return err } @@ -102,13 +104,15 @@ func runSupp(cmd *cobra.Command, args []string) error { // waitForSuppPropagation polls until the order's LastModifiedDate advances // past the pre-PUT timestamp (real propagation), or error code 7300 appears // (silent failure), or the timeout expires. -func waitForSuppPropagation(client *api.Client, acctID, orderID, preTS string, timeout time.Duration) (interface{}, error) { +func waitForSuppPropagation(ctx context.Context, client *api.Client, acctID, orderID, preTS string, timeout time.Duration) (interface{}, error) { return cmdutil.Poll(cmdutil.PollConfig{ + Context: ctx, Interval: 2 * time.Second, Timeout: timeout, Check: func() (bool, interface{}, error) { var r interface{} if err := client.Get( + ctx, fmt.Sprintf("/accounts/%s/portins/%s", acctID, orderID), &r, ); err != nil { diff --git a/cmd/portin/upload_loa.go b/cmd/portin/upload_loa.go index 36a9482..9f78b09 100644 --- a/cmd/portin/upload_loa.go +++ b/cmd/portin/upload_loa.go @@ -46,7 +46,7 @@ func runUploadLoa(cmd *cobra.Command, args []string) error { // header. We default to LOA — the most common case by far. Future flags // can extend this for invoices, CSRs, etc. path := fmt.Sprintf("/accounts/%s/portins/%s/loas?documentType=LOA", acctID, orderID) - if _, err := client.PostMultipart(path, "loaFile", filepath.Base(filePath), data, contentType); err != nil { + if _, err := client.PostMultipart(cmd.Context(), path, "loaFile", filepath.Base(filePath), data, contentType); err != nil { return portinError(err, "uploading LOA") } diff --git a/cmd/portin/validate_tf.go b/cmd/portin/validate_tf.go index 779c113..b728a10 100644 --- a/cmd/portin/validate_tf.go +++ b/cmd/portin/validate_tf.go @@ -58,6 +58,7 @@ func runValidateTF(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Post( + cmd.Context(), fmt.Sprintf("/accounts/%s/tollFreePortingValidations", acctID), api.XMLBody{RootElement: "TollFreePortingValidation", Data: body}, &result, @@ -71,11 +72,13 @@ func runValidateTF(cmd *cobra.Command, args []string) error { return fmt.Errorf("validation submitted but response had no OrderId — cannot poll") } final, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 2 * time.Second, Timeout: validateTFTimeout, Check: func() (bool, interface{}, error) { var r interface{} if err := client.Get( + cmd.Context(), fmt.Sprintf("/accounts/%s/tollFreePortingValidations/%s", acctID, orderID), &r, ); err != nil { diff --git a/cmd/quickstart/quickstart.go b/cmd/quickstart/quickstart.go index 8fd3d0f..26fe675 100644 --- a/cmd/quickstart/quickstart.go +++ b/cmd/quickstart/quickstart.go @@ -1,6 +1,7 @@ package quickstart import ( + "context" "encoding/json" "errors" "fmt" @@ -105,7 +106,7 @@ func runVCPQuickstart(cmd *cobra.Command) error { result := quickstartResult{CallbackURL: qsCallbackURL, Path: "vcp"} // Step 1: Create voice application (idempotent: reuse if already exists) - appID, err := ensureVoiceApp(dashClient, acctID, qsName+" App", qsCallbackURL) + appID, err := ensureVoiceApp(cmd.Context(), dashClient, acctID, qsName+" App", qsCallbackURL) if err != nil { // App provisioning failing often means this is a legacy account. fmt.Fprintf(os.Stderr, "\nVoice application setup failed. If this is a legacy account, try:\n") @@ -116,7 +117,7 @@ func runVCPQuickstart(cmd *cobra.Command) error { // Step 2: Create VCP linked to the app (idempotent: reuse if already exists) vcpName := qsName + " VCP" - existingVCP, err := findExistingID(platClient, fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), "name", vcpName, "voiceConfigurationPackageId") + existingVCP, err := findExistingID(cmd.Context(), platClient, fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), "name", vcpName, "voiceConfigurationPackageId") if err != nil { return failWithPartial(result, err) } @@ -131,7 +132,7 @@ func runVCPQuickstart(cmd *cobra.Command) error { "name": vcpName, "httpVoiceV2ApplicationId": appID, } - vcpErr := platClient.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), vcpBody, &vcpResp) + vcpErr := platClient.Post(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), vcpBody, &vcpResp) vcpSpin.Stop() if vcpErr != nil { fmt.Fprintf(os.Stderr, "\nVCP creation failed. If this is a legacy account, try:\n") @@ -147,7 +148,7 @@ func runVCPQuickstart(cmd *cobra.Command) error { vcpID := result.VCPID // Step 3: Search and order a number (idempotent: skip if VCP already has one) - existingNum, err := firstAssignedNumber(platClient, acctID, vcpID) + existingNum, err := firstAssignedNumber(cmd.Context(), platClient, acctID, vcpID) if err != nil { return failWithPartial(result, err) } @@ -157,12 +158,12 @@ func runVCPQuickstart(cmd *cobra.Command) error { ui.Successf("Number (existing): %s", ui.ID(existingNum)) } else { // Orders require a sub-account (SiteId), so ensure one exists before ordering. - siteID, err := ensureSubaccount(dashClient, acctID, qsName+" Sub-account") + siteID, err := ensureSubaccount(cmd.Context(), dashClient, acctID, qsName+" Sub-account") if err != nil { return failWithPartial(result, err) } result.SiteID = siteID - phoneNumber, err := searchAndOrderNumber(dashClient, acctID, siteID) + phoneNumber, err := searchAndOrderNumber(cmd.Context(), dashClient, acctID, siteID) if err != nil { result.Status = "complete_no_number" ui.Warnf("%v", err) @@ -182,11 +183,12 @@ func runVCPQuickstart(cmd *cobra.Command) error { } var lastAssignErr error _, pollErr := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 3 * time.Second, Timeout: 90 * time.Second, Check: func() (bool, interface{}, error) { var assignResp interface{} - err := platClient.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s/phoneNumbers/bulk", acctID, vcpID), assignBody, &assignResp) + err := platClient.Post(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s/phoneNumbers/bulk", acctID, vcpID), assignBody, &assignResp) if err == nil { return true, assignResp, nil } @@ -231,7 +233,7 @@ func runLegacyQuickstart(cmd *cobra.Command) error { // Step 1: Create sub-account (idempotent: reuse if already exists) siteName := qsName + " Sub-account" - existingSite, err := findExistingID(client, fmt.Sprintf("/accounts/%s/sites", acctID), "Name", siteName, "Id", "id", "siteId") + existingSite, err := findExistingID(cmd.Context(), client, fmt.Sprintf("/accounts/%s/sites", acctID), "Name", siteName, "Id", "id", "siteId") if err != nil { return failWithPartial(result, err) } @@ -248,7 +250,7 @@ func runLegacyQuickstart(cmd *cobra.Command) error { RootElement: "Site", Data: map[string]interface{}{"Name": siteName}, } - siteErr := client.Post(fmt.Sprintf("/accounts/%s/sites", acctID), siteBody, &siteResp) + siteErr := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/sites", acctID), siteBody, &siteResp) siteSpin.Stop() if siteErr != nil { return failWithPartial(result, fmt.Errorf("creating sub-account: %w", siteErr)) @@ -263,7 +265,7 @@ func runLegacyQuickstart(cmd *cobra.Command) error { // Step 2: Create SIP peer / location (idempotent: reuse if already exists) peerName := qsName + " Location" - existingPeer, err := findExistingID(client, fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), "PeerName", peerName, "PeerId", "Id", "id") + existingPeer, err := findExistingID(cmd.Context(), client, fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), "PeerName", peerName, "PeerId", "Id", "id") if err != nil { return failWithPartial(result, err) } @@ -281,7 +283,7 @@ func runLegacyQuickstart(cmd *cobra.Command) error { "IsDefaultPeer": "true", }, } - sipErr := client.Post(fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), sipBody, &sipResp) + sipErr := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), sipBody, &sipResp) sipSpin.Stop() if sipErr != nil { return failWithPartial(result, fmt.Errorf("creating location: %w", sipErr)) @@ -294,7 +296,7 @@ func runLegacyQuickstart(cmd *cobra.Command) error { } // Step 3: Create voice application (idempotent: reuse if already exists) - appID, err := ensureVoiceApp(client, acctID, qsName+" App", qsCallbackURL) + appID, err := ensureVoiceApp(cmd.Context(), client, acctID, qsName+" App", qsCallbackURL) if err != nil { return failWithPartial(result, err) } @@ -308,7 +310,7 @@ func runLegacyQuickstart(cmd *cobra.Command) error { // if Bandwidth exposes a sub-account-scoped in-service TN endpoint, or if // number.fetchAccountNumbers is exported and a heuristic is deemed acceptable. ui.Warnf("Note: the legacy number-ordering step is not idempotent — each time you re-run quickstart --legacy, another number may be ordered. The default (VCP) path does not have this limitation.") - phoneNumber, err := searchAndOrderNumber(client, acctID, siteID) + phoneNumber, err := searchAndOrderNumber(cmd.Context(), client, acctID, siteID) if err != nil { result.Status = "complete_no_number" ui.Warnf("%v", err) @@ -342,9 +344,9 @@ func failWithPartial(result quickstartResult, err error) error { // whose nameField matches name (or "" if none). It FAILS CLOSED: a list error // is returned to the caller rather than swallowed, because quickstart spends // money — a transient list failure must NOT cause us to create a duplicate. -func findExistingID(client *api.Client, listPath, nameField, name string, idKeys ...string) (string, error) { +func findExistingID(ctx context.Context, client *api.Client, listPath, nameField, name string, idKeys ...string) (string, error) { var resp interface{} - if err := client.Get(listPath, &resp); err != nil { + if err := client.Get(ctx, listPath, &resp); err != nil { return "", fmt.Errorf("checking for existing resource at %s: %w", listPath, err) } match := output.FindByName(resp, nameField, name) @@ -360,10 +362,10 @@ func findExistingID(client *api.Client, listPath, nameField, name string, idKeys // does NOT order a duplicate paid number on a transient failure. The // voiceConfigurationPackageId filter is honored server-side (verified live), and // the response shape is {"data":[{"phoneNumber":"+1...", ...}], ...}. -func firstAssignedNumber(client *api.Client, acctID, vcpID string) (string, error) { +func firstAssignedNumber(ctx context.Context, client *api.Client, acctID, vcpID string) (string, error) { var resp interface{} path := fmt.Sprintf("/v2/accounts/%s/phoneNumbers/voice?voiceConfigurationPackageId=%s", acctID, url.QueryEscape(vcpID)) - if err := client.Get(path, &resp); err != nil { + if err := client.Get(ctx, path, &resp); err != nil { return "", fmt.Errorf("checking existing VCP numbers for %s: %w", vcpID, err) } // FlattenResponse unwraps the {data, links, errors, page} envelope to the data array. @@ -386,9 +388,9 @@ func firstAssignedNumber(client *api.Client, acctID, vcpID string) (string, erro // SiteId AND a default SIP peer on that site — without the peer the orders API // fails with code 5020 ("No default SIP peer is set on the account and site"). // Idempotent: re-running reuses the same named sub-account and location. -func ensureSubaccount(client *api.Client, acctID, name string) (string, error) { +func ensureSubaccount(ctx context.Context, client *api.Client, acctID, name string) (string, error) { // Sub-account (site). - siteID, err := findExistingID(client, fmt.Sprintf("/accounts/%s/sites", acctID), "Name", name, "Id", "id", "siteId") + siteID, err := findExistingID(ctx, client, fmt.Sprintf("/accounts/%s/sites", acctID), "Name", name, "Id", "id", "siteId") if err != nil { return "", err } @@ -399,7 +401,7 @@ func ensureSubaccount(client *api.Client, acctID, name string) (string, error) { spin.Start() var resp interface{} body := api.XMLBody{RootElement: "Site", Data: map[string]interface{}{"Name": name}} - err = client.Post(fmt.Sprintf("/accounts/%s/sites", acctID), body, &resp) + err = client.Post(ctx, fmt.Sprintf("/accounts/%s/sites", acctID), body, &resp) spin.Stop() if err != nil { return "", fmt.Errorf("creating sub-account: %w", err) @@ -413,7 +415,7 @@ func ensureSubaccount(client *api.Client, acctID, name string) (string, error) { // Default SIP peer (location) — required for ordering (avoids code 5020). peerName := name + " Location" - existingPeer, err := findExistingID(client, fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), "PeerName", peerName, "PeerId", "Id", "id") + existingPeer, err := findExistingID(ctx, client, fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), "PeerName", peerName, "PeerId", "Id", "id") if err != nil { return "", err } @@ -424,7 +426,7 @@ func ensureSubaccount(client *api.Client, acctID, name string) (string, error) { spin.Start() var resp interface{} body := api.XMLBody{RootElement: "SipPeer", Data: map[string]interface{}{"PeerName": peerName, "IsDefaultPeer": "true"}} - err = client.Post(fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), body, &resp) + err = client.Post(ctx, fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), body, &resp) spin.Stop() if err != nil { return "", fmt.Errorf("creating default location: %w", err) @@ -442,8 +444,8 @@ func ensureSubaccount(client *api.Client, acctID, name string) (string, error) { // given callback URL and returns its application ID. Idempotent: re-running // reuses an existing app with the same name. Shared by both quickstart paths so // the app payload can't drift between them. -func ensureVoiceApp(client *api.Client, acctID, appName, callbackURL string) (string, error) { - existing, err := findExistingID(client, fmt.Sprintf("/accounts/%s/applications", acctID), "AppName", appName, "ApplicationId", "applicationId") +func ensureVoiceApp(ctx context.Context, client *api.Client, acctID, appName, callbackURL string) (string, error) { + existing, err := findExistingID(ctx, client, fmt.Sprintf("/accounts/%s/applications", acctID), "AppName", appName, "ApplicationId", "applicationId") if err != nil { return "", err } @@ -462,7 +464,7 @@ func ensureVoiceApp(client *api.Client, acctID, appName, callbackURL string) (st "CallInitiatedCallbackUrl": callbackURL, }, } - err = client.Post(fmt.Sprintf("/accounts/%s/applications", acctID), body, &resp) + err = client.Post(ctx, fmt.Sprintf("/accounts/%s/applications", acctID), body, &resp) spin.Stop() if err != nil { return "", fmt.Errorf("creating voice application: %w", err) @@ -475,11 +477,11 @@ func ensureVoiceApp(client *api.Client, acctID, appName, callbackURL string) (st return id, nil } -func searchAndOrderNumber(client *api.Client, acctID, siteID string) (string, error) { +func searchAndOrderNumber(ctx context.Context, client *api.Client, acctID, siteID string) (string, error) { searchSpin := ui.NewSpinner(fmt.Sprintf("Searching for number in area code %s...", qsAreaCode)) searchSpin.Start() var searchResp interface{} - searchErr := client.Get(fmt.Sprintf("/accounts/%s/availableNumbers?areaCode=%s&quantity=1", acctID, qsAreaCode), &searchResp) + searchErr := client.Get(ctx, fmt.Sprintf("/accounts/%s/availableNumbers?areaCode=%s&quantity=1", acctID, qsAreaCode), &searchResp) searchSpin.Stop() if searchErr != nil { return "", fmt.Errorf("number search failed: %w", searchErr) @@ -495,7 +497,7 @@ func searchAndOrderNumber(client *api.Client, acctID, siteID string) (string, er var orderResp interface{} // Reuse the shared, live-verified order body (SiteId + ExistingTelephoneNumberOrderType). orderBody := api.XMLBody{RootElement: "Order", Data: numbercmd.BuildOrderBody(siteID, []string{phoneNumber})} - orderErr := client.Post(fmt.Sprintf("/accounts/%s/orders", acctID), orderBody, &orderResp) + orderErr := client.Post(ctx, fmt.Sprintf("/accounts/%s/orders", acctID), orderBody, &orderResp) orderSpin.Stop() if orderErr != nil { return "", fmt.Errorf("number order failed: %w", orderErr) diff --git a/cmd/recording/delete.go b/cmd/recording/delete.go index 873c093..b1bef62 100644 --- a/cmd/recording/delete.go +++ b/cmd/recording/delete.go @@ -34,7 +34,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return err } - if err := client.Delete(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), nil); err != nil { + if err := client.Delete(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), nil); err != nil { return fmt.Errorf("deleting recording: %w", err) } diff --git a/cmd/recording/download.go b/cmd/recording/download.go index 8219304..8e392a7 100644 --- a/cmd/recording/download.go +++ b/cmd/recording/download.go @@ -39,7 +39,7 @@ func runDownload(cmd *cobra.Command, args []string) error { return err } - data, err := client.GetRaw(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/media", acctID, url.PathEscape(args[0]), url.PathEscape(args[1]))) + data, err := client.GetRaw(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/media", acctID, url.PathEscape(args[0]), url.PathEscape(args[1]))) if err != nil { return fmt.Errorf("downloading recording: %w", err) } diff --git a/cmd/recording/get.go b/cmd/recording/get.go index 3835886..9c3153a 100644 --- a/cmd/recording/get.go +++ b/cmd/recording/get.go @@ -37,7 +37,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), &result); err != nil { return fmt.Errorf("getting recording: %w", err) } diff --git a/cmd/recording/list.go b/cmd/recording/list.go index ccf709a..ed7fffd 100644 --- a/cmd/recording/list.go +++ b/cmd/recording/list.go @@ -34,7 +34,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s/recordings", acctID, url.PathEscape(args[0])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recordings", acctID, url.PathEscape(args[0])), &result); err != nil { return fmt.Errorf("listing recordings: %w", err) } diff --git a/cmd/recording/pause.go b/cmd/recording/pause.go index 8fda7c0..f0982d1 100644 --- a/cmd/recording/pause.go +++ b/cmd/recording/pause.go @@ -33,7 +33,7 @@ func runPause(cmd *cobra.Command, args []string) error { "state": "paused", } - if err := client.Put(fmt.Sprintf("/accounts/%s/calls/%s/recording", acctID, url.PathEscape(args[0])), reqBody, nil); err != nil { + if err := client.Put(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recording", acctID, url.PathEscape(args[0])), reqBody, nil); err != nil { return fmt.Errorf("pausing recording: %w", err) } diff --git a/cmd/recording/resume.go b/cmd/recording/resume.go index 67a86bc..34a06cb 100644 --- a/cmd/recording/resume.go +++ b/cmd/recording/resume.go @@ -33,7 +33,7 @@ func runResume(cmd *cobra.Command, args []string) error { "state": "recording", } - if err := client.Put(fmt.Sprintf("/accounts/%s/calls/%s/recording", acctID, url.PathEscape(args[0])), reqBody, nil); err != nil { + if err := client.Put(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recording", acctID, url.PathEscape(args[0])), reqBody, nil); err != nil { return fmt.Errorf("resuming recording: %w", err) } diff --git a/cmd/root.go b/cmd/root.go index 0d508fd..e0502c0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,9 +3,12 @@ package cmd import ( + "context" "fmt" "os" + "os/signal" "strings" + "syscall" "github.com/spf13/cobra" "golang.org/x/term" @@ -132,9 +135,16 @@ func Root() *cobra.Command { return rootCmd } +// Execute runs the CLI with a context cancelled on SIGINT/SIGTERM, so every +// command sees cancellation via cmd.Context(): in-flight API requests abort +// and --wait loops exit through their receipt-emitting cancellation paths. +// NotifyContext stops trapping after the first signal, so a second Ctrl-C +// falls through to the Go runtime default and hard-exits the process. func Execute() error { api.Version = version - return rootCmd.Execute() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return rootCmd.ExecuteContext(ctx) } func GetFormat() string { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..c72d9f7 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,66 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + "syscall" + "testing" + "time" + + "github.com/spf13/cobra" +) + +// Execute must run commands under a context that is cancelled by SIGINT, so +// --wait loops can exit through their receipt-emitting cancellation paths and +// in-flight requests abort. The test command blocks until its context ends; +// a SIGINT sent to our own process must unblock it. +func TestExecuteCancelsCommandContextOnSIGINT(t *testing.T) { + sawCancel := make(chan error, 1) + testCmd := newTestSignalCommand(sawCancel) + rootCmd.AddCommand(testCmd) + defer rootCmd.RemoveCommand(testCmd) + + rootCmd.SetArgs([]string{testCmd.Use}) + defer rootCmd.SetArgs(nil) + + execDone := make(chan error, 1) + go func() { execDone <- Execute() }() + + // Give Execute time to install the signal handler and start the command, + // then interrupt ourselves. The handler traps the signal, so the test + // process survives and the command's context is cancelled. + time.Sleep(100 * time.Millisecond) + if err := syscall.Kill(syscall.Getpid(), syscall.SIGINT); err != nil { + t.Fatalf("sending SIGINT: %v", err) + } + + select { + case err := <-sawCancel: + if !errors.Is(err, context.Canceled) { + t.Errorf("command context ended with %v, want context.Canceled", err) + } + case <-time.After(5 * time.Second): + t.Fatal("command context was not cancelled after SIGINT") + } + select { + case <-execDone: + case <-time.After(5 * time.Second): + t.Fatal("Execute did not return after SIGINT") + } +} + +// newTestSignalCommand returns a hidden command that blocks until its context +// is cancelled and reports the context error on done. +func newTestSignalCommand(done chan<- error) *cobra.Command { + return &cobra.Command{ + Use: "test-signal-wait", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + <-cmd.Context().Done() + done <- cmd.Context().Err() + return nil + }, + } +} diff --git a/cmd/shortcode/get.go b/cmd/shortcode/get.go index 61a9557..090f4de 100644 --- a/cmd/shortcode/get.go +++ b/cmd/shortcode/get.go @@ -39,7 +39,7 @@ func runGet(cmd *cobra.Command, args []string) error { acctID, url.PathEscape(args[0]), url.PathEscape(getCountry)) var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { if apiErr, ok := err.(*api.APIError); ok { switch apiErr.StatusCode { case 403: diff --git a/cmd/shortcode/list.go b/cmd/shortcode/list.go index 74b0e0d..52f90b5 100644 --- a/cmd/shortcode/list.go +++ b/cmd/shortcode/list.go @@ -40,7 +40,7 @@ func runList(cmd *cobra.Command, args []string) error { acctID, listLimit, listOffset) var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return shortcodeError(err) } diff --git a/cmd/sip/credential_create.go b/cmd/sip/credential_create.go index 351f2d7..dd81551 100644 --- a/cmd/sip/credential_create.go +++ b/cmd/sip/credential_create.go @@ -62,7 +62,7 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { if err != nil { return err } - realm, err := svc.GetRealm(credCreate.realm) + realm, err := svc.GetRealm(cmd.Context(), credCreate.realm) if err != nil { return faultExit(err) } @@ -76,7 +76,7 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { } hash1, hash1b := sipsvc.ComputeHashes(credCreateUsername, realm.Hostname, password) - cred, err := svc.CreateCredential(realm.ID, credCreateUsername, hash1, hash1b, credCreateAppID) + cred, err := svc.CreateCredential(cmd.Context(), realm.ID, credCreateUsername, hash1, hash1b, credCreateAppID) if err != nil { var fault *sipsvc.APIFault if credCreateIfNotExists && errors.As(err, &fault) && fault.Code == "23026" { @@ -117,14 +117,14 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { // reuseCredential implements --if-not-exists after a 23026 duplicate. Identity // is realm + username; desired state is both hashes plus the app binding. func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Realm, hash1, hash1b, password string, generated bool) error { - found, err := svc.FindCredentialByUsername(realm.ID, credCreateUsername) + found, err := svc.FindCredentialByUsername(cmd.Context(), realm.ID, credCreateUsername) if err != nil { return faultExit(err) } // Re-read the single credential rather than trusting FindCredentialByUsername's // list-derived AppID: the collection response's app-binding field has not // been confirmed to round-trip the same shape as the single-item GET. - existing, err := svc.GetCredential(realm.ID, found.ID) + existing, err := svc.GetCredential(cmd.Context(), realm.ID, found.ID) if err != nil { return faultExit(err) } @@ -139,7 +139,7 @@ func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Real Message: fmt.Sprintf("credential %q already exists and its password cannot be recovered — rotate it: band sip credential rotate %s --realm %s --generate-password", credCreateUsername, existing.ID, realm.Name), } } - match, err := svc.CredentialHashesMatch(realm.ID, existing.ID, hash1, hash1b) + match, err := svc.CredentialHashesMatch(cmd.Context(), realm.ID, existing.ID, hash1, hash1b) if err != nil { return faultExit(err) } diff --git a/cmd/sip/credential_delete.go b/cmd/sip/credential_delete.go index e4c5af5..0e1db07 100644 --- a/cmd/sip/credential_delete.go +++ b/cmd/sip/credential_delete.go @@ -25,11 +25,11 @@ var credentialDeleteCmd = &cobra.Command{ if err != nil { return err } - realm, err := svc.GetRealm(credDeleteRealm) + realm, err := svc.GetRealm(cmd.Context(), credDeleteRealm) if err != nil { return faultExit(err) } - if err := svc.DeleteCredential(realm.ID, args[0]); err != nil { + if err := svc.DeleteCredential(cmd.Context(), realm.ID, args[0]); err != nil { return faultExit(err) } format, plain := cmdutil.OutputFlags(cmd) diff --git a/cmd/sip/credential_get.go b/cmd/sip/credential_get.go index a81cfd7..876b598 100644 --- a/cmd/sip/credential_get.go +++ b/cmd/sip/credential_get.go @@ -25,11 +25,11 @@ var credentialGetCmd = &cobra.Command{ if err != nil { return err } - realm, err := svc.GetRealm(credGetRealm) + realm, err := svc.GetRealm(cmd.Context(), credGetRealm) if err != nil { return faultExit(err) } - cred, err := svc.GetCredential(realm.ID, args[0]) + cred, err := svc.GetCredential(cmd.Context(), realm.ID, args[0]) if err != nil { return faultExit(err) } diff --git a/cmd/sip/credential_list.go b/cmd/sip/credential_list.go index dc59f30..2d818a5 100644 --- a/cmd/sip/credential_list.go +++ b/cmd/sip/credential_list.go @@ -24,11 +24,11 @@ var credentialListCmd = &cobra.Command{ if err != nil { return err } - realm, err := svc.GetRealm(credListRealm) + realm, err := svc.GetRealm(cmd.Context(), credListRealm) if err != nil { return faultExit(err) } - creds, err := svc.ListCredentials(realm.ID) + creds, err := svc.ListCredentials(cmd.Context(), realm.ID) if err != nil { return faultExit(err) } diff --git a/cmd/sip/credential_rotate.go b/cmd/sip/credential_rotate.go index a4042a8..924e08b 100644 --- a/cmd/sip/credential_rotate.go +++ b/cmd/sip/credential_rotate.go @@ -32,11 +32,11 @@ var credentialRotateCmd = &cobra.Command{ if err != nil { return err } - realm, err := svc.GetRealm(credRotate.realm) + realm, err := svc.GetRealm(cmd.Context(), credRotate.realm) if err != nil { return faultExit(err) } - existing, err := svc.GetCredential(realm.ID, args[0]) + existing, err := svc.GetCredential(cmd.Context(), realm.ID, args[0]) if err != nil { return faultExit(err) } @@ -45,7 +45,7 @@ var credentialRotateCmd = &cobra.Command{ return err } hash1, hash1b := sipsvc.ComputeHashes(existing.Username, realm.Hostname, password) - cred, err := svc.RotateCredential(realm.ID, existing.ID, hash1, hash1b) + cred, err := svc.RotateCredential(cmd.Context(), realm.ID, existing.ID, hash1, hash1b) if err != nil { // The PUT may have replaced the hashes server-side even though this // call reports failure (e.g. a decode error after a successful diff --git a/cmd/sip/realm_create.go b/cmd/sip/realm_create.go index cc7e789..44897dc 100644 --- a/cmd/sip/realm_create.go +++ b/cmd/sip/realm_create.go @@ -1,6 +1,7 @@ package sip import ( + "context" "fmt" "strings" "time" @@ -57,7 +58,7 @@ func runRealmCreate(cmd *cobra.Command, args []string) error { descSet := cmd.Flags().Changed("description") if realmCreateIfNotExists { - realms, err := svc.ListRealms() + realms, err := svc.ListRealms(cmd.Context()) if err != nil { return faultExit(err) } @@ -83,7 +84,7 @@ func runRealmCreate(cmd *cobra.Command, args []string) error { // --if-not-exists after a --wait timeout is safe, which is exactly // how that combination gets used. if realmCreateWait && r.Status != "ACTIVE" { - final, err := waitForRealmActive(svc, r.ID, realmCreateTimeout) + final, err := waitForRealmActive(cmd.Context(), svc, r.ID, realmCreateTimeout) if err != nil { return err } @@ -93,13 +94,13 @@ func runRealmCreate(cmd *cobra.Command, args []string) error { } } - realm, err := svc.CreateRealm(realmCreateName, realmCreateDescription, realmCreateDefault) + realm, err := svc.CreateRealm(cmd.Context(), realmCreateName, realmCreateDescription, realmCreateDefault) if err != nil { return faultExit(err) } if realmCreateWait && realm.Status != "ACTIVE" { - final, err := waitForRealmActive(svc, realm.ID, realmCreateTimeout) + final, err := waitForRealmActive(cmd.Context(), svc, realm.ID, realmCreateTimeout) if err != nil { return err } @@ -110,12 +111,13 @@ func runRealmCreate(cmd *cobra.Command, args []string) error { // waitForRealmActive polls until the realm is ACTIVE. Terminal-failure states // stop the loop immediately rather than burning the whole timeout. -func waitForRealmActive(svc *sipsvc.Service, realmID string, timeoutSeconds int) (*sipsvc.Realm, error) { +func waitForRealmActive(ctx context.Context, svc *sipsvc.Service, realmID string, timeoutSeconds int) (*sipsvc.Realm, error) { result, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: ctx, Interval: 2 * time.Second, Timeout: time.Duration(timeoutSeconds) * time.Second, Check: func() (bool, interface{}, error) { - r, err := svc.GetRealm(realmID) + r, err := svc.GetRealm(ctx, realmID) if err != nil { return false, nil, faultExit(err) } diff --git a/cmd/sip/realm_delete.go b/cmd/sip/realm_delete.go index 11e3aee..9c8da31 100644 --- a/cmd/sip/realm_delete.go +++ b/cmd/sip/realm_delete.go @@ -40,11 +40,11 @@ var realmDeleteCmd = &cobra.Command{ // {"id":"vapi"} — a name in a field an agent reads as an ID. This costs // one extra GET on the delete path, which is the price of the output // field meaning what it says. - realm, err := svc.GetRealm(args[0]) + realm, err := svc.GetRealm(cmd.Context(), args[0]) if err != nil { return faultExit(err) } - if err := svc.DeleteRealm(realm.ID); err != nil { + if err := svc.DeleteRealm(cmd.Context(), realm.ID); err != nil { return faultExit(err) } format, plain := cmdutil.OutputFlags(cmd) @@ -55,13 +55,14 @@ var realmDeleteCmd = &cobra.Command{ if realmDeleteWait { if _, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 2 * time.Second, Timeout: time.Duration(realmDeleteTimeout) * time.Second, Check: func() (bool, interface{}, error) { // Poll by canonical ID, not by the caller's ref: the name a // deleted realm answered to is not guaranteed to keep // resolving, and the ID is what the delete was issued against. - _, err := svc.GetRealm(realm.ID) + _, err := svc.GetRealm(cmd.Context(), realm.ID) if err == nil { return false, nil, nil // still present } diff --git a/cmd/sip/realm_get.go b/cmd/sip/realm_get.go index 6d6ec2f..db73764 100644 --- a/cmd/sip/realm_get.go +++ b/cmd/sip/realm_get.go @@ -19,7 +19,7 @@ var realmGetCmd = &cobra.Command{ if err != nil { return err } - realm, err := svc.GetRealm(args[0]) + realm, err := svc.GetRealm(cmd.Context(), args[0]) if err != nil { return faultExit(err) } diff --git a/cmd/sip/realm_list.go b/cmd/sip/realm_list.go index a41b244..2ca4673 100644 --- a/cmd/sip/realm_list.go +++ b/cmd/sip/realm_list.go @@ -17,7 +17,7 @@ var realmListCmd = &cobra.Command{ if err != nil { return err } - realms, err := svc.ListRealms() + realms, err := svc.ListRealms(cmd.Context()) if err != nil { return faultExit(err) } diff --git a/cmd/sip/realm_update.go b/cmd/sip/realm_update.go index fbdf1e0..b421adf 100644 --- a/cmd/sip/realm_update.go +++ b/cmd/sip/realm_update.go @@ -54,7 +54,7 @@ var realmUpdateCmd = &cobra.Command{ if descSet { desc = &realmUpdateDescription } - realm, err := svc.UpdateRealm(args[0], defaultSet && realmUpdateDefault, desc) + realm, err := svc.UpdateRealm(cmd.Context(), args[0], defaultSet && realmUpdateDefault, desc) if err != nil { return faultExit(err) } diff --git a/cmd/sip/status.go b/cmd/sip/status.go index 1691c5a..f094153 100644 --- a/cmd/sip/status.go +++ b/cmd/sip/status.go @@ -26,7 +26,7 @@ var statusCmd = &cobra.Command{ } format, plain := cmdutil.OutputFlags(cmd) - if _, err := svc.ListRealms(); err != nil { + if _, err := svc.ListRealms(cmd.Context()); err != nil { var fault *sipsvc.APIFault if errors.As(err, &fault) && fault.Code == "33004" { // A successful probe reporting a negative fact: exit 0. diff --git a/cmd/site/create.go b/cmd/site/create.go index 438ef39..a92414c 100644 --- a/cmd/site/create.go +++ b/cmd/site/create.go @@ -46,7 +46,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if createIfNotExists { var listResult interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &listResult); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/sites", acctID), &listResult); err != nil { return fmt.Errorf("listing sub-accounts: %w", err) } if existing := output.FindByName(listResult, "Name", createName); existing != nil { @@ -62,7 +62,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/sites", acctID), api.XMLBody{RootElement: "Site", Data: bodyData}, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/sites", acctID), api.XMLBody{RootElement: "Site", Data: bodyData}, &result); err != nil { return fmt.Errorf("creating sub-account: %w", err) } diff --git a/cmd/site/delete.go b/cmd/site/delete.go index 90a9f2d..5bc6cd0 100644 --- a/cmd/site/delete.go +++ b/cmd/site/delete.go @@ -31,7 +31,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return err } - if err := client.Delete(fmt.Sprintf("/accounts/%s/sites/%s", acctID, url.PathEscape(args[0])), nil); err != nil { + if err := client.Delete(cmd.Context(), fmt.Sprintf("/accounts/%s/sites/%s", acctID, url.PathEscape(args[0])), nil); err != nil { return fmt.Errorf("deleting sub-account: %w", err) } diff --git a/cmd/site/get.go b/cmd/site/get.go index 71e3742..8ec95c1 100644 --- a/cmd/site/get.go +++ b/cmd/site/get.go @@ -34,7 +34,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/sites/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/sites/%s", acctID, url.PathEscape(args[0])), &result); err != nil { return fmt.Errorf("getting sub-account: %w", err) } diff --git a/cmd/site/list.go b/cmd/site/list.go index 976026d..1302cc4 100644 --- a/cmd/site/list.go +++ b/cmd/site/list.go @@ -29,7 +29,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/sites", acctID), &result); err != nil { return cmdutil.Wrap403(err, "listing sub-accounts", "Sub-Accounts") } diff --git a/cmd/tendlc/async.go b/cmd/tendlc/async.go index c00f016..81f5229 100644 --- a/cmd/tendlc/async.go +++ b/cmd/tendlc/async.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "fmt" "time" @@ -177,9 +178,9 @@ func awaitTerminal(cmd *cobra.Command, t pollTarget, receipt map[string]any, tim // fetchBrand adapts a brand read into pollTarget.Fetch, translating a 404 into // found=false rather than an error. -func fetchBrand(svc *tendlcsvc.Service, brandID string) func() (map[string]any, bool, error) { +func fetchBrand(ctx context.Context, svc *tendlcsvc.Service, brandID string) func() (map[string]any, bool, error) { return func() (map[string]any, bool, error) { - env, err := svc.GetBrand(brandID) + env, err := svc.GetBrand(ctx, brandID) if err != nil { if isNotFound(err) { return nil, false, nil @@ -197,9 +198,9 @@ func fetchBrand(svc *tendlcsvc.Service, brandID string) func() (map[string]any, // fetchCampaign adapts a campaign read into pollTarget.Fetch, translating a // 404 into found=false rather than an error. fetchBrand's twin, for // 'campaign create --wait'. -func fetchCampaign(svc *tendlcsvc.Service, campaignID string) func() (map[string]any, bool, error) { +func fetchCampaign(ctx context.Context, svc *tendlcsvc.Service, campaignID string) func() (map[string]any, bool, error) { return func() (map[string]any, bool, error) { - env, err := svc.GetCampaign(campaignID) + env, err := svc.GetCampaign(ctx, campaignID) if err != nil { if isNotFound(err) { return nil, false, nil diff --git a/cmd/tendlc/async_test.go b/cmd/tendlc/async_test.go index 322f24d..524e305 100644 --- a/cmd/tendlc/async_test.go +++ b/cmd/tendlc/async_test.go @@ -2,6 +2,7 @@ package tendlc import ( "bytes" + "context" "encoding/json" "errors" "io" @@ -279,7 +280,7 @@ func fetchBrandStubServer(t *testing.T, code int, body string) *tendlcsvc.Servic // HTTP response, not just the classifier logic above it. func TestFetchBrandFoundReturnsObject(t *testing.T) { svc := fetchBrandStubServer(t, http.StatusOK, `{"data":{"bandwidthId":"WABC","brandIdentityStatus":"VERIFIED"}}`) - obj, found, err := fetchBrand(svc, "WABC")() + obj, found, err := fetchBrand(context.Background(), svc, "WABC")() if err != nil { t.Fatalf("want no error, got %v", err) } @@ -297,7 +298,7 @@ func TestFetchBrandFoundReturnsObject(t *testing.T) { // retrying until it appears or the poll times out. func TestFetchBrandNotFoundIsFoundFalseNotError(t *testing.T) { svc := fetchBrandStubServer(t, http.StatusNotFound, `{"errors":[{"description":"brand not found"}]}`) - obj, found, err := fetchBrand(svc, "WABC")() + obj, found, err := fetchBrand(context.Background(), svc, "WABC")() if err != nil { t.Fatalf("want no error for a 404, got %v", err) } @@ -311,7 +312,7 @@ func TestFetchBrandNotFoundIsFoundFalseNotError(t *testing.T) { func TestFetchBrandServerErrorIsError(t *testing.T) { svc := fetchBrandStubServer(t, http.StatusInternalServerError, `{"errors":[{"description":"boom"}]}`) - _, found, err := fetchBrand(svc, "WABC")() + _, found, err := fetchBrand(context.Background(), svc, "WABC")() if err == nil { t.Fatal("want an error for a 500") } @@ -326,7 +327,7 @@ func TestFetchBrandServerErrorIsError(t *testing.T) { // response shape it can never recover from. func TestFetchBrandMalformedDataIsError(t *testing.T) { svc := fetchBrandStubServer(t, http.StatusOK, `{"data":[{"bandwidthId":"WABC"}]}`) - _, found, err := fetchBrand(svc, "WABC")() + _, found, err := fetchBrand(context.Background(), svc, "WABC")() if err == nil { t.Fatal("want an error when data is not an object") } diff --git a/cmd/tendlc/brand_create.go b/cmd/tendlc/brand_create.go index 00f2641..a4bb76c 100644 --- a/cmd/tendlc/brand_create.go +++ b/cmd/tendlc/brand_create.go @@ -123,7 +123,7 @@ submitted first. Retrying blind risks a second brand against the same profile.`, return err } - env, err := svc.CreateBrand(tendlcsvc.BuildBrandCreateRequest(brandCreateOpts)) + env, err := svc.CreateBrand(cmd.Context(), tendlcsvc.BuildBrandCreateRequest(brandCreateOpts)) if err != nil { return roleGateError(err, "Campaign Management") } @@ -140,7 +140,7 @@ submitted first. Retrying blind risks a second brand against the same profile.`, target := pollTarget{ Noun: "brand", - Fetch: fetchBrand(svc, bandwidthID), + Fetch: fetchBrand(cmd.Context(), svc, bandwidthID), Classify: func(o map[string]any) tendlcsvc.StateClass { status, _ := o["brandIdentityStatus"].(string) return tendlcsvc.ClassifyBrandIdentity(status) @@ -191,7 +191,7 @@ created.`, if err != nil { return err } - env, err := svc.CreateBrand(tendlcsvc.BuildBrandRefreshRequest(args[0])) + env, err := svc.CreateBrand(cmd.Context(), tendlcsvc.BuildBrandRefreshRequest(args[0])) if err != nil { return roleGateError(err, "Campaign Management") } @@ -213,7 +213,7 @@ created.`, func preflightCustomerProfile(cmd *cobra.Command, profileID string) error { cpSvc, err := customerProfileService(cmd) if err == nil { - _, err = cpSvc.Get(profileID) + _, err = cpSvc.Get(cmd.Context(), profileID) } if err == nil { return nil diff --git a/cmd/tendlc/brand_delete.go b/cmd/tendlc/brand_delete.go index 4748528..aa25436 100644 --- a/cmd/tendlc/brand_delete.go +++ b/cmd/tendlc/brand_delete.go @@ -65,7 +65,7 @@ because --wait gave up waiting.`, if err != nil { return err } - if err := svc.DeleteBrand(args[0]); err != nil { + if err := svc.DeleteBrand(cmd.Context(), args[0]); err != nil { return roleGateError(err, "Campaign Management") } @@ -101,7 +101,7 @@ because --wait gave up waiting.`, // the --timeout path, so if the deadline arrives before this ever // fires, deleted is still false and the note above is still there — // the timeout receipt (exit 5) never contradicts its own exit code. - fetch := fetchBrand(svc, args[0]) + fetch := fetchBrand(cmd.Context(), svc, args[0]) confirmedFetch := func() (map[string]any, bool, error) { obj, found, ferr := fetch() if ferr == nil && !found { diff --git a/cmd/tendlc/brand_get.go b/cmd/tendlc/brand_get.go index 438c0a4..c2b0ad8 100644 --- a/cmd/tendlc/brand_get.go +++ b/cmd/tendlc/brand_get.go @@ -26,7 +26,7 @@ here — pass whichever one you have.`, if err != nil { return err } - env, err := svc.GetBrand(args[0]) + env, err := svc.GetBrand(cmd.Context(), args[0]) if err != nil { return roleGateError(err, "Campaign Management") } diff --git a/cmd/tendlc/brand_history.go b/cmd/tendlc/brand_history.go index c7d311e..54cb34d 100644 --- a/cmd/tendlc/brand_history.go +++ b/cmd/tendlc/brand_history.go @@ -46,7 +46,7 @@ Either the TCR brandId or the Bandwidth bandwidthId works as the positional.`, format, plain := cmdutil.OutputFlags(cmd) if !brandHistoryAll { - env, err := svc.BrandHistory(args[0], brandHistoryLimit, brandHistoryOffset) + env, err := svc.BrandHistory(cmd.Context(), args[0], brandHistoryLimit, brandHistoryOffset) if err != nil { return roleGateError(err, "Campaign Management") } @@ -60,7 +60,7 @@ Either the TCR brandId or the Bandwidth bandwidthId works as the positional.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.BrandHistory(args[0], limit, offset) + return svc.BrandHistory(cmd.Context(), args[0], limit, offset) }, brandHistoryLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/tendlc/brand_identity.go b/cmd/tendlc/brand_identity.go index 1123135..8eeb823 100644 --- a/cmd/tendlc/brand_identity.go +++ b/cmd/tendlc/brand_identity.go @@ -41,7 +41,7 @@ Requires --confirm.`, if err != nil { return err } - if err := svc.ReverifyBrand(args[0]); err != nil { + if err := svc.ReverifyBrand(cmd.Context(), args[0]); err != nil { return roleGateError(err, "Campaign Management") } @@ -75,7 +75,7 @@ writes in this command set, this does not require --confirm.`, if err != nil { return err } - if err := svc.Resend2FA(args[0]); err != nil { + if err := svc.Resend2FA(cmd.Context(), args[0]); err != nil { return roleGateError(err, "Campaign Management") } diff --git a/cmd/tendlc/brand_list.go b/cmd/tendlc/brand_list.go index 522cbf2..729b0d3 100644 --- a/cmd/tendlc/brand_list.go +++ b/cmd/tendlc/brand_list.go @@ -124,7 +124,7 @@ rather than filtering. Use 'brand get ' to fetch one directly.`, format, plain := cmdutil.OutputFlags(cmd) if !brandListAll { - env, err := svc.ListBrands(brandListLimit, brandListOffset, filters) + env, err := svc.ListBrands(cmd.Context(), brandListLimit, brandListOffset, filters) if err != nil { return roleGateError(err, "Campaign Management") } @@ -147,7 +147,7 @@ rather than filtering. Use 'brand get ' to fetch one directly.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.ListBrands(limit, offset, filters) + return svc.ListBrands(cmd.Context(), limit, offset, filters) }, brandListLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/tendlc/brand_update.go b/cmd/tendlc/brand_update.go index 6c4072f..ebf459b 100644 --- a/cmd/tendlc/brand_update.go +++ b/cmd/tendlc/brand_update.go @@ -100,7 +100,7 @@ PUBLIC_PROFIT brand revokes Auth+ compliance.`, return err } - env, err := svc.GetBrand(args[0]) + env, err := svc.GetBrand(cmd.Context(), args[0]) if err != nil { return roleGateError(err, "Campaign Management") } @@ -132,7 +132,7 @@ PUBLIC_PROFIT brand revokes Auth+ compliance.`, return err } - updated, err := svc.UpdateBrand(args[0], body) + updated, err := svc.UpdateBrand(cmd.Context(), args[0], body) if err != nil { return brandUpdateConflictHint(args[0], err) } diff --git a/cmd/tendlc/campaign_create.go b/cmd/tendlc/campaign_create.go index cfff6e7..832f58d 100644 --- a/cmd/tendlc/campaign_create.go +++ b/cmd/tendlc/campaign_create.go @@ -149,7 +149,7 @@ first. Retrying blind risks a second campaign against the same brand.`, return err } - env, err := svc.CreateCampaign(tendlcsvc.BuildCampaignCreateRequest(campaignCreateOpts, changed)) + env, err := svc.CreateCampaign(cmd.Context(), tendlcsvc.BuildCampaignCreateRequest(campaignCreateOpts, changed)) if err != nil { return roleGateError(err, "Campaign Management") } @@ -166,7 +166,7 @@ first. Retrying blind risks a second campaign against the same brand.`, target := pollTarget{ Noun: "campaign", - Fetch: fetchCampaign(svc, bandwidthID), + Fetch: fetchCampaign(cmd.Context(), svc, bandwidthID), Classify: func(o map[string]any) tendlcsvc.StateClass { status, _ := o["status"].(string) return tendlcsvc.ClassifyCampaignStatus(status) @@ -211,7 +211,7 @@ success before the sync actually applied.`, if err != nil { return err } - env, err := svc.CreateCampaign(tendlcsvc.BuildCampaignSyncRequest(args[0], campaignSyncName)) + env, err := svc.CreateCampaign(cmd.Context(), tendlcsvc.BuildCampaignSyncRequest(args[0], campaignSyncName)) if err != nil { return roleGateError(err, "Campaign Management") } @@ -252,7 +252,7 @@ success before the sync actually applied.`, // is the safer direction to be wrong in, so this lets the API speak instead // of guessing. func preflightBrand(cmd *cobra.Command, svc *tendlcsvc.Service, brandID string) error { - env, err := svc.GetBrand(brandID) + env, err := svc.GetBrand(cmd.Context(), brandID) if err == nil { var obj map[string]any obj, err = env.Object() diff --git a/cmd/tendlc/campaign_get.go b/cmd/tendlc/campaign_get.go index 98893bb..d8d8471 100644 --- a/cmd/tendlc/campaign_get.go +++ b/cmd/tendlc/campaign_get.go @@ -21,7 +21,7 @@ var campaignGetCmd = &cobra.Command{ if err != nil { return err } - env, err := svc.GetCampaign(args[0]) + env, err := svc.GetCampaign(cmd.Context(), args[0]) if err != nil { return roleGateError(err, "Campaign Management") } diff --git a/cmd/tendlc/campaign_history.go b/cmd/tendlc/campaign_history.go index 304a1e0..851ee96 100644 --- a/cmd/tendlc/campaign_history.go +++ b/cmd/tendlc/campaign_history.go @@ -50,7 +50,7 @@ rather than one.`, format, plain := cmdutil.OutputFlags(cmd) if !campaignHistoryAll { - env, err := svc.CampaignHistory(args[0], campaignHistoryLimit, campaignHistoryOffset) + env, err := svc.CampaignHistory(cmd.Context(), args[0], campaignHistoryLimit, campaignHistoryOffset) if err != nil { return roleGateError(err, "Campaign Management") } @@ -64,7 +64,7 @@ rather than one.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.CampaignHistory(args[0], limit, offset) + return svc.CampaignHistory(cmd.Context(), args[0], limit, offset) }, campaignHistoryLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/tendlc/campaign_lifecycle.go b/cmd/tendlc/campaign_lifecycle.go index 705e495..bdf8678 100644 --- a/cmd/tendlc/campaign_lifecycle.go +++ b/cmd/tendlc/campaign_lifecycle.go @@ -68,7 +68,7 @@ deactivated:true merely because --wait gave up waiting.`, if err != nil { return err } - if err := svc.DeactivateCampaign(args[0]); err != nil { + if err := svc.DeactivateCampaign(cmd.Context(), args[0]); err != nil { return roleGateError(err, "Campaign Management") } @@ -100,7 +100,7 @@ deactivated:true merely because --wait gave up waiting.`, // the --timeout path, so if the deadline arrives before this ever // fires, deactivated is still false and the note above is still there // — the timeout receipt (exit 5) never contradicts its own exit code. - fetch := fetchCampaign(svc, args[0]) + fetch := fetchCampaign(cmd.Context(), svc, args[0]) confirmedFetch := func() (map[string]any, bool, error) { obj, found, ferr := fetch() if ferr == nil && !found { @@ -164,7 +164,7 @@ unlike 'campaign deactivate' this does not require --confirm.`, body["description"] = campaignNudgeDescription } - if err := svc.NudgeCampaign(args[0], body); err != nil { + if err := svc.NudgeCampaign(cmd.Context(), args[0], body); err != nil { return roleGateError(err, "Campaign Management") } diff --git a/cmd/tendlc/campaign_list.go b/cmd/tendlc/campaign_list.go index b9b5ed1..954d89e 100644 --- a/cmd/tendlc/campaign_list.go +++ b/cmd/tendlc/campaign_list.go @@ -112,7 +112,7 @@ client-side on the output of --all instead.`, format, plain := cmdutil.OutputFlags(cmd) if !campaignListAll { - env, err := svc.ListCampaigns(campaignListLimit, campaignListOffset, filters) + env, err := svc.ListCampaigns(cmd.Context(), campaignListLimit, campaignListOffset, filters) if err != nil { return roleGateError(err, "Campaign Management") } @@ -126,7 +126,7 @@ client-side on the output of --all instead.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.ListCampaigns(limit, offset, filters) + return svc.ListCampaigns(cmd.Context(), limit, offset, filters) }, campaignListLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/tendlc/campaign_phonenumbers.go b/cmd/tendlc/campaign_phonenumbers.go index be98699..a05f4b0 100644 --- a/cmd/tendlc/campaign_phonenumbers.go +++ b/cmd/tendlc/campaign_phonenumbers.go @@ -41,7 +41,7 @@ provisioning errors.`, format, plain := cmdutil.OutputFlags(cmd) if !campaignPhoneNumbersAll { - env, err := svc.CampaignPhoneNumbers(args[0], campaignPhoneNumbersLimit, campaignPhoneNumbersOffset) + env, err := svc.CampaignPhoneNumbers(cmd.Context(), args[0], campaignPhoneNumbersLimit, campaignPhoneNumbersOffset) if err != nil { return roleGateError(err, "Campaign Management") } @@ -55,7 +55,7 @@ provisioning errors.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.CampaignPhoneNumbers(args[0], limit, offset) + return svc.CampaignPhoneNumbers(cmd.Context(), args[0], limit, offset) }, campaignPhoneNumbersLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/tendlc/campaign_update.go b/cmd/tendlc/campaign_update.go index 54223fb..fb8a409 100644 --- a/cmd/tendlc/campaign_update.go +++ b/cmd/tendlc/campaign_update.go @@ -108,7 +108,7 @@ campaigns, so there is no API-justified reason to gate this behind a flag.`, return err } - env, err := svc.GetCampaign(args[0]) + env, err := svc.GetCampaign(cmd.Context(), args[0]) if err != nil { return roleGateError(err, "Campaign Management") } @@ -122,7 +122,7 @@ campaigns, so there is no API-justified reason to gate this behind a flag.`, return err } - updated, err := svc.UpdateCampaign(args[0], body) + updated, err := svc.UpdateCampaign(cmd.Context(), args[0], body) if err != nil { return campaignUpdateConflictHint(args[0], err) } diff --git a/cmd/tendlc/number.go b/cmd/tendlc/number.go index f743da3..574c679 100644 --- a/cmd/tendlc/number.go +++ b/cmd/tendlc/number.go @@ -109,7 +109,7 @@ fields -- check for them rather than relying on their absence.`, } if !numberListAll { - env, err := svc.ListPhoneNumbers(numberListLimit, numberListOffset, filters) + env, err := svc.ListPhoneNumbers(cmd.Context(), numberListLimit, numberListOffset, filters) if err != nil { return roleGateError(err, "Campaign Management") } @@ -123,7 +123,7 @@ fields -- check for them rather than relying on their absence.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.ListPhoneNumbers(limit, offset, filters) + return svc.ListPhoneNumbers(cmd.Context(), limit, offset, filters) }, numberListLimit, func(batch []any) error { all = append(all, batch...) return nil @@ -162,7 +162,7 @@ wherever the endpoint does, without a bespoke error message guessing at why.`, if err != nil { return err } - env, err := svc.GetPhoneNumber(args[0]) + env, err := svc.GetPhoneNumber(cmd.Context(), args[0]) if err != nil { return roleGateError(err, "Campaign Management") } @@ -197,7 +197,7 @@ per-entry fetch -- this is the only history view for a phone number.`, format, plain := cmdutil.OutputFlags(cmd) if !numberHistoryAll { - env, err := svc.PhoneNumberHistory(args[0], numberHistoryLimit, numberHistoryOffset) + env, err := svc.PhoneNumberHistory(cmd.Context(), args[0], numberHistoryLimit, numberHistoryOffset) if err != nil { return roleGateError(err, "Campaign Management") } @@ -211,7 +211,7 @@ per-entry fetch -- this is the only history view for a phone number.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.PhoneNumberHistory(args[0], limit, offset) + return svc.PhoneNumberHistory(cmd.Context(), args[0], limit, offset) }, numberHistoryLimit, func(batch []any) error { all = append(all, batch...) return nil diff --git a/cmd/tendlc/status.go b/cmd/tendlc/status.go index 8e95ed4..8f6a5bb 100644 --- a/cmd/tendlc/status.go +++ b/cmd/tendlc/status.go @@ -96,7 +96,7 @@ which yours is, ask your Bandwidth account contact rather than guessing.`, } format, plain := cmdutil.OutputFlags(cmd) - _, probeErr := svc.ListBrands(1, 0, nil) + _, probeErr := svc.ListBrands(cmd.Context(), 1, 0, nil) if probeErr == nil { return output.StdoutAuto(format, plain, statusResult(200, "")) } diff --git a/cmd/tendlc/vetting.go b/cmd/tendlc/vetting.go index ba3f0dd..d11f9cb 100644 --- a/cmd/tendlc/vetting.go +++ b/cmd/tendlc/vetting.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "fmt" "strings" "time" @@ -168,12 +169,12 @@ func buildVettingReceipt(cmd *cobra.Command, env *api.Envelope, brandID string) // GET .../vettings/{id} — the vettings list is the only read surface for a // vetting's status — so --wait re-lists (walking every page) and returns the // entry whose ID, under either vettingIDKeys name, matches vettingID. -func fetchVetting(svc *tendlcsvc.Service, brandID, vettingID string) func() (map[string]any, bool, error) { +func fetchVetting(ctx context.Context, svc *tendlcsvc.Service, brandID, vettingID string) func() (map[string]any, bool, error) { return func() (map[string]any, bool, error) { const pageSize = 100 offset := 0 for { - env, err := svc.ListVettings(brandID, pageSize, offset) + env, err := svc.ListVettings(ctx, brandID, pageSize, offset) if err != nil { if isNotFound(err) { return nil, false, nil @@ -250,7 +251,7 @@ campaign vetting endpoint.`, format, plain := cmdutil.OutputFlags(cmd) if !vettingListAll { - env, err := svc.ListVettings(args[0], vettingListLimit, vettingListOffset) + env, err := svc.ListVettings(cmd.Context(), args[0], vettingListLimit, vettingListOffset) if err != nil { return roleGateError(err, "Campaign Management") } @@ -264,7 +265,7 @@ campaign vetting endpoint.`, var all []any err = api.ForEachPage(func(limit, offset int) (*api.Envelope, error) { - return svc.ListVettings(args[0], limit, offset) + return svc.ListVettings(cmd.Context(), args[0], limit, offset) }, vettingListLimit, func(batch []any) error { all = append(all, batch...) return nil @@ -335,7 +336,7 @@ This places a billable order with an external vetting provider, so "evpId": vettingRequestEvp, "vettingClass": vettingRequestClass, } - env, err := svc.RequestVetting(brandID, body) + env, err := svc.RequestVetting(cmd.Context(), brandID, body) if err != nil { return roleGateError(err, "Campaign Management") } @@ -352,7 +353,7 @@ This places a billable order with an external vetting provider, so target := pollTarget{ Noun: "vetting", - Fetch: fetchVetting(svc, brandID, vettingID), + Fetch: fetchVetting(cmd.Context(), svc, brandID, vettingID), Classify: classifyVettingObj, } return awaitTerminal(cmd, target, receipt, time.Duration(vettingRequestTimeout)*time.Second, vettingPollInterval) @@ -407,7 +408,7 @@ request' this takes no --confirm.`, body["vettingToken"] = vettingImportVettingToken } - env, err := svc.ImportVetting(brandID, vettingID, body) + env, err := svc.ImportVetting(cmd.Context(), brandID, vettingID, body) if err != nil { return roleGateError(err, "Campaign Management") } @@ -429,7 +430,7 @@ request' this takes no --confirm.`, target := pollTarget{ Noun: "vetting", - Fetch: fetchVetting(svc, brandID, respVettingID), + Fetch: fetchVetting(cmd.Context(), svc, brandID, respVettingID), Classify: classifyVettingObj, } return awaitTerminal(cmd, target, receipt, time.Duration(vettingImportTimeout)*time.Second, vettingPollInterval) diff --git a/cmd/tfv/get.go b/cmd/tfv/get.go index 4e8639d..9bd37f9 100644 --- a/cmd/tfv/get.go +++ b/cmd/tfv/get.go @@ -37,7 +37,7 @@ func runGet(cmd *cobra.Command, args []string) error { acctID, url.PathEscape(number)) var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return tfvError(err, number) } diff --git a/cmd/tfv/submit.go b/cmd/tfv/submit.go index 53b4150..06aa298 100644 --- a/cmd/tfv/submit.go +++ b/cmd/tfv/submit.go @@ -132,7 +132,7 @@ func runSubmit(cmd *cobra.Command, args []string) error { acctID, url.PathEscape(number)) var result interface{} - if err := client.Post(path, body, &result); err != nil { + if err := client.Post(cmd.Context(), path, body, &result); err != nil { if apiErr, ok := err.(*api.APIError); ok { switch apiErr.StatusCode { case 403: diff --git a/cmd/tnoption/assign.go b/cmd/tnoption/assign.go index 01a18e3..50e66f0 100644 --- a/cmd/tnoption/assign.go +++ b/cmd/tnoption/assign.go @@ -68,6 +68,7 @@ func runAssign(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Post( + cmd.Context(), fmt.Sprintf("/accounts/%s/tnoptions", acctID), api.XMLBody{RootElement: "TnOptionOrder", Data: body}, &result, @@ -89,11 +90,13 @@ func runAssign(cmd *cobra.Command, args []string) error { } final, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 2 * time.Second, Timeout: assignTimeout, Check: func() (bool, interface{}, error) { var orderResult interface{} if err := client.Get( + cmd.Context(), fmt.Sprintf("/accounts/%s/tnoptions/%s", acctID, orderID), &orderResult, ); err != nil { diff --git a/cmd/tnoption/get.go b/cmd/tnoption/get.go index e2a156c..7d1743a 100644 --- a/cmd/tnoption/get.go +++ b/cmd/tnoption/get.go @@ -29,7 +29,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/tnoptions/%s", acctID, args[0]), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/tnoptions/%s", acctID, args[0]), &result); err != nil { return fmt.Errorf("getting TN option order: %w", err) } diff --git a/cmd/tnoption/list.go b/cmd/tnoption/list.go index f7ae945..550bdf3 100644 --- a/cmd/tnoption/list.go +++ b/cmd/tnoption/list.go @@ -51,7 +51,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(path, &result); err != nil { + if err := client.Get(cmd.Context(), path, &result); err != nil { return fmt.Errorf("listing TN option orders: %w", err) } diff --git a/cmd/tollfree/template.go b/cmd/tollfree/template.go index 26f29b5..f44c69c 100644 --- a/cmd/tollfree/template.go +++ b/cmd/tollfree/template.go @@ -106,7 +106,7 @@ func runTemplate(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/api/v2/accounts/%s/tollFreeTemplateAssignments/search", acctID) var result interface{} - if err := client.Post(path, templateSearchBody(numbers), &result); err != nil { + if err := client.Post(cmd.Context(), path, templateSearchBody(numbers), &result); err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 403 { return fmt.Errorf("toll-free template search is not enabled on account %s — ask your Bandwidth account manager to enable it: %w", acctID, err) diff --git a/cmd/transcription/create.go b/cmd/transcription/create.go index 74a3fb7..3493bc2 100644 --- a/cmd/transcription/create.go +++ b/cmd/transcription/create.go @@ -48,7 +48,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), nil, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), nil, &result); err != nil { return fmt.Errorf("creating transcription: %w", err) } @@ -61,11 +61,12 @@ func runCreate(cmd *cobra.Command, args []string) error { getPath := fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(callID), url.PathEscape(recordingID)) final, err := cmdutil.Poll(cmdutil.PollConfig{ + Context: cmd.Context(), Interval: 5 * time.Second, Timeout: createTimeout, Check: func() (bool, interface{}, error) { var t interface{} - if err := client.Get(getPath, &t); err != nil { + if err := client.Get(cmd.Context(), getPath, &t); err != nil { return false, nil, fmt.Errorf("polling transcription: %w", err) } // Consider done when the result is non-nil and has content. diff --git a/cmd/transcription/get.go b/cmd/transcription/get.go index 22b8396..533b2b3 100644 --- a/cmd/transcription/get.go +++ b/cmd/transcription/get.go @@ -37,7 +37,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), &result); err != nil { return fmt.Errorf("getting transcription: %w", err) } diff --git a/cmd/vcp/assign.go b/cmd/vcp/assign.go index bd2a883..3f2f96a 100644 --- a/cmd/vcp/assign.go +++ b/cmd/vcp/assign.go @@ -47,7 +47,7 @@ func runAssign(cmd *cobra.Command, args []string) error { body := BuildAssignBody(numbers) var result interface{} - if err := client.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s/phoneNumbers/bulk", acctID, vcpID), body, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s/phoneNumbers/bulk", acctID, vcpID), body, &result); err != nil { return fmt.Errorf("assigning numbers to VCP: %w", err) } diff --git a/cmd/vcp/create.go b/cmd/vcp/create.go index 3a58980..f14c2f1 100644 --- a/cmd/vcp/create.go +++ b/cmd/vcp/create.go @@ -88,7 +88,7 @@ func runCreate(cmd *cobra.Command, args []string) error { if createIfNotExists { var listResult interface{} - if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &listResult); err == nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &listResult); err == nil { matches := findAllByName(listResult, "name", createName) if len(matches) > 1 { return &cmdutil.ConflictError{Message: fmt.Sprintf( @@ -117,7 +117,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), body, &result); err != nil { + if err := client.Post(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), body, &result); err != nil { return fmt.Errorf("creating VCP: %w", err) } diff --git a/cmd/vcp/delete.go b/cmd/vcp/delete.go index 589c9ef..e0068bc 100644 --- a/cmd/vcp/delete.go +++ b/cmd/vcp/delete.go @@ -32,7 +32,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return err } - if err := client.Delete(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, url.PathEscape(args[0])), nil); err != nil { + if err := client.Delete(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, url.PathEscape(args[0])), nil); err != nil { return fmt.Errorf("deleting VCP: %w", err) } diff --git a/cmd/vcp/get.go b/cmd/vcp/get.go index 8aea199..54f530a 100644 --- a/cmd/vcp/get.go +++ b/cmd/vcp/get.go @@ -35,7 +35,7 @@ func runGet(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, url.PathEscape(args[0])), &result); err != nil { return fmt.Errorf("getting VCP: %w", err) } diff --git a/cmd/vcp/list.go b/cmd/vcp/list.go index a4487b6..437314d 100644 --- a/cmd/vcp/list.go +++ b/cmd/vcp/list.go @@ -29,7 +29,7 @@ func runList(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &result); err != nil { return cmdutil.Wrap403(err, "listing VCPs", "VCP") } diff --git a/cmd/vcp/numbers.go b/cmd/vcp/numbers.go index 5efe6f5..4cf82b1 100644 --- a/cmd/vcp/numbers.go +++ b/cmd/vcp/numbers.go @@ -38,7 +38,7 @@ func runNumbers(cmd *cobra.Command, args []string) error { params.Set("voiceConfigurationPackageId", args[0]) var result interface{} - if err := client.Get(fmt.Sprintf("/v2/accounts/%s/phoneNumbers/voice?%s", acctID, params.Encode()), &result); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/phoneNumbers/voice?%s", acctID, params.Encode()), &result); err != nil { return fmt.Errorf("listing VCP numbers: %w", err) } diff --git a/cmd/vcp/update.go b/cmd/vcp/update.go index fb382cf..e701f2b 100644 --- a/cmd/vcp/update.go +++ b/cmd/vcp/update.go @@ -117,7 +117,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { if plan != nil { var current map[string]interface{} - if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), ¤t); err != nil { + if err := client.Get(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), ¤t); err != nil { return fmt.Errorf("reading current VCP: %w", err) } existingPlan := current["originationRoutePlan"] @@ -136,7 +136,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } var result interface{} - if err := client.Patch(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), body, &result); err != nil { + if err := client.Patch(cmd.Context(), fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), body, &result); err != nil { return fmt.Errorf("updating VCP: %w", err) } diff --git a/internal/api/client.go b/internal/api/client.go index 20a6c8f..0e26c10 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -4,6 +4,7 @@ package api import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -70,15 +71,17 @@ func (e *APIError) Error() string { } // Requester is the interface satisfied by Client. Commands accept this so -// tests can substitute a mock without hitting real Bandwidth APIs. +// tests can substitute a mock without hitting real Bandwidth APIs. Every +// method takes the caller's context so an in-flight request is aborted when +// the command is cancelled (Ctrl-C); commands pass cmd.Context(). type Requester interface { - Get(path string, result interface{}) error - Post(path string, body, result interface{}) error - Put(path string, body, result interface{}) error - Patch(path string, body, result interface{}) error - Delete(path string, result interface{}) error - GetRaw(path string) ([]byte, error) - PutRaw(path string, data []byte, contentType string) error + Get(ctx context.Context, path string, result interface{}) error + Post(ctx context.Context, path string, body, result interface{}) error + Put(ctx context.Context, path string, body, result interface{}) error + Patch(ctx context.Context, path string, body, result interface{}) error + Delete(ctx context.Context, path string, result interface{}) error + GetRaw(ctx context.Context, path string) ([]byte, error) + PutRaw(ctx context.Context, path string, data []byte, contentType string) error } // Client is an authenticated HTTP client for Bandwidth APIs. @@ -133,8 +136,12 @@ func NewClientNoAuth(baseURL string) *Client { } // newRequest creates an authenticated HTTP request with standard headers. -func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequest(method, c.BaseURL+path, body) +// The request carries ctx, so cancelling it aborts the request in flight. +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } @@ -172,7 +179,7 @@ func (c *Client) doRaw(req *http.Request) ([]byte, error) { // do executes an HTTP request and unmarshals the response into result. // result may be nil (e.g., for 204 No Content responses). -func (c *Client) do(method, path string, reqBody, result interface{}) error { +func (c *Client) do(ctx context.Context, method, path string, reqBody, result interface{}) error { var bodyReader io.Reader var contentTypeHeader string @@ -198,7 +205,7 @@ func (c *Client) do(method, path string, reqBody, result interface{}) error { } } - req, err := c.newRequest(method, path, bodyReader) + req, err := c.newRequest(ctx, method, path, bodyReader) if err != nil { return err } @@ -238,34 +245,34 @@ func (c *Client) do(method, path string, reqBody, result interface{}) error { } // Get performs a GET request and unmarshals the response into result. -func (c *Client) Get(path string, result interface{}) error { - return c.do(http.MethodGet, path, nil, result) +func (c *Client) Get(ctx context.Context, path string, result interface{}) error { + return c.do(ctx, http.MethodGet, path, nil, result) } // Post performs a POST request with body and unmarshals the response into result. -func (c *Client) Post(path string, body, result interface{}) error { - return c.do(http.MethodPost, path, body, result) +func (c *Client) Post(ctx context.Context, path string, body, result interface{}) error { + return c.do(ctx, http.MethodPost, path, body, result) } // Put performs a PUT request with body and unmarshals the response into result. -func (c *Client) Put(path string, body, result interface{}) error { - return c.do(http.MethodPut, path, body, result) +func (c *Client) Put(ctx context.Context, path string, body, result interface{}) error { + return c.do(ctx, http.MethodPut, path, body, result) } // Patch performs a PATCH request with body and unmarshals the response into result. -func (c *Client) Patch(path string, body, result interface{}) error { - return c.do(http.MethodPatch, path, body, result) +func (c *Client) Patch(ctx context.Context, path string, body, result interface{}) error { + return c.do(ctx, http.MethodPatch, path, body, result) } // Delete performs a DELETE request and unmarshals the response into result. -func (c *Client) Delete(path string, result interface{}) error { - return c.do(http.MethodDelete, path, nil, result) +func (c *Client) Delete(ctx context.Context, path string, result interface{}) error { + return c.do(ctx, http.MethodDelete, path, nil, result) } // GetRaw performs a GET request and returns the raw response bytes. // Useful for file downloads like recordings. -func (c *Client) GetRaw(path string) ([]byte, error) { - req, err := c.newRequest(http.MethodGet, path, nil) +func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { + req, err := c.newRequest(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } @@ -274,8 +281,8 @@ func (c *Client) GetRaw(path string) ([]byte, error) { // PutRaw performs a PUT request with raw binary data and a custom content type. // Useful for uploading files like MMS media. -func (c *Client) PutRaw(path string, data []byte, contentType string) error { - req, err := c.newRequest(http.MethodPut, path, bytes.NewReader(data)) +func (c *Client) PutRaw(ctx context.Context, path string, data []byte, contentType string) error { + req, err := c.newRequest(ctx, http.MethodPut, path, bytes.NewReader(data)) if err != nil { return err } @@ -287,18 +294,18 @@ func (c *Client) PutRaw(path string, data []byte, contentType string) error { // PostRaw posts a JSON body and returns the raw response bytes, so callers can // parse an envelope without a typed target. JSON-only: returns an error // without making a request if c is configured for XML (see NewXMLClient). -func (c *Client) PostRaw(path string, body interface{}) ([]byte, error) { - return c.doRawJSON("POST", path, body) +func (c *Client) PostRaw(ctx context.Context, path string, body interface{}) ([]byte, error) { + return c.doRawJSON(ctx, "POST", path, body) } // PutRawJSON puts a JSON body and returns the raw response bytes. JSON-only: // returns an error without making a request if c is configured for XML (see // NewXMLClient). -func (c *Client) PutRawJSON(path string, body interface{}) ([]byte, error) { - return c.doRawJSON("PUT", path, body) +func (c *Client) PutRawJSON(ctx context.Context, path string, body interface{}) ([]byte, error) { + return c.doRawJSON(ctx, "PUT", path, body) } -func (c *Client) doRawJSON(method, path string, body interface{}) ([]byte, error) { +func (c *Client) doRawJSON(ctx context.Context, method, path string, body interface{}) ([]byte, error) { if c.contentType == "xml" { return nil, fmt.Errorf("PostRaw/PutRawJSON send JSON; this client is configured for XML (use the XML methods instead)") } @@ -306,7 +313,7 @@ func (c *Client) doRawJSON(method, path string, body interface{}) ([]byte, error if err != nil { return nil, fmt.Errorf("encoding request body: %w", err) } - req, err := c.newRequest(method, path, bytes.NewReader(data)) + req, err := c.newRequest(ctx, method, path, bytes.NewReader(data)) if err != nil { return nil, err } @@ -318,12 +325,12 @@ func (c *Client) doRawJSON(method, path string, body interface{}) ([]byte, error // Location response header. Useful for endpoints that respond 201 Created // with an empty body and put the new resource's URL in Location (the // Bandwidth Numbers API does this for notes, sippeers, sites, etc.). -func (c *Client) PostXMLReturnLocation(path string, body XMLBody) (string, error) { +func (c *Client) PostXMLReturnLocation(ctx context.Context, path string, body XMLBody) (string, error) { data, err := MapToXML(body.RootElement, body.Data) if err != nil { return "", fmt.Errorf("marshaling XML request body: %w", err) } - req, err := c.newRequest(http.MethodPost, path, bytes.NewReader(data)) + req, err := c.newRequest(ctx, http.MethodPost, path, bytes.NewReader(data)) if err != nil { return "", err } @@ -346,7 +353,7 @@ func (c *Client) PostXMLReturnLocation(path string, body XMLBody) (string, error // PostMultipart performs a POST request with a multipart/form-data body containing // a single file part. Used for endpoints that accept document uploads (LOAs, // supporting docs on port-in orders). -func (c *Client) PostMultipart(path, fieldName, filename string, fileData []byte, fileContentType string) ([]byte, error) { +func (c *Client) PostMultipart(ctx context.Context, path, fieldName, filename string, fileData []byte, fileContentType string) ([]byte, error) { var buf bytes.Buffer w := multipart.NewWriter(&buf) h := make(textproto.MIMEHeader) @@ -362,7 +369,7 @@ func (c *Client) PostMultipart(path, fieldName, filename string, fileData []byte if err := w.Close(); err != nil { return nil, fmt.Errorf("closing multipart writer: %w", err) } - req, err := c.newRequest(http.MethodPost, path, &buf) + req, err := c.newRequest(ctx, http.MethodPost, path, &buf) if err != nil { return nil, err } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index fac1c2d..07fcda1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "net/http" @@ -51,7 +52,7 @@ func TestClient_Get(t *testing.T) { client := NewClient(srv.URL, tm) var got response - if err := client.Get("/", &got); err != nil { + if err := client.Get(context.Background(), "/", &got); err != nil { t.Fatalf("Get() error: %v", err) } if got.Name != "test" { @@ -59,6 +60,31 @@ func TestClient_Get(t *testing.T) { } } +// A cancelled context must abort an in-flight request, not just be observed +// between requests — the request is built with NewRequestWithContext, so the +// HTTP client aborts the connection as soon as the context ends. +func TestClient_ContextCancelsInFlightRequest(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release // hold the request open well past the context deadline + })) + defer srv.Close() + defer close(release) + + client := NewClientNoAuth(srv.URL) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err := client.Get(ctx, "/", nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want context.DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("Get returned after %v — the context deadline did not abort the in-flight request", elapsed) + } +} + func TestClient_Post(t *testing.T) { type request struct { Value string `json:"value"` @@ -93,7 +119,7 @@ func TestClient_Post(t *testing.T) { client := NewClient(srv.URL, tm) var got response - if err := client.Post("/", request{Value: "hello"}, &got); err != nil { + if err := client.Post(context.Background(), "/", request{Value: "hello"}, &got); err != nil { t.Fatalf("Post() error: %v", err) } if got.Result != "ok" { @@ -117,7 +143,7 @@ func TestClient_ErrorResponse(t *testing.T) { client := NewClient(srv.URL, tm) var got struct{} - err := client.Get("/missing", &got) + err := client.Get(context.Background(), "/missing", &got) if err == nil { t.Fatal("expected error for non-2xx response, got nil") } @@ -158,7 +184,7 @@ func TestClient_Put(t *testing.T) { client := NewClient(srv.URL, tm) var got response - if err := client.Put("/", request{Name: "test"}, &got); err != nil { + if err := client.Put(context.Background(), "/", request{Name: "test"}, &got); err != nil { t.Fatalf("Put() error: %v", err) } if !got.Updated { @@ -185,7 +211,7 @@ func TestClient_Delete(t *testing.T) { client := NewClient(srv.URL, tm) // nil result is valid for 204 No Content - if err := client.Delete("/", nil); err != nil { + if err := client.Delete(context.Background(), "/", nil); err != nil { t.Fatalf("Delete() error: %v", err) } } @@ -207,7 +233,7 @@ func TestClient_GetRaw(t *testing.T) { tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) client := NewClient(srv.URL, tm) - got, err := client.GetRaw("/") + got, err := client.GetRaw(context.Background(), "/") if err != nil { t.Fatalf("GetRaw() error: %v", err) } @@ -229,7 +255,7 @@ func TestClient_NoAuth(t *testing.T) { client := NewClientNoAuth(srv.URL) var got struct{} - if err := client.Get("/", &got); err != nil { + if err := client.Get(context.Background(), "/", &got); err != nil { t.Fatalf("Get() error: %v", err) } } @@ -327,7 +353,7 @@ func TestXMLClient_Post(t *testing.T) { } var result interface{} - if err := client.Post("/sippeers", body, &result); err != nil { + if err := client.Post(context.Background(), "/sippeers", body, &result); err != nil { t.Fatalf("Post() error: %v", err) } @@ -365,7 +391,7 @@ func TestXMLClient_Get(t *testing.T) { client := NewXMLClient(srv.URL, tm) var result interface{} - if err := client.Get("/sites/99", &result); err != nil { + if err := client.Get(context.Background(), "/sites/99", &result); err != nil { t.Fatalf("Get() error: %v", err) } @@ -411,7 +437,7 @@ func TestClient_PutRaw(t *testing.T) { tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) client := NewClient(srv.URL, tm) - if err := client.PutRaw("/media/test.png", payload, "image/png"); err != nil { + if err := client.PutRaw(context.Background(), "/media/test.png", payload, "image/png"); err != nil { t.Fatalf("PutRaw() error: %v", err) } } @@ -431,7 +457,7 @@ func TestClient_PutRaw_Error(t *testing.T) { tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) client := NewClient(srv.URL, tm) - err := client.PutRaw("/media/test.xyz", []byte("data"), "application/octet-stream") + err := client.PutRaw(context.Background(), "/media/test.xyz", []byte("data"), "application/octet-stream") if err == nil { t.Fatal("expected error for 415 response, got nil") } @@ -460,7 +486,7 @@ func TestXMLClient_NonXMLBodyReturnsError(t *testing.T) { client := NewXMLClient(srv.URL, tm) // Passing a plain map (not XMLBody) to an XML client should return an error. - err := client.Post("/test", map[string]string{"key": "val"}, nil) + err := client.Post(context.Background(), "/test", map[string]string{"key": "val"}, nil) if err == nil { t.Fatal("expected error when passing non-XMLBody to XML client, got nil") } @@ -479,13 +505,13 @@ func TestPostRawAndPutRawJSON_RefuseXMLClient(t *testing.T) { client := NewXMLClient(srv.URL, nil) - if _, err := client.PostRaw("/", map[string]any{"a": "b"}); err == nil { + if _, err := client.PostRaw(context.Background(), "/", map[string]any{"a": "b"}); err == nil { t.Error("PostRaw on an XML-configured client: want error, got nil") } else if !strings.Contains(err.Error(), "XML") { t.Errorf("PostRaw error = %q, want mention of XML", err) } - if _, err := client.PutRawJSON("/", map[string]any{"a": "b"}); err == nil { + if _, err := client.PutRawJSON(context.Background(), "/", map[string]any{"a": "b"}); err == nil { t.Error("PutRawJSON on an XML-configured client: want error, got nil") } else if !strings.Contains(err.Error(), "XML") { t.Errorf("PutRawJSON error = %q, want mention of XML", err) @@ -507,7 +533,7 @@ func TestAPIErrorCapturesHeaders(t *testing.T) { c := NewClientNoAuth(srv.URL) var out any - err := c.Get("/x", &out) + err := c.Get(context.Background(), "/x", &out) var apiErr *APIError if !errors.As(err, &apiErr) { diff --git a/internal/api/response.go b/internal/api/response.go index 60c7b67..54985ca 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "fmt" "io" "net/http" @@ -54,12 +55,12 @@ func port(u *url.URL) string { // DoRawResponse executes a request and returns the full response, including // non-2xx statuses (the caller decides how to interpret them). Transport // failures still return an error. -func (c *Client) DoRawResponse(method, path string, body []byte) (*RawResponse, error) { +func (c *Client) DoRawResponse(ctx context.Context, method, path string, body []byte) (*RawResponse, error) { var r io.Reader if body != nil { r = bytes.NewReader(body) } - req, err := c.newRequest(method, path, r) + req, err := c.newRequest(ctx, method, path, r) if err != nil { return nil, err } diff --git a/internal/api/response_test.go b/internal/api/response_test.go index aedf0e2..04b2313 100644 --- a/internal/api/response_test.go +++ b/internal/api/response_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "net/http/httptest" "net/url" @@ -20,7 +21,7 @@ func TestDoRawResponse_ExposesStatusAndHeaders(t *testing.T) { defer srv.Close() c := NewXMLClient(srv.URL, nil) - resp, err := c.DoRawResponse("GET", "/realms", nil) + resp, err := c.DoRawResponse(context.Background(), "GET", "/realms", nil) if err != nil { t.Fatalf("DoRawResponse() error = %v", err) } @@ -47,7 +48,7 @@ func TestDoRawResponse_FollowsSameOriginRedirectAndReportsFinalURL(t *testing.T) defer srv.Close() c := NewXMLClient(srv.URL, nil) - resp, err := c.DoRawResponse("GET", "/realms/1/sipcredentials", nil) + resp, err := c.DoRawResponse(context.Background(), "GET", "/realms/1/sipcredentials", nil) if err != nil { t.Fatalf("DoRawResponse() error = %v", err) } @@ -125,7 +126,7 @@ func TestConstructors_RefuseCrossOriginRedirect(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := tt.client.DoRawResponse("GET", "/redirect", nil) + _, err := tt.client.DoRawResponse(context.Background(), "GET", "/redirect", nil) if err == nil { t.Fatalf("DoRawResponse() error = nil, want refusal of cross-origin redirect") } diff --git a/internal/customerprofile/service.go b/internal/customerprofile/service.go index de211ed..bff876b 100644 --- a/internal/customerprofile/service.go +++ b/internal/customerprofile/service.go @@ -7,6 +7,7 @@ package customerprofile import ( + "context" "fmt" "net/url" @@ -29,8 +30,8 @@ func (s *Service) base() string { return "/api/v2/accounts/" + url.PathEscape(s.accountID) + "/customerProfiles" } -func (s *Service) get(path string) (*api.Envelope, error) { - raw, err := s.client.GetRaw(path) +func (s *Service) get(ctx context.Context, path string) (*api.Envelope, error) { + raw, err := s.client.GetRaw(ctx, path) if err != nil { return nil, err } @@ -38,16 +39,16 @@ func (s *Service) get(path string) (*api.Envelope, error) { } // List returns customer profiles on the account. -func (s *Service) List(limit, offset int, filters []api.Filter) (*api.Envelope, error) { - return s.get(s.base() + api.EncodeQuery(limit, offset, filters)) +func (s *Service) List(ctx context.Context, limit, offset int, filters []api.Filter) (*api.Envelope, error) { + return s.get(ctx, s.base()+api.EncodeQuery(limit, offset, filters)) } // Get returns one customer profile. Soft-deleted profiles are still // returned individually, with softDeleted set to true — check it before // creating any association. There is no "deleted" field on reads. -func (s *Service) Get(profileID string) (*api.Envelope, error) { +func (s *Service) Get(ctx context.Context, profileID string) (*api.Envelope, error) { if profileID == "" { return nil, fmt.Errorf("customer profile ID is required") } - return s.get(s.base() + "/" + url.PathEscape(profileID)) + return s.get(ctx, s.base()+"/"+url.PathEscape(profileID)) } diff --git a/internal/customerprofile/service_test.go b/internal/customerprofile/service_test.go index f3b3a97..824a326 100644 --- a/internal/customerprofile/service_test.go +++ b/internal/customerprofile/service_test.go @@ -1,6 +1,7 @@ package customerprofile import ( + "context" "errors" "net/http" "net/http/httptest" @@ -28,7 +29,7 @@ func TestListBuildsPathAndQuery(t *testing.T) { }) defer done() - _, err := svc.List(10, 0, nil) + _, err := svc.List(context.Background(), 10, 0, nil) if err != nil { t.Fatalf("List: %v", err) } @@ -48,7 +49,7 @@ func TestListWithFilterBuildsQuery(t *testing.T) { }) defer done() - _, err := svc.List(25, 5, []api.Filter{{Field: "brandId", Op: api.OpEq, Value: "BEXMPL8"}}) + _, err := svc.List(context.Background(), 25, 5, []api.Filter{{Field: "brandId", Op: api.OpEq, Value: "BEXMPL8"}}) if err != nil { t.Fatalf("List: %v", err) } @@ -65,7 +66,7 @@ func TestListEscapesAccountID(t *testing.T) { }) defer done() - _, err := svc.List(0, 0, nil) + _, err := svc.List(context.Background(), 0, 0, nil) if err != nil { t.Fatalf("List: %v", err) } @@ -80,7 +81,7 @@ func TestGetReturnsObjectEnvelope(t *testing.T) { }) defer done() - env, err := svc.Get("CP123") + env, err := svc.Get(context.Background(), "CP123") if err != nil { t.Fatalf("Get: %v", err) } @@ -101,7 +102,7 @@ func TestGetEscapesID(t *testing.T) { }) defer done() - _, _ = svc.Get("CP/../evil") + _, _ = svc.Get(context.Background(), "CP/../evil") if want := "/api/v2/accounts/9901287/customerProfiles/CP%2F..%2Fevil"; gotPath != want { t.Errorf("escaped path = %q, want %q", gotPath, want) } @@ -117,7 +118,7 @@ func TestGetEmptyIDErrorsBeforeRequest(t *testing.T) { }) defer done() - if _, err := svc.Get(""); err == nil { + if _, err := svc.Get(context.Background(), ""); err == nil { t.Fatal("expected an error for an empty profile ID") } } @@ -129,7 +130,7 @@ func TestServicePropagatesAPIError(t *testing.T) { }) defer done() - _, err := svc.List(0, 0, nil) + _, err := svc.List(context.Background(), 0, 0, nil) if err == nil { t.Fatal("expected an error for 403") } diff --git a/internal/customerprofile/write.go b/internal/customerprofile/write.go index 2d5507c..1522c64 100644 --- a/internal/customerprofile/write.go +++ b/internal/customerprofile/write.go @@ -1,6 +1,7 @@ package customerprofile import ( + "context" "fmt" "net/url" @@ -8,8 +9,8 @@ import ( ) // Create posts a new customer profile. Callers build body via BuildCreateRequest. -func (s *Service) Create(body map[string]any) (*api.Envelope, error) { - raw, err := s.client.PostRaw(s.base(), body) +func (s *Service) Create(ctx context.Context, body map[string]any) (*api.Envelope, error) { + raw, err := s.client.PostRaw(ctx, s.base(), body) if err != nil { return nil, err } @@ -24,11 +25,11 @@ func (s *Service) Create(body map[string]any) (*api.Envelope, error) { // measured against production. Callers must build body with // BuildUpdateRequest, which starts from the current resource so nothing is // dropped. -func (s *Service) Update(profileID string, body map[string]any) (*api.Envelope, error) { +func (s *Service) Update(ctx context.Context, profileID string, body map[string]any) (*api.Envelope, error) { if profileID == "" { return nil, fmt.Errorf("customer profile ID is required") } - raw, err := s.client.PutRawJSON(s.base()+"/"+url.PathEscape(profileID), body) + raw, err := s.client.PutRawJSON(ctx, s.base()+"/"+url.PathEscape(profileID), body) if err != nil { return nil, err } @@ -37,30 +38,30 @@ func (s *Service) Update(profileID string, body map[string]any) (*api.Envelope, // Delete soft-deletes a customer profile. The record remains retrievable by ID // with softDeleted set to true, and can be restored — see BuildRestoreRequest. -func (s *Service) Delete(profileID string) error { +func (s *Service) Delete(ctx context.Context, profileID string) error { if profileID == "" { return fmt.Errorf("customer profile ID is required") } - return s.client.Delete(s.base()+"/"+url.PathEscape(profileID), nil) + return s.client.Delete(ctx, s.base()+"/"+url.PathEscape(profileID), nil) } // History returns the version history of a profile. -func (s *Service) History(profileID string, limit, offset int) (*api.Envelope, error) { +func (s *Service) History(ctx context.Context, profileID string, limit, offset int) (*api.Envelope, error) { if profileID == "" { return nil, fmt.Errorf("customer profile ID is required") } - return s.get(s.base() + "/" + url.PathEscape(profileID) + "/history" + + return s.get(ctx, s.base()+"/"+url.PathEscape(profileID)+"/history"+ api.EncodeQuery(limit, offset, nil)) } // HistoryVersion returns one historical version of a profile. -func (s *Service) HistoryVersion(profileID, version string) (*api.Envelope, error) { +func (s *Service) HistoryVersion(ctx context.Context, profileID, version string) (*api.Envelope, error) { if profileID == "" { return nil, fmt.Errorf("customer profile ID is required") } if version == "" { return nil, fmt.Errorf("version is required") } - return s.get(s.base() + "/" + url.PathEscape(profileID) + - "/history/" + url.PathEscape(version)) + return s.get(ctx, s.base()+"/"+url.PathEscape(profileID)+ + "/history/"+url.PathEscape(version)) } diff --git a/internal/customerprofile/write_test.go b/internal/customerprofile/write_test.go index 29e012d..992e5ee 100644 --- a/internal/customerprofile/write_test.go +++ b/internal/customerprofile/write_test.go @@ -1,6 +1,7 @@ package customerprofile import ( + "context" "encoding/json" "errors" "io" @@ -36,7 +37,7 @@ func TestCreatePostsToCollection(t *testing.T) { svc, cap, done := newCapturingService(t, 200, `{"data":{"id":"abc","version":0}}`) defer done() - env, err := svc.Create(map[string]any{"name": "Acme"}) + env, err := svc.Create(context.Background(), map[string]any{"name": "Acme"}) if err != nil { t.Fatalf("Create: %v", err) } @@ -65,7 +66,7 @@ func TestUpdatePutsToResourceAndSendsBodyVerbatim(t *testing.T) { // An unknown field must reach the wire untouched — that is the whole // point of building the payload from the read map. body := map[string]any{"name": "Acme", "version": 2, "somethingWeNeverModeled": "keep me"} - if _, err := svc.Update("abc", body); err != nil { + if _, err := svc.Update(context.Background(), "abc", body); err != nil { t.Fatalf("Update: %v", err) } if cap.method != http.MethodPut { @@ -83,7 +84,7 @@ func TestDeleteHitsResource(t *testing.T) { svc, cap, done := newCapturingService(t, 204, ``) defer done() - if err := svc.Delete("abc"); err != nil { + if err := svc.Delete(context.Background(), "abc"); err != nil { t.Fatalf("Delete: %v", err) } if cap.method != http.MethodDelete { @@ -98,7 +99,7 @@ func TestHistoryPathsAndPaging(t *testing.T) { svc, cap, done := newCapturingService(t, 200, `{"data":[],"page":{"totalElements":0}}`) defer done() - if _, err := svc.History("abc", 10, 20); err != nil { + if _, err := svc.History(context.Background(), "abc", 10, 20); err != nil { t.Fatalf("History: %v", err) } if want := "/api/v2/accounts/9901287/customerProfiles/abc/history"; cap.path != want { @@ -110,7 +111,7 @@ func TestHistoryVersionPath(t *testing.T) { svc, cap, done := newCapturingService(t, 200, `{"data":{"version":2}}`) defer done() - if _, err := svc.HistoryVersion("abc", "2"); err != nil { + if _, err := svc.HistoryVersion(context.Background(), "abc", "2"); err != nil { t.Fatalf("HistoryVersion: %v", err) } if want := "/api/v2/accounts/9901287/customerProfiles/abc/history/2"; cap.path != want { @@ -122,13 +123,13 @@ func TestWriteMethodsRequireAnID(t *testing.T) { svc, _, done := newCapturingService(t, 200, `{}`) defer done() - if _, err := svc.Update("", map[string]any{}); err == nil { + if _, err := svc.Update(context.Background(), "", map[string]any{}); err == nil { t.Error("Update(\"\") should error before making a request") } - if err := svc.Delete(""); err == nil { + if err := svc.Delete(context.Background(), ""); err == nil { t.Error("Delete(\"\") should error before making a request") } - if _, err := svc.HistoryVersion("abc", ""); err == nil { + if _, err := svc.HistoryVersion(context.Background(), "abc", ""); err == nil { t.Error("HistoryVersion with empty version should error") } } @@ -138,7 +139,7 @@ func TestWriteMethodsPropagateAPIErrorType(t *testing.T) { `{"errors":[{"description":"entity has been modified by another process or user"}]}`) defer done() - _, err := svc.Update("abc", map[string]any{"name": "x"}) + _, err := svc.Update(context.Background(), "abc", map[string]any{"name": "x"}) var apiErr *api.APIError if !errors.As(err, &apiErr) { t.Fatalf("error type = %T, want *api.APIError to survive", err) @@ -152,7 +153,7 @@ func TestUpdateEscapesID(t *testing.T) { svc, cap, done := newCapturingService(t, 200, `{"data":{}}`) defer done() - _, _ = svc.Update("CP/../evil", map[string]any{"name": "x", "version": 1}) + _, _ = svc.Update(context.Background(), "CP/../evil", map[string]any{"name": "x", "version": 1}) if want := "/api/v2/accounts/9901287/customerProfiles/CP%2F..%2Fevil"; cap.path != want { t.Errorf("escaped path = %q, want %q", cap.path, want) } @@ -162,7 +163,7 @@ func TestDeleteEscapesID(t *testing.T) { svc, cap, done := newCapturingService(t, 204, ``) defer done() - _ = svc.Delete("CP/../evil") + _ = svc.Delete(context.Background(), "CP/../evil") if want := "/api/v2/accounts/9901287/customerProfiles/CP%2F..%2Fevil"; cap.path != want { t.Errorf("escaped path = %q, want %q", cap.path, want) } @@ -172,7 +173,7 @@ func TestHistoryVersionEscapesIDAndVersion(t *testing.T) { svc, cap, done := newCapturingService(t, 200, `{"data":{}}`) defer done() - _, _ = svc.HistoryVersion("CP/../evil", "v/../1") + _, _ = svc.HistoryVersion(context.Background(), "CP/../evil", "v/../1") if want := "/api/v2/accounts/9901287/customerProfiles/CP%2F..%2Fevil/history/v%2F..%2F1"; cap.path != want { t.Errorf("escaped path = %q, want %q", cap.path, want) } diff --git a/internal/sip/service.go b/internal/sip/service.go index eee3e36..690dc87 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -1,6 +1,7 @@ package sip import ( + "context" "encoding/xml" "fmt" "io" @@ -52,7 +53,7 @@ func (s *Service) base() string { // do issues a request and returns the response body, converting documented // error envelopes into *APIFault. Bodies are scrubbed of digest hashes before // ever being placed in an error. -func (s *Service) do(method, path string, reqBody interface{}) ([]byte, error) { +func (s *Service) do(ctx context.Context, method, path string, reqBody interface{}) ([]byte, error) { var payload []byte if reqBody != nil { b, err := xml.Marshal(reqBody) @@ -62,7 +63,7 @@ func (s *Service) do(method, path string, reqBody interface{}) ([]byte, error) { payload = append([]byte(xml.Header), b...) } - resp, err := s.client.DoRawResponse(method, path, payload) + resp, err := s.client.DoRawResponse(ctx, method, path, payload) if err != nil { return nil, err } @@ -174,8 +175,8 @@ func toCredential(w *credentialWire) *Credential { // CreateRealm creates a realm. isDefault is always transmitted: the API rejects // the request without it (error 1003). -func (s *Service) CreateRealm(name, description string, isDefault bool) (*Realm, error) { - body, err := s.do("POST", s.base()+"/realms", realmRequest{ +func (s *Service) CreateRealm(ctx context.Context, name, description string, isDefault bool) (*Realm, error) { + body, err := s.do(ctx, "POST", s.base()+"/realms", realmRequest{ Realm: name, Description: description, Default: isDefault, }) if err != nil { @@ -192,8 +193,8 @@ func (s *Service) CreateRealm(name, description string, isDefault bool) (*Realm, } // GetRealm fetches one realm. ref may be an ID or a name. -func (s *Service) GetRealm(ref string) (*Realm, error) { - body, err := s.do("GET", s.base()+"/realms/"+url.PathEscape(ref), nil) +func (s *Service) GetRealm(ctx context.Context, ref string) (*Realm, error) { + body, err := s.do(ctx, "GET", s.base()+"/realms/"+url.PathEscape(ref), nil) if err != nil { return nil, err } @@ -208,8 +209,8 @@ func (s *Service) GetRealm(ref string) (*Realm, error) { } // ListRealms returns every realm on the account, always as a non-nil slice. -func (s *Service) ListRealms() ([]Realm, error) { - body, err := s.do("GET", s.base()+"/realms", nil) +func (s *Service) ListRealms(ctx context.Context) ([]Realm, error) { + body, err := s.do(ctx, "GET", s.base()+"/realms", nil) if err != nil { return nil, err } @@ -225,8 +226,8 @@ func (s *Service) ListRealms() ([]Realm, error) { } // DeleteRealm submits an async delete (the API returns 202). -func (s *Service) DeleteRealm(ref string) error { - _, err := s.do("DELETE", s.base()+"/realms/"+url.PathEscape(ref), nil) +func (s *Service) DeleteRealm(ctx context.Context, ref string) error { + _, err := s.do(ctx, "DELETE", s.base()+"/realms/"+url.PathEscape(ref), nil) return err } @@ -238,8 +239,8 @@ func (s *Service) DeleteRealm(ref string) error { // Read-modify-write is mandatory, not an optimization: realm PUT is a full // replace, so any field the caller did not name must be echoed back from the // current realm or the API will clear it. -func (s *Service) UpdateRealm(ref string, promoteDefault bool, description *string) (*Realm, error) { - current, err := s.GetRealm(ref) +func (s *Service) UpdateRealm(ctx context.Context, ref string, promoteDefault bool, description *string) (*Realm, error) { + current, err := s.GetRealm(ctx, ref) if err != nil { return nil, err } @@ -247,7 +248,7 @@ func (s *Service) UpdateRealm(ref string, promoteDefault bool, description *stri if description != nil { desc = *description } - body, err := s.do("PUT", s.base()+"/realms/"+url.PathEscape(ref), realmRequest{ + body, err := s.do(ctx, "PUT", s.base()+"/realms/"+url.PathEscape(ref), realmRequest{ Realm: current.Name, Description: desc, Default: current.Default || promoteDefault, }) if err != nil { @@ -258,7 +259,7 @@ func (s *Service) UpdateRealm(ref string, promoteDefault bool, description *stri return nil, fmt.Errorf("decoding realm response: %w", err) } if resp.Realm == nil { - return s.GetRealm(ref) + return s.GetRealm(ctx, ref) } return toRealm(resp.Realm), nil } @@ -269,11 +270,11 @@ func (s *Service) credentialsPath(realmID string) string { // CreateCredential creates one credential. A 201 carrying an Errors entry is // treated as failure, not success. -func (s *Service) CreateCredential(realmID, username, hash1, hash1b, appID string) (*Credential, error) { +func (s *Service) CreateCredential(ctx context.Context, realmID, username, hash1, hash1b, appID string) (*Credential, error) { req := credentialCreateRequest{Credentials: []credentialCreateOne{{ UserName: username, Hash1: hash1, Hash1b: hash1b, AppID: appID, }}} - body, err := s.do("POST", s.credentialsPath(realmID), req) + body, err := s.do(ctx, "POST", s.credentialsPath(realmID), req) if err != nil { return nil, err } @@ -307,9 +308,9 @@ func (s *Service) CreateCredential(realmID, username, hash1, hash1b, appID strin // RotateCredential replaces a credential's hashes. The credential ID is stable // across rotation. UserName must not be sent (see credentialRotateRequest). -func (s *Service) RotateCredential(realmID, credentialID, hash1, hash1b string) (*Credential, error) { +func (s *Service) RotateCredential(ctx context.Context, realmID, credentialID, hash1, hash1b string) (*Credential, error) { req := credentialRotateRequest{RealmID: realmID, Hash1: hash1, Hash1b: hash1b} - body, err := s.do("PUT", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), req) + body, err := s.do(ctx, "PUT", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), req) if err != nil { return nil, err } @@ -337,8 +338,8 @@ var warnOut io.Writer = os.Stderr // Auto-pagination is NOT implemented (see the spec's deferred list). What is // implemented is the refusal to be silent about it: a full page warns on stderr // rather than reading as a complete list. -func (s *Service) ListCredentials(realmID string) ([]Credential, error) { - body, err := s.do("GET", s.credentialsPath(realmID), nil) +func (s *Service) ListCredentials(ctx context.Context, realmID string) ([]Credential, error) { + body, err := s.do(ctx, "GET", s.credentialsPath(realmID), nil) if err != nil { return nil, err } @@ -359,8 +360,8 @@ func (s *Service) ListCredentials(realmID string) ([]Credential, error) { } // GetCredential fetches one credential. -func (s *Service) GetCredential(realmID, credentialID string) (*Credential, error) { - body, err := s.do("GET", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) +func (s *Service) GetCredential(ctx context.Context, realmID, credentialID string) (*Credential, error) { + body, err := s.do(ctx, "GET", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) if err != nil { return nil, err } @@ -375,8 +376,8 @@ func (s *Service) GetCredential(realmID, credentialID string) (*Credential, erro } // DeleteCredential removes a credential. -func (s *Service) DeleteCredential(realmID, credentialID string) error { - _, err := s.do("DELETE", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) +func (s *Service) DeleteCredential(ctx context.Context, realmID, credentialID string) error { + _, err := s.do(ctx, "DELETE", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) return err } @@ -386,13 +387,13 @@ func (s *Service) DeleteCredential(realmID, credentialID string) error { // a caller invoking this after a 23026 duplicate with a different case than // what is stored must still find it. Bounded retry absorbs read-after-write // lag following that duplicate error. -func (s *Service) FindCredentialByUsername(realmID, username string) (*Credential, error) { +func (s *Service) FindCredentialByUsername(ctx context.Context, realmID, username string) (*Credential, error) { var lastErr error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { time.Sleep(time.Second) } - creds, err := s.ListCredentials(realmID) + creds, err := s.ListCredentials(ctx, realmID) if err != nil { lastErr = err continue @@ -436,8 +437,8 @@ type credentialHashWire struct { // CredentialHashesMatch reports whether the stored digest hashes equal the // supplied ones. The hashes never leave this function. -func (s *Service) CredentialHashesMatch(realmID, credentialID, hash1, hash1b string) (bool, error) { - body, err := s.do("GET", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) +func (s *Service) CredentialHashesMatch(ctx context.Context, realmID, credentialID, hash1, hash1b string) (bool, error) { + body, err := s.do(ctx, "GET", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) if err != nil { return false, err } diff --git a/internal/sip/service_test.go b/internal/sip/service_test.go index 55b3630..4f93690 100644 --- a/internal/sip/service_test.go +++ b/internal/sip/service_test.go @@ -1,6 +1,7 @@ package sip import ( + "context" "errors" "io" "net/http" @@ -32,7 +33,7 @@ func TestCreateRealm_ParsesFQDNAndStatus(t *testing.T) { }) defer done() - r, err := svc.CreateRealm("bwclitest", "d", false) + r, err := svc.CreateRealm(context.Background(), "bwclitest", "d", false) if err != nil { t.Fatalf("CreateRealm() error = %v", err) } @@ -67,7 +68,7 @@ func TestCreateRealm_ReturnsAPIFault(t *testing.T) { }) defer done() - _, err := svc.CreateRealm("x", "", false) + _, err := svc.CreateRealm(context.Background(), "x", "", false) var fault *APIFault if !errorsAs(err, &fault) { t.Fatalf("error = %v (%T), want *APIFault", err, err) @@ -87,7 +88,7 @@ func TestListCredentials_FollowsRedirectAndAlwaysReturnsSlice(t *testing.T) { }) defer done() - creds, err := svc.ListCredentials("1103") + creds, err := svc.ListCredentials(context.Background(), "1103") if err != nil { t.Fatalf("ListCredentials() error = %v", err) } @@ -114,7 +115,7 @@ func TestRotateCredential_SendsRealmIDAndOmitsUserName(t *testing.T) { }) defer done() - c, err := svc.RotateCredential("1105", "870880", "h1", "h1b") + c, err := svc.RotateCredential(context.Background(), "1105", "870880", "h1", "h1b") if err != nil { t.Fatalf("RotateCredential() error = %v", err) } @@ -145,7 +146,7 @@ func TestCreateCredential_PartialSuccessIsFailure(t *testing.T) { }) defer done() - _, err := svc.CreateCredential("1103", "clitest", "h1", "h1b", "") + _, err := svc.CreateCredential(context.Background(), "1103", "clitest", "h1", "h1b", "") var fault *APIFault if !errorsAs(err, &fault) || fault.Code != "23026" { t.Fatalf("error = %v, want APIFault 23026", err) @@ -164,7 +165,7 @@ func TestCreateCredential_NotInValidListIsFailure(t *testing.T) { }) defer done() - _, err := svc.CreateCredential("1103", "clitest", "h1", "h1b", "") + _, err := svc.CreateCredential(context.Background(), "1103", "clitest", "h1", "h1b", "") if err == nil { t.Fatal("CreateCredential() error = nil, want error") } @@ -187,7 +188,7 @@ func TestCreateCredential_CaseMismatchReportsUnusableCredential(t *testing.T) { }) defer done() - _, err := svc.CreateCredential("1103", "clitest", "h1", "h1b", "") + _, err := svc.CreateCredential(context.Background(), "1103", "clitest", "h1", "h1b", "") if err == nil { t.Fatal("CreateCredential() error = nil, want error") } @@ -204,7 +205,7 @@ func TestDo_2xxEmptyBodyIsSuccess(t *testing.T) { }) defer done() - if err := svc.DeleteRealm("1103"); err != nil { + if err := svc.DeleteRealm(context.Background(), "1103"); err != nil { t.Fatalf("DeleteRealm() error = %v, want nil for empty 202 body", err) } } @@ -220,7 +221,7 @@ func TestDo_2xxErrorEnvelopeIsFailure(t *testing.T) { }) defer done() - _, err := svc.ListRealms() + _, err := svc.ListRealms(context.Background()) var fault *APIFault if !errorsAs(err, &fault) || fault.Code != "12666" { t.Fatalf("error = %v, want APIFault 12666", err) @@ -251,7 +252,7 @@ func TestParseFault_UnparseableBodyFallsThroughToAPIError(t *testing.T) { }) defer done() - _, err := svc.GetRealm("1103") + _, err := svc.GetRealm(context.Background(), "1103") var fault *APIFault if errorsAs(err, &fault) { t.Fatalf("error = %v, want *api.APIError, not *APIFault", err) @@ -275,7 +276,7 @@ func TestDo_ScrubsHashesFromUnfaultedErrorBody(t *testing.T) { }) defer done() - _, err := svc.CreateRealm("x", "", false) + _, err := svc.CreateRealm(context.Background(), "x", "", false) if err == nil { t.Fatal("CreateRealm() error = nil, want error") } @@ -324,7 +325,7 @@ func TestFindCredentialByUsername_ZeroMatches(t *testing.T) { }) defer done() - _, err := svc.FindCredentialByUsername("1103", "missing") + _, err := svc.FindCredentialByUsername(context.Background(), "1103", "missing") if err == nil { t.Fatal("FindCredentialByUsername() error = nil, want error") } @@ -345,7 +346,7 @@ func TestFindCredentialByUsername_OneMatchIsCaseInsensitive(t *testing.T) { }) defer done() - cred, err := svc.FindCredentialByUsername("1103", "agent") + cred, err := svc.FindCredentialByUsername(context.Background(), "1103", "agent") if err != nil { t.Fatalf("FindCredentialByUsername() error = %v", err) } @@ -363,7 +364,7 @@ func TestFindCredentialByUsername_MultipleMatchesIsError(t *testing.T) { }) defer done() - _, err := svc.FindCredentialByUsername("1103", "agent") + _, err := svc.FindCredentialByUsername(context.Background(), "1103", "agent") if err == nil { t.Fatal("FindCredentialByUsername() error = nil, want error for multiple matches") } @@ -390,7 +391,7 @@ func TestFindCredentialByUsername_TransportErrorThenSuccess(t *testing.T) { }) defer done() - cred, err := svc.FindCredentialByUsername("1103", "agent") + cred, err := svc.FindCredentialByUsername(context.Background(), "1103", "agent") if err != nil { t.Fatalf("FindCredentialByUsername() error = %v", err) } @@ -410,7 +411,7 @@ func TestCredentialHashesMatch_Match(t *testing.T) { }) defer done() - match, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + match, err := svc.CredentialHashesMatch(context.Background(), "1105", "870880", "h1", "h1b") if err != nil { t.Fatalf("CredentialHashesMatch() error = %v", err) } @@ -427,7 +428,7 @@ func TestCredentialHashesMatch_Mismatch(t *testing.T) { }) defer done() - match, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + match, err := svc.CredentialHashesMatch(context.Background(), "1105", "870880", "h1", "h1b") if err != nil { t.Fatalf("CredentialHashesMatch() error = %v", err) } @@ -448,7 +449,7 @@ func TestCredentialHashesMatch_AbsentHashesIsError(t *testing.T) { }) defer done() - _, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + _, err := svc.CredentialHashesMatch(context.Background(), "1105", "870880", "h1", "h1b") if err == nil { t.Fatal("CredentialHashesMatch() error = nil, want error for a response with no hashes") } @@ -466,7 +467,7 @@ func TestCredentialHashesMatch_WrongShapedBodyIsDecodeError(t *testing.T) { }) defer done() - _, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + _, err := svc.CredentialHashesMatch(context.Background(), "1105", "870880", "h1", "h1b") if err == nil { t.Fatal("CredentialHashesMatch() error = nil, want decode error for a wrong-shaped body") } @@ -489,7 +490,7 @@ func TestParseFault_ScrubsHashEchoedInDescription(t *testing.T) { }) defer done() - _, err := svc.CreateCredential("1103", "agent", hash, hash, "") + _, err := svc.CreateCredential(context.Background(), "1103", "agent", hash, hash, "") if err == nil { t.Fatal("CreateCredential() error = nil, want a fault") } @@ -522,7 +523,7 @@ func TestDo_TruncatedBodyIsDiscardedNotPartiallyScrubbed(t *testing.T) { }) defer done() - _, err := svc.GetRealm("1103") + _, err := svc.GetRealm(context.Background(), "1103") if err == nil { t.Fatal("GetRealm() error = nil, want an error") } @@ -547,7 +548,7 @@ func TestDo_ParsedBodyWithoutErrorCodeKeepsItsBody(t *testing.T) { }) defer done() - _, err := svc.GetRealm("9999") + _, err := svc.GetRealm(context.Background(), "9999") if err == nil { t.Fatal("GetRealm() error = nil, want a 404") } @@ -579,7 +580,7 @@ func TestListCredentials_FullPageWarnsAboutTruncation(t *testing.T) { warnOut = &warnings defer func() { warnOut = orig }() - creds, err := svc.ListCredentials("1103") + creds, err := svc.ListCredentials(context.Background(), "1103") if err != nil { t.Fatalf("ListCredentials() error = %v", err) } @@ -611,7 +612,7 @@ func TestListCredentials_PartialPageDoesNotWarn(t *testing.T) { warnOut = &warnings defer func() { warnOut = orig }() - if _, err := svc.ListCredentials("1103"); err != nil { + if _, err := svc.ListCredentials(context.Background(), "1103"); err != nil { t.Fatalf("ListCredentials() error = %v", err) } if warnings.Len() != 0 { @@ -656,7 +657,7 @@ func TestUpdateRealm_ReadModifyWritePreservesUnspecifiedFields(t *testing.T) { }) defer done() - if _, err := svc.UpdateRealm("vapi", tt.promote, tt.description); err != nil { + if _, err := svc.UpdateRealm(context.Background(), "vapi", tt.promote, tt.description); err != nil { t.Fatalf("UpdateRealm() error = %v", err) } if !strings.Contains(sent, ""+tt.wantDesc+"") { diff --git a/internal/tendlc/campaignwrite.go b/internal/tendlc/campaignwrite.go index 44d0b68..c2bd6ed 100644 --- a/internal/tendlc/campaignwrite.go +++ b/internal/tendlc/campaignwrite.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "fmt" "net/url" @@ -11,8 +12,8 @@ import ( // refreshes an existing campaign from TCR (all customers, body = // {"campaignId": id}). Both are POST /campaigns; which one happens is // decided by the body, not the path. -func (s *Service) CreateCampaign(body map[string]any) (*api.Envelope, error) { - raw, err := s.client.PostRaw(s.base()+"/campaigns", body) +func (s *Service) CreateCampaign(ctx context.Context, body map[string]any) (*api.Envelope, error) { + raw, err := s.client.PostRaw(ctx, s.base()+"/campaigns", body) if err != nil { return nil, err } @@ -37,7 +38,7 @@ func (s *Service) CreateCampaign(body map[string]any) (*api.Envelope, error) { // reach but that still hold real data), so neither category is ever silently // stripped and re-sent. See putReplaceWithReadOnlyRetry's INVARIANT for why // "changed this call" is not the right test. -func (s *Service) UpdateCampaign(campaignID string, body map[string]any) (*api.Envelope, error) { +func (s *Service) UpdateCampaign(ctx context.Context, campaignID string, body map[string]any) (*api.Envelope, error) { if campaignID == "" { return nil, fmt.Errorf("campaign ID is required") } @@ -45,7 +46,7 @@ func (s *Service) UpdateCampaign(campaignID string, body map[string]any) (*api.E for f := range campaignNeverDropFields { neverDrop[f] = true } - raw, err := putReplaceWithReadOnlyRetry(s.client, s.campaignPath(campaignID), body, neverDrop) + raw, err := putReplaceWithReadOnlyRetry(ctx, s.client, s.campaignPath(campaignID), body, neverDrop) if err != nil { return nil, err } @@ -53,38 +54,38 @@ func (s *Service) UpdateCampaign(campaignID string, body map[string]any) (*api.E } // DeactivateCampaign permanently deactivates a campaign. Returns 204. -func (s *Service) DeactivateCampaign(campaignID string) error { +func (s *Service) DeactivateCampaign(ctx context.Context, campaignID string) error { if campaignID == "" { return fmt.Errorf("campaign ID is required") } - return s.client.Delete(s.campaignPath(campaignID), nil) + return s.client.Delete(ctx, s.campaignPath(campaignID), nil) } // NudgeCampaign resubmits a campaign stuck in a pending state to TCR for // re-evaluation. Returns 204. -func (s *Service) NudgeCampaign(campaignID string, body map[string]any) error { +func (s *Service) NudgeCampaign(ctx context.Context, campaignID string, body map[string]any) error { if campaignID == "" { return fmt.Errorf("campaign ID is required") } - return s.client.Post(s.campaignPath(campaignID)+"/nudge", body, nil) + return s.client.Post(ctx, s.campaignPath(campaignID)+"/nudge", body, nil) } // CampaignPhoneNumbers returns the phone numbers assigned to a campaign. -func (s *Service) CampaignPhoneNumbers(campaignID string, limit, offset int) (*api.Envelope, error) { +func (s *Service) CampaignPhoneNumbers(ctx context.Context, campaignID string, limit, offset int) (*api.Envelope, error) { if campaignID == "" { return nil, fmt.Errorf("campaign ID is required") } - return s.get(s.campaignPath(campaignID) + "/phoneNumbers" + api.EncodeQuery(limit, offset, nil)) + return s.get(ctx, s.campaignPath(campaignID)+"/phoneNumbers"+api.EncodeQuery(limit, offset, nil)) } // CampaignHistory returns the campaign's activity log: free-text // {createdDate, message} entries, newest first. As with BrandHistory there // are no versioned snapshots and no per-version fetch. -func (s *Service) CampaignHistory(campaignID string, limit, offset int) (*api.Envelope, error) { +func (s *Service) CampaignHistory(ctx context.Context, campaignID string, limit, offset int) (*api.Envelope, error) { if campaignID == "" { return nil, fmt.Errorf("campaign ID is required") } - return s.get(s.campaignPath(campaignID) + "/history" + api.EncodeQuery(limit, offset, nil)) + return s.get(ctx, s.campaignPath(campaignID)+"/history"+api.EncodeQuery(limit, offset, nil)) } // campaignPath builds /campaigns/{id}. diff --git a/internal/tendlc/campaignwrite_test.go b/internal/tendlc/campaignwrite_test.go index 239138c..72b149e 100644 --- a/internal/tendlc/campaignwrite_test.go +++ b/internal/tendlc/campaignwrite_test.go @@ -1,12 +1,15 @@ package tendlc -import "testing" +import ( + "context" + "testing" +) func TestCreateCampaignPostsToCampaignsPath(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"CABC123"}}`, &got) - env, err := s.CreateCampaign(map[string]any{"campaignName": "Acme Alerts"}) + env, err := s.CreateCampaign(context.Background(), map[string]any{"campaignName": "Acme Alerts"}) if err != nil { t.Fatalf("CreateCampaign: %v", err) } @@ -32,7 +35,7 @@ func TestCreateCampaignSyncBodyCarriesOnlyCampaignID(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"CABC123"}}`, &got) - if _, err := s.CreateCampaign(map[string]any{"campaignId": "CEXMPL1"}); err != nil { + if _, err := s.CreateCampaign(context.Background(), map[string]any{"campaignId": "CEXMPL1"}); err != nil { t.Fatalf("CreateCampaign: %v", err) } if got.method != "POST" { @@ -50,7 +53,7 @@ func TestUpdateCampaignPutsToCampaignPath(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"CEXMPL1"}}`, &got) - if _, err := s.UpdateCampaign("CEXMPL1", map[string]any{"campaignName": "Acme Alerts"}); err != nil { + if _, err := s.UpdateCampaign(context.Background(), "CEXMPL1", map[string]any{"campaignName": "Acme Alerts"}); err != nil { t.Fatalf("UpdateCampaign: %v", err) } if got.method != "PUT" { @@ -68,7 +71,7 @@ func TestDeactivateCampaignUsesDelete(t *testing.T) { var got captured s := stubService(t, 204, "", &got) - if err := s.DeactivateCampaign("CEXMPL1"); err != nil { + if err := s.DeactivateCampaign(context.Background(), "CEXMPL1"); err != nil { t.Fatalf("DeactivateCampaign: %v", err) } if got.method != "DELETE" { @@ -85,7 +88,7 @@ func TestNudgeCampaignPostsToNudgePath(t *testing.T) { // envelope out of nothing. s := stubService(t, 204, "", &got) - if err := s.NudgeCampaign("CEXMPL1", map[string]any{"reason": "retry"}); err != nil { + if err := s.NudgeCampaign(context.Background(), "CEXMPL1", map[string]any{"reason": "retry"}); err != nil { t.Fatalf("NudgeCampaign: %v", err) } if got.method != "POST" { @@ -103,7 +106,7 @@ func TestCampaignPhoneNumbersEncodesPagination(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.CampaignPhoneNumbers("CEXMPL1", 10, 20); err != nil { + if _, err := s.CampaignPhoneNumbers(context.Background(), "CEXMPL1", 10, 20); err != nil { t.Fatalf("CampaignPhoneNumbers: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/campaigns/CEXMPL1/phoneNumbers"; got.path != want { @@ -118,7 +121,7 @@ func TestCampaignHistoryEncodesPagination(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.CampaignHistory("CEXMPL1", 10, 20); err != nil { + if _, err := s.CampaignHistory(context.Background(), "CEXMPL1", 10, 20); err != nil { t.Fatalf("CampaignHistory: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/campaigns/CEXMPL1/history"; got.path != want { @@ -138,15 +141,15 @@ func TestEmptyCampaignIDsRejectedWithoutRequest(t *testing.T) { s := stubService(t, 200, `{"data":{}}`, &got) calls := map[string]func() error{ - "UpdateCampaign": func() error { _, err := s.UpdateCampaign("", map[string]any{}); return err }, - "DeactivateCampaign": func() error { return s.DeactivateCampaign("") }, - "NudgeCampaign": func() error { return s.NudgeCampaign("", map[string]any{}) }, + "UpdateCampaign": func() error { _, err := s.UpdateCampaign(context.Background(), "", map[string]any{}); return err }, + "DeactivateCampaign": func() error { return s.DeactivateCampaign(context.Background(), "") }, + "NudgeCampaign": func() error { return s.NudgeCampaign(context.Background(), "", map[string]any{}) }, "CampaignPhoneNumbers": func() error { - _, err := s.CampaignPhoneNumbers("", 10, 0) + _, err := s.CampaignPhoneNumbers(context.Background(), "", 10, 0) return err }, "CampaignHistory": func() error { - _, err := s.CampaignHistory("", 10, 0) + _, err := s.CampaignHistory(context.Background(), "", 10, 0) return err }, } @@ -171,7 +174,7 @@ func TestCampaignIDIsPathEscaped(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.CampaignHistory("a/b c", 10, 0); err != nil { + if _, err := s.CampaignHistory(context.Background(), "a/b c", 10, 0); err != nil { t.Fatalf("CampaignHistory: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/campaigns/a%2Fb%20c/history"; got.escapedPath != want { diff --git a/internal/tendlc/numbers.go b/internal/tendlc/numbers.go index 26c020f..3309ae1 100644 --- a/internal/tendlc/numbers.go +++ b/internal/tendlc/numbers.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "fmt" "net/url" @@ -28,8 +29,8 @@ import ( // // So the command layer offers --campaign-id-contains and deliberately does // not offer --status. -func (s *Service) ListPhoneNumbers(limit, offset int, filters []api.Filter) (*api.Envelope, error) { - return s.get(s.base() + "/phoneNumbers" + api.EncodeQuery(limit, offset, filters)) +func (s *Service) ListPhoneNumbers(ctx context.Context, limit, offset int, filters []api.Filter) (*api.Envelope, error) { + return s.get(ctx, s.base()+"/phoneNumbers"+api.EncodeQuery(limit, offset, filters)) } // GetPhoneNumber returns one phone number. @@ -40,21 +41,21 @@ func (s *Service) ListPhoneNumbers(limit, offset int, filters []api.Filter) (*ap // available to test against, and this API reports authorization failures as // 403, so a 404 here is not a permissions mask in disguise. `band tendlc // number get `, which calls this, inherits the same 404. -func (s *Service) GetPhoneNumber(phoneNumber string) (*api.Envelope, error) { +func (s *Service) GetPhoneNumber(ctx context.Context, phoneNumber string) (*api.Envelope, error) { if phoneNumber == "" { return nil, fmt.Errorf("phone number is required") } - return s.get(s.phoneNumberPath(phoneNumber)) + return s.get(ctx, s.phoneNumberPath(phoneNumber)) } // PhoneNumberHistory returns the phone number's activity log: free-text // {createdDate, message} entries, newest first. As with BrandHistory and // CampaignHistory there are no versioned snapshots and no per-version fetch. -func (s *Service) PhoneNumberHistory(phoneNumber string, limit, offset int) (*api.Envelope, error) { +func (s *Service) PhoneNumberHistory(ctx context.Context, phoneNumber string, limit, offset int) (*api.Envelope, error) { if phoneNumber == "" { return nil, fmt.Errorf("phone number is required") } - return s.get(s.phoneNumberPath(phoneNumber) + "/history" + api.EncodeQuery(limit, offset, nil)) + return s.get(ctx, s.phoneNumberPath(phoneNumber)+"/history"+api.EncodeQuery(limit, offset, nil)) } // phoneNumberPath builds /phoneNumbers/{tn}. diff --git a/internal/tendlc/numbers_test.go b/internal/tendlc/numbers_test.go index f48925c..69720b5 100644 --- a/internal/tendlc/numbers_test.go +++ b/internal/tendlc/numbers_test.go @@ -1,12 +1,15 @@ package tendlc -import "testing" +import ( + "context" + "testing" +) func TestListPhoneNumbersEncodesPagination(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.ListPhoneNumbers(10, 20, nil); err != nil { + if _, err := s.ListPhoneNumbers(context.Background(), 10, 20, nil); err != nil { t.Fatalf("ListPhoneNumbers: %v", err) } if got.method != "GET" { @@ -24,7 +27,7 @@ func TestGetPhoneNumberGetsToPhoneNumberPath(t *testing.T) { var got captured s := stubService(t, 200, `{"data":{"phoneNumber":"+15555550100"}}`, &got) - env, err := s.GetPhoneNumber("+15555550100") + env, err := s.GetPhoneNumber(context.Background(), "+15555550100") if err != nil { t.Fatalf("GetPhoneNumber: %v", err) } @@ -47,7 +50,7 @@ func TestPhoneNumberHistoryEncodesPagination(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.PhoneNumberHistory("+15555550100", 10, 20); err != nil { + if _, err := s.PhoneNumberHistory(context.Background(), "+15555550100", 10, 20); err != nil { t.Fatalf("PhoneNumberHistory: %v", err) } if got.method != "GET" { @@ -70,9 +73,9 @@ func TestEmptyPhoneNumbersRejectedWithoutRequest(t *testing.T) { s := stubService(t, 200, `{"data":{}}`, &got) calls := map[string]func() error{ - "GetPhoneNumber": func() error { _, err := s.GetPhoneNumber(""); return err }, + "GetPhoneNumber": func() error { _, err := s.GetPhoneNumber(context.Background(), ""); return err }, "PhoneNumberHistory": func() error { - _, err := s.PhoneNumberHistory("", 10, 0) + _, err := s.PhoneNumberHistory(context.Background(), "", 10, 0) return err }, } @@ -106,7 +109,7 @@ func TestPhoneNumberIsPathEscaped(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.PhoneNumberHistory("+1/555 0100", 10, 0); err != nil { + if _, err := s.PhoneNumberHistory(context.Background(), "+1/555 0100", 10, 0); err != nil { t.Fatalf("PhoneNumberHistory: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/phoneNumbers/+1%2F555%200100/history"; got.escapedPath != want { diff --git a/internal/tendlc/putretry.go b/internal/tendlc/putretry.go index 290f1f8..65dd0a0 100644 --- a/internal/tendlc/putretry.go +++ b/internal/tendlc/putretry.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "encoding/json" "fmt" "os" @@ -67,8 +68,8 @@ import ( // own copy: both are already identical PUT-then-parse-envelope shapes, and // this series has already had one bug from a fix landing on one arm and not // its twin. -func putReplaceWithReadOnlyRetry(client *api.Client, path string, body map[string]any, neverDrop map[string]bool) ([]byte, error) { - raw, err := client.PutRawJSON(path, body) +func putReplaceWithReadOnlyRetry(ctx context.Context, client *api.Client, path string, body map[string]any, neverDrop map[string]bool) ([]byte, error) { + raw, err := client.PutRawJSON(ctx, path, body) if err == nil { return raw, nil } @@ -109,7 +110,7 @@ func putReplaceWithReadOnlyRetry(client *api.Client, path string, body map[strin delete(retryBody, f) } - raw2, err2 := client.PutRawJSON(path, retryBody) + raw2, err2 := client.PutRawJSON(ctx, path, retryBody) if err2 != nil { // The retry's own failure is discarded on purpose: the original error // is the one that describes what the caller did. diff --git a/internal/tendlc/putretry_test.go b/internal/tendlc/putretry_test.go index e47df30..d6b8dc6 100644 --- a/internal/tendlc/putretry_test.go +++ b/internal/tendlc/putretry_test.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "encoding/json" "io" "net/http" @@ -104,7 +105,7 @@ func TestPutRetry_UnmodeledField_RetriesOnceAndSucceeds(t *testing.T) { var raw []byte var err error stderr := captureStderr(t, func() { - raw, err = putReplaceWithReadOnlyRetry(client, "/thing/1", body, brandNeverDropFields()) + raw, err = putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, brandNeverDropFields()) }) if err != nil { @@ -155,7 +156,7 @@ func TestPutRetry_BrandUnchangedFlagReachableField_NoRetry(t *testing.T) { // displayName is what the caller actually changed; website merely rode // along from the read-modify-write, untouched this call. body := map[string]any{"displayName": "New Name", "website": "https://example.com"} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, brandNeverDropFields()) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, brandNeverDropFields()) if err == nil { t.Fatal("want an error, got nil") @@ -188,7 +189,7 @@ func TestPutRetry_CampaignUnchangedFlagReachableField_NoRetry(t *testing.T) { neverDrop[f] = true } body := map[string]any{"description": "Updated description", "sample2": "Reply STOP to opt out"} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, neverDrop) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, neverDrop) if err == nil { t.Fatal("want an error, got nil") @@ -220,7 +221,7 @@ func TestPutRetry_SubscriberOptin_NoRetry(t *testing.T) { for f := range campaignNeverDropFields { neverDrop[f] = true } - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, neverDrop) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, neverDrop) if err == nil { t.Fatal("want an error, got nil") @@ -239,7 +240,7 @@ func TestPutRetry_FieldNotSent_PassesThroughNoSecondRequest(t *testing.T) { }) body := map[string]any{"displayName": "Acme"} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, nil) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, nil) if err == nil { t.Fatal("want an error, got nil") @@ -262,7 +263,7 @@ func TestPutRetry_GenericBadRequest_NoUsablePointers_NoRetry(t *testing.T) { }) body := map[string]any{"displayName": ""} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, nil) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, nil) if err == nil { t.Fatal("want an error, got nil") @@ -279,7 +280,7 @@ func TestPutRetry_RetryAlsoFails_OriginalErrorSurfaces(t *testing.T) { }) body := map[string]any{"displayName": "Acme", "legacyFlag": true} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, nil) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, nil) if err == nil { t.Fatal("want an error, got nil") @@ -301,7 +302,7 @@ func TestPutRetry_NestedPointer_NoRetry(t *testing.T) { }) body := map[string]any{"accounts": []any{map[string]any{"customerProfileId": "CEXMPL1"}}} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, nil) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, nil) if err == nil { t.Fatal("want an error, got nil") @@ -317,7 +318,7 @@ func TestPutRetry_409_NoRetry(t *testing.T) { }) body := map[string]any{"legacyFlag": true} - _, err := putReplaceWithReadOnlyRetry(client, "/thing/1", body, nil) + _, err := putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, nil) if err == nil { t.Fatal("want an error, got nil") @@ -339,7 +340,7 @@ func TestPutRetry_HappyPath_NoStderrNote(t *testing.T) { body := map[string]any{"displayName": "Acme"} var err error stderr := captureStderr(t, func() { - _, err = putReplaceWithReadOnlyRetry(client, "/thing/1", body, nil) + _, err = putReplaceWithReadOnlyRetry(context.Background(), client, "/thing/1", body, nil) }) if err != nil { diff --git a/internal/tendlc/service.go b/internal/tendlc/service.go index 49fc2d9..640be9c 100644 --- a/internal/tendlc/service.go +++ b/internal/tendlc/service.go @@ -3,6 +3,7 @@ package tendlc import ( + "context" "fmt" "net/url" @@ -26,8 +27,8 @@ func (s *Service) base() string { } // get issues a GET and parses the standard {data, page} envelope. -func (s *Service) get(path string) (*api.Envelope, error) { - raw, err := s.client.GetRaw(path) +func (s *Service) get(ctx context.Context, path string) (*api.Envelope, error) { + raw, err := s.client.GetRaw(ctx, path) if err != nil { return nil, err } @@ -35,30 +36,30 @@ func (s *Service) get(path string) (*api.Envelope, error) { } // ListBrands returns the brands on the account. -func (s *Service) ListBrands(limit, offset int, filters []api.Filter) (*api.Envelope, error) { - return s.get(s.base() + "/brands" + api.EncodeQuery(limit, offset, filters)) +func (s *Service) ListBrands(ctx context.Context, limit, offset int, filters []api.Filter) (*api.Envelope, error) { + return s.get(ctx, s.base()+"/brands"+api.EncodeQuery(limit, offset, filters)) } // GetBrand returns one brand. The response wraps data as an object, not a // single-element array — verified against production. -func (s *Service) GetBrand(brandID string) (*api.Envelope, error) { +func (s *Service) GetBrand(ctx context.Context, brandID string) (*api.Envelope, error) { if brandID == "" { return nil, fmt.Errorf("brand ID is required") } - return s.get(s.base() + "/brands/" + url.PathEscape(brandID)) + return s.get(ctx, s.base()+"/brands/"+url.PathEscape(brandID)) } // ListCampaigns returns the campaigns on the account. The list projection // omits fields the campaign schema defines (imported, cspId, samples, // messageFlow) — use GetCampaign when those are needed. -func (s *Service) ListCampaigns(limit, offset int, filters []api.Filter) (*api.Envelope, error) { - return s.get(s.base() + "/campaigns" + api.EncodeQuery(limit, offset, filters)) +func (s *Service) ListCampaigns(ctx context.Context, limit, offset int, filters []api.Filter) (*api.Envelope, error) { + return s.get(ctx, s.base()+"/campaigns"+api.EncodeQuery(limit, offset, filters)) } // GetCampaign returns one campaign, including the fields the list omits. -func (s *Service) GetCampaign(campaignID string) (*api.Envelope, error) { +func (s *Service) GetCampaign(ctx context.Context, campaignID string) (*api.Envelope, error) { if campaignID == "" { return nil, fmt.Errorf("campaign ID is required") } - return s.get(s.base() + "/campaigns/" + url.PathEscape(campaignID)) + return s.get(ctx, s.base()+"/campaigns/"+url.PathEscape(campaignID)) } diff --git a/internal/tendlc/service_test.go b/internal/tendlc/service_test.go index 26766d0..c0a92c1 100644 --- a/internal/tendlc/service_test.go +++ b/internal/tendlc/service_test.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "errors" "net/http" "net/http/httptest" @@ -28,7 +29,7 @@ func TestListBrandsBuildsPathAndQuery(t *testing.T) { }) defer done() - _, err := svc.ListBrands(25, 0, []api.Filter{{Field: "brandType", Op: api.OpEq, Value: "PUBLIC_PROFIT"}}) + _, err := svc.ListBrands(context.Background(), 25, 0, []api.Filter{{Field: "brandType", Op: api.OpEq, Value: "PUBLIC_PROFIT"}}) if err != nil { t.Fatalf("ListBrands: %v", err) } @@ -46,7 +47,7 @@ func TestGetBrandReturnsObjectEnvelope(t *testing.T) { }) defer done() - env, err := svc.GetBrand("BEXMPL8") + env, err := svc.GetBrand(context.Background(), "BEXMPL8") if err != nil { t.Fatalf("GetBrand: %v", err) } @@ -67,7 +68,7 @@ func TestGetBrandEscapesID(t *testing.T) { }) defer done() - _, _ = svc.GetBrand("B/../evil") + _, _ = svc.GetBrand(context.Background(), "B/../evil") if want := "/api/v2/accounts/9901287/tendlc/brands/B%2F..%2Fevil"; gotPath != want { t.Errorf("escaped path = %q, want %q", gotPath, want) } @@ -81,7 +82,7 @@ func TestListBrandsEscapesAccountID(t *testing.T) { }) defer done() - _, err := svc.ListBrands(0, 0, nil) + _, err := svc.ListBrands(context.Background(), 0, 0, nil) if err != nil { t.Fatalf("ListBrands: %v", err) } @@ -97,7 +98,7 @@ func TestServicePropagatesAPIError(t *testing.T) { }) defer done() - _, err := svc.ListBrands(0, 0, nil) + _, err := svc.ListBrands(context.Background(), 0, 0, nil) if err == nil { t.Fatal("expected an error for 403") } @@ -124,7 +125,7 @@ func TestListCampaignsBuildsPathAndQuery(t *testing.T) { }) defer done() - _, err := svc.ListCampaigns(10, 5, nil) + _, err := svc.ListCampaigns(context.Background(), 10, 5, nil) if err != nil { t.Fatalf("ListCampaigns: %v", err) } @@ -144,7 +145,7 @@ func TestGetCampaignEscapesID(t *testing.T) { }) defer done() - env, err := svc.GetCampaign("C/../evil") + env, err := svc.GetCampaign(context.Background(), "C/../evil") if err != nil { t.Fatalf("GetCampaign: %v", err) } @@ -166,7 +167,7 @@ func TestGetBrandEmptyIDErrors(t *testing.T) { }) defer done() - if _, err := svc.GetBrand(""); err == nil { + if _, err := svc.GetBrand(context.Background(), ""); err == nil { t.Fatal("expected an error for an empty brand ID") } } @@ -177,7 +178,7 @@ func TestGetCampaignEmptyIDErrors(t *testing.T) { }) defer done() - if _, err := svc.GetCampaign(""); err == nil { + if _, err := svc.GetCampaign(context.Background(), ""); err == nil { t.Fatal("expected an error for an empty campaign ID") } } diff --git a/internal/tendlc/write.go b/internal/tendlc/write.go index 6827ed6..ee347fb 100644 --- a/internal/tendlc/write.go +++ b/internal/tendlc/write.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "fmt" "net/url" @@ -11,8 +12,8 @@ import ( // an existing brand from TCR (all customers, body = {"brandId": id}). Both are // POST /brands; which one happens is decided by the body, not the path. // Returns 202 with a bandwidthId — the TCR brandId may not exist yet. -func (s *Service) CreateBrand(body map[string]any) (*api.Envelope, error) { - raw, err := s.client.PostRaw(s.base()+"/brands", body) +func (s *Service) CreateBrand(ctx context.Context, body map[string]any) (*api.Envelope, error) { + raw, err := s.client.PostRaw(ctx, s.base()+"/brands", body) if err != nil { return nil, err } @@ -36,11 +37,11 @@ func (s *Service) CreateBrand(body map[string]any) (*api.Envelope, error) { // this call never touched it) surfaces as a real validation failure instead // of being silently stripped and re-sent. See putReplaceWithReadOnlyRetry's // INVARIANT for why "changed this call" is not the right test. -func (s *Service) UpdateBrand(brandID string, body map[string]any) (*api.Envelope, error) { +func (s *Service) UpdateBrand(ctx context.Context, brandID string, body map[string]any) (*api.Envelope, error) { if brandID == "" { return nil, fmt.Errorf("brand ID is required") } - raw, err := putReplaceWithReadOnlyRetry(s.client, s.brandPath(brandID), body, brandNeverDropFields()) + raw, err := putReplaceWithReadOnlyRetry(ctx, s.client, s.brandPath(brandID), body, brandNeverDropFields()) if err != nil { return nil, err } @@ -53,58 +54,58 @@ func (s *Service) UpdateBrand(brandID string, body map[string]any) (*api.Envelop // claim it does, but measured against production, both test profiles // survived with softDeleted: false. Delete the profile separately if it is // no longer needed. -func (s *Service) DeleteBrand(brandID string) error { +func (s *Service) DeleteBrand(ctx context.Context, brandID string) error { if brandID == "" { return fmt.Errorf("brand ID is required") } - return s.client.Delete(s.brandPath(brandID), nil) + return s.client.Delete(ctx, s.brandPath(brandID), nil) } // ReverifyBrand resubmits the brand for identity verification. This incurs a // $4 fee and resets brandIdentityStatus toward re-registration — documented // as REGISTERING, but production reads it back as UNVERIFIED until TCR // responds. Returns 204. -func (s *Service) ReverifyBrand(brandID string) error { +func (s *Service) ReverifyBrand(ctx context.Context, brandID string) error { if brandID == "" { return fmt.Errorf("brand ID is required") } - return s.client.Post(s.brandPath(brandID)+"/identity/reverify", nil, nil) + return s.client.Post(ctx, s.brandPath(brandID)+"/identity/reverify", nil, nil) } // Resend2FA re-sends the Business Authentication 2FA email to the brand's // business contact. Returns 204. -func (s *Service) Resend2FA(brandID string) error { +func (s *Service) Resend2FA(ctx context.Context, brandID string) error { if brandID == "" { return fmt.Errorf("brand ID is required") } - return s.client.Post(s.brandPath(brandID)+"/identity/resend2faEmail", nil, nil) + return s.client.Post(ctx, s.brandPath(brandID)+"/identity/resend2faEmail", nil, nil) } // BrandHistory returns the brand's activity log: free-text {createdDate, // message} entries, newest first. Unlike customer profiles there are no // versioned snapshots and no per-version fetch. -func (s *Service) BrandHistory(brandID string, limit, offset int) (*api.Envelope, error) { +func (s *Service) BrandHistory(ctx context.Context, brandID string, limit, offset int) (*api.Envelope, error) { if brandID == "" { return nil, fmt.Errorf("brand ID is required") } - return s.get(s.brandPath(brandID) + "/history" + api.EncodeQuery(limit, offset, nil)) + return s.get(ctx, s.brandPath(brandID)+"/history"+api.EncodeQuery(limit, offset, nil)) } // ListVettings returns the external vettings on a brand. Vettings are // brand-scoped: there is no campaign vetting endpoint. -func (s *Service) ListVettings(brandID string, limit, offset int) (*api.Envelope, error) { +func (s *Service) ListVettings(ctx context.Context, brandID string, limit, offset int) (*api.Envelope, error) { if brandID == "" { return nil, fmt.Errorf("brand ID is required") } - return s.get(s.brandPath(brandID) + "/vettings" + api.EncodeQuery(limit, offset, nil)) + return s.get(ctx, s.brandPath(brandID)+"/vettings"+api.EncodeQuery(limit, offset, nil)) } // RequestVetting orders a new external vetting for a brand. Billable. -func (s *Service) RequestVetting(brandID string, body map[string]any) (*api.Envelope, error) { +func (s *Service) RequestVetting(ctx context.Context, brandID string, body map[string]any) (*api.Envelope, error) { if brandID == "" { return nil, fmt.Errorf("brand ID is required") } - raw, err := s.client.PostRaw(s.brandPath(brandID)+"/vettings", body) + raw, err := s.client.PostRaw(ctx, s.brandPath(brandID)+"/vettings", body) if err != nil { return nil, err } @@ -112,14 +113,14 @@ func (s *Service) RequestVetting(brandID string, body map[string]any) (*api.Enve } // ImportVetting records an externally-performed vetting against a brand. -func (s *Service) ImportVetting(brandID, vettingID string, body map[string]any) (*api.Envelope, error) { +func (s *Service) ImportVetting(ctx context.Context, brandID, vettingID string, body map[string]any) (*api.Envelope, error) { if brandID == "" { return nil, fmt.Errorf("brand ID is required") } if vettingID == "" { return nil, fmt.Errorf("vetting ID is required") } - raw, err := s.client.PutRawJSON( + raw, err := s.client.PutRawJSON(ctx, s.brandPath(brandID)+"/vettings/"+url.PathEscape(vettingID), body) if err != nil { return nil, err diff --git a/internal/tendlc/write_test.go b/internal/tendlc/write_test.go index 0dabb56..c51e8ad 100644 --- a/internal/tendlc/write_test.go +++ b/internal/tendlc/write_test.go @@ -1,6 +1,7 @@ package tendlc import ( + "context" "encoding/json" "io" "net/http" @@ -44,7 +45,7 @@ func TestCreateBrandPostsToBrandsPath(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"WABC123"}}`, &got) - env, err := s.CreateBrand(map[string]any{"displayName": "Acme"}) + env, err := s.CreateBrand(context.Background(), map[string]any{"displayName": "Acme"}) if err != nil { t.Fatalf("CreateBrand: %v", err) } @@ -70,7 +71,7 @@ func TestUpdateBrandPutsToBrandPath(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"WABC123"}}`, &got) - if _, err := s.UpdateBrand("BEXMPL6", map[string]any{"displayName": "Acme"}); err != nil { + if _, err := s.UpdateBrand(context.Background(), "BEXMPL6", map[string]any{"displayName": "Acme"}); err != nil { t.Fatalf("UpdateBrand: %v", err) } if got.method != "PUT" { @@ -85,7 +86,7 @@ func TestDeleteBrandUsesDelete(t *testing.T) { var got captured s := stubService(t, 202, "", &got) - if err := s.DeleteBrand("WET8JUY8H0"); err != nil { + if err := s.DeleteBrand(context.Background(), "WET8JUY8H0"); err != nil { t.Fatalf("DeleteBrand: %v", err) } if got.method != "DELETE" { @@ -102,9 +103,9 @@ func TestReverifyAndResend2FAPostToIdentityPaths(t *testing.T) { call func(*Service) error want string }{ - {"reverify", func(s *Service) error { return s.ReverifyBrand("BEXMPL6") }, + {"reverify", func(s *Service) error { return s.ReverifyBrand(context.Background(), "BEXMPL6") }, "/api/v2/accounts/9901287/tendlc/brands/BEXMPL6/identity/reverify"}, - {"resend2fa", func(s *Service) error { return s.Resend2FA("BEXMPL6") }, + {"resend2fa", func(s *Service) error { return s.Resend2FA(context.Background(), "BEXMPL6") }, "/api/v2/accounts/9901287/tendlc/brands/BEXMPL6/identity/resend2faEmail"}, } for _, tt := range tests { @@ -130,7 +131,7 @@ func TestBrandHistoryEncodesPagination(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.BrandHistory("BEXMPL6", 10, 20); err != nil { + if _, err := s.BrandHistory(context.Background(), "BEXMPL6", 10, 20); err != nil { t.Fatalf("BrandHistory: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/brands/BEXMPL6/history"; got.path != want { @@ -145,7 +146,7 @@ func TestListVettingsEncodesPagination(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.ListVettings("BEXMPL6", 10, 0); err != nil { + if _, err := s.ListVettings(context.Background(), "BEXMPL6", 10, 0); err != nil { t.Fatalf("ListVettings: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/brands/BEXMPL6/vettings"; got.path != want { @@ -158,7 +159,7 @@ func TestRequestVettingPostsBody(t *testing.T) { s := stubService(t, 202, `{"data":{"bandwidthId":"WV123"}}`, &got) body := map[string]any{"evpId": "AEGIS", "vettingClass": "STANDARD"} - if _, err := s.RequestVetting("BEXMPL6", body); err != nil { + if _, err := s.RequestVetting(context.Background(), "BEXMPL6", body); err != nil { t.Fatalf("RequestVetting: %v", err) } if got.method != "POST" { @@ -173,7 +174,7 @@ func TestImportVettingPutsToVettingPath(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"WV123"}}`, &got) - if _, err := s.ImportVetting("BEXMPL6", "978de74a-7191", map[string]any{"evpId": "AEGIS"}); err != nil { + if _, err := s.ImportVetting(context.Background(), "BEXMPL6", "978de74a-7191", map[string]any{"evpId": "AEGIS"}); err != nil { t.Fatalf("ImportVetting: %v", err) } if got.method != "PUT" { @@ -192,22 +193,22 @@ func TestEmptyIDsRejectedWithoutRequest(t *testing.T) { s := stubService(t, 200, `{"data":{}}`, &got) calls := map[string]func() error{ - "UpdateBrand": func() error { _, err := s.UpdateBrand("", map[string]any{}); return err }, - "DeleteBrand": func() error { return s.DeleteBrand("") }, - "ReverifyBrand": func() error { return s.ReverifyBrand("") }, - "Resend2FA": func() error { return s.Resend2FA("") }, - "BrandHistory": func() error { _, err := s.BrandHistory("", 10, 0); return err }, - "ListVettings": func() error { _, err := s.ListVettings("", 10, 0); return err }, + "UpdateBrand": func() error { _, err := s.UpdateBrand(context.Background(), "", map[string]any{}); return err }, + "DeleteBrand": func() error { return s.DeleteBrand(context.Background(), "") }, + "ReverifyBrand": func() error { return s.ReverifyBrand(context.Background(), "") }, + "Resend2FA": func() error { return s.Resend2FA(context.Background(), "") }, + "BrandHistory": func() error { _, err := s.BrandHistory(context.Background(), "", 10, 0); return err }, + "ListVettings": func() error { _, err := s.ListVettings(context.Background(), "", 10, 0); return err }, "RequestVetting": func() error { - _, err := s.RequestVetting("", map[string]any{}) + _, err := s.RequestVetting(context.Background(), "", map[string]any{}) return err }, "ImportVettingNoBrand": func() error { - _, err := s.ImportVetting("", "v1", map[string]any{}) + _, err := s.ImportVetting(context.Background(), "", "v1", map[string]any{}) return err }, "ImportVettingNoVetting": func() error { - _, err := s.ImportVetting("B1", "", map[string]any{}) + _, err := s.ImportVetting(context.Background(), "B1", "", map[string]any{}) return err }, } @@ -230,7 +231,7 @@ func TestBrandIDIsPathEscaped(t *testing.T) { var got captured s := stubService(t, 200, `{"data":[],"page":{"totalElements":0}}`, &got) - if _, err := s.BrandHistory("a/b c", 10, 0); err != nil { + if _, err := s.BrandHistory(context.Background(), "a/b c", 10, 0); err != nil { t.Fatalf("BrandHistory: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/brands/a%2Fb%20c/history"; got.escapedPath != want { @@ -250,7 +251,7 @@ func TestVettingIDIsPathEscaped(t *testing.T) { var got captured s := stubService(t, 202, `{"data":{"bandwidthId":"WV123"}}`, &got) - if _, err := s.ImportVetting("BEXMPL6", "v/1 2", map[string]any{"evpId": "AEGIS"}); err != nil { + if _, err := s.ImportVetting(context.Background(), "BEXMPL6", "v/1 2", map[string]any{"evpId": "AEGIS"}); err != nil { t.Fatalf("ImportVetting: %v", err) } if want := "/api/v2/accounts/9901287/tendlc/brands/BEXMPL6/vettings/v%2F1%202"; got.escapedPath != want { diff --git a/internal/testutil/golden.go b/internal/testutil/golden.go index e53fb50..5091d88 100644 --- a/internal/testutil/golden.go +++ b/internal/testutil/golden.go @@ -6,6 +6,7 @@ package testutil import ( + "context" "encoding/json" "io" "os" @@ -21,16 +22,16 @@ type FakeClient struct { GetResult interface{} } -func (f *FakeClient) Get(path string, result interface{}) error { +func (f *FakeClient) Get(_ context.Context, path string, result interface{}) error { b, _ := json.Marshal(f.GetResult) return json.Unmarshal(b, result) } -func (f *FakeClient) Post(string, interface{}, interface{}) error { return nil } -func (f *FakeClient) Put(string, interface{}, interface{}) error { return nil } -func (f *FakeClient) Patch(string, interface{}, interface{}) error { return nil } -func (f *FakeClient) Delete(string, interface{}) error { return nil } -func (f *FakeClient) GetRaw(string) ([]byte, error) { return nil, nil } -func (f *FakeClient) PutRaw(string, []byte, string) error { return nil } +func (f *FakeClient) Post(context.Context, string, interface{}, interface{}) error { return nil } +func (f *FakeClient) Put(context.Context, string, interface{}, interface{}) error { return nil } +func (f *FakeClient) Patch(context.Context, string, interface{}, interface{}) error { return nil } +func (f *FakeClient) Delete(context.Context, string, interface{}) error { return nil } +func (f *FakeClient) GetRaw(context.Context, string) ([]byte, error) { return nil, nil } +func (f *FakeClient) PutRaw(context.Context, string, []byte, string) error { return nil } // NewTestRoot builds a minimal root command carrying the persistent flags that // command implementations read via cmd.Root().Flag(...), with child attached.