From f0935a9a4fd07c0be61fda3b46013b1a41c217f5 Mon Sep 17 00:00:00 2001 From: James Peru Date: Sat, 20 Jun 2026 14:18:34 +0300 Subject: [PATCH] Fix list-response JSON tags and guard getRawValue against empty arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several ListResponse structs use a json tag that does not match the key CloudStack actually returns (the generator derives it from parseSingular()), so Count parses but the slice stays nil — silent data loss. Verified against the CloudStack source object names (setObjectName): - listHypervisorCapabilities: hypervisorcapability -> hypervisorCapabilities - listGuestNetworkIpv6Prefixes: guestnetworkipv6prefixe -> guestnetworkipv6prefix - listLBHealthCheckPolicies: lbhealthcheckpolicy -> healthcheckpolicies - listLBStickinessPolicies: lbstickinesspolicy -> stickinesspolicies Fixed via explicit cases in the generator's override switch (alongside the existing metrics cases) and the regenerated tags. Also guards getRawValue() against a count-wrapped response carrying an empty data array, which previously panicked on resp[0] (index out of range); it now returns a descriptive error. Adds regression tests for both. Signed-off-by: James Peru --- cloudstack/GetRawValueGuard_test.go | 36 ++++++++++ cloudstack/HypervisorService.go | 2 +- cloudstack/LoadBalancerService.go | 4 +- cloudstack/NetworkService.go | 2 +- cloudstack/cloudstack.go | 3 + generate/generate.go | 12 ++++ test/ListResponseJSONTagsRegression_test.go | 73 +++++++++++++++++++++ 7 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 cloudstack/GetRawValueGuard_test.go create mode 100644 test/ListResponseJSONTagsRegression_test.go diff --git a/cloudstack/GetRawValueGuard_test.go b/cloudstack/GetRawValueGuard_test.go new file mode 100644 index 00000000..8fa4baef --- /dev/null +++ b/cloudstack/GetRawValueGuard_test.go @@ -0,0 +1,36 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import "testing" + +// getRawValue must not panic when a count-wrapped response carries an empty +// data array ({"count":0,"":[]}); it should return a descriptive error. +func TestGetRawValueEmptyArrayReturnsErrorNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("getRawValue panicked on a count-wrapped empty array: %v", r) + } + }() + _, err := getRawValue([]byte(`{"count":0,"entity":[]}`)) + if err == nil { + t.Fatal("expected an error for a count-wrapped empty array, got nil") + } +} diff --git a/cloudstack/HypervisorService.go b/cloudstack/HypervisorService.go index 605070ed..688b2033 100644 --- a/cloudstack/HypervisorService.go +++ b/cloudstack/HypervisorService.go @@ -229,7 +229,7 @@ func (s *HypervisorService) ListHypervisorCapabilities(p *ListHypervisorCapabili type ListHypervisorCapabilitiesResponse struct { Count int `json:"count"` - HypervisorCapabilities []*HypervisorCapability `json:"hypervisorcapability"` + HypervisorCapabilities []*HypervisorCapability `json:"hypervisorCapabilities"` } type HypervisorCapability struct { diff --git a/cloudstack/LoadBalancerService.go b/cloudstack/LoadBalancerService.go index c66a8c0c..e525e533 100644 --- a/cloudstack/LoadBalancerService.go +++ b/cloudstack/LoadBalancerService.go @@ -3539,7 +3539,7 @@ func (s *LoadBalancerService) ListLBHealthCheckPolicies(p *ListLBHealthCheckPoli type ListLBHealthCheckPoliciesResponse struct { Count int `json:"count"` - LBHealthCheckPolicies []*LBHealthCheckPolicy `json:"lbhealthcheckpolicy"` + LBHealthCheckPolicies []*LBHealthCheckPolicy `json:"healthcheckpolicies"` } type LBHealthCheckPolicy struct { @@ -3782,7 +3782,7 @@ func (s *LoadBalancerService) ListLBStickinessPolicies(p *ListLBStickinessPolici type ListLBStickinessPoliciesResponse struct { Count int `json:"count"` - LBStickinessPolicies []*LBStickinessPolicy `json:"lbstickinesspolicy"` + LBStickinessPolicies []*LBStickinessPolicy `json:"stickinesspolicies"` } type LBStickinessPolicy struct { diff --git a/cloudstack/NetworkService.go b/cloudstack/NetworkService.go index 96f1a2e1..cccb28fa 100644 --- a/cloudstack/NetworkService.go +++ b/cloudstack/NetworkService.go @@ -8475,7 +8475,7 @@ func (s *NetworkService) ListGuestNetworkIpv6Prefixes(p *ListGuestNetworkIpv6Pre type ListGuestNetworkIpv6PrefixesResponse struct { Count int `json:"count"` - GuestNetworkIpv6Prefixes []*GuestNetworkIpv6Prefixe `json:"guestnetworkipv6prefixe"` + GuestNetworkIpv6Prefixes []*GuestNetworkIpv6Prefixe `json:"guestnetworkipv6prefix"` } type GuestNetworkIpv6Prefixe struct { diff --git a/cloudstack/cloudstack.go b/cloudstack/cloudstack.go index e16a9341..240174e1 100644 --- a/cloudstack/cloudstack.go +++ b/cloudstack/cloudstack.go @@ -653,6 +653,9 @@ func getRawValue(b json.RawMessage) (json.RawMessage, error) { if err := json.Unmarshal(v, &resp); err != nil { return nil, err } + if len(resp) == 0 { + return nil, fmt.Errorf("Unable to extract raw value: empty array for key %q in:\n\n%s\n\n", k, string(b)) + } return resp[0], nil } } diff --git a/generate/generate.go b/generate/generate.go index 448b6144..9f3dd64e 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -2096,6 +2096,18 @@ func (s *service) generateResponseType(a *API) { case "quotaSummary": pn(" Count int `json:\"count\"`") pn(" %s []*%s `json:\"%s\"`", ln, parseSingular(ln), "summary") + case "listHypervisorCapabilities": + pn(" Count int `json:\"count\"`") + pn(" %s []*%s `json:\"%s\"`", ln, parseSingular(ln), "hypervisorCapabilities") + case "listGuestNetworkIpv6Prefixes": + pn(" Count int `json:\"count\"`") + pn(" %s []*%s `json:\"%s\"`", ln, parseSingular(ln), "guestnetworkipv6prefix") + case "listLBHealthCheckPolicies": + pn(" Count int `json:\"count\"`") + pn(" %s []*%s `json:\"%s\"`", ln, parseSingular(ln), "healthcheckpolicies") + case "listLBStickinessPolicies": + pn(" Count int `json:\"count\"`") + pn(" %s []*%s `json:\"%s\"`", ln, parseSingular(ln), "stickinesspolicies") default: pn(" Count int `json:\"count\"`") pn(" %s []*%s `json:\"%s\"`", ln, parseSingular(ln), strings.ToLower(parseSingular(ln))) diff --git a/test/ListResponseJSONTagsRegression_test.go b/test/ListResponseJSONTagsRegression_test.go new file mode 100644 index 00000000..15523282 --- /dev/null +++ b/test/ListResponseJSONTagsRegression_test.go @@ -0,0 +1,73 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package test + +import ( + "encoding/json" + "testing" + + "github.com/apache/cloudstack-go/v2/cloudstack" +) + +// Regression test for list-response JSON-tag mismatches: the response struct +// tag must match the object key CloudStack actually returns, otherwise Count +// parses but the slice stays nil (silent data loss). Keys below are the +// authoritative server object names (setObjectName in the CloudStack source). +func TestListResponseJSONTagsPopulateSlices(t *testing.T) { + t.Run("HypervisorCapabilities", func(t *testing.T) { + var r cloudstack.ListHypervisorCapabilitiesResponse + if err := json.Unmarshal([]byte(`{"count":1,"hypervisorCapabilities":[{"id":"h1"}]}`), &r); err != nil { + t.Fatal(err) + } + if len(r.HypervisorCapabilities) != 1 { + t.Fatalf("expected 1 item under key 'hypervisorCapabilities', got %d (nil slice = wrong json tag)", len(r.HypervisorCapabilities)) + } + }) + + t.Run("GuestNetworkIpv6Prefixes", func(t *testing.T) { + var r cloudstack.ListGuestNetworkIpv6PrefixesResponse + if err := json.Unmarshal([]byte(`{"count":1,"guestnetworkipv6prefix":[{"id":"p1"}]}`), &r); err != nil { + t.Fatal(err) + } + if len(r.GuestNetworkIpv6Prefixes) != 1 { + t.Fatalf("expected 1 item under key 'guestnetworkipv6prefix', got %d", len(r.GuestNetworkIpv6Prefixes)) + } + }) + + t.Run("LBHealthCheckPolicies", func(t *testing.T) { + var r cloudstack.ListLBHealthCheckPoliciesResponse + if err := json.Unmarshal([]byte(`{"count":1,"healthcheckpolicies":[{"lbruleid":"r1"}]}`), &r); err != nil { + t.Fatal(err) + } + if len(r.LBHealthCheckPolicies) != 1 { + t.Fatalf("expected 1 item under key 'healthcheckpolicies', got %d", len(r.LBHealthCheckPolicies)) + } + }) + + t.Run("LBStickinessPolicies", func(t *testing.T) { + var r cloudstack.ListLBStickinessPoliciesResponse + if err := json.Unmarshal([]byte(`{"count":1,"stickinesspolicies":[{"lbruleid":"r1"}]}`), &r); err != nil { + t.Fatal(err) + } + if len(r.LBStickinessPolicies) != 1 { + t.Fatalf("expected 1 item under key 'stickinesspolicies', got %d", len(r.LBStickinessPolicies)) + } + }) +}