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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 126 additions & 30 deletions calling/callingclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"

Expand Down Expand Up @@ -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...)
}
}

Expand All @@ -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))
Expand Down
115 changes: 115 additions & 0 deletions calling/callingclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* SPDX-License-Identifier: MPL-2.0
* Copyright 2026 Tejus Pratap <tejzpr@gmail.com>
*
* 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)
}
})
}
}
5 changes: 5 additions & 0 deletions calling/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
}
}

Expand Down
Loading