# Project Structure Patterns: Bose SoundTouch API Client ## Summary for Reuse in API Client Projects This document describes the most important patterns for the Bose SoundTouch API client, especially for XML-based API clients with Web UI, CLI tool, and WASM support. ## 1. Multi-Target Build Pattern ### The Core Pattern for Different Deployment Targets ```go //go:build !wasm // +build !wasm // Native Go implementation //go:build wasm // +build wasm // WASM-specific implementation ``` **Key Aspects:** - **Native Builds**: Full API functionality for CLI and server - **WASM Builds**: Browser-compatible subset functionality - **Cross-Platform**: Linux, macOS, Windows support - **Embedded Assets**: Web UI directly embedded in binary ### Build System for Multi-Target ```makefile # Native builds build: go build -o $(BINARY_NAME) ./cmd/cli # WASM build build-wasm: GOOS=js GOARCH=wasm go build -o web/soundtouch.wasm ./cmd/wasm # Web application with embedded assets build-webapp: go build -o $(BINARY_NAME)-webapp ./cmd/webapp ``` ## 2. XML-API Client Pattern ### HTTP Client with XML Parsing ```go type Client struct { baseURL string httpClient *http.Client timeout time.Duration } func NewClient(host string, port int) *Client { return &Client{ baseURL: fmt.Sprintf("http://%s:%d", host, port), httpClient: &http.Client{Timeout: 10 * time.Second}, timeout: 10 * time.Second, } } func (c *Client) GetNowPlaying() (*models.NowPlaying, error) { resp, err := c.httpClient.Get(c.baseURL + "/now_playing") if err != nil { return nil, err } defer resp.Body.Close() var nowPlaying models.NowPlaying err = xml.NewDecoder(resp.Body).Decode(&nowPlaying) return &nowPlaying, err } ``` **XML Request Pattern:** ```go func (c *Client) SendKey(key models.Key) error { keyXML := fmt.Sprintf(`%s`, key) resp, err := c.httpClient.Post( c.baseURL+"/key", "application/xml", strings.NewReader(keyXML), ) if err != nil { return err } defer resp.Body.Close() return nil } ``` ## 3. Device Discovery Pattern ### UPnP Discovery for Local Devices ```go type DiscoveryService struct { timeout time.Duration cache map[string]*Device mutex sync.RWMutex } type Device struct { Name string `json:"name"` Host string `json:"host"` Port int `json:"port"` ModelID string `json:"modelId"` SerialNo string `json:"serialNo"` } func (d *DiscoveryService) DiscoverDevices() ([]Device, error) { // UPnP SSDP Discovery implementation conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) if err != nil { return nil, err } defer conn.Close() // Send M-SEARCH request searchRequest := "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "MAN: \"ssdp:discover\"\r\n" + "ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" + "MX: 3\r\n\r\n" // Implementation details... return devices, nil } ``` ## 4. WebSocket Event Stream Pattern ### Real-time Updates for Audio Devices ```go type EventClient struct { client *Client conn *websocket.Conn handlers map[string]EventHandler stopChan chan bool reconnect bool } type EventHandler func(event Event) type Event struct { Type string `xml:"type,attr"` DeviceID string `xml:"deviceID,attr"` Data interface{} `xml:",innerxml"` Timestamp time.Time } func (e *EventClient) Subscribe(eventType string, handler EventHandler) { e.handlers[eventType] = handler } func (e *EventClient) Start() error { u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"} conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) if err != nil { return err } e.conn = conn go e.eventLoop() return nil } func (e *EventClient) eventLoop() { for { select { case <-e.stopChan: return default: var event Event err := e.conn.ReadJSON(&event) if err != nil { if e.reconnect { e.reconnectWithBackoff() continue } return } if handler, exists := e.handlers[event.Type]; exists { go handler(event) } } } } ``` ## 5. WASM JavaScript Bridge Pattern ### Go-to-JavaScript Function Mapping ```go //go:build wasm // +build wasm import ( "syscall/js" "encoding/json" ) func RegisterWASMFunctions() { js.Global().Set("boseAPI", js.ValueOf(map[string]interface{}{ "discoverDevices": js.FuncOf(wasmDiscoverDevices), "createClient": js.FuncOf(wasmCreateClient), "getNowPlaying": js.FuncOf(wasmGetNowPlaying), "sendKey": js.FuncOf(wasmSendKey), "setVolume": js.FuncOf(wasmSetVolume), })) } func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} { handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} { go func() { devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices() result := make(map[string]interface{}) if err != nil { result["error"] = err.Error() } else { devicesJSON, _ := json.Marshal(devices) result["devices"] = string(devicesJSON) } // Call JavaScript callback args[0].Invoke(js.ValueOf(result)) }() return nil }) return handler } ``` ### JavaScript Integration ```javascript // Browser usage async function discoverDevices() { return new Promise((resolve, reject) => { window.boseAPI.discoverDevices((result) => { if (result.error) { reject(new Error(result.error)); } else { resolve(JSON.parse(result.devices)); } }); }); } // Usage example const devices = await discoverDevices(); const client = boseAPI.createClient(devices[0].host, 8090); const nowPlaying = await client.getNowPlaying(); ``` ## 6. CLI Tool Pattern with Device Selection ### Interactive Device Selection ```go // cmd/cli/main.go func main() { app := &cli.App{ Name: "soundtouch", Usage: "Bose SoundTouch API Client", Commands: []*cli.Command{ { Name: "discover", Usage: "Discover SoundTouch devices", Action: func(c *cli.Context) error { devices, err := discovery.DiscoverDevices() if err != nil { return err } for i, device := range devices { fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host) } return nil }, }, { Name: "play", Usage: "Send play command", Flags: []cli.Flag{ &cli.StringFlag{Name: "device", Aliases: []string{"d"}}, }, Action: func(c *cli.Context) error { client := getClientFromContext(c) return client.SendKey(models.KeyPlay) }, }, }, } app.Run(os.Args) } func getClientFromContext(c *cli.Context) *client.Client { deviceHost := c.String("device") if deviceHost == "" { // Interactive device selection devices, _ := discovery.DiscoverDevices() deviceHost = selectDeviceInteractive(devices) } return client.NewClient(deviceHost, 8090) } ``` ## 7. Web Application with Embedded Assets ### Single Binary Web Tool ```go // cmd/webapp/main.go //go:embed web var webAssets embed.FS func main() { mux := http.NewServeMux() // Embedded web assets webFS, err := fs.Sub(webAssets, "web") if err != nil { log.Fatal(err) } // SPA routing mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.FileServer(http.FS(webFS)).ServeHTTP(w, r) return } data, err := webAssets.ReadFile("web/index.html") if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/html") w.Write(data) }) // API endpoints mux.HandleFunc("/api/devices", handleDeviceDiscovery) mux.HandleFunc("/api/client/", handleClientProxy) log.Println("SoundTouch Web UI starting on :8080") log.Fatal(http.ListenAndServe(":8080", mux)) } ``` ### CORS Proxy for Browser Restrictions ```go func handleClientProxy(w http.ResponseWriter, r *http.Request) { // Extract device IP from path: /api/client/192.168.1.100/now_playing pathParts := strings.Split(r.URL.Path, "/") if len(pathParts) < 5 { http.Error(w, "Invalid path", http.StatusBadRequest) return } deviceIP := pathParts[3] apiPath := "/" + strings.Join(pathParts[4:], "/") // Proxy request to SoundTouch device targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath) proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Copy headers for k, v := range r.Header { proxyReq.Header[k] = v } resp, err := http.DefaultClient.Do(proxyReq) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() // Enable CORS w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") // Copy response w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } ``` ## 8. Robust XML Model Definition ### Structured Data Models ```go // pkg/models/nowplaying.go type NowPlaying struct { XMLName xml.Name `xml:"nowPlaying"` DeviceID string `xml:"deviceID,attr"` Source string `xml:"source,attr"` Content ContentItem `xml:"ContentItem"` Track string `xml:"track"` Artist string `xml:"artist"` Album string `xml:"album"` Art Art `xml:"art"` PlayStatus PlayStatus `xml:"playStatus"` Position Position `xml:"position,omitempty"` } type ContentItem struct { Source string `xml:"source,attr"` Type string `xml:"type,attr"` Location string `xml:"location,attr"` SourceAccount string `xml:"sourceAccount,attr"` ItemName string `xml:"itemName"` ContainerArt string `xml:"containerArt"` } type PlayStatus string const ( PlayStatusPlaying PlayStatus = "PLAY_STATE" PlayStatusPaused PlayStatus = "PAUSE_STATE" PlayStatusStopped PlayStatus = "STOP_STATE" ) // Custom unmarshaling for enum validation func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var s string if err := d.DecodeElement(&s, &start); err != nil { return err } switch s { case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped): *p = PlayStatus(s) default: *p = PlayStatusStopped // Default fallback } return nil } ``` ## 9. Configuration Management for Multi-Environment ### Environment-based Configuration ```go // pkg/config/config.go type Config struct { // Server configuration WebPort int `env:"WEB_PORT" default:"8080"` APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"` // Discovery configuration DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"` CacheDevices bool `env:"CACHE_DEVICES" default:"true"` // CORS configuration (for web proxy) CORSOrigins []string `env:"CORS_ORIGINS" default:"*"` // Logging LogLevel string `env:"LOG_LEVEL" default:"info"` } func Load() Config { var cfg Config // Load from .env file loadDotEnv() // Parse environment variables with reflection parseEnvVars(&cfg) return cfg } func parseEnvVars(cfg interface{}) { v := reflect.ValueOf(cfg).Elem() t := v.Type() for i := 0; i < v.NumField(); i++ { field := v.Field(i) fieldType := t.Field(i) envTag := fieldType.Tag.Get("env") defaultTag := fieldType.Tag.Get("default") if envTag != "" { if envValue := os.Getenv(envTag); envValue != "" { setFieldValue(field, envValue) } else if defaultTag != "" { setFieldValue(field, defaultTag) } } } } ``` ## 10. Testing Pattern for Hardware API ### Mock-based Unit Tests ```go // internal/testing/mock_client.go type MockClient struct { responses map[string]interface{} errors map[string]error } func NewMockClient() *MockClient { return &MockClient{ responses: make(map[string]interface{}), errors: make(map[string]error), } } func (m *MockClient) SetResponse(endpoint string, response interface{}) { m.responses[endpoint] = response } func (m *MockClient) SetError(endpoint string, err error) { m.errors[endpoint] = err } func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) { if err, exists := m.errors["now_playing"]; exists { return nil, err } if resp, exists := m.responses["now_playing"]; exists { return resp.(*models.NowPlaying), nil } return &models.NowPlaying{ Track: "Mock Track", Artist: "Mock Artist", Album: "Mock Album", }, nil } ``` ### Integration Tests with Docker ```dockerfile # test/docker/Dockerfile FROM golang:1.25-alpine WORKDIR /app COPY . . # Install test dependencies RUN go mod download # Run tests CMD ["go", "test", "-v", "./..."] ``` ```bash # Makefile test target test-integration: docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit docker-compose -f test/docker-compose.yml down ``` ## Recommended Project Structure ``` bose-soundtouch/ ├── cmd/ │ ├── cli/ # CLI Tool │ │ └── main.go │ ├── webapp/ # Web Application │ │ ├── main.go │ │ └── web/ # Embedded Assets │ │ ├── index.html │ │ ├── app.js │ │ └── style.css │ └── wasm/ # WASM Entry Point │ └── main.go ├── pkg/ # Public API │ ├── client/ # HTTP Client │ ├── discovery/ # Device Discovery │ ├── models/ # XML Data Models │ ├── websocket/ # Event Streaming │ └── wasm/ # WASM Bindings ├── internal/ # Private Implementation │ ├── xml/ # XML Utilities │ ├── http/ # HTTP Utilities │ └── testing/ # Test Utilities ├── web/ # Frontend Assets (source) │ ├── src/ │ └── dist/ # Built assets → cmd/webapp/web/ ├── examples/ # Usage Examples ├── test/ # Integration Tests ├── Makefile # Build Automation ├── .env.example # Configuration Template ├── go.mod └── README.md ``` ## Build System for Multi-Target ### Makefile with Cross-Platform Support ```makefile BINARY_NAME=soundtouch VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') GO_VERSION=$(shell go version | cut -d ' ' -f 3) LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GoVersion=$(GO_VERSION)" BUILD_FLAGS=-trimpath $(LDFLAGS) # Standard builds build: go build $(BUILD_FLAGS) -o $(BINARY_NAME) ./cmd/cli build-webapp: go build $(BUILD_FLAGS) -o $(BINARY_NAME)-webapp ./cmd/webapp # WASM build build-wasm: GOOS=js GOARCH=wasm go build $(BUILD_FLAGS) -o web/soundtouch.wasm ./cmd/wasm cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" web/ # Cross-platform builds build-all: build-linux build-darwin build-windows build-linux: GOOS=linux GOARCH=amd64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-linux-amd64 ./cmd/cli build-darwin: GOOS=darwin GOARCH=amd64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-darwin-amd64 ./cmd/cli GOOS=darwin GOARCH=arm64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-darwin-arm64 ./cmd/cli build-windows: GOOS=windows GOARCH=amd64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-windows-amd64.exe ./cmd/cli # Development dev-webapp: air -c .air-webapp.toml dev-wasm: GOOS=js GOARCH=wasm go build -o web/soundtouch.wasm ./cmd/wasm cd web && python3 -m http.server 8080 # Testing test: go test -v ./... test-coverage: go test -v -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html # Linting and formatting check: fmt vet lint test fmt: go fmt ./... vet: go vet ./... lint: golangci-lint run # Cleanup clean: rm -f $(BINARY_NAME)* rm -f web/soundtouch.wasm web/wasm_exec.js rm -f coverage.out coverage.html .PHONY: build build-webapp build-wasm build-all dev-webapp dev-wasm test check clean ``` ## Reusable Patterns for API Client Projects ### 1. Basic Setup for XML-API Client **Step 1:** Create project structure ```bash mkdir -p cmd/{cli,webapp/web,wasm} mkdir -p pkg/{client,discovery,models,websocket,wasm} mkdir -p internal/{xml,http,testing} mkdir -p examples test web/src ``` **Step 2:** Initialize Go module ```bash go mod init github.com/username/api-client go get github.com/gorilla/websocket go get github.com/urfave/cli/v2 ``` **Step 3:** Create basic HTTP client ```go // pkg/client/client.go type Client struct { baseURL string httpClient *http.Client } func NewClient(baseURL string) *Client { return &Client{ baseURL: baseURL, httpClient: &http.Client{Timeout: 10 * time.Second}, } } ``` ### 2. XML Models Pattern ```go // pkg/models/base.go type XMLResponse struct { XMLName xml.Name `xml:",innerxml"` Error *APIError `xml:"error,omitempty"` } type APIError struct { Code string `xml:"code,attr"` Message string `xml:",innerxml"` } // pkg/models/device.go type DeviceInfo struct { XMLResponse Name string `xml:"name"` Type string `xml:"type"` DeviceID string `xml:"deviceID,attr"` } ``` ### 3. CLI Framework ```go // cmd/cli/main.go func main() { app := &cli.App{ Name: "api-client", Usage: "API Client Tool", Version: Version, Commands: []*cli.Command{ { Name: "discover", Usage: "Discover devices", Action: discoverCommand, }, { Name: "status", Usage: "Get device status", Flags: deviceFlags, Action: statusCommand, }, }, } app.Run(os.Args) } ``` ## Advantages of This Pattern Approach ### 1. Multi-Platform Deployment - **Native Binaries**: Optimal performance for server/CLI - **WASM Support**: Browser integration without backend - **Cross-Platform**: One codebase for all systems ### 2. API Client Best Practices - **Type Safety**: Strict XML-to-Go mappings - **Error Handling**: Structured error handling - **Timeout Management**: Robust network calls ### 3. Developer Experience - **Hot Reload**: Live updates during development - **Mock Testing**: Hardware-independent testing - **Comprehensive Tooling**: Build, test, lint automated ### 4. Production Ready - **Graceful Shutdown**: Clean resource release - **Structured Logging**: Monitoring-friendly logs - **Configuration Management**: Environment-based config ## Conclusion This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.