diff --git a/AGENTS.md b/AGENTS.md index 856bb9b..aa84aa9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,14 @@ For full flag/argument reference, use `band --help`. This section cove - **`number order` costs money.** No undo — you must `number release` to give it back. - **`number search` results are not reserved.** Between search and order, someone else can take the number. +- **`number details` is the Dashboard view; `number get` is the Universal Platform voice record.** `details` works on any account and shows geography, features (E911/LIDB/DLDA), messaging settings, TN attributes, and the per-number origination route plan (priority + weight per endpoint) where one is configured. `get` shows the VCP assignment and only works where the UP voice API is enabled. To answer "how is this number routed?": try `number details` first — if it shows an `OriginationRoutePlan`, that is the routing; on UP accounts follow `number get` → `vcp get ` instead. +- **`number list` filter flags hit different endpoints than the bare command.** Plain `number list` uses `/tns`, which works for credentials without the inservice role. The `--npa-nxx`/`--state`/`--ratecenter`/`--lata`/`--subaccount` filters use the inservice endpoints and may return 403 where the bare list succeeds. `--ratecenter` requires `--state`; `--location` requires `--subaccount`; `--disconnected` combines with nothing. +- **`number count` is cheap.** It uses the totals endpoints — prefer it over listing and counting client-side. + +### Toll-free routing + +- **`tollfree template` is account-gated.** The underlying endpoint requires the `TollFreeTemplateAssignmentSearch` account setting (off by default; Bandwidth enables it on request). Expect exit 2 with a "not enabled on account" message until then — that is the correct behavior, not a bug. Numbers must be in-service on the account, toll-free (800/888/877/866/855/844/833), and at most 5000 per invocation. +- **The template name is the answer, not a carrier name.** The CLI returns `templateName` exactly as the registry stores it; mapping template names to ingress carriers is operator knowledge the API does not expose. ### VCPs diff --git a/README.md b/README.md index b880ccc..6752b5a 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ band number list # list your number band number search --area-code 919 --quantity 5 # search available numbers band number order +15555550100 --subaccount --wait # order (blocks until active) band number activate +15555550100 --voice-inbound --wait # turn on inbound voice +band number details +15555550100 # everything configured on a number +band number count # how many numbers do I have? band number release +15555550100 # release a number ``` @@ -422,9 +424,18 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band number get ` | Get voice config details (including VCP assignment) | | `band number activate ` | Activate voice/messaging services (e.g. enable inbound) | | `band number deactivate ` | Deactivate voice/messaging services | -| `band number list` | List your in-service numbers | +| `band number list` | List your in-service numbers (filter with `--npa-nxx`, `--state`, `--ratecenter`, `--subaccount`, `--location`, `--disconnected`) | +| `band number count` | Count numbers without listing them | +| `band number details ` | Full Dashboard view of a number (geography, features, messaging, per-number route plan) | +| `band number nnroutes ` | List NetNumber routes available to a number | | `band number release ` | Release a number | +### Toll-free routing + +| Command | What it does | +|---------|-------------| +| `band tollfree template ` | Look up the routing template assigned to toll-free numbers (account-gated; 403 until enabled) | + ### Messaging | Command | What it does | diff --git a/cmd/number/count.go b/cmd/number/count.go new file mode 100644 index 0000000..8ee6eed --- /dev/null +++ b/cmd/number/count.go @@ -0,0 +1,81 @@ +package number + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + countSubaccount string + countLocation string + countDisconnected bool +) + +func init() { + countCmd.Flags().StringVar(&countSubaccount, "subaccount", "", "Count numbers on a sub-account (site ID)") + countCmd.Flags().StringVar(&countLocation, "location", "", "Count numbers on a location (SIP peer ID); requires --subaccount") + countCmd.Flags().BoolVar(&countDisconnected, "disconnected", false, "Count disconnected numbers instead of in-service ones") + Cmd.AddCommand(countCmd) +} + +var countCmd = &cobra.Command{ + Use: "count", + Short: "Count phone numbers without listing them", + Long: `Returns the number of phone numbers on the account, a sub-account, or a +location using the Dashboard totals endpoints — no paging through the +full inventory.`, + Example: ` band number count + band number count --disconnected + band number count --subaccount 407 + band number count --subaccount 407 --location 500017`, + RunE: runCount, +} + +// countPath maps count flags to the matching totals endpoint. +func countPath(acctID string, subaccount, location string, disconnected bool) (string, error) { + if disconnected && (subaccount != "" || location != "") { + return "", cmdutil.NewFlagError("--disconnected cannot be combined with --subaccount or --location") + } + if location != "" && subaccount == "" { + return "", cmdutil.NewFlagError("--location requires --subaccount") + } + switch { + case disconnected: + return fmt.Sprintf("/accounts/%s/discnumbers/totals", acctID), nil + case subaccount != "" && location != "": + return fmt.Sprintf("/accounts/%s/sites/%s/sippeers/%s/totaltns", acctID, subaccount, location), nil + case subaccount != "": + return fmt.Sprintf("/accounts/%s/sites/%s/totaltns", acctID, subaccount), nil + default: + return fmt.Sprintf("/accounts/%s/inserviceNumbers/totals", acctID), nil + } +} + +func runCount(cmd *cobra.Command, args []string) error { + // Validate flags before authenticating so misuse fails fast. + if _, err := countPath("x", countSubaccount, countLocation, countDisconnected); err != nil { + return err + } + + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path, err := countPath(acctID, countSubaccount, countLocation, countDisconnected) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(path, &result); err != nil { + return fmt.Errorf("counting phone numbers: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, output.FlattenAndNormalize(result)) +} diff --git a/cmd/number/details.go b/cmd/number/details.go new file mode 100644 index 0000000..2ad33d0 --- /dev/null +++ b/cmd/number/details.go @@ -0,0 +1,73 @@ +package number + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(detailsCmd) +} + +var detailsCmd = &cobra.Command{ + Use: "details ", + Short: "Get the full Dashboard view of a phone number", + Long: `Returns everything the Bandwidth Dashboard knows about a phone number: +geography (LATA, state, rate center), vendor, sub-account and location, +service types, features (E911, LIDB, DLDA), messaging settings including +the assigned NN route, TN attributes, and — where configured — the +per-number origination route plan with priority and weight per endpoint. + +This is the Dashboard (legacy platform) view and works for any number on +the account. For the Universal Platform voice record (VCP assignment), +use "band number get" instead.`, + Example: ` band number details +19195551234 + band number details 8005551234 --plain`, + Args: cobra.ExactArgs(1), + RunE: runDetails, +} + +// unwrapTelephoneNumberDetails strips the TelephoneNumberResponse envelope so +// the useful fields sit at the top level. Unexpected shapes pass through. +func unwrapTelephoneNumberDetails(result interface{}) interface{} { + m, ok := result.(map[string]interface{}) + if !ok { + return result + } + resp, ok := m["TelephoneNumberResponse"].(map[string]interface{}) + if !ok { + resp = m + } + if details, ok := resp["TelephoneNumberDetails"]; ok { + return details + } + return result +} + +func runDetails(cmd *cobra.Command, args []string) error { + number := cmdutil.NormalizeE164(args[0]) + + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(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. + return fmt.Errorf("getting number details: %s not found on account %s: %w", number, acctID, err) + } + return fmt.Errorf("getting number details: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, unwrapTelephoneNumberDetails(result)) +} diff --git a/cmd/number/list.go b/cmd/number/list.go index 7932ffb..ae93de0 100644 --- a/cmd/number/list.go +++ b/cmd/number/list.go @@ -13,12 +13,28 @@ import ( "github.com/Bandwidth/cli/internal/output" ) -var listStatus string +var ( + listStatus string + listNpaNxx string + listState string + listRateCenter string + listLata string + listSubaccount string + listLocation string + listDisconnected bool +) func init() { listCmd.Flags().StringVar(&listStatus, "status", "Inservice", "Comma-separated statuses to include. Common values: Inservice (live), "+ "InAccount (assigned, not yet live), Aging (released, in aging period).") + listCmd.Flags().StringVar(&listNpaNxx, "npa-nxx", "", "Filter to a 6-digit NPA-NXX prefix (in-service numbers only; requires the inservice role)") + listCmd.Flags().StringVar(&listState, "state", "", "Filter to a 2-letter state/province (in-service numbers only)") + listCmd.Flags().StringVar(&listRateCenter, "ratecenter", "", "Filter to a rate center; requires --state") + listCmd.Flags().StringVar(&listLata, "lata", "", "Filter to a LATA (in-service numbers only)") + listCmd.Flags().StringVar(&listSubaccount, "subaccount", "", "List numbers on a sub-account (site ID)") + listCmd.Flags().StringVar(&listLocation, "location", "", "List numbers on a location (SIP peer ID); requires --subaccount") + listCmd.Flags().BoolVar(&listDisconnected, "disconnected", false, "List disconnected numbers instead of in-service ones") Cmd.AddCommand(listCmd) } @@ -31,17 +47,45 @@ By default, returns only numbers in service (ready to route calls or send messages). Pass --status to include numbers in other states.`, Example: ` band number list # default: only in-service band number list --status Inservice,InAccount # include numbers just ordered - band number list --status Aging # numbers being released`, + band number list --status Aging # numbers being released + band number list --npa-nxx 919555 # in-service numbers in an NPA-NXX + band number list --state NC --ratecenter RALEIGH # in-service numbers in a rate center + band number list --subaccount 407 # numbers on a sub-account + band number list --subaccount 407 --location 500017 + band number list --disconnected # recently disconnected numbers`, RunE: runList, } func runList(cmd *cobra.Command, args []string) error { + // --status has a default, so only treat it as user intent when changed; + // otherwise the default value would conflict with every filter flag. + opts := listOptions{ + NpaNxx: listNpaNxx, + State: listState, + RateCenter: listRateCenter, + Lata: listLata, + Subaccount: listSubaccount, + Location: listLocation, + Disconnected: listDisconnected, + } + if cmd.Flags().Changed("status") { + opts.Status = listStatus + } + if err := opts.validate(); err != nil { + return err + } + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) if err != nil { return err } - numbers, err := fetchAccountNumbers(client, acctID, listStatus) + var numbers []string + if query := buildListQuery(acctID, opts); query != nil { + numbers, err = fetchPagedNumbers(client, query) + } else { + numbers, err = fetchAccountNumbers(client, acctID, listStatus) + } if err != nil { return err } @@ -112,6 +156,85 @@ func wrapTNsError(err error, acctID string, isBuild bool) error { "Contact your Bandwidth account manager to assign this role.", acctID), err) } +// pagedListSize is the page size for the inserviceNumbers/discnumbers/site +// list endpoints. These endpoints document no maximum; 1000 keeps request +// counts low while staying well under any plausible server cap. +const pagedListSize = 1000 + +// fetchPagedNumbers pages through a filtered list endpoint and returns the +// merged E.164 numbers. The page parameter advances per the endpoint's +// dialect (see pageStyle). Termination prefers the response's TotalCount — +// 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) { + var all []string + for requests := 0; requests < tnsMaxPages; requests++ { + q := url.Values{} + for k, vs := range query.Query { + for _, v := range vs { + q.Add(k, v) + } + } + switch query.PageStyle { + case pageByNumber: + q.Set("page", strconv.Itoa(requests+1)) + default: // pageByFirstElementID: 1, 1001, 2001, ... + q.Set("page", strconv.Itoa(len(all)+1)) + } + q.Set("size", strconv.Itoa(pagedListSize)) + + var result interface{} + if err := client.Get(query.Path+"?"+q.Encode(), &result); err != nil { + return nil, fmt.Errorf("listing phone numbers: %w", err) + } + + batch := extractFullNumbers(result) + all = append(all, batch...) + + if total, ok := extractTotalCount(result); ok && len(all) >= total { + return all, nil + } + if len(batch) < pagedListSize { + return all, nil + } + } + return nil, fmt.Errorf("listing phone numbers: exceeded %d pages (%d numbers); "+ + "narrow the query or contact support", tnsMaxPages, tnsMaxPages*pagedListSize) +} + +// extractTotalCount finds the response's TotalCount field. The XML decoder +// yields it as a string; a missing or unparseable value returns ok=false so +// the caller falls back to short-batch termination. +func extractTotalCount(v interface{}) (int, bool) { + switch x := v.(type) { + case map[string]interface{}: + if tc, ok := x["TotalCount"]; ok { + switch t := tc.(type) { + case string: + if n, err := strconv.Atoi(t); err == nil { + return n, true + } + case float64: + return int(t), true + } + return 0, false + } + for _, child := range x { + if n, ok := extractTotalCount(child); ok { + return n, ok + } + } + case []interface{}: + for _, item := range x { + if n, ok := extractTotalCount(item); ok { + return n, ok + } + } + } + return 0, false +} + // extractFullNumbers walks a decoded /tns response and returns each // TelephoneNumber's FullNumber formatted as E.164. func extractFullNumbers(raw interface{}) []string { @@ -127,6 +250,13 @@ func collectFullNumbers(v interface{}, out *[]string) { *out = append(*out, cmdutil.NormalizeE164(fn)) return } + // The inserviceNumbers and discnumbers endpoints return bare strings + // under , not FullNumber objects. + if tn, ok := x["TelephoneNumber"]; ok { + if collectBareNumbers(tn, out) { + return + } + } for _, child := range x { collectFullNumbers(child, out) } @@ -136,3 +266,31 @@ func collectFullNumbers(v interface{}, out *[]string) { } } } + +// collectBareNumbers appends bare-string telephone numbers (a single string +// or a list of strings) and reports whether it consumed the value. Object +// forms of TelephoneNumber return false so the caller keeps walking. +func collectBareNumbers(v interface{}, out *[]string) bool { + switch x := v.(type) { + case string: + if x != "" { + *out = append(*out, cmdutil.NormalizeE164(x)) + } + return true + case []interface{}: + consumed := false + for _, item := range x { + if s, ok := item.(string); ok { + if s != "" { + *out = append(*out, cmdutil.NormalizeE164(s)) + } + consumed = true + } else { + collectFullNumbers(item, out) + consumed = true + } + } + return consumed + } + return false +} diff --git a/cmd/number/listquery.go b/cmd/number/listquery.go new file mode 100644 index 0000000..a835cb2 --- /dev/null +++ b/cmd/number/listquery.go @@ -0,0 +1,106 @@ +package number + +import ( + "fmt" + "net/url" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +// listOptions carries every `band number list` flag that selects an endpoint. +type listOptions struct { + Status string + NpaNxx string + State string + RateCenter string + Lata string + Subaccount string + Location string + Disconnected bool +} + +// geoFiltered reports whether any NANP geography filter is set. +func (o listOptions) geoFiltered() bool { + return o.NpaNxx != "" || o.State != "" || o.RateCenter != "" || o.Lata != "" +} + +// validate rejects flag combinations the API cannot serve. It runs before +// authentication so misuse fails fast with a FlagError (exit 6). +func (o listOptions) validate() error { + if o.Disconnected && (o.Status != "" || o.geoFiltered() || o.Subaccount != "" || o.Location != "") { + return cmdutil.NewFlagError("--disconnected cannot be combined with other filters") + } + if o.Location != "" && o.Subaccount == "" { + return cmdutil.NewFlagError("--location requires --subaccount") + } + if o.RateCenter != "" && o.State == "" { + return cmdutil.NewFlagError("--ratecenter requires --state (API constraint)") + } + if o.Status != "" && (o.geoFiltered() || o.Subaccount != "") { + return cmdutil.NewFlagError("--status cannot be combined with geography or sub-account filters (filtered lists return in-service numbers only)") + } + if o.Location != "" && o.geoFiltered() { + return cmdutil.NewFlagError("geography filters cannot be combined with --location") + } + return nil +} + +// pageStyle names the pagination dialect a list endpoint speaks. The +// Dashboard read endpoints disagree: inserviceNumbers and discnumbers define +// `page` as the 1-based ID of the FIRST ELEMENT of the page (1, 1001, 2001, +// ...), while the sippeer tns endpoint defines it as an ordinary page number +// (1, 2, 3, ...). +type pageStyle int + +const ( + pageByFirstElementID pageStyle = iota + pageByNumber +) + +// listQuery describes one page-parameterized list request. Page and size are +// appended by the fetch loop, not here. +type listQuery struct { + Path string + Query url.Values + PageStyle pageStyle +} + +// buildListQuery maps validated options onto the Dashboard list endpoints. +// nil means "use the default /tns path" (the historical behavior, preserved +// because /tns works for credentials without the inservice role). +func buildListQuery(acctID string, o listOptions) *listQuery { + if o.Disconnected { + return &listQuery{Path: fmt.Sprintf("/accounts/%s/discnumbers", acctID), Query: url.Values{}, PageStyle: pageByFirstElementID} + } + + q := url.Values{} + if o.NpaNxx != "" { + q.Set("npaNxx", o.NpaNxx) + } + if o.State != "" { + q.Set("state", o.State) + } + if o.Lata != "" { + q.Set("lata", o.Lata) + } + + switch { + case o.Subaccount != "" && o.Location != "": + return &listQuery{Path: fmt.Sprintf("/accounts/%s/sites/%s/sippeers/%s/tns", acctID, o.Subaccount, o.Location), Query: q, PageStyle: pageByNumber} + case o.Subaccount != "": + // The site-level endpoint documents this parameter as "rateCenter"; + // the account-level endpoint documents it as "ratecenter". Follow + // each endpoint's published casing. + if o.RateCenter != "" { + q.Set("rateCenter", o.RateCenter) + } + return &listQuery{Path: fmt.Sprintf("/accounts/%s/sites/%s/inserviceNumbers", acctID, o.Subaccount), Query: q, PageStyle: pageByFirstElementID} + case o.geoFiltered(): + if o.RateCenter != "" { + q.Set("ratecenter", o.RateCenter) + } + return &listQuery{Path: fmt.Sprintf("/accounts/%s/inserviceNumbers", acctID), Query: q, PageStyle: pageByFirstElementID} + default: + return nil + } +} diff --git a/cmd/number/listquery_test.go b/cmd/number/listquery_test.go new file mode 100644 index 0000000..7a360da --- /dev/null +++ b/cmd/number/listquery_test.go @@ -0,0 +1,247 @@ +package number + +import ( + "testing" +) + +func TestListOptionsValidate(t *testing.T) { + tests := []struct { + name string + opts listOptions + wantErr bool + }{ + {name: "no flags", opts: listOptions{}}, + {name: "status only", opts: listOptions{Status: "Aging"}}, + {name: "geo filters", opts: listOptions{NpaNxx: "919555", State: "NC"}}, + {name: "ratecenter with state", opts: listOptions{RateCenter: "RALEIGH", State: "NC"}}, + {name: "subaccount", opts: listOptions{Subaccount: "407"}}, + {name: "subaccount and location", opts: listOptions{Subaccount: "407", Location: "500017"}}, + {name: "subaccount with geo", opts: listOptions{Subaccount: "407", State: "NC"}}, + {name: "disconnected alone", opts: listOptions{Disconnected: true}}, + + {name: "ratecenter without state", opts: listOptions{RateCenter: "RALEIGH"}, wantErr: true}, + {name: "location without subaccount", opts: listOptions{Location: "500017"}, wantErr: true}, + {name: "disconnected with status", opts: listOptions{Disconnected: true, Status: "Aging"}, wantErr: true}, + {name: "disconnected with geo", opts: listOptions{Disconnected: true, State: "NC"}, wantErr: true}, + {name: "disconnected with subaccount", opts: listOptions{Disconnected: true, Subaccount: "407"}, wantErr: true}, + {name: "status with geo", opts: listOptions{Status: "Aging", State: "NC"}, wantErr: true}, + {name: "status with subaccount", opts: listOptions{Status: "Aging", Subaccount: "407"}, wantErr: true}, + {name: "geo with location", opts: listOptions{Subaccount: "407", Location: "500017", State: "NC"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.opts.validate() + if tt.wantErr && err == nil { + t.Errorf("validate(%+v) = nil, want error", tt.opts) + } + if !tt.wantErr && err != nil { + t.Errorf("validate(%+v) = %v, want nil", tt.opts, err) + } + }) + } +} + +func TestBuildListQuery(t *testing.T) { + tests := []struct { + name string + opts listOptions + wantNil bool + wantPath string + wantQuery map[string]string + }{ + {name: "no flags falls back to /tns", opts: listOptions{}, wantNil: true}, + {name: "status only falls back to /tns", opts: listOptions{Status: "Aging"}, wantNil: true}, + { + name: "geo filters hit account inserviceNumbers", + opts: listOptions{NpaNxx: "919555", State: "NC", RateCenter: "RALEIGH", Lata: "426"}, + wantPath: "/accounts/123/inserviceNumbers", + wantQuery: map[string]string{"npaNxx": "919555", "state": "NC", "ratecenter": "RALEIGH", "lata": "426"}, + }, + { + name: "subaccount hits site inserviceNumbers", + opts: listOptions{Subaccount: "407"}, + wantPath: "/accounts/123/sites/407/inserviceNumbers", + }, + { + name: "subaccount with geo keeps filters", + opts: listOptions{Subaccount: "407", State: "NC"}, + wantPath: "/accounts/123/sites/407/inserviceNumbers", + wantQuery: map[string]string{"state": "NC"}, + }, + { + name: "site-level ratecenter uses documented rateCenter casing", + opts: listOptions{Subaccount: "407", State: "NC", RateCenter: "RALEIGH"}, + wantPath: "/accounts/123/sites/407/inserviceNumbers", + wantQuery: map[string]string{"state": "NC", "rateCenter": "RALEIGH"}, + }, + { + name: "subaccount and location hit sippeer tns", + opts: listOptions{Subaccount: "407", Location: "500017"}, + wantPath: "/accounts/123/sites/407/sippeers/500017/tns", + }, + { + name: "disconnected hits discnumbers", + opts: listOptions{Disconnected: true}, + wantPath: "/accounts/123/discnumbers", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildListQuery("123", tt.opts) + if tt.wantNil { + if got != nil { + t.Fatalf("buildListQuery = %+v, want nil", got) + } + return + } + if got == nil { + t.Fatal("buildListQuery = nil, want query") + } + if got.Path != tt.wantPath { + t.Errorf("Path = %q, want %q", got.Path, tt.wantPath) + } + for k, v := range tt.wantQuery { + if got.Query.Get(k) != v { + t.Errorf("Query[%s] = %q, want %q", k, got.Query.Get(k), v) + } + } + }) + } +} + +func TestBuildListQueryPageStyle(t *testing.T) { + // inserviceNumbers and discnumbers page by first-element ID; the sippeer + // tns endpoint pages by page number. Getting this wrong re-fetches or + // skips records, so pin the mapping. + if q := buildListQuery("123", listOptions{State: "NC"}); q.PageStyle != pageByFirstElementID { + t.Error("account inserviceNumbers should page by first-element ID") + } + if q := buildListQuery("123", listOptions{Subaccount: "407"}); q.PageStyle != pageByFirstElementID { + t.Error("site inserviceNumbers should page by first-element ID") + } + if q := buildListQuery("123", listOptions{Disconnected: true}); q.PageStyle != pageByFirstElementID { + t.Error("discnumbers should page by first-element ID") + } + if q := buildListQuery("123", listOptions{Subaccount: "407", Location: "500017"}); q.PageStyle != pageByNumber { + t.Error("sippeer tns should page by page number") + } +} + +func TestExtractTotalCount(t *testing.T) { + // XML decoding yields TotalCount as a string. + resp := map[string]interface{}{ + "TNs": map[string]interface{}{ + "TotalCount": "54", + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": "+14158714245", + }, + }, + } + if n, ok := extractTotalCount(resp); !ok || n != 54 { + t.Errorf("extractTotalCount = %d, %v; want 54, true", n, ok) + } + + if _, ok := extractTotalCount(map[string]interface{}{"NoCount": "x"}); ok { + t.Error("missing TotalCount should return ok=false") + } + if _, ok := extractTotalCount(map[string]interface{}{"TotalCount": "not-a-number"}); ok { + t.Error("unparseable TotalCount should return ok=false") + } + if n, ok := extractTotalCount(map[string]interface{}{"TotalCount": float64(7)}); !ok || n != 7 { + t.Errorf("numeric TotalCount = %d, %v; want 7, true", n, ok) + } +} + +func TestCollectBareNumbers(t *testing.T) { + // XML-decoded shape of the inserviceNumbers/discnumbers response: + // TNs > TelephoneNumbers > TelephoneNumber as bare string(s). + single := map[string]interface{}{ + "TNs": map[string]interface{}{ + "TotalCount": "1", + "TelephoneNumbers": map[string]interface{}{ + "Count": "1", + "TelephoneNumber": "+14158714245", + }, + }, + } + if got := extractFullNumbers(single); len(got) != 1 || got[0] != "+14158714245" { + t.Errorf("single bare number: got %v", got) + } + + multi := map[string]interface{}{ + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": []interface{}{"+14158714245", "4352154439"}, + }, + } + got := extractFullNumbers(multi) + if len(got) != 2 || got[0] != "+14158714245" || got[1] != "+14352154439" { + t.Errorf("bare number list: got %v", got) + } + + // The /tns object shape must keep working. + objects := map[string]interface{}{ + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": []interface{}{ + map[string]interface{}{"FullNumber": "9195551234"}, + map[string]interface{}{"FullNumber": "9195551235"}, + }, + }, + } + got = extractFullNumbers(objects) + if len(got) != 2 || got[0] != "+19195551234" { + t.Errorf("object list: got %v", got) + } +} + +func TestCountPath(t *testing.T) { + tests := []struct { + name string + subaccount string + location string + disconnected bool + want string + wantErr bool + }{ + {name: "default", want: "/accounts/123/inserviceNumbers/totals"}, + {name: "disconnected", disconnected: true, want: "/accounts/123/discnumbers/totals"}, + {name: "subaccount", subaccount: "407", want: "/accounts/123/sites/407/totaltns"}, + {name: "subaccount and location", subaccount: "407", location: "500017", want: "/accounts/123/sites/407/sippeers/500017/totaltns"}, + {name: "location without subaccount", location: "500017", wantErr: true}, + {name: "disconnected with subaccount", subaccount: "407", disconnected: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := countPath("123", tt.subaccount, tt.location, tt.disconnected) + if tt.wantErr { + if err == nil { + t.Fatalf("countPath = %q, want error", got) + } + return + } + if err != nil { + t.Fatalf("countPath error: %v", err) + } + if got != tt.want { + t.Errorf("countPath = %q, want %q", got, tt.want) + } + }) + } +} + +func TestUnwrapTelephoneNumberDetails(t *testing.T) { + details := map[string]interface{}{"FullNumber": "9195551234", "Lata": "426"} + wrapped := map[string]interface{}{ + "TelephoneNumberResponse": map[string]interface{}{ + "TelephoneNumberDetails": details, + }, + } + got, ok := unwrapTelephoneNumberDetails(wrapped).(map[string]interface{}) + if !ok || got["Lata"] != "426" { + t.Errorf("unwrap = %#v, want details map", unwrapTelephoneNumberDetails(wrapped)) + } + + // Already-unwrapped and unexpected shapes pass through. + if got := unwrapTelephoneNumberDetails(details).(map[string]interface{}); got["Lata"] != "426" { + t.Errorf("pass-through failed: %#v", got) + } +} diff --git a/cmd/number/nnroutes.go b/cmd/number/nnroutes.go new file mode 100644 index 0000000..6c24d30 --- /dev/null +++ b/cmd/number/nnroutes.go @@ -0,0 +1,42 @@ +package number + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(nnroutesCmd) +} + +var nnroutesCmd = &cobra.Command{ + Use: "nnroutes ", + Short: "List the NetNumber routes available to a phone number", + Long: `Lists the NetNumber (NN) routes available to a phone number, each with +its NNID and name. The route currently assigned to the number is shown by +"band number details" under MessagingSettings.`, + Example: ` band number nnroutes +19195551234`, + Args: cobra.ExactArgs(1), + RunE: runNNRoutes, +} + +func runNNRoutes(cmd *cobra.Command, args []string) error { + number := cmdutil.NormalizeE164(args[0]) + + client, _, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/tns/%s/availableNnRoutes", number), &result); err != nil { + return fmt.Errorf("listing NN routes: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, output.FlattenAndNormalize(result)) +} diff --git a/cmd/root.go b/cmd/root.go index c2b0d43..0d508fd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,7 @@ import ( tendlccmd "github.com/Bandwidth/cli/cmd/tendlc" tfvcmd "github.com/Bandwidth/cli/cmd/tfv" tnoptioncmd "github.com/Bandwidth/cli/cmd/tnoption" + tollfreecmd "github.com/Bandwidth/cli/cmd/tollfree" transcriptioncmd "github.com/Bandwidth/cli/cmd/transcription" vcpcmd "github.com/Bandwidth/cli/cmd/vcp" ) @@ -114,6 +115,7 @@ func init() { rootCmd.AddCommand(shortcodecmd.Cmd) rootCmd.AddCommand(tfvcmd.Cmd) rootCmd.AddCommand(tnoptioncmd.Cmd) + rootCmd.AddCommand(tollfreecmd.Cmd) rootCmd.AddCommand(portincmd.Cmd) rootCmd.AddCommand(versionCmd) } diff --git a/cmd/tollfree/template.go b/cmd/tollfree/template.go new file mode 100644 index 0000000..26f29b5 --- /dev/null +++ b/cmd/tollfree/template.go @@ -0,0 +1,119 @@ +package tollfree + +import ( + "errors" + "fmt" + "regexp" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(templateCmd) +} + +var templateCmd = &cobra.Command{ + Use: "template [number...]", + Short: "Look up the routing template assigned to toll-free numbers", + Long: `Returns the toll-free routing template name assigned to each number. + +The template identifies how inbound calls to the number are routed at the +toll-free registry level. Numbers must be in-service on the account. Up to +5000 numbers per invocation. + +This endpoint is gated per account: a 403 means toll-free template search +is not enabled — ask your Bandwidth account manager to enable it.`, + Example: ` band tollfree template +18005551234 + band tollfree template 8005551234 8885551234 --plain`, + Args: cobra.MinimumNArgs(1), + RunE: runTemplate, +} + +// maxTemplateNumbers is the API's documented per-request limit. +const maxTemplateNumbers = 5000 + +// nanpE164Re matches a full NANP number in E.164. ClassifyNumber checks only +// length and area code, so this guards against non-digit input (e.g. vanity +// letters) reaching the API. +var nanpE164Re = regexp.MustCompile(`^\+1\d{10}$`) + +// normalizeTollFreeNumbers converts each argument to E.164, rejects numbers +// that are not NANP toll-free, and drops duplicates while preserving order. +func normalizeTollFreeNumbers(args []string) ([]string, error) { + seen := make(map[string]bool, len(args)) + out := make([]string, 0, len(args)) + for _, a := range args { + n := cmdutil.NormalizeE164(a) + if !nanpE164Re.MatchString(n) || cmdutil.ClassifyNumber(n) != cmdutil.NumberTypeTollFree { + return nil, cmdutil.NewFlagError(fmt.Sprintf("%s is not a toll-free number (toll-free prefixes: 800, 888, 877, 866, 855, 844, 833; digits only)", a)) + } + if seen[n] { + continue + } + seen[n] = true + out = append(out, n) + } + if len(out) > maxTemplateNumbers { + return nil, cmdutil.NewFlagError(fmt.Sprintf("too many numbers: %d (the API accepts at most %d per request)", len(out), maxTemplateNumbers)) + } + return out, nil +} + +// templateSearchBody builds the request payload for the template search +// endpoint, which accepts exactly one IN criterion on phoneNumbers. +func templateSearchBody(numbers []string) map[string]interface{} { + return map[string]interface{}{ + "queryCriteria": []map[string]interface{}{{ + "operator": "IN", + "parameter": "phoneNumbers", + "values": numbers, + }}, + } +} + +// unwrapTemplateMappings extracts data.phoneNumberTemplateMappings from the +// response. If the shape is unexpected, the raw response is returned so the +// user still sees what the server said. +func unwrapTemplateMappings(result interface{}) interface{} { + m, ok := result.(map[string]interface{}) + if !ok { + return result + } + data, ok := m["data"].(map[string]interface{}) + if !ok { + return result + } + if mappings, ok := data["phoneNumberTemplateMappings"]; ok { + return mappings + } + return result +} + +func runTemplate(cmd *cobra.Command, args []string) error { + numbers, err := normalizeTollFreeNumbers(args) + if err != nil { + return err + } + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/tollFreeTemplateAssignments/search", acctID) + var result interface{} + if err := client.Post(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) + } + return fmt.Errorf("searching toll-free template assignments: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, unwrapTemplateMappings(result)) +} diff --git a/cmd/tollfree/tollfree.go b/cmd/tollfree/tollfree.go new file mode 100644 index 0000000..a7edf8f --- /dev/null +++ b/cmd/tollfree/tollfree.go @@ -0,0 +1,16 @@ +// Package tollfree implements `band tollfree`, read commands for toll-free +// routing configuration. +package tollfree + +import "github.com/spf13/cobra" + +// Cmd is the `band tollfree` parent command. +var Cmd = &cobra.Command{ + Use: "tollfree", + Short: "Toll-free routing reads", + Long: `Read toll-free routing configuration for numbers on the account. + +Toll-free routing template search is gated per account and is off by +default. If you get a 403 error, ask your Bandwidth account manager to +enable toll-free template search on the account.`, +} diff --git a/cmd/tollfree/tollfree_test.go b/cmd/tollfree/tollfree_test.go new file mode 100644 index 0000000..33c227f --- /dev/null +++ b/cmd/tollfree/tollfree_test.go @@ -0,0 +1,130 @@ +package tollfree + +import ( + "fmt" + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "tollfree" { + t.Errorf("Use = %q, want %q", Cmd.Use, "tollfree") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + if !subs["template [number...]"] { + t.Error("missing subcommand template") + } +} + +func TestNormalizeTollFreeNumbers(t *testing.T) { + tests := []struct { + name string + args []string + want []string + wantErr bool + }{ + { + name: "mixed input formats normalize to E.164", + args: []string{"+18005551234", "8885551234", "18775551234", "(866) 555-1234"}, + want: []string{"+18005551234", "+18885551234", "+18775551234", "+18665551234"}, + }, + { + name: "duplicates collapse, order preserved", + args: []string{"8005551234", "+18005551234", "8885551234"}, + want: []string{"+18005551234", "+18885551234"}, + }, + { + name: "local number rejected", + args: []string{"+19195551234"}, + wantErr: true, + }, + { + name: "short code rejected", + args: []string{"55512"}, + wantErr: true, + }, + { + name: "non-NANP rejected", + args: []string{"+448005551234"}, + wantErr: true, + }, + { + name: "vanity letters rejected", + args: []string{"800ABC-DEFG"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeTollFreeNumbers(tt.args) + if tt.wantErr { + if err == nil { + t.Fatalf("normalizeTollFreeNumbers(%v) = %v, want error", tt.args, got) + } + return + } + if err != nil { + t.Fatalf("normalizeTollFreeNumbers(%v) error: %v", tt.args, err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestNormalizeTollFreeNumbersLimit(t *testing.T) { + args := make([]string, maxTemplateNumbers+1) + for i := range args { + // Distinct valid toll-free numbers: 800555XXXX. + args[i] = fmt.Sprintf("+1800%07d", i) + } + if _, err := normalizeTollFreeNumbers(args); err == nil { + t.Error("expected error for over-limit input") + } +} + +func TestTemplateSearchBody(t *testing.T) { + body := templateSearchBody([]string{"+18005551234"}) + criteria, ok := body["queryCriteria"].([]map[string]interface{}) + if !ok || len(criteria) != 1 { + t.Fatalf("queryCriteria = %#v, want single-entry slice", body["queryCriteria"]) + } + c := criteria[0] + if c["operator"] != "IN" || c["parameter"] != "phoneNumbers" { + t.Errorf("criterion = %#v, want operator IN on phoneNumbers", c) + } + values, ok := c["values"].([]string) + if !ok || len(values) != 1 || values[0] != "+18005551234" { + t.Errorf("values = %#v, want [+18005551234]", c["values"]) + } +} + +func TestUnwrapTemplateMappings(t *testing.T) { + mappings := []interface{}{ + map[string]interface{}{"phoneNumber": "+18004329876", "templateName": "ATemplate", "reasonForNoTemplate": nil}, + } + wrapped := map[string]interface{}{ + "data": map[string]interface{}{"phoneNumberTemplateMappings": mappings}, + "errors": []interface{}{}, + "links": []interface{}{}, + } + got, ok := unwrapTemplateMappings(wrapped).([]interface{}) + if !ok || len(got) != 1 { + t.Fatalf("unwrap = %#v, want the mappings array", unwrapTemplateMappings(wrapped)) + } + + // Unexpected shapes pass through untouched. + odd := map[string]interface{}{"surprise": true} + if got := unwrapTemplateMappings(odd); got == nil { + t.Error("unexpected shape should pass through, not vanish") + } +}