From 64ce3192a46418f701e334c91c875648f8a507c2 Mon Sep 17 00:00:00 2001 From: ProtoTess <32490978+0x524A@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:50:26 +0000 Subject: [PATCH] feat: simplify endpoint API and enhance documentation --- CHANGELOG.md | 8 ++ QUICKSTART.md | 9 +- README.md | 9 +- client.go | 48 +++++++- client_test.go | 157 +++++++++++++++++++++++++++ examples/simplified-endpoint/main.go | 79 ++++++++++++++ 6 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 examples/simplified-endpoint/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ae2049..5983215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Simplified Endpoint API**: `NewClient()` now accepts multiple endpoint formats + - Simple IP address: `"192.168.1.100"` + - IP with port: `"192.168.1.100:8080"` + - Full URL: `"http://192.168.1.100/onvif/device_service"` (backward compatible) + - Automatically adds `http://` scheme and `/onvif/device_service` path when needed + - See `docs/SIMPLIFIED_ENDPOINT.md` for details +- Comprehensive test coverage for endpoint normalization (12 test cases) +- New example: `examples/simplified-endpoint/` demonstrating all endpoint formats - Initial release of go-onvif library - ONVIF Client with context support - Device service implementation diff --git a/QUICKSTART.md b/QUICKSTART.md index c942be8..4334455 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -42,7 +42,7 @@ func main() { ## Step 2: Connect to Camera -Create a client and get basic information: +Create a client and get basic information. The endpoint can be specified in multiple formats: ```go package main @@ -56,9 +56,12 @@ import ( ) func main() { - // Create client + // Create client - endpoint accepts multiple formats: + // - Simple IP: "192.168.1.100" + // - IP with port: "192.168.1.100:8080" + // - Full URL: "http://192.168.1.100/onvif/device_service" client, err := onvif.NewClient( - "http://192.168.1.100/onvif/device_service", + "192.168.1.100", // Simple IP address works! onvif.WithCredentials("admin", "password"), onvif.WithTimeout(30*time.Second), ) diff --git a/README.md b/README.md index 6d48944..2b191de 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,12 @@ import ( ) func main() { - // Create client + // Create client - endpoint can be: + // - Full URL: "http://192.168.1.100/onvif/device_service" + // - IP with port: "192.168.1.100:8080" + // - IP only: "192.168.1.100" (automatically adds http:// and path) client, err := onvif.NewClient( - "http://192.168.1.100/onvif/device_service", + "192.168.1.100", // Simple IP address onvif.WithCredentials("admin", "password"), onvif.WithTimeout(30*time.Second), ) @@ -379,7 +382,7 @@ go run main.go ## Architecture ``` -go-onvif/ +onvif-go/ ├── client.go # Main ONVIF client ├── types.go # ONVIF data types ├── errors.go # Error definitions diff --git a/client.go b/client.go index 660e528..f392c79 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "sync" "time" ) @@ -50,18 +51,19 @@ func WithCredentials(username, password string) ClientOption { } // NewClient creates a new ONVIF client +// The endpoint can be provided in multiple formats: +// - Full URL: "http://192.168.1.100/onvif/device_service" +// - IP with port: "192.168.1.100:80" (http assumed, /onvif/device_service added) +// - IP only: "192.168.1.100" (http://IP:80/onvif/device_service used) func NewClient(endpoint string, opts ...ClientOption) (*Client, error) { - // Validate endpoint - parsedURL, err := url.Parse(endpoint) + // Normalize endpoint to full URL + normalizedEndpoint, err := normalizeEndpoint(endpoint) if err != nil { return nil, fmt.Errorf("invalid endpoint: %w", err) } - if parsedURL.Scheme == "" || parsedURL.Host == "" { - return nil, fmt.Errorf("invalid endpoint: must include scheme and host") - } client := &Client{ - endpoint: endpoint, + endpoint: normalizedEndpoint, httpClient: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ @@ -80,6 +82,40 @@ func NewClient(endpoint string, opts ...ClientOption) (*Client, error) { return client, nil } +// normalizeEndpoint converts various endpoint formats to a full ONVIF URL +func normalizeEndpoint(endpoint string) (string, error) { + // Check if endpoint starts with a scheme + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + // Parse as full URL + parsedURL, err := url.Parse(endpoint) + if err != nil { + return "", err + } + if parsedURL.Host == "" { + return "", fmt.Errorf("URL missing host") + } + // If path is empty or just "/", add default ONVIF path + if parsedURL.Path == "" || parsedURL.Path == "/" { + parsedURL.Path = "/onvif/device_service" + } + return parsedURL.String(), nil + } + + // No scheme - treat as IP, IP:port, hostname, or hostname:port + // Add http:// scheme and validate + fullURL := "http://" + endpoint + "/onvif/device_service" + parsedURL, err := url.Parse(fullURL) + if err != nil { + return "", fmt.Errorf("invalid IP address or hostname: %w", err) + } + + if parsedURL.Host == "" { + return "", fmt.Errorf("invalid endpoint format") + } + + return fullURL, nil +} + // Initialize discovers and initializes service endpoints func (c *Client) Initialize(ctx context.Context) error { // Get device information and capabilities diff --git a/client_test.go b/client_test.go index 7aaa230..2a3feae 100644 --- a/client_test.go +++ b/client_test.go @@ -10,6 +10,163 @@ import ( "time" ) +func TestNormalizeEndpoint(t *testing.T) { + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + { + name: "full URL with path", + input: "http://192.168.1.100/onvif/device_service", + expected: "http://192.168.1.100/onvif/device_service", + wantErr: false, + }, + { + name: "full URL with port and path", + input: "http://192.168.1.100:8080/onvif/device_service", + expected: "http://192.168.1.100:8080/onvif/device_service", + wantErr: false, + }, + { + name: "full URL without path", + input: "http://192.168.1.100", + expected: "http://192.168.1.100/onvif/device_service", + wantErr: false, + }, + { + name: "full URL with just slash", + input: "http://192.168.1.100/", + expected: "http://192.168.1.100/onvif/device_service", + wantErr: false, + }, + { + name: "IP address only", + input: "192.168.1.100", + expected: "http://192.168.1.100/onvif/device_service", + wantErr: false, + }, + { + name: "IP with port", + input: "192.168.1.100:8080", + expected: "http://192.168.1.100:8080/onvif/device_service", + wantErr: false, + }, + { + name: "IP with default HTTP port", + input: "192.168.1.100:80", + expected: "http://192.168.1.100:80/onvif/device_service", + wantErr: false, + }, + { + name: "hostname only", + input: "camera.local", + expected: "http://camera.local/onvif/device_service", + wantErr: false, + }, + { + name: "hostname with port", + input: "camera.local:8080", + expected: "http://camera.local:8080/onvif/device_service", + wantErr: false, + }, + { + name: "HTTPS URL", + input: "https://192.168.1.100/onvif/device_service", + expected: "https://192.168.1.100/onvif/device_service", + wantErr: false, + }, + { + name: "HTTPS with custom port", + input: "https://192.168.1.100:8443/onvif/device_service", + expected: "https://192.168.1.100:8443/onvif/device_service", + wantErr: false, + }, + { + name: "URL with custom path", + input: "http://192.168.1.100/custom/path", + expected: "http://192.168.1.100/custom/path", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := normalizeEndpoint(tt.input) + + if tt.wantErr { + if err == nil { + t.Errorf("normalizeEndpoint() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("normalizeEndpoint() unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("normalizeEndpoint() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestNewClientWithVariousEndpoints(t *testing.T) { + tests := []struct { + name string + endpoint string + expectScheme string + expectHost string + expectPath string + }{ + { + name: "IP only", + endpoint: "192.168.1.100", + expectScheme: "http", + expectHost: "192.168.1.100", + expectPath: "/onvif/device_service", + }, + { + name: "IP with port", + endpoint: "192.168.1.100:8080", + expectScheme: "http", + expectHost: "192.168.1.100:8080", + expectPath: "/onvif/device_service", + }, + { + name: "Full URL", + endpoint: "http://192.168.1.100/onvif/device_service", + expectScheme: "http", + expectHost: "192.168.1.100", + expectPath: "/onvif/device_service", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(tt.endpoint) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if !strings.HasPrefix(client.endpoint, tt.expectScheme+"://") { + t.Errorf("Expected scheme %s, got endpoint %s", tt.expectScheme, client.endpoint) + } + + if !strings.Contains(client.endpoint, tt.expectHost) { + t.Errorf("Expected host %s in endpoint %s", tt.expectHost, client.endpoint) + } + + if !strings.HasSuffix(client.endpoint, tt.expectPath) { + t.Errorf("Expected path %s in endpoint %s", tt.expectPath, client.endpoint) + } + }) + } +} + // Mock ONVIF server for comprehensive testing type MockONVIFServer struct { server *httptest.Server diff --git a/examples/simplified-endpoint/main.go b/examples/simplified-endpoint/main.go new file mode 100644 index 0000000..07bfb06 --- /dev/null +++ b/examples/simplified-endpoint/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/0x524A/onvif-go" +) + +func main() { + // Demonstrates the three different endpoint formats supported by NewClient + + examples := []struct { + name string + endpoint string + desc string + }{ + { + name: "Simple IP", + endpoint: "192.168.1.100", + desc: "Just the IP address - automatically adds http:// and /onvif/device_service", + }, + { + name: "IP with Port", + endpoint: "192.168.1.100:8080", + desc: "IP and port - automatically adds http:// and /onvif/device_service", + }, + { + name: "Full URL", + endpoint: "http://192.168.1.100/onvif/device_service", + desc: "Complete URL - used as-is", + }, + } + + fmt.Println("ONVIF Client - Simplified Endpoint Formats Demo") + fmt.Println("================================================") + fmt.Println() + + for _, ex := range examples { + fmt.Printf("%s:\n", ex.name) + fmt.Printf(" Input: %s\n", ex.endpoint) + fmt.Printf(" Description: %s\n", ex.desc) + + // Create client with simplified endpoint + client, err := onvif.NewClient( + ex.endpoint, + onvif.WithCredentials("admin", "password"), + onvif.WithTimeout(5*time.Second), + ) + + if err != nil { + log.Printf(" Error: %v\n\n", err) + continue + } + + fmt.Printf(" Client created successfully!\n") + fmt.Printf(" Endpoint will be: %s\n\n", client.Endpoint()) + + // Try to get device information (will fail if camera doesn't exist) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + info, err := client.GetDeviceInformation(ctx) + cancel() + + if err != nil { + fmt.Printf(" Note: Could not connect to camera (this is expected in demo)\n") + fmt.Printf(" Error: %v\n\n", err) + } else { + fmt.Printf(" Connected to: %s %s\n", info.Manufacturer, info.Model) + fmt.Printf(" Firmware: %s\n\n", info.FirmwareVersion) + } + } + + fmt.Println("Key Benefits:") + fmt.Println("- Simpler API: Just provide '192.168.1.100' instead of full URL") + fmt.Println("- Flexible: Works with IP, IP:port, or full URL") + fmt.Println("- Backward Compatible: Existing code continues to work") +}