Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ A comprehensive Go client library for Cisco Modeling Labs (CML) 2.x, providing m
## Features

- 🚀 **Modern Service Architecture**: Clean, modular design with dedicated services for each resource type
- 🔄 **Full Backward Compatibility**: Drop-in replacement for existing gocmlclient code
- 🔄 **Focused API**: Modern, service-based client (not a drop-in replacement for older gocmlclient versions)
- 🔐 **Flexible Authentication**: Support for username/password, tokens, and custom providers
- 🛡️ **Production Ready**: Comprehensive error handling, retries, and connection management
- 📊 **Built-in Monitoring**: Request/response statistics and health checks
Expand All @@ -46,7 +46,7 @@ go get github.com/rschmied/gocmlclient
**Requirements:**

- Go 1.25 or later
- Access to a CML 2.x controller (version 2.4.0+)
- Access to a CML 2.x controller (version 2.9.0+ recommended; 2.9/2.10 tested)

## Quick Start

Expand Down Expand Up @@ -86,11 +86,11 @@ func main() {
By default, the client automatically performs a system readiness check during initialization to ensure the CML server is compatible and ready. This check:

- Verifies the server is running and accessible
- Validates version compatibility (>=2.4.0, <3.0.0)
- Validates version compatibility (>=2.9.0, <3.0.0)
- Caches version information for subsequent operations
- Checks for named configuration support (>=2.7.0)

If you need to skip this check (e.g., for testing or when working with servers that don't support the system_information endpoint):
If you need to skip this check (e.g., for testing or when working with servers that don't support the system_information endpoint) or when working with older versions (might or might not work, untested):

```go
client, err := gocmlclient.New("https://cml-controller.example.com",
Expand Down Expand Up @@ -378,16 +378,26 @@ create := models.AnnotationCreate{
}
ann, err := client.Annotation.Create(ctx, "lab-uuid", create)

// List annotations
anns, err := client.Annotation.List(ctx, "lab-uuid")
// List annotations
anns, err := client.Annotation.List(ctx, "lab-uuid")

// Patch an annotation (OpenAPI requires `type`)
updated := "hello-updated"
upd := models.AnnotationUpdate{Type: models.AnnotationTypeText, Text: &models.TextAnnotationPartial{Type: models.AnnotationTypeText, TextContent: &updated}}
ann, err = client.Annotation.Update(ctx, "lab-uuid", ann.Text.ID, upd)
// Patch an annotation (OpenAPI requires `type`)
updated := "hello-updated"
upd := models.AnnotationUpdate{Type: models.AnnotationTypeText, Text: &models.TextAnnotationPartial{Type: models.AnnotationTypeText, TextContent: &updated}}
ann, err = client.Annotation.Update(ctx, "lab-uuid", ann.Text.ID, upd)

// Delete
err = client.Annotation.Delete(ctx, "lab-uuid", ann.Text.ID)
// Line annotations: line_start/line_end are required but may be null.
// On PATCH, gocmlclient always includes these keys so callers can send explicit nulls.
arrow := models.LineStyleArrow
lineCreate := models.AnnotationCreate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotation{Type: models.AnnotationTypeLine, BorderColor: "#000000", BorderStyle: "", Color: "#ffffff", Thickness: 1, X1: 10, Y1: 10, X2: 100, Y2: 10, ZIndex: 0, LineStart: &arrow, LineEnd: &arrow}}
line, err := client.Annotation.Create(ctx, "lab-uuid", lineCreate)
if line.Line != nil {
// Clear both line ends (explicit JSON null)
_, err = client.Annotation.Update(ctx, "lab-uuid", line.Line.ID, models.AnnotationUpdate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotationPartial{Type: models.AnnotationTypeLine, LineStart: nil, LineEnd: nil}})
}

// Delete
err = client.Annotation.Delete(ctx, "lab-uuid", ann.Text.ID)

// Smart annotations
smart, err := client.SmartAnnotation.List(ctx, "lab-uuid")
Expand All @@ -405,7 +415,7 @@ Access system-level information and configuration.
version := client.System.Version()

// Check version compatibility
compatible, err := client.System.VersionCheck(ctx, ">=2.4.0")
compatible, err := client.System.VersionCheck(ctx, ">=2.9.0")

// Check system readiness
err = client.System.Ready(ctx)
Expand Down
2 changes: 1 addition & 1 deletion integration/system_it_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ func TestIntegration_System(t *testing.T) {
}

_ = c.System.Version()
_, _ = c.System.VersionCheck(ctx, ">=2.4.0")
_, _ = c.System.VersionCheck(ctx, ">=2.9.0")
}
18 changes: 16 additions & 2 deletions integration/topology_triangle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,31 @@ func TestIntegration_TriangleWithExtConnAndUnmanagedSwitch(t *testing.T) {
requireNoErrorOrSkipStatus(t, err, 400, 403, 404)
}

arrow := models.LineStyle("arrow")
_, err = annSvc.Create(ctx, lab.ID, models.AnnotationCreate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotation{Type: models.AnnotationTypeLine, BorderColor: "#0b132bff", BorderStyle: "", Color: "#5bc0becc", Thickness: 1, X1: 100, Y1: 250, X2: 300, Y2: 250, ZIndex: 7, LineStart: &arrow, LineEnd: &arrow}})
arrow := models.LineStyleArrow
circle := models.LineStyleCircle
square := models.LineStyleSquare
createdLine, err := annSvc.Create(ctx, lab.ID, models.AnnotationCreate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotation{Type: models.AnnotationTypeLine, BorderColor: "#0b132bff", BorderStyle: "", Color: "#5bc0becc", Thickness: 1, X1: 100, Y1: 250, X2: 300, Y2: 250, ZIndex: 7, LineStart: &arrow, LineEnd: &circle}})
if err != nil {
requireNoErrorOrSkipStatus(t, err, 400, 403, 404)
}
if createdLine.Line != nil {
// Exercise update semantics: explicit null line_start/line_end clears markers.
_, err = annSvc.Update(ctx, lab.ID, createdLine.Line.ID, models.AnnotationUpdate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotationPartial{Type: models.AnnotationTypeLine, LineStart: nil, LineEnd: nil}})
if err != nil {
requireNoErrorOrSkipStatus(t, err, 400, 403, 404)
}
}

_, err = annSvc.Create(ctx, lab.ID, models.AnnotationCreate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotation{Type: models.AnnotationTypeLine, BorderColor: "#0b132bff", BorderStyle: "", Color: "#6fffe9cc", Thickness: 1, X1: 100, Y1: 270, X2: 300, Y2: 270, ZIndex: 6, LineStart: nil, LineEnd: nil}})
if err != nil {
requireNoErrorOrSkipStatus(t, err, 400, 403, 404)
}

_, err = annSvc.Create(ctx, lab.ID, models.AnnotationCreate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotation{Type: models.AnnotationTypeLine, BorderColor: "#0b132bff", BorderStyle: "", Color: "#ffd166cc", Thickness: 1, X1: 100, Y1: 290, X2: 300, Y2: 290, ZIndex: 5, LineStart: &square, LineEnd: nil}})
if err != nil {
requireNoErrorOrSkipStatus(t, err, 400, 403, 404)
}

// start the lab
err = c.Lab.Start(ctx, lab.ID)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/services/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func NewSystemService(apiClient *api.Client) *SystemService {
// 2.5.0-dev0+build.3.2f7875762

const (
versionConstraint = ">=2.4.0,<3.0.0"
versionConstraint = ">=2.9.0,<3.0.0"
namedConfigsConstraint = ">=2.7.0"
versionRegexPattern = `^(\d+\.\d+\.\d+)((-dev0)?\+build.*)?$`
)
Expand Down
44 changes: 22 additions & 22 deletions internal/services/system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,15 @@ func TestVersionCheck(t *testing.T) {
})

t.Run("unknown version", func(t *testing.T) {
compatible, err := service.VersionCheck(ctx, ">=2.4.0")
compatible, err := service.VersionCheck(ctx, ">=2.9.0")
assert.False(t, compatible)
assert.Error(t, err)
assert.Contains(t, err.Error(), "version unknown")
})

t.Run("valid version and constraint", func(t *testing.T) {
service.version = "2.5.0"
compatible, err := service.VersionCheck(ctx, ">=2.4.0")
service.version = "2.10.0"
compatible, err := service.VersionCheck(ctx, ">=2.9.0")
assert.NoError(t, err)
assert.True(t, compatible)
})
Expand All @@ -99,35 +99,35 @@ func TestCheckVersionConstraint(t *testing.T) {
}{
{
name: "valid version satisfies constraint",
version: "2.5.0",
constraint: ">=2.4.0",
version: "2.10.0",
constraint: ">=2.9.0",
expectError: false,
expected: true,
},
{
name: "valid version does not satisfy constraint",
version: "2.3.0",
constraint: ">=2.4.0",
version: "2.8.0",
constraint: ">=2.9.0",
expectError: false,
expected: false,
},
{
name: "dev version",
version: "2.5.0-dev0+build.abc123",
constraint: ">=2.4.0",
version: "2.10.0-dev0+build.abc123",
constraint: ">=2.9.0",
expectError: false,
expected: true,
},
{
name: "invalid version format",
version: "invalid",
constraint: ">=2.4.0",
constraint: ">=2.9.0",
expectError: true,
expected: false,
},
{
name: "invalid constraint",
version: "2.5.0",
version: "2.10.0",
constraint: "invalid",
expectError: true,
expected: false,
Expand All @@ -151,7 +151,7 @@ func TestVersionError(t *testing.T) {
err := versionError("1.0.0")
assert.Error(t, err)
assert.Contains(t, err.Error(), "server not compatible")
assert.Contains(t, err.Error(), ">=2.4.0,<3.0.0")
assert.Contains(t, err.Error(), ">=2.9.0,<3.0.0")
assert.Contains(t, err.Error(), "1.0.0")
}

Expand Down Expand Up @@ -227,7 +227,7 @@ func TestSystemServiceIntegration(t *testing.T) {
assert.Equal(t, "/api/v0/system_information", r.URL.Path)

w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"version": "2.5.0", "ready": true}`)) //nolint:errcheck
w.Write([]byte(`{"version": "2.10.0", "ready": true}`)) //nolint:errcheck
}))
defer server.Close()

Expand All @@ -240,10 +240,10 @@ func TestSystemServiceIntegration(t *testing.T) {
assert.NoError(t, err)

// Verify version is set
assert.Equal(t, "2.5.0", service.Version())
assert.Equal(t, "2.10.0", service.Version())

// Test VersionCheck
compatible, err := service.VersionCheck(ctx, ">=2.4.0")
compatible, err := service.VersionCheck(ctx, ">=2.9.0")
assert.NoError(t, err)
assert.True(t, compatible)

Expand All @@ -264,18 +264,18 @@ func TestSystemService_VersionConstraints(t *testing.T) {
service := NewSystemService(client)

// Set version manually for testing
service.version = "2.5.0"
service.version = "2.10.0"

// Test valid version constraints
compatible, err := service.VersionCheck(context.Background(), ">=2.4.0")
compatible, err := service.VersionCheck(context.Background(), ">=2.9.0")
assert.NoError(t, err)
assert.True(t, compatible)

compatible, err = service.VersionCheck(context.Background(), ">=2.6.0")
compatible, err = service.VersionCheck(context.Background(), ">=2.11.0")
assert.NoError(t, err)
assert.False(t, compatible)

compatible, err = service.VersionCheck(context.Background(), ">=2.4.0,<3.0.0")
compatible, err = service.VersionCheck(context.Background(), ">=2.9.0,<3.0.0")
assert.NoError(t, err)
assert.True(t, compatible)

Expand All @@ -299,7 +299,7 @@ func TestSystemService_Ready_Success(t *testing.T) {
// Mock successful system info response
httpmock.RegisterResponder("GET", "https://mock/api/v0/system_information",
httpmock.NewStringResponder(200, `{
"version": "2.5.0",
"version": "2.10.0",
"ready": true
}`))

Expand All @@ -309,7 +309,7 @@ func TestSystemService_Ready_Success(t *testing.T) {
err := service.Ready(ctx)

assert.NoError(t, err)
assert.Equal(t, "2.5.0", service.Version())
assert.Equal(t, "2.10.0", service.Version())
}

func TestSystemService_Ready_SystemNotReady(t *testing.T) {
Expand All @@ -323,7 +323,7 @@ func TestSystemService_Ready_SystemNotReady(t *testing.T) {
// Mock system not ready response
httpmock.RegisterResponder("GET", "https://mock/api/v0/system_information",
httpmock.NewStringResponder(200, `{
"version": "2.5.0",
"version": "2.10.0",
"ready": false
}`))

Expand Down
2 changes: 1 addition & 1 deletion internal/testutil/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,5 @@ func SetupCommonMocks() {
httpmock.NewStringResponder(200, `{"id":"user-123","username":"testuser","token":"mock-token-12345","admin":false}`))

httpmock.RegisterResponder("GET", "https://mock/api/v0/system_information",
httpmock.NewStringResponder(200, `{"ready":true,"version":"2.8.1","build":"123"}`))
httpmock.NewStringResponder(200, `{"ready":true,"version":"2.10.0","build":"123"}`))
}
4 changes: 2 additions & 2 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func TestReadyCheckIntegration(t *testing.T) {
switch r.URL.Path {
case "/api/v0/system_information":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"version": "2.5.0", "ready": true}`)) //nolint:errcheck
w.Write([]byte(`{"version": "2.10.0", "ready": true}`)) //nolint:errcheck
case "/api/v0/auth_extended":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"user-123","username":"testuser","token":"mock-token-12345","admin":false}`)) //nolint:errcheck
Expand All @@ -170,7 +170,7 @@ func TestReadyCheckIntegration(t *testing.T) {
client, err := New(server.URL, WithToken("test-token"))
assert.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, "2.5.0", client.System.Version())
assert.Equal(t, "2.10.0", client.System.Version())
}

func TestNewAPIClient(t *testing.T) {
Expand Down
31 changes: 29 additions & 2 deletions pkg/models/annotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ type BorderStyle string
// Values observed in schema: "arrow", "square", "circle".
type LineStyle string

const (
// LineStyleArrow represents the Arrow line end
LineStyleArrow LineStyle = "arrow"
// LineStyleSquare represents the Square line end
LineStyleSquare LineStyle = "square"
// LineStyleCircle represents the Circle line end
LineStyleCircle LineStyle = "circle"
)

// MarshalJSON validates LineStyle values during marshaling.
//
// The server treats line markers as an enum and rejects empty strings.
// Enforcing this client-side prevents accidentally emitting invalid payloads.
func (s LineStyle) MarshalJSON() ([]byte, error) {
if s == "" {
return nil, fmt.Errorf("line style cannot be empty")
}
switch s {
case LineStyleArrow, LineStyleSquare, LineStyleCircle:
return json.Marshal(string(s))
default:
return nil, fmt.Errorf("invalid line style %q", s)
}
}

// Annotation is a discriminated union wrapper.
// Exactly one of Text/Rectangle/Ellipse/Line is set after unmarshaling.
type Annotation struct {
Expand Down Expand Up @@ -334,8 +359,10 @@ type LineAnnotationPartial struct {
X1 *float64 `json:"x1,omitempty"`
Y1 *float64 `json:"y1,omitempty"`
ZIndex *float64 `json:"z_index,omitempty"`
LineStart *LineStyle `json:"line_start,omitempty"`
LineEnd *LineStyle `json:"line_end,omitempty"`
// line_start/line_end are required by the schema but may be null.
// For updates, we always include these keys so callers can send explicit null.
LineStart *LineStyle `json:"line_start"`
LineEnd *LineStyle `json:"line_end"`
}

// LineAnnotationResponse represents a line annotation with ID.
Expand Down
31 changes: 31 additions & 0 deletions pkg/models/annotation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,37 @@ func TestLineAnnotationCreate_Marshal_EmitsNullLineEnds(t *testing.T) {
assert.Nil(t, m["line_end"])
}

func TestLineAnnotationUpdate_Marshal_EmitsNullLineEnds(t *testing.T) {
in := AnnotationUpdate{Type: AnnotationTypeLine, Line: &LineAnnotationPartial{Type: AnnotationTypeLine, LineStart: nil, LineEnd: nil}}
b, err := json.Marshal(in)
assert.NoError(t, err)
var m map[string]any
assert.NoError(t, json.Unmarshal(b, &m))
assert.Contains(t, m, "line_start")
assert.Contains(t, m, "line_end")
assert.Nil(t, m["line_start"])
assert.Nil(t, m["line_end"])
}

func TestLineAnnotationUpdate_Marshal_LineEndsWithValues(t *testing.T) {
start := LineStyleArrow
end := LineStyleCircle
in := AnnotationUpdate{Type: AnnotationTypeLine, Line: &LineAnnotationPartial{Type: AnnotationTypeLine, LineStart: &start, LineEnd: &end}}
b, err := json.Marshal(in)
assert.NoError(t, err)
var m map[string]any
assert.NoError(t, json.Unmarshal(b, &m))
assert.Equal(t, "arrow", m["line_start"])
assert.Equal(t, "circle", m["line_end"])
}

func TestLineStyle_MarshalJSON_RejectsEmptyOrUnknown(t *testing.T) {
_, err := json.Marshal(LineStyle(""))
assert.Error(t, err)
_, err = json.Marshal(LineStyle("bogus"))
assert.Error(t, err)
}

func TestAnnotation_Unmarshal_ClearsOtherUnionFields(t *testing.T) {
// Start with a non-empty Annotation value.
a := Annotation{
Expand Down
Loading