Repository: hetznercloud/hcloud-go
Pull request #10 (2017-12-04 19:45:34)

Implement getting and listing Floating IPs


Patch:
diff --git a/hcloud/client.go b/hcloud/client.go
index 77dacb1..4e454dd 100644
--- a/hcloud/client.go
+++ b/hcloud/client.go
@@ -48,9 +48,10 @@ type Client struct {
 	backoffFunc BackoffFunc
 	httpClient  *http.Client
 
-	Action ActionClient
-	Server ServerClient
-	SSHKey SSHKeyClient
+	Action     ActionClient
+	FloatingIP FloatingIPClient
+	Server     ServerClient
+	SSHKey     SSHKeyClient
 }
 
 // A ClientOption is used to configure a Client.
@@ -89,6 +90,7 @@ func NewClient(options ...ClientOption) *Client {
 	}
 
 	client.Action = ActionClient{client: client}
+	client.FloatingIP = FloatingIPClient{client: client}
 	client.Server = ServerClient{client: client}
 	client.SSHKey = SSHKeyClient{client: client}
 
diff --git a/hcloud/floating_ip.go b/hcloud/floating_ip.go
index 9313a94..3fa587d 100644
--- a/hcloud/floating_ip.go
+++ b/hcloud/floating_ip.go
@@ -1,6 +1,123 @@
 package hcloud
 
+import (
+	"context"
+	"fmt"
+
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
 // FloatingIP represents a Floating IP in the Hetzner Cloud.
 type FloatingIP struct {
-	ID int
+	ID           int
+	Description  string
+	IP           string
+	Type         FloatingIPType
+	Server       *Server
+	DNSPtr       map[string]string
+	HomeLocation *Location
+}
+
+// FloatingIPType represents the type of a Floating IP.
+type FloatingIPType string
+
+// Floating IP types.
+const (
+	FloatingIPTypeIPv4 FloatingIPType = "ipv4"
+	FloatingIPTypeIPv6                = "ipv6"
+)
+
+// FloatingIPFromSchema converts a schema.FloatingIP to a FloatingIP.
+func FloatingIPFromSchema(s schema.FloatingIP) *FloatingIP {
+	f := &FloatingIP{
+		ID:           s.ID,
+		IP:           s.IP,
+		Type:         FloatingIPType(s.Type),
+		HomeLocation: LocationFromSchema(s.HomeLocation),
+	}
+	if s.Description != nil {
+		f.Description = *s.Description
+	}
+	if s.Server != nil {
+		f.Server = &Server{ID: *s.Server}
+	}
+	if s.DNSPtr.IPv4 != nil {
+		f.DNSPtr = map[string]string{
+			s.IP: *s.DNSPtr.IPv4,
+		}
+	} else if s.DNSPtr.IPv6 != nil {
+		f.DNSPtr = map[string]string{}
+		for _, entry := range s.DNSPtr.IPv6 {
+			f.DNSPtr[entry.IP] = entry.DNSPtr
+		}
+	}
+	return f
+}
+
+// FloatingIPClient is a client for the Floating IP API.
+type FloatingIPClient struct {
+	client *Client
+}
+
+// Get retrieves a Floating IP.
+func (c *FloatingIPClient) Get(ctx context.Context, id int) (*FloatingIP, *Response, error) {
+	req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/floating_ips/%d", id), nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.FloatingIPGetResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, resp, err
+	}
+	return FloatingIPFromSchema(body.FloatingIP), resp, nil
+}
+
+// FloatingIPListOpts specifies options for listing Floating IPs.
+type FloatingIPListOpts struct {
+	ListOpts
+}
+
+// List returns a list of Floating IPs for a specific page.
+func (c *FloatingIPClient) List(ctx context.Context, opts FloatingIPListOpts) ([]*FloatingIP, *Response, error) {
+	path := "/floating_ips?" + valuesForListOpts(opts.ListOpts).Encode()
+	req, err := c.client.NewRequest(ctx, "GET", path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.FloatingIPListResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, nil, err
+	}
+	floatingIPs := make([]*FloatingIP, 0, len(body.FloatingIPs))
+	for _, s := range body.FloatingIPs {
+		floatingIPs = append(floatingIPs, FloatingIPFromSchema(s))
+	}
+	return floatingIPs, resp, nil
+}
+
+// All returns all Floating IPs.
+func (c *FloatingIPClient) All(ctx context.Context) ([]*FloatingIP, error) {
+	allFloatingIPs := []*FloatingIP{}
+
+	opts := FloatingIPListOpts{}
+	opts.PerPage = 50
+
+	_, err := c.client.all(func(page int) (*Response, error) {
+		opts.Page = page
+		floatingIPs, resp, err := c.List(ctx, opts)
+		if err != nil {
+			return resp, err
+		}
+		allFloatingIPs = append(allFloatingIPs, floatingIPs...)
+		return resp, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return allFloatingIPs, nil
 }
diff --git a/hcloud/location.go b/hcloud/location.go
new file mode 100644
index 0000000..929ddcf
--- /dev/null
+++ b/hcloud/location.go
@@ -0,0 +1,27 @@
+package hcloud
+
+import "github.com/hetznercloud/hcloud-go/hcloud/schema"
+
+// Location represents a location in the Hetzner Cloud.
+type Location struct {
+	ID          int
+	Name        string
+	Description string
+	Country     string
+	City        string
+	Latitude    float64
+	Longitude   float64
+}
+
+// LocationFromSchema converts a schema.Location to a Location.
+func LocationFromSchema(s schema.Location) *Location {
+	return &Location{
+		ID:          s.ID,
+		Name:        s.Name,
+		Description: s.Description,
+		Country:     s.Country,
+		City:        s.City,
+		Latitude:    s.Latitude,
+		Longitude:   s.Longitude,
+	}
+}
diff --git a/hcloud/schema/floating_ip.go b/hcloud/schema/floating_ip.go
new file mode 100644
index 0000000..6163e71
--- /dev/null
+++ b/hcloud/schema/floating_ip.go
@@ -0,0 +1,71 @@
+package schema
+
+import (
+	"encoding/json"
+	"errors"
+)
+
+// FloatingIP defines the schema of a Floating IP.
+type FloatingIP struct {
+	ID           int              `json:"id"`
+	Description  *string          `json:"description"`
+	IP           string           `json:"ip"`
+	Type         string           `json:"type"`
+	Server       *int             `json:"server"`
+	DNSPtr       FloatingIPDNSPtr `json:"dns_ptr"`
+	HomeLocation Location         `json:"home_location"`
+}
+
+// FloatingIPDNSPtr contains reverse DNS information for a
+// IPv4 or IPv6 Floating IP.
+type FloatingIPDNSPtr struct {
+	IPv4 *string
+	IPv6 []FloatingIPDNSPtrIPv6
+}
+
+// FloatingIPDNSPtrIPv6 defines the schema of reverse DNS
+// information for a single IPv6.
+type FloatingIPDNSPtrIPv6 struct {
+	IP     string `json:"ip"`
+	DNSPtr string `json:"dns_ptr"`
+}
+
+// MarshalJSON implements json.Marshaler.
+func (p FloatingIPDNSPtr) MarshalJSON() ([]byte, error) {
+	if p.IPv4 != nil {
+		return json.Marshal(p.IPv4)
+	}
+	if p.IPv6 != nil {
+		return json.Marshal(p.IPv6)
+	}
+	return json.Marshal(nil)
+}
+
+// UnmarshalJSON implements json.Unmarshaler.
+func (p *FloatingIPDNSPtr) UnmarshalJSON(data []byte) error {
+	var ipv4 string
+	if err := json.Unmarshal(data, &ipv4); err == nil {
+		p.IPv4 = &ipv4
+		return nil
+	}
+
+	var ipv6 []FloatingIPDNSPtrIPv6
+	if err := json.Unmarshal(data, &ipv6); err == nil {
+		p.IPv6 = ipv6
+		return nil
+	}
+
+	return errors.New("schema: unable to unmarshal dns_ptr")
+}
+
+// FloatingIPGetResponse defines the schema of the response when
+// retrieving a single Floating IP.
+type FloatingIPGetResponse struct {
+	FloatingIP FloatingIP `json:"floating_ip"`
+}
+
+// FloatingIPListResponse defines the schema of the response when
+// listing Floating IPs.
+type FloatingIPListResponse struct {
+	FloatingIPs []FloatingIP `json:"floating_ips"`
+}
diff --git a/hcloud/schema/location.go b/hcloud/schema/location.go
new file mode 100644
index 0000000..c9eb7cf
--- /dev/null
+++ b/hcloud/schema/location.go
@@ -0,0 +1,12 @@
+package schema
+
+// Location defines the schema of a location.
+type Location struct {
+	ID          int     `json:"id"`
+	Name        string  `json:"name"`
+	Description string  `json:"description"`
+	Country     string  `json:"country"`
+	City        string  `json:"city"`
+	Latitude    float64 `json:"latitude"`
+	Longitude   float64 `json:"longitude"`
+}


Test patch:
diff --git a/hcloud/floating_ip_test.go b/hcloud/floating_ip_test.go
new file mode 100644
index 0000000..95c5b72
--- /dev/null
+++ b/hcloud/floating_ip_test.go
@@ -0,0 +1,116 @@
+package hcloud
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
+func TestFloatingIPFromSchema(t *testing.T) {
+	data := []byte(`{
+		"id": 4711,
+		"description": "Web Frontend",
+		"ip": "131.232.99.1",
+		"type": "ipv4",
+		"server": 42,
+		"dns_ptr": "fip01.example.com",
+		"home_location": {
+			"id": 1,
+			"name": "fsn1",
+			"description": "Falkenstein DC Park 1",
+			"country": "DE",
+			"city": "Falkenstein",
+			"latitude": 50.47612,
+			"longitude": 12.370071
+		}
+	}`)
+
+	var s schema.FloatingIP
+	if err := json.Unmarshal(data, &s); err != nil {
+		t.Fatal(err)
+	}
+	floatingIP := FloatingIPFromSchema(s)
+
+	if floatingIP.ID != 4711 {
+		t.Errorf("unexpected ID: %v", floatingIP.ID)
+	}
+	if floatingIP.Description != "Web Frontend" {
+		t.Errorf("unexpected description: %v", floatingIP.Description)
+	}
+	if floatingIP.IP != "131.232.99.1" {
+		t.Errorf("unexpected IP: %v", floatingIP.IP)
+	}
+	if floatingIP.Type != FloatingIPTypeIPv4 {
+		t.Errorf("unexpected type: %v", floatingIP.Type)
+	}
+	if floatingIP.Server == nil || floatingIP.Server.ID != 42 {
+		t.Errorf("unexpected server: %v", floatingIP.Server)
+	}
+	if floatingIP.DNSPtr == nil || floatingIP.DNSPtr[floatingIP.IP] != "fip01.example.com" {
+		t.Errorf("unexpected DNS ptr: %v", floatingIP.DNSPtr)
+	}
+	if floatingIP.HomeLocation == nil || floatingIP.HomeLocation.ID != 1 {
+		t.Errorf("unexpected home location: %v", floatingIP.HomeLocation)
+	}
+}
+
+func TestFloatingIPClientGet(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/floating_ips/1", func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(schema.FloatingIPGetResponse{
+			FloatingIP: schema.FloatingIP{
+				ID: 1,
+			},
+		})
+	})
+
+	ctx := context.Background()
+	floatingIP, _, err := env.Client.FloatingIP.Get(ctx, 1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if floatingIP == nil {
+		t.Fatal("no Floating IP")
+	}
+	if floatingIP.ID != 1 {
+		t.Errorf("unexpected ID: %v", floatingIP.ID)
+	}
+}
+
+func TestFloatingIPClientList(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/floating_ips", func(w http.ResponseWriter, r *http.Request) {
+		if page := r.URL.Query().Get("page"); page != "2" {
+			t.Errorf("expected page 2; got %q", page)
+		}
+		if perPage := r.URL.Query().Get("per_page"); perPage != "50" {
+			t.Errorf("expected per_page 50; got %q", perPage)
+		}
+		json.NewEncoder(w).Encode(schema.FloatingIPListResponse{
+			FloatingIPs: []schema.FloatingIP{
+				{ID: 1},
+				{ID: 2},
+			},
+		})
+	})
+
+	opts := FloatingIPListOpts{}
+	opts.Page = 2
+	opts.PerPage = 50
+
+	ctx := context.Background()
+	floatingIPs, _, err := env.Client.FloatingIP.List(ctx, opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(floatingIPs) != 2 {
+		t.Fatal("expected 2 Floating IPs")
+	}
+}
diff --git a/hcloud/schema/floating_ip_test.go b/hcloud/schema/floating_ip_test.go
new file mode 100644
index 0000000..91c2a81
--- /dev/null
+++ b/hcloud/schema/floating_ip_test.go
@@ -0,0 +1,71 @@
+package schema
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+)
+
+func TestFloatingIPDNSPtrMarshalJSON(t *testing.T) {
+	t.Run("IPv4", func(t *testing.T) {
+		s := "foo.example"
+		data, err := json.Marshal(FloatingIPDNSPtr{IPv4: &s})
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !bytes.Equal(data, []byte(`"foo.example"`)) {
+			t.Errorf("unexpected data: %s", data)
+		}
+	})
+	t.Run("IPv6", func(t *testing.T) {
+		data, err := json.Marshal(FloatingIPDNSPtr{
+			IPv6: []FloatingIPDNSPtrIPv6{
+				{IP: "::1", DNSPtr: "foo.example.com"},
+			},
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !bytes.Equal(data, []byte(`[{"ip":"::1","dns_ptr":"foo.example.com"}]`)) {
+			t.Errorf("unexpected data: %s", data)
+		}
+	})
+}
+
+func TestFloatingIPDNSPtrUnmarshalJSON(t *testing.T) {
+	t.Run("IPv4", func(t *testing.T) {
+		data := []byte(`"foo.example"`)
+		var p FloatingIPDNSPtr
+		if err := json.Unmarshal(data, &p); err != nil {
+			t.Fatal(err)
+		}
+		if p.IPv4 == nil {
+			t.Fatalf("missing IPv4")
+		}
+		if *p.IPv4 != "foo.example" {
+			t.Errorf("unexpected IPv4: %v", *p.IPv4)
+		}
+		if p.IPv6 != nil {
+			t.Error("IPv6 should be nil")
+		}
+	})
+	t.Run("IPv6", func(t *testing.T) {
+		data := []byte(`[{"ip":"::1","dns_ptr":"foo.example.com"}]`)
+		var p FloatingIPDNSPtr
+		if err := json.Unmarshal(data, &p); err != nil {
+			t.Fatal(err)
+		}
+		if p.IPv4 != nil {
+			t.Error("IPv4 should be nil")
+		}
+		if len(p.IPv6) != 1 {
+			t.Fatalf("unexpected one entry")
+		}
+		if p.IPv6[0].IP != "::1" {
+			t.Errorf("unexpected IP: %v", p.IPv6[0].IP)
+		}
+		if p.IPv6[0].DNSPtr != "foo.example.com" {
+			t.Errorf("unexpected ptr: %v", p.IPv6[0].DNSPtr)
+		}
+	})
+}