Update Subnet Gateway Behavior (#102)

This commit enables all three behaviors of a gateway during subnet creation and
updating.

If a GatewayIP is omitted, Neutron will provision a default gateway.
If a GatewayIP is set to an empty string, no gateway will be provisioned.
If a GatewayIP is specified, it will be used as the gateway IP.
diff --git a/acceptance/openstack/networking/v2/networking.go b/acceptance/openstack/networking/v2/networking.go
index 9432dda..cc5befb 100644
--- a/acceptance/openstack/networking/v2/networking.go
+++ b/acceptance/openstack/networking/v2/networking.go
@@ -72,13 +72,12 @@
 	subnetOctet := tools.RandomInt(1, 250)
 	subnetCIDR := fmt.Sprintf("192.168.%d.0/24", subnetOctet)
 	subnetGateway := fmt.Sprintf("192.168.%d.1", subnetOctet)
-	iFalse := false
 	createOpts := subnets.CreateOpts{
 		NetworkID:  networkID,
 		CIDR:       subnetCIDR,
 		IPVersion:  4,
 		Name:       subnetName,
-		EnableDHCP: &iFalse,
+		EnableDHCP: gophercloud.Disabled,
 		GatewayIP:  &subnetGateway,
 	}
 
@@ -93,6 +92,68 @@
 	return subnet, nil
 }
 
+// CreateSubnetWithDefaultGateway will create a subnet on the specified Network
+// ID and have Neutron set the gateway by default An error will be returned if
+// the subnet could not be created.
+func CreateSubnetWithDefaultGateway(t *testing.T, client *gophercloud.ServiceClient, networkID string) (*subnets.Subnet, error) {
+	subnetName := tools.RandomString("TESTACC-", 8)
+	subnetOctet := tools.RandomInt(1, 250)
+	subnetCIDR := fmt.Sprintf("192.168.%d.0/24", subnetOctet)
+	createOpts := subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       subnetCIDR,
+		IPVersion:  4,
+		Name:       subnetName,
+		EnableDHCP: gophercloud.Disabled,
+	}
+
+	t.Logf("Attempting to create subnet: %s", subnetName)
+
+	subnet, err := subnets.Create(client, createOpts).Extract()
+	if err != nil {
+		return subnet, err
+	}
+
+	t.Logf("Successfully created subnet.")
+	return subnet, nil
+}
+
+// CreateSubnetWithNoGateway will create a subnet with no gateway on the
+// specified Network ID.  An error will be returned if the subnet could not be
+// created.
+func CreateSubnetWithNoGateway(t *testing.T, client *gophercloud.ServiceClient, networkID string) (*subnets.Subnet, error) {
+	var noGateway = ""
+	subnetName := tools.RandomString("TESTACC-", 8)
+	subnetOctet := tools.RandomInt(1, 250)
+	subnetCIDR := fmt.Sprintf("192.168.%d.0/24", subnetOctet)
+	dhcpStart := fmt.Sprintf("192.168.%d.10", subnetOctet)
+	dhcpEnd := fmt.Sprintf("192.168.%d.200", subnetOctet)
+	createOpts := subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       subnetCIDR,
+		IPVersion:  4,
+		Name:       subnetName,
+		EnableDHCP: gophercloud.Disabled,
+		GatewayIP:  &noGateway,
+		AllocationPools: []subnets.AllocationPool{
+			{
+				Start: dhcpStart,
+				End:   dhcpEnd,
+			},
+		},
+	}
+
+	t.Logf("Attempting to create subnet: %s", subnetName)
+
+	subnet, err := subnets.Create(client, createOpts).Extract()
+	if err != nil {
+		return subnet, err
+	}
+
+	t.Logf("Successfully created subnet.")
+	return subnet, nil
+}
+
 // DeleteNetwork will delete a network with a specified ID. A fatal error will
 // occur if the delete was not successful. This works best when used as a
 // deferred function.
diff --git a/acceptance/openstack/networking/v2/subnets_test.go b/acceptance/openstack/networking/v2/subnets_test.go
index 49c970a..1d7696c 100644
--- a/acceptance/openstack/networking/v2/subnets_test.go
+++ b/acceptance/openstack/networking/v2/subnets_test.go
@@ -3,6 +3,8 @@
 package v2
 
 import (
+	"fmt"
+	"strings"
 	"testing"
 
 	"github.com/gophercloud/gophercloud/acceptance/clients"
@@ -71,3 +73,86 @@
 
 	PrintSubnet(t, newSubnet)
 }
+
+func TestSubnetsDefaultGateway(t *testing.T) {
+	client, err := clients.NewNetworkV2Client()
+	if err != nil {
+		t.Fatalf("Unable to create a network client: %v", err)
+	}
+
+	// Create Network
+	network, err := CreateNetwork(t, client)
+	if err != nil {
+		t.Fatalf("Unable to create network: %v", err)
+	}
+	defer DeleteNetwork(t, client, network.ID)
+
+	// Create Subnet
+	subnet, err := CreateSubnetWithDefaultGateway(t, client, network.ID)
+	if err != nil {
+		t.Fatalf("Unable to create subnet: %v", err)
+	}
+	defer DeleteSubnet(t, client, subnet.ID)
+
+	PrintSubnet(t, subnet)
+
+	if subnet.GatewayIP == "" {
+		t.Fatalf("A default gateway was not created.")
+	}
+
+	var noGateway = ""
+	updateOpts := subnets.UpdateOpts{
+		GatewayIP: &noGateway,
+	}
+
+	newSubnet, err := subnets.Update(client, subnet.ID, updateOpts).Extract()
+	if err != nil {
+		t.Fatalf("Unable to update subnet")
+	}
+
+	if newSubnet.GatewayIP != "" {
+		t.Fatalf("Gateway was not updated correctly")
+	}
+}
+
+func TestSubnetsNoGateway(t *testing.T) {
+	client, err := clients.NewNetworkV2Client()
+	if err != nil {
+		t.Fatalf("Unable to create a network client: %v", err)
+	}
+
+	// Create Network
+	network, err := CreateNetwork(t, client)
+	if err != nil {
+		t.Fatalf("Unable to create network: %v", err)
+	}
+	defer DeleteNetwork(t, client, network.ID)
+
+	// Create Subnet
+	subnet, err := CreateSubnetWithNoGateway(t, client, network.ID)
+	if err != nil {
+		t.Fatalf("Unable to create subnet: %v", err)
+	}
+	defer DeleteSubnet(t, client, subnet.ID)
+
+	PrintSubnet(t, subnet)
+
+	if subnet.GatewayIP != "" {
+		t.Fatalf("A gateway exists when it shouldn't.")
+	}
+
+	subnetParts := strings.Split(subnet.CIDR, ".")
+	newGateway := fmt.Sprintf("%s.%s.%s.1", subnetParts[0], subnetParts[1], subnetParts[2])
+	updateOpts := subnets.UpdateOpts{
+		GatewayIP: &newGateway,
+	}
+
+	newSubnet, err := subnets.Update(client, subnet.ID, updateOpts).Extract()
+	if err != nil {
+		t.Fatalf("Unable to update subnet")
+	}
+
+	if newSubnet.GatewayIP == "" {
+		t.Fatalf("Gateway was not updated correctly")
+	}
+}
diff --git a/openstack/networking/v2/subnets/requests.go b/openstack/networking/v2/subnets/requests.go
index 769474d..896f13e 100644
--- a/openstack/networking/v2/subnets/requests.go
+++ b/openstack/networking/v2/subnets/requests.go
@@ -79,7 +79,7 @@
 	Name            string                `json:"name,omitempty"`
 	TenantID        string                `json:"tenant_id,omitempty"`
 	AllocationPools []AllocationPool      `json:"allocation_pools,omitempty"`
-	GatewayIP       *string               `json:"gateway_ip"`
+	GatewayIP       *string               `json:"gateway_ip,omitempty"`
 	IPVersion       gophercloud.IPVersion `json:"ip_version,omitempty"`
 	EnableDHCP      *bool                 `json:"enable_dhcp,omitempty"`
 	DNSNameservers  []string              `json:"dns_nameservers,omitempty"`
@@ -88,7 +88,16 @@
 
 // ToSubnetCreateMap casts a CreateOpts struct to a map.
 func (opts CreateOpts) ToSubnetCreateMap() (map[string]interface{}, error) {
-	return gophercloud.BuildRequestBody(opts, "subnet")
+	b, err := gophercloud.BuildRequestBody(opts, "subnet")
+	if err != nil {
+		return nil, err
+	}
+
+	if m := b["subnet"].(map[string]interface{}); m["gateway_ip"] == "" {
+		m["gateway_ip"] = nil
+	}
+
+	return b, nil
 }
 
 // Create accepts a CreateOpts struct and creates a new subnet using the values
@@ -112,7 +121,7 @@
 // UpdateOpts represents the attributes used when updating an existing subnet.
 type UpdateOpts struct {
 	Name           string      `json:"name,omitempty"`
-	GatewayIP      string      `json:"gateway_ip,omitempty"`
+	GatewayIP      *string     `json:"gateway_ip,omitempty"`
 	DNSNameservers []string    `json:"dns_nameservers,omitempty"`
 	HostRoutes     []HostRoute `json:"host_routes,omitempty"`
 	EnableDHCP     *bool       `json:"enable_dhcp,omitempty"`
@@ -120,7 +129,16 @@
 
 // ToSubnetUpdateMap casts an UpdateOpts struct to a map.
 func (opts UpdateOpts) ToSubnetUpdateMap() (map[string]interface{}, error) {
-	return gophercloud.BuildRequestBody(opts, "subnet")
+	b, err := gophercloud.BuildRequestBody(opts, "subnet")
+	if err != nil {
+		return nil, err
+	}
+
+	if m := b["subnet"].(map[string]interface{}); m["gateway_ip"] == "" {
+		m["gateway_ip"] = nil
+	}
+
+	return b, nil
 }
 
 // Update accepts a UpdateOpts struct and updates an existing subnet using the
diff --git a/openstack/networking/v2/subnets/testing/requests_test.go b/openstack/networking/v2/subnets/testing/requests_test.go
index 13fa9df..9ff9181 100644
--- a/openstack/networking/v2/subnets/testing/requests_test.go
+++ b/openstack/networking/v2/subnets/testing/requests_test.go
@@ -231,16 +231,16 @@
     "subnet": {
         "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
         "ip_version": 4,
-				"gateway_ip": null,
+        "gateway_ip": "192.168.199.1",
         "cidr": "192.168.199.0/24",
-				"dns_nameservers": ["foo"],
-				"allocation_pools": [
-						{
-								"start": "192.168.199.2",
-								"end": "192.168.199.254"
-						}
-				],
-				"host_routes": [{"destination":"","nexthop": "bar"}]
+        "dns_nameservers": ["foo"],
+        "allocation_pools": [
+            {
+                "start": "192.168.199.2",
+                "end": "192.168.199.254"
+            }
+        ],
+        "host_routes": [{"destination":"","nexthop": "bar"}]
     }
 }
 			`)
@@ -272,10 +272,12 @@
 		`)
 	})
 
+	var gatewayIP = "192.168.199.1"
 	opts := subnets.CreateOpts{
 		NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
 		IPVersion: 4,
 		CIDR:      "192.168.199.0/24",
+		GatewayIP: &gatewayIP,
 		AllocationPools: []subnets.AllocationPool{
 			{
 				Start: "192.168.199.2",
@@ -323,13 +325,13 @@
         "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a23",
         "ip_version": 4,
         "cidr": "192.168.1.0/24",
-				"gateway_ip": null,
-				"allocation_pools": [
-						{
-								"start": "192.168.1.2",
-								"end": "192.168.1.254"
-						}
-				]
+        "gateway_ip": null,
+        "allocation_pools": [
+            {
+                "start": "192.168.1.2",
+                "end": "192.168.1.254"
+            }
+        ]
     }
 }
 			`)
@@ -360,6 +362,91 @@
 		`)
 	})
 
+	var noGateway = ""
+	opts := subnets.CreateOpts{
+		NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+		IPVersion: 4,
+		CIDR:      "192.168.1.0/24",
+		GatewayIP: &noGateway,
+		AllocationPools: []subnets.AllocationPool{
+			{
+				Start: "192.168.1.2",
+				End:   "192.168.1.254",
+			},
+		},
+		DNSNameservers: []string{},
+	}
+	s, err := subnets.Create(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "")
+	th.AssertEquals(t, s.EnableDHCP, true)
+	th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a23")
+	th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertDeepEquals(t, s.AllocationPools, []subnets.AllocationPool{
+		{
+			Start: "192.168.1.2",
+			End:   "192.168.1.254",
+		},
+	})
+	th.AssertDeepEquals(t, s.HostRoutes, []subnets.HostRoute{})
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.GatewayIP, "")
+	th.AssertEquals(t, s.CIDR, "192.168.1.0/24")
+	th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0c")
+}
+
+func TestCreateDefaultGateway(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "subnet": {
+        "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+        "ip_version": 4,
+        "cidr": "192.168.1.0/24",
+        "allocation_pools": [
+            {
+                "start": "192.168.1.2",
+                "end": "192.168.1.254"
+            }
+        ]
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "",
+        "enable_dhcp": true,
+        "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "allocation_pools": [
+            {
+                "start": "192.168.1.2",
+                "end": "192.168.1.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "192.168.1.1",
+        "cidr": "192.168.1.0/24",
+        "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0c"
+    }
+}
+		`)
+	})
+
 	opts := subnets.CreateOpts{
 		NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a23",
 		IPVersion: 4,
@@ -387,7 +474,7 @@
 	})
 	th.AssertDeepEquals(t, s.HostRoutes, []subnets.HostRoute{})
 	th.AssertEquals(t, s.IPVersion, 4)
-	th.AssertEquals(t, s.GatewayIP, "")
+	th.AssertEquals(t, s.GatewayIP, "192.168.1.1")
 	th.AssertEquals(t, s.CIDR, "192.168.1.0/24")
 	th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0c")
 }
@@ -422,8 +509,8 @@
 {
     "subnet": {
         "name": "my_new_subnet",
-				"dns_nameservers": ["foo"],
-				"host_routes": [{"destination":"","nexthop": "bar"}]
+        "dns_nameservers": ["foo"],
+        "host_routes": [{"destination":"","nexthop": "bar"}]
     }
 }
 		`)
@@ -469,6 +556,122 @@
 	th.AssertEquals(t, s.ID, "08eae331-0402-425a-923c-34f7cfe39c1b")
 }
 
+func TestUpdateGateway(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "subnet": {
+        "name": "my_new_subnet",
+        "gateway_ip": "10.0.0.1"
+    }
+}
+		`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "my_new_subnet",
+        "enable_dhcp": true,
+        "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+        "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "10.0.0.2",
+                "end": "10.0.0.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "10.0.0.1",
+        "cidr": "10.0.0.0/24",
+        "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+    }
+}
+	`)
+	})
+
+	var gatewayIP = "10.0.0.1"
+	opts := subnets.UpdateOpts{
+		Name:      "my_new_subnet",
+		GatewayIP: &gatewayIP,
+	}
+	s, err := subnets.Update(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b", opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "my_new_subnet")
+	th.AssertEquals(t, s.ID, "08eae331-0402-425a-923c-34f7cfe39c1b")
+	th.AssertEquals(t, s.GatewayIP, "10.0.0.1")
+}
+
+func TestUpdateRemoveGateway(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "subnet": {
+        "name": "my_new_subnet",
+        "gateway_ip": null
+    }
+}
+		`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "my_new_subnet",
+        "enable_dhcp": true,
+        "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+        "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "10.0.0.2",
+                "end": "10.0.0.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": null,
+        "cidr": "10.0.0.0/24",
+        "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+    }
+}
+	`)
+	})
+
+	var noGateway = ""
+	opts := subnets.UpdateOpts{
+		Name:      "my_new_subnet",
+		GatewayIP: &noGateway,
+	}
+	s, err := subnets.Update(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b", opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "my_new_subnet")
+	th.AssertEquals(t, s.ID, "08eae331-0402-425a-923c-34f7cfe39c1b")
+	th.AssertEquals(t, s.GatewayIP, "")
+}
+
 func TestDelete(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()