feat: simplify endpoint API and enhance documentation
This commit is contained in:
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user