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:
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- Initial release of go-onvif library
|
||||||
- ONVIF Client with context support
|
- ONVIF Client with context support
|
||||||
- Device service implementation
|
- Device service implementation
|
||||||
|
|||||||
+6
-3
@@ -42,7 +42,7 @@ func main() {
|
|||||||
|
|
||||||
## Step 2: Connect to Camera
|
## 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
|
```go
|
||||||
package main
|
package main
|
||||||
@@ -56,9 +56,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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(
|
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.WithCredentials("admin", "password"),
|
||||||
onvif.WithTimeout(30*time.Second),
|
onvif.WithTimeout(30*time.Second),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -122,9 +122,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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(
|
client, err := onvif.NewClient(
|
||||||
"http://192.168.1.100/onvif/device_service",
|
"192.168.1.100", // Simple IP address
|
||||||
onvif.WithCredentials("admin", "password"),
|
onvif.WithCredentials("admin", "password"),
|
||||||
onvif.WithTimeout(30*time.Second),
|
onvif.WithTimeout(30*time.Second),
|
||||||
)
|
)
|
||||||
@@ -379,7 +382,7 @@ go run main.go
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
go-onvif/
|
onvif-go/
|
||||||
├── client.go # Main ONVIF client
|
├── client.go # Main ONVIF client
|
||||||
├── types.go # ONVIF data types
|
├── types.go # ONVIF data types
|
||||||
├── errors.go # Error definitions
|
├── errors.go # Error definitions
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -50,18 +51,19 @@ func WithCredentials(username, password string) ClientOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new ONVIF client
|
// 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) {
|
func NewClient(endpoint string, opts ...ClientOption) (*Client, error) {
|
||||||
// Validate endpoint
|
// Normalize endpoint to full URL
|
||||||
parsedURL, err := url.Parse(endpoint)
|
normalizedEndpoint, err := normalizeEndpoint(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid endpoint: %w", err)
|
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{
|
client := &Client{
|
||||||
endpoint: endpoint,
|
endpoint: normalizedEndpoint,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
@@ -80,6 +82,40 @@ func NewClient(endpoint string, opts ...ClientOption) (*Client, error) {
|
|||||||
return client, nil
|
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
|
// Initialize discovers and initializes service endpoints
|
||||||
func (c *Client) Initialize(ctx context.Context) error {
|
func (c *Client) Initialize(ctx context.Context) error {
|
||||||
// Get device information and capabilities
|
// Get device information and capabilities
|
||||||
|
|||||||
+157
@@ -10,6 +10,163 @@ import (
|
|||||||
"time"
|
"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
|
// Mock ONVIF server for comprehensive testing
|
||||||
type MockONVIFServer struct {
|
type MockONVIFServer struct {
|
||||||
server *httptest.Server
|
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