diff --git a/README.md b/README.md index e26d49a..e37f863 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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", @@ -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") @@ -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) diff --git a/integration/system_it_test.go b/integration/system_it_test.go index f85f47d..cbfbb39 100644 --- a/integration/system_it_test.go +++ b/integration/system_it_test.go @@ -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") } diff --git a/integration/topology_triangle_test.go b/integration/topology_triangle_test.go index adcdd6a..3f633d7 100644 --- a/integration/topology_triangle_test.go +++ b/integration/topology_triangle_test.go @@ -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 { diff --git a/internal/services/system.go b/internal/services/system.go index eb351d8..473b25b 100644 --- a/internal/services/system.go +++ b/internal/services/system.go @@ -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.*)?$` ) diff --git a/internal/services/system_test.go b/internal/services/system_test.go index e945aec..940d6d1 100644 --- a/internal/services/system_test.go +++ b/internal/services/system_test.go @@ -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) }) @@ -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, @@ -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") } @@ -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() @@ -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) @@ -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) @@ -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 }`)) @@ -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) { @@ -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 }`)) diff --git a/internal/testutil/client.go b/internal/testutil/client.go index b529e5d..9ab352f 100644 --- a/internal/testutil/client.go +++ b/internal/testutil/client.go @@ -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"}`)) } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 66fbefe..be756fd 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -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 @@ -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) { diff --git a/pkg/models/annotation.go b/pkg/models/annotation.go index b008ed1..af7e49a 100644 --- a/pkg/models/annotation.go +++ b/pkg/models/annotation.go @@ -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 { @@ -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. diff --git a/pkg/models/annotation_test.go b/pkg/models/annotation_test.go index fd9563b..f5dcf96 100644 --- a/pkg/models/annotation_test.go +++ b/pkg/models/annotation_test.go @@ -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{