Merge pull request #21 from 0x524A/20-feature-update-newclient-api-to-accept-simplified-endpoint-formats

feat: simplify endpoint API and enhance documentation
This commit is contained in:
ProtoTess
2025-11-12 14:19:02 -05:00
committed by GitHub
6 changed files with 298 additions and 12 deletions
+8
View File
@@ -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
+6 -3
View File
@@ -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),
)
+6 -3
View File
@@ -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
+42 -6
View File
@@ -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
+157
View File
@@ -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
+79
View File
@@ -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")
}