Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.16.0 (Unreleased)

FEATURES:

* Distinguish unset vs empty zone.networks

## 2.15.0 (August 28th, 2025)

FEATURES:
Expand Down
57 changes: 57 additions & 0 deletions rest/_examples/networks_empty.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"encoding/json"
"fmt"

"gopkg.in/ns1/ns1-go.v2/rest/model/dns"
)

func main() {
fmt.Println("NS1 SDK Empty Networks Example")
fmt.Println("------------------------------")

// Case 1: Default behavior (nil Networks)
zone1 := dns.NewZone("example.com")
data1, _ := json.Marshal(zone1)
fmt.Println("Case 1 - Default (nil Networks):")
fmt.Printf(" JSON: %s\n", string(data1))
fmt.Printf(" Contains 'networks': %v\n\n", contains(string(data1), "networks"))

// Case 2: Empty Networks (explicit empty array)
zone2 := dns.NewZone("empty.example.com")
empty := []int{}
zone2.Networks = &empty
data2, _ := json.Marshal(zone2)
fmt.Println("Case 2 - Empty Networks (explicit empty array):")
fmt.Printf(" JSON: %s\n", string(data2))
fmt.Printf(" Contains 'networks': %v\n", contains(string(data2), "networks"))
fmt.Printf(" Has empty networks array: %v\n\n", contains(string(data2), `"networks":[]`))

// Case 3: Populated Networks
zone3 := dns.NewZone("populated.example.com")
networks := []int{1, 2}
zone3.Networks = &networks
data3, _ := json.Marshal(zone3)
fmt.Println("Case 3 - Populated Networks:")
fmt.Printf(" JSON: %s\n", string(data3))
fmt.Printf(" Contains networks: %v\n\n", contains(string(data3), `"networks":[1,2]`))

// Case 4: Legacy NetworkIDs with EnsureNetworksFromLegacy
zone4 := dns.NewZone("legacy.example.com")
zone4.NetworkIDs = []int{3, 4}
zone4.EnsureNetworksFromLegacy()
data4, _ := json.Marshal(zone4)
fmt.Println("Case 4 - Legacy NetworkIDs with EnsureNetworksFromLegacy:")
fmt.Printf(" JSON: %s\n", string(data4))
fmt.Printf(" Contains networks: %v\n", contains(string(data4), `"networks":[3,4]`))
}

func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
142 changes: 142 additions & 0 deletions rest/model/dns/networks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package dns

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
)

func TestZoneNetworks_MarshalJSON(t *testing.T) {
// Test Case 1: nil Networks should be omitted
z := Zone{
Zone: "example.com",
Networks: nil,
NetworkIDs: nil,
}
data, err := json.Marshal(z)
assert.NoError(t, err)
assert.NotContains(t, string(data), "networks")

// Test Case 2: Empty Networks should be included as []
empty := []int{}
z = Zone{
Zone: "example.com",
Networks: &empty,
NetworkIDs: nil,
}
data, err = json.Marshal(z)
assert.NoError(t, err)
assert.Contains(t, string(data), `"networks":[]`)

// Test Case 3: Populated Networks should be included
networks := []int{1, 2, 3}
z = Zone{
Zone: "example.com",
Networks: &networks,
NetworkIDs: nil,
}
data, err = json.Marshal(z)
assert.NoError(t, err)
assert.Contains(t, string(data), `"networks":[1,2,3]`)
}

func TestZoneNetworks_UnmarshalJSON(t *testing.T) {
// Test Case 1: JSON with networks field should populate both Networks and NetworkIDs
jsonStr := `{"zone":"example.com","networks":[1,2,3]}`
var z Zone
err := json.Unmarshal([]byte(jsonStr), &z)
assert.NoError(t, err)
assert.NotNil(t, z.Networks)
assert.Equal(t, []int{1, 2, 3}, *z.Networks)
assert.Equal(t, []int{1, 2, 3}, z.NetworkIDs)

// Test Case 2: JSON with empty networks array should result in empty slices
jsonStr = `{"zone":"example.com","networks":[]}`
z = Zone{}
err = json.Unmarshal([]byte(jsonStr), &z)
assert.NoError(t, err)
assert.NotNil(t, z.Networks)
assert.Equal(t, 0, len(*z.Networks))
assert.Equal(t, 0, len(z.NetworkIDs))

// Test Case 3: JSON without networks field should result in nil fields
jsonStr = `{"zone":"example.com"}`
z = Zone{}
err = json.Unmarshal([]byte(jsonStr), &z)
assert.NoError(t, err)
assert.Nil(t, z.Networks)
assert.Nil(t, z.NetworkIDs)
}

func TestZone_EnsureNetworksFromLegacy(t *testing.T) {
// Test Case 1: When Networks is nil and NetworkIDs has values
networkIDs := []int{1, 2, 3}
z := Zone{
Zone: "example.com",
Networks: nil,
NetworkIDs: networkIDs,
}
z.EnsureNetworksFromLegacy()
assert.NotNil(t, z.Networks)
assert.Equal(t, networkIDs, *z.Networks)

// Test Case 2: When Networks is already set, it shouldn't change
networks := []int{4, 5, 6}
z = Zone{
Zone: "example.com",
Networks: &networks,
NetworkIDs: networkIDs,
}
z.EnsureNetworksFromLegacy()
assert.NotNil(t, z.Networks)
assert.Equal(t, networks, *z.Networks)
assert.NotEqual(t, networkIDs, *z.Networks)

// Test Case 3: When both are empty/nil
z = Zone{
Zone: "example.com",
Networks: nil,
NetworkIDs: nil,
}
z.EnsureNetworksFromLegacy()
assert.Nil(t, z.Networks)
assert.Nil(t, z.NetworkIDs)
}

// Integration test to verify the entire flow works as expected
func TestZoneNetworks_IntegrationFlow(t *testing.T) {
// Starting with a Zone using legacy NetworkIDs
z := Zone{
Zone: "example.com",
NetworkIDs: []int{1, 2, 3},
}

// Step 1: Call EnsureNetworksFromLegacy to populate Networks
z.EnsureNetworksFromLegacy()
assert.NotNil(t, z.Networks)
assert.Equal(t, z.NetworkIDs, *z.Networks)

// Step 2: Marshal to JSON
data, err := json.Marshal(z)
assert.NoError(t, err)
assert.Contains(t, string(data), `"networks":[1,2,3]`)

// Step 3: Change to empty networks
empty := []int{}
z.Networks = &empty
z.NetworkIDs = nil

// Step 4: Marshal again to verify empty array is sent
data, err = json.Marshal(z)
assert.NoError(t, err)
assert.Contains(t, string(data), `"networks":[]`)

// Step 5: Unmarshal from JSON with networks field
jsonStr := `{"zone":"example.com","networks":[4,5]}`
err = json.Unmarshal([]byte(jsonStr), &z)
assert.NoError(t, err)
assert.NotNil(t, z.Networks)
assert.Equal(t, []int{4, 5}, *z.Networks)
assert.Equal(t, []int{4, 5}, z.NetworkIDs)
}
44 changes: 42 additions & 2 deletions rest/model/dns/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ type Zone struct {

// Networks contains the network ids the zone is available. Most zones
// will be in the NSONE Global Network(which is id 0).
NetworkIDs []int `json:"networks,omitempty"`
Records []*ZoneRecord `json:"records,omitempty"`
// Deprecated: maintained for source compatibility in Go code.
// Not marshaled to JSON.
NetworkIDs []int `json:"-"`
// New: distinguishes unset (nil) vs explicit empty ([]).
Networks *[]int `json:"networks,omitempty"`
Records []*ZoneRecord `json:"records,omitempty"`

// Primary contains info to enable slaving of the zone by third party dns servers.
Primary *ZonePrimary `json:"primary,omitempty"`
Expand All @@ -51,6 +55,41 @@ type Zone struct {
Tags map[string]string `json:"tags,omitempty"` // Only relevant for DDI
}

// UnmarshalJSON ensures backward compatibility by populating NetworkIDs from Networks
func (z *Zone) UnmarshalJSON(data []byte) error {
type Alias Zone
aux := &struct {
*Alias
Networks *[]int `json:"networks"`
}{
Alias: (*Alias)(z),
}

if err := json.Unmarshal(data, aux); err != nil {
return err
}

// Preserve presence semantics
z.Networks = aux.Networks

if aux.Networks != nil {
// Copy to avoid sharing memory
z.NetworkIDs = append([]int(nil), *aux.Networks...)
} else {
z.NetworkIDs = nil
}

return nil
}

// EnsureNetworksFromLegacy ensures the Networks field is populated from NetworkIDs
func (z *Zone) EnsureNetworksFromLegacy() {
if z.Networks == nil && len(z.NetworkIDs) > 0 {
v := append([]int(nil), z.NetworkIDs...)
z.Networks = &v
}
}

func (z Zone) String() string {
return z.Zone
}
Expand Down Expand Up @@ -176,6 +215,7 @@ func (z *Zone) LinkTo(to string) {
z.Primary = nil
z.DNSServers = nil
z.NetworkIDs = nil
z.Networks = nil // Also clear the new field
z.NetworkPools = nil
z.Hostmaster = ""
z.Pool = ""
Expand Down
6 changes: 6 additions & 0 deletions rest/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ func (s *ZonesService) Get(zone string, records bool) (*dns.Zone, *http.Response
//
// NS1 API docs: https://ns1.com/api/#zones-put
func (s *ZonesService) Create(z *dns.Zone) (*http.Response, error) {
// Ensure Networks field is populated from NetworkIDs
z.EnsureNetworksFromLegacy()

path := fmt.Sprintf("zones/%s", z.Zone)

req, err := s.client.NewRequest("PUT", path, &z)
Expand Down Expand Up @@ -102,6 +105,9 @@ func (s *ZonesService) Create(z *dns.Zone) (*http.Response, error) {
//
// NS1 API docs: https://ns1.com/api/#zones-post
func (s *ZonesService) Update(z *dns.Zone) (*http.Response, error) {
// Ensure Networks field is populated from NetworkIDs
z.EnsureNetworksFromLegacy()

path := fmt.Sprintf("zones/%s", z.Zone)

req, err := s.client.NewRequest("POST", path, &z)
Expand Down
Loading