diff --git a/calling/callingclient.go b/calling/callingclient.go index 03bc90c..17343c2 100644 --- a/calling/callingclient.go +++ b/calling/callingclient.go @@ -13,6 +13,7 @@ import ( "io" "log" "net/http" + "net/url" "strings" "sync" @@ -398,34 +399,20 @@ func (cc *CallingClient) registerWDMDevice() ([]string, error) { } } - // Also check v2 services for serviceName=mobius (may be array or object) - if len(wdmResp.Services) > 0 { - var v2Services []struct { - ServiceName string `json:"serviceName"` - ServiceURLs []struct { - BaseURL string `json:"baseUrl"` - Priority int `json:"priority"` - } `json:"serviceUrls"` - } - if json.Unmarshal(wdmResp.Services, &v2Services) == nil { - for _, svc := range v2Services { - if svc.ServiceName == "mobius" { - for _, su := range svc.ServiceURLs { - if su.BaseURL != "" { - host := su.BaseURL - host = strings.TrimPrefix(host, "https://") - host = strings.TrimPrefix(host, "http://") - if idx := strings.Index(host, "/"); idx > 0 { - host = host[:idx] - } - if host != "" && !seen[host] { - seen[host] = true - mobiusHosts = append(mobiusHosts, host) - } - } - } - } - } + // Some WDM versions include Services v2 directly in the device response. + mobiusHosts = append(mobiusHosts, extractMobiusHosts(wdmResp.Services, seen)...) + + // Current Services v2 catalogs are fetched separately from U2C. + if len(mobiusHosts) == 0 { + u2cURL := serviceLink(wdmResp.ServiceHostMap.ServiceLinks, "u2c") + if u2cURL == "" { + u2cURL = cc.config.U2CURL + } + u2cHosts, catalogErr := cc.fetchMobiusHostsFromU2C(u2cURL, seen) + if catalogErr != nil { + log.Printf("U2C Mobius catalog discovery failed: %v", catalogErr) + } else { + mobiusHosts = append(mobiusHosts, u2cHosts...) } } @@ -444,14 +431,123 @@ func (cc *CallingClient) registerWDMDevice() ([]string, error) { } if len(mobiusHosts) > 0 { - log.Printf("Found %d Mobius hosts from WDM: %v", len(mobiusHosts), mobiusHosts) + log.Printf("Found %d Mobius hosts from service discovery: %v", len(mobiusHosts), mobiusHosts) } else { - log.Printf("No Mobius hosts found in WDM response, will use defaults") + log.Printf("No Mobius hosts found through WDM or U2C, will use defaults") } return mobiusHosts, nil } +func (cc *CallingClient) fetchMobiusHostsFromU2C(baseURL string, seen map[string]bool) ([]string, error) { + if baseURL == "" { + return nil, fmt.Errorf("U2C service URL is not configured") + } + + catalogURL := strings.TrimRight(baseURL, "/") + "/catalog" + req, err := http.NewRequest(http.MethodGet, catalogURL, nil) + if err != nil { + return nil, fmt.Errorf("create U2C catalog request: %w", err) + } + query := req.URL.Query() + query.Set("format", "U2CV2") + req.URL.RawQuery = query.Encode() + req.Header.Set("Authorization", "Bearer "+cc.core.GetAccessToken()) + req.Header.Set("Accept", "application/json") + req.Header.Set("spark-user-agent", "webex-calling/go-sdk (web)") + + resp, err := cc.core.GetHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("request U2C catalog: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read U2C catalog: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("U2C catalog returned %d: %s", resp.StatusCode, string(body)) + } + + var catalog struct { + Services json.RawMessage `json:"services"` + } + if err := json.Unmarshal(body, &catalog); err != nil { + return nil, fmt.Errorf("parse U2C catalog: %w", err) + } + hosts := extractMobiusHosts(catalog.Services, seen) + if len(hosts) == 0 { + return nil, fmt.Errorf("U2C catalog contained no Mobius services") + } + return hosts, nil +} + +func serviceLink(links map[string]string, name string) string { + for key, value := range links { + if strings.EqualFold(key, name) { + return value + } + } + return "" +} + +type wdmService struct { + ServiceName string `json:"serviceName"` + ServiceURLs []struct { + BaseURL string `json:"baseUrl"` + } `json:"serviceUrls"` +} + +// extractMobiusHosts supports both WDM Services v2 response shapes. +func extractMobiusHosts(raw json.RawMessage, seen map[string]bool) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var services []wdmService + if err := json.Unmarshal(raw, &services); err != nil { + var keyedServices map[string]json.RawMessage + if err := json.Unmarshal(raw, &keyedServices); err != nil { + return nil + } + for key, serviceRaw := range keyedServices { + var service wdmService + if err := json.Unmarshal(serviceRaw, &service); err != nil { + continue + } + if service.ServiceName == "" { + service.ServiceName = key + } + services = append(services, service) + } + } + + var hosts []string + for _, service := range services { + if !strings.EqualFold(service.ServiceName, "mobius") { + continue + } + for _, serviceURL := range service.ServiceURLs { + host := serviceHost(serviceURL.BaseURL) + if host == "" || seen[host] { + continue + } + seen[host] = true + hosts = append(hosts, host) + } + } + return hosts +} + +func serviceHost(baseURL string) string { + parsedURL, err := url.Parse(baseURL) + if err != nil || parsedURL.Hostname() == "" { + return "" + } + return parsedURL.Hostname() +} + // contains checks if s contains substr (case-insensitive) func contains(s, substr string) bool { return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) diff --git a/calling/callingclient_test.go b/calling/callingclient_test.go new file mode 100644 index 0000000..59f30e7 --- /dev/null +++ b/calling/callingclient_test.go @@ -0,0 +1,115 @@ +/* SPDX-License-Identifier: MPL-2.0 + * Copyright 2026 Tejus Pratap + * + * See CONTRIBUTORS.md for full contributor list. + */ + +package calling + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/WebexCommunity/webex-go-sdk/v2/webexsdk" +) + +func TestFetchMobiusHostsFromU2C(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/catalog" { + t.Errorf("path = %q, want /catalog", r.URL.Path) + } + if r.URL.Query().Get("format") != "U2CV2" { + t.Errorf("format = %q, want U2CV2", r.URL.Query().Get("format")) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("authorization header = %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "services":[ + {"serviceName":"mobius","serviceUrls":[ + {"baseUrl":"https://mobius-us-east-2.prod.infra.webex.com/api/v1"}, + {"baseUrl":"https://mobius-eu-central-1.prod.infra.webex.com/api/v1"} + ]} + ] + }`)) + })) + defer server.Close() + + core, err := webexsdk.NewClient("test-token", nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + client := NewCallingClient(core, DefaultConfig(), nil) + got, err := client.fetchMobiusHostsFromU2C(server.URL, map[string]bool{}) + if err != nil { + t.Fatalf("fetchMobiusHostsFromU2C() error = %v", err) + } + want := []string{ + "mobius-us-east-2.prod.infra.webex.com", + "mobius-eu-central-1.prod.infra.webex.com", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("fetchMobiusHostsFromU2C() = %v, want %v", got, want) + } +} + +func TestExtractMobiusHosts(t *testing.T) { + tests := []struct { + name string + services string + seen map[string]bool + want []string + }{ + { + name: "services array", + services: `[ + {"serviceName":"mobius","serviceUrls":[ + {"baseUrl":"https://mobius-us-east-2.prod.infra.webex.com/api/v1"}, + {"baseUrl":"https://mobius-eu-central-1.prod.infra.webex.com/api/v1"} + ]}, + {"serviceName":"wdm","serviceUrls":[{"baseUrl":"https://wdm-a.wbx2.com/wdm/api/v1"}]} + ]`, + seen: map[string]bool{}, + want: []string{ + "mobius-us-east-2.prod.infra.webex.com", + "mobius-eu-central-1.prod.infra.webex.com", + }, + }, + { + name: "keyed services object", + services: `{ + "mobius":{"serviceUrls":[ + {"baseUrl":"https://mobius-us-east-1.prod.infra.webex.com/api/v1"}, + {"baseUrl":"not-a-url"} + ]}, + "wdm":{"serviceUrls":[{"baseUrl":"https://wdm-a.wbx2.com/wdm/api/v1"}]} + }`, + seen: map[string]bool{}, + want: []string{"mobius-us-east-1.prod.infra.webex.com"}, + }, + { + name: "deduplicates existing host", + services: `[ + {"serviceName":"MOBIUS","serviceUrls":[ + {"baseUrl":"https://mobius-us-east-2.prod.infra.webex.com/api/v1"}, + {"baseUrl":"https://mobius-ca-central-1.prod.infra.webex.com/api/v1"} + ]} + ]`, + seen: map[string]bool{"mobius-us-east-2.prod.infra.webex.com": true}, + want: []string{"mobius-ca-central-1.prod.infra.webex.com"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := extractMobiusHosts(json.RawMessage(test.services), test.seen) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("extractMobiusHosts() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/calling/types.go b/calling/types.go index b232f57..2959aef 100644 --- a/calling/types.go +++ b/calling/types.go @@ -456,6 +456,10 @@ type Config struct { // RegionDiscoveryURL is the URL for the Webex region discovery service. // Default: https://ds.ciscospark.com/v1/region RegionDiscoveryURL string + + // U2CURL is the base URL for the Webex Services v2 catalog used for Mobius discovery. + // Default: https://u2c-a.wbx2.com/u2c/api/v1 + U2CURL string } // DefaultConfig returns a Config with sensible defaults @@ -465,6 +469,7 @@ func DefaultConfig() *Config { RequestTimeout: 30 * time.Second, WDMURL: "https://wdm-a.wbx2.com/wdm/api/v1/devices", RegionDiscoveryURL: "https://ds.ciscospark.com/v1/region", + U2CURL: "https://u2c-a.wbx2.com/u2c/api/v1", } }