Merge pull request #207 from DonSchenck/win-issues

Added very small parenthetical note regarding Windows.
diff --git a/acceptance/openstack/networking/v2/apiversion_test.go b/acceptance/openstack/networking/v2/apiversion_test.go
new file mode 100644
index 0000000..99e1d01
--- /dev/null
+++ b/acceptance/openstack/networking/v2/apiversion_test.go
@@ -0,0 +1,51 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/networking/v2/apiversions"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListAPIVersions(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := apiversions.ListVersions(Client)
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		versions, err := apiversions.ExtractAPIVersions(page)
+		th.AssertNoErr(t, err)
+
+		for _, v := range versions {
+			t.Logf("API Version: ID [%s] Status [%s]", v.ID, v.Status)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestListAPIResources(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := apiversions.ListVersionResources(Client, "v2.0")
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		vrs, err := apiversions.ExtractVersionResources(page)
+		th.AssertNoErr(t, err)
+
+		for _, vr := range vrs {
+			t.Logf("Network: Name [%s] Collection [%s]", vr.Name, vr.Collection)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
diff --git a/acceptance/openstack/networking/v2/common.go b/acceptance/openstack/networking/v2/common.go
new file mode 100644
index 0000000..6dd58af
--- /dev/null
+++ b/acceptance/openstack/networking/v2/common.go
@@ -0,0 +1,40 @@
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack"
+	"github.com/rackspace/gophercloud/openstack/utils"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+var Client *gophercloud.ServiceClient
+
+func NewClient() (*gophercloud.ServiceClient, error) {
+	opts, err := utils.AuthOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	provider, err := openstack.AuthenticatedClient(opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{
+		Name:   "neutron",
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+}
+
+func Setup(t *testing.T) {
+	client, err := NewClient()
+	th.AssertNoErr(t, err)
+	Client = client
+}
+
+func Teardown() {
+	Client = nil
+}
diff --git a/acceptance/openstack/networking/v2/extension_test.go b/acceptance/openstack/networking/v2/extension_test.go
new file mode 100644
index 0000000..edcbba4
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extension_test.go
@@ -0,0 +1,45 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/networking/v2/extensions"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListExts(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := extensions.List(Client)
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		exts, err := extensions.ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+
+		for _, ext := range exts {
+			t.Logf("Extension: Name [%s] Description [%s]", ext.Name, ext.Description)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestGetExt(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	ext, err := extensions.Get(Client, "service-type").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, ext.Updated, "2013-01-20T00:00:00-00:00")
+	th.AssertEquals(t, ext.Name, "Neutron Service Type Management")
+	th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/neutron/service-type/api/v1.0")
+	th.AssertEquals(t, ext.Alias, "service-type")
+	th.AssertEquals(t, ext.Description, "API for retrieving service providers for Neutron advanced services")
+}
diff --git a/acceptance/openstack/networking/v2/network_test.go b/acceptance/openstack/networking/v2/network_test.go
new file mode 100644
index 0000000..9f18597
--- /dev/null
+++ b/acceptance/openstack/networking/v2/network_test.go
@@ -0,0 +1,67 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"strconv"
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestNetworkCRUDOperations(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	// Create a network
+	n, err := networks.Create(Client, networks.CreateOpts{Name: "sample_network", AdminStateUp: true}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Name, "sample_network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	networkID := n.ID
+
+	// List networks
+	pager := networks.List(Client, networks.ListOpts{Limit: 2})
+	err = pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		networkList, err := networks.ExtractNetworks(page)
+		th.AssertNoErr(t, err)
+
+		for _, n := range networkList {
+			t.Logf("Network: ID [%s] Name [%s] Status [%s] Is shared? [%s]",
+				n.ID, n.Name, n.Status, strconv.FormatBool(n.Shared))
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+
+	// Get a network
+	if networkID == "" {
+		t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+	}
+	n, err = networks.Get(Client, networkID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{})
+	th.AssertEquals(t, n.Name, "sample_network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.Shared, false)
+	th.AssertEquals(t, n.ID, networkID)
+
+	// Update network
+	n, err = networks.Update(Client, networkID, networks.UpdateOpts{Name: "new_network_name"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Name, "new_network_name")
+
+	// Delete network
+	res := networks.Delete(Client, networkID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateMultipleNetworks(t *testing.T) {
+	//networks.CreateMany()
+}
diff --git a/acceptance/openstack/networking/v2/pkg.go b/acceptance/openstack/networking/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/openstack/networking/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/openstack/networking/v2/port_test.go b/acceptance/openstack/networking/v2/port_test.go
new file mode 100644
index 0000000..363a7fe
--- /dev/null
+++ b/acceptance/openstack/networking/v2/port_test.go
@@ -0,0 +1,118 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestPortCRUD(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	// Setup network
+	t.Log("Setting up network")
+	networkID, err := createNetwork()
+	th.AssertNoErr(t, err)
+	defer networks.Delete(Client, networkID)
+
+	// Setup subnet
+	t.Logf("Setting up subnet on network %s", networkID)
+	subnetID, err := createSubnet(networkID)
+	th.AssertNoErr(t, err)
+	defer subnets.Delete(Client, subnetID)
+
+	// Create port
+	t.Logf("Create port based on subnet %s", subnetID)
+	portID := createPort(t, networkID, subnetID)
+
+	// List ports
+	t.Logf("Listing all ports")
+	listPorts(t)
+
+	// Get port
+	if portID == "" {
+		t.Fatalf("In order to retrieve a port, the portID must be set")
+	}
+	p, err := ports.Get(Client, portID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.ID, portID)
+
+	// Update port
+	p, err = ports.Update(Client, portID, ports.UpdateOpts{Name: "new_port_name"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.Name, "new_port_name")
+
+	// Delete port
+	res := ports.Delete(Client, portID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func createPort(t *testing.T, networkID, subnetID string) string {
+	enable := false
+	opts := ports.CreateOpts{
+		NetworkID:    networkID,
+		Name:         "my_port",
+		AdminStateUp: &enable,
+		FixedIPs:     []ports.IP{ports.IP{SubnetID: subnetID}},
+	}
+	p, err := ports.Create(Client, opts).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.NetworkID, networkID)
+	th.AssertEquals(t, p.Name, "my_port")
+	th.AssertEquals(t, p.AdminStateUp, false)
+
+	return p.ID
+}
+
+func listPorts(t *testing.T) {
+	count := 0
+	pager := ports.List(Client, ports.ListOpts{})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		t.Logf("--- Page ---")
+
+		portList, err := ports.ExtractPorts(page)
+		th.AssertNoErr(t, err)
+
+		for _, p := range portList {
+			t.Logf("Port: ID [%s] Name [%s] Status [%d] MAC addr [%s] Fixed IPs [%#v] Security groups [%#v]",
+				p.ID, p.Name, p.Status, p.MACAddress, p.FixedIPs, p.SecurityGroups)
+		}
+
+		return true, nil
+	})
+
+	th.CheckNoErr(t, err)
+
+	if count == 0 {
+		t.Logf("No pages were iterated over when listing ports")
+	}
+}
+
+func createNetwork() (string, error) {
+	res, err := networks.Create(Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: true}).Extract()
+	return res.ID, err
+}
+
+func createSubnet(networkID string) (string, error) {
+	enable := false
+	s, err := subnets.Create(Client, subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       "192.168.199.0/24",
+		IPVersion:  subnets.IPv4,
+		Name:       "my_subnet",
+		EnableDHCP: &enable,
+	}).Extract()
+	return s.ID, err
+}
+
+func TestPortBatchCreate(t *testing.T) {
+	// todo
+}
diff --git a/acceptance/openstack/networking/v2/subnet_test.go b/acceptance/openstack/networking/v2/subnet_test.go
new file mode 100644
index 0000000..2758e2a
--- /dev/null
+++ b/acceptance/openstack/networking/v2/subnet_test.go
@@ -0,0 +1,86 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := subnets.List(Client, subnets.ListOpts{Limit: 2})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		subnetList, err := subnets.ExtractSubnets(page)
+		th.AssertNoErr(t, err)
+
+		for _, s := range subnetList {
+			t.Logf("Subnet: ID [%s] Name [%s] IP Version [%d] CIDR [%s] GatewayIP [%s]",
+				s.ID, s.Name, s.IPVersion, s.CIDR, s.GatewayIP)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestCRUD(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	// Setup network
+	t.Log("Setting up network")
+	n, err := networks.Create(Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: true}).Extract()
+	th.AssertNoErr(t, err)
+	networkID := n.ID
+	defer networks.Delete(Client, networkID)
+
+	// Create subnet
+	t.Log("Create subnet")
+	enable := false
+	opts := subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       "192.168.199.0/24",
+		IPVersion:  subnets.IPv4,
+		Name:       "my_subnet",
+		EnableDHCP: &enable,
+	}
+	s, err := subnets.Create(Client, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.NetworkID, networkID)
+	th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.Name, "my_subnet")
+	th.AssertEquals(t, s.EnableDHCP, false)
+	subnetID := s.ID
+
+	// Get subnet
+	t.Log("Getting subnet")
+	s, err = subnets.Get(Client, subnetID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, s.ID, subnetID)
+
+	// Update subnet
+	t.Log("Update subnet")
+	s, err = subnets.Update(Client, subnetID, subnets.UpdateOpts{Name: "new_subnet_name"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, s.Name, "new_subnet_name")
+
+	// Delete subnet
+	t.Log("Delete subnet")
+	res := subnets.Delete(Client, subnetID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestBatchCreate(t *testing.T) {
+	// todo
+}
diff --git a/openstack/client.go b/openstack/client.go
index 4fcf057..a73f301 100644
--- a/openstack/client.go
+++ b/openstack/client.go
@@ -293,3 +293,12 @@
 	}
 	return &gophercloud.ServiceClient{Provider: client, Endpoint: url}, nil
 }
+
+func NewNetworkV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("network")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{Provider: client, Endpoint: url}, nil
+}
diff --git a/openstack/networking/v2/apiversions/doc.go b/openstack/networking/v2/apiversions/doc.go
new file mode 100644
index 0000000..0208ee2
--- /dev/null
+++ b/openstack/networking/v2/apiversions/doc.go
@@ -0,0 +1,4 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Neutron service. This functionality is not
+// restricted to this particular version.
+package apiversions
diff --git a/openstack/networking/v2/apiversions/errors.go b/openstack/networking/v2/apiversions/errors.go
new file mode 100644
index 0000000..76bdb14
--- /dev/null
+++ b/openstack/networking/v2/apiversions/errors.go
@@ -0,0 +1 @@
+package apiversions
diff --git a/openstack/networking/v2/apiversions/requests.go b/openstack/networking/v2/apiversions/requests.go
new file mode 100644
index 0000000..b653f51
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests.go
@@ -0,0 +1,21 @@
+package apiversions
+
+import (
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// ListVersions lists all the Neutron API versions available to end-users
+func ListVersions(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, apiVersionsURL(c), func(r pagination.LastHTTPResponse) pagination.Page {
+		return APIVersionPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// ListVersionResources lists all of the different API resources for a particular
+// API versions. Typical resources for Neutron might be: networks, subnets, etc.
+func ListVersionResources(c *gophercloud.ServiceClient, v string) pagination.Pager {
+	return pagination.NewPager(c, apiInfoURL(c, v), func(r pagination.LastHTTPResponse) pagination.Page {
+		return APIVersionResourcePage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/networking/v2/apiversions/requests_test.go b/openstack/networking/v2/apiversions/requests_test.go
new file mode 100644
index 0000000..7c713e1
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests_test.go
@@ -0,0 +1,161 @@
+package apiversions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const TokenID = "123"
+
+func ServiceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: TokenID,
+		},
+		Endpoint: th.Endpoint(),
+	}
+}
+
+func TestListVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "versions": [
+        {
+            "status": "CURRENT",
+            "id": "v2.0",
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0",
+                    "rel": "self"
+                }
+            ]
+        }
+    ]
+}`)
+	})
+
+	count := 0
+
+	ListVersions(ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractAPIVersions(page)
+		if err != nil {
+			t.Errorf("Failed to extract API versions: %v", err)
+			return false, err
+		}
+
+		expected := []APIVersion{
+			APIVersion{
+				Status: "CURRENT",
+				ID:     "v2.0",
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestAPIInfo(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "resources": [
+        {
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0/subnets",
+                    "rel": "self"
+                }
+            ],
+            "name": "subnet",
+            "collection": "subnets"
+        },
+        {
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0/networks",
+                    "rel": "self"
+                }
+            ],
+            "name": "network",
+            "collection": "networks"
+        },
+        {
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0/ports",
+                    "rel": "self"
+                }
+            ],
+            "name": "port",
+            "collection": "ports"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	ListVersionResources(ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractVersionResources(page)
+		if err != nil {
+			t.Errorf("Failed to extract version resources: %v", err)
+			return false, err
+		}
+
+		expected := []APIVersionResource{
+			APIVersionResource{
+				Name:       "subnet",
+				Collection: "subnets",
+			},
+			APIVersionResource{
+				Name:       "network",
+				Collection: "networks",
+			},
+			APIVersionResource{
+				Name:       "port",
+				Collection: "ports",
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
diff --git a/openstack/networking/v2/apiversions/results.go b/openstack/networking/v2/apiversions/results.go
new file mode 100644
index 0000000..e13998d
--- /dev/null
+++ b/openstack/networking/v2/apiversions/results.go
@@ -0,0 +1,83 @@
+package apiversions
+
+import (
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Neutron. It contains the status of
+// the API, and its unique ID.
+type APIVersion struct {
+	Status string `mapstructure:"status" json:"status"`
+	ID     string `mapstructure:"id" json:"id"`
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+	is, err := ExtractAPIVersions(r)
+	if err != nil {
+		return true, err
+	}
+	return len(is) == 0, nil
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(page pagination.Page) ([]APIVersion, error) {
+	var resp struct {
+		Versions []APIVersion `mapstructure:"versions"`
+	}
+
+	err := mapstructure.Decode(page.(APIVersionPage).Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Versions, nil
+}
+
+// APIVersionResource represents a generic API resource. It contains the name
+// of the resource and its plural collection name.
+type APIVersionResource struct {
+	Name       string `mapstructure:"name" json:"name"`
+	Collection string `mapstructure:"collection" json:"collection"`
+}
+
+// APIVersionResourcePage is a concrete type which embeds the common
+// SinglePageBase struct, and is used when traversing API versions collections.
+type APIVersionResourcePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty is a concrete function which indicates whether an
+// APIVersionResourcePage is empty or not.
+func (r APIVersionResourcePage) IsEmpty() (bool, error) {
+	is, err := ExtractVersionResources(r)
+	if err != nil {
+		return true, err
+	}
+	return len(is) == 0, nil
+}
+
+// ExtractVersionResources accepts a Page struct, specifically a
+// APIVersionResourcePage struct, and extracts the elements into a slice of
+// APIVersionResource structs. In other words, the collection is mapped into
+// a relevant slice.
+func ExtractVersionResources(page pagination.Page) ([]APIVersionResource, error) {
+	var resp struct {
+		APIVersionResources []APIVersionResource `mapstructure:"resources"`
+	}
+
+	err := mapstructure.Decode(page.(APIVersionResourcePage).Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.APIVersionResources, nil
+}
diff --git a/openstack/networking/v2/apiversions/urls.go b/openstack/networking/v2/apiversions/urls.go
new file mode 100644
index 0000000..c43f991
--- /dev/null
+++ b/openstack/networking/v2/apiversions/urls.go
@@ -0,0 +1,15 @@
+package apiversions
+
+import (
+	"strings"
+
+	"github.com/rackspace/gophercloud"
+)
+
+func apiVersionsURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("")
+}
+
+func apiInfoURL(c *gophercloud.ServiceClient, version string) string {
+	return c.ServiceURL(strings.TrimRight(version, "/") + "/")
+}
diff --git a/openstack/networking/v2/apiversions/urls_test.go b/openstack/networking/v2/apiversions/urls_test.go
new file mode 100644
index 0000000..7dd069c
--- /dev/null
+++ b/openstack/networking/v2/apiversions/urls_test.go
@@ -0,0 +1,26 @@
+package apiversions
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestAPIVersionsURL(t *testing.T) {
+	actual := apiVersionsURL(endpointClient())
+	expected := endpoint
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestAPIInfoURL(t *testing.T) {
+	actual := apiInfoURL(endpointClient(), "v2.0")
+	expected := endpoint + "v2.0/"
+	th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/extensions/doc.go b/openstack/networking/v2/extensions/doc.go
new file mode 100644
index 0000000..7942c39
--- /dev/null
+++ b/openstack/networking/v2/extensions/doc.go
@@ -0,0 +1,15 @@
+// Package extensions provides information and interaction with the different
+// extensions available for the OpenStack Neutron service.
+//
+// The purpose of Networking API v2.0 extensions is to:
+//
+// - Introduce new features in the API without requiring a version change.
+// - Introduce vendor-specific niche functionality.
+// - Act as a proving ground for experimental functionalities that might be
+//   included in a future version of the API.
+//
+// Extensions usually have tags that prevent conflicts with other extensions
+// that define attributes or resources with the same names, and with core
+// resources and attributes. Because an extension might not be supported by all
+// plug-ins, its availability varies with deployments and the specific plug-in.
+package extensions
diff --git a/openstack/networking/v2/extensions/errors.go b/openstack/networking/v2/extensions/errors.go
new file mode 100644
index 0000000..aeec0fa
--- /dev/null
+++ b/openstack/networking/v2/extensions/errors.go
@@ -0,0 +1 @@
+package extensions
diff --git a/openstack/networking/v2/extensions/requests.go b/openstack/networking/v2/extensions/requests.go
new file mode 100644
index 0000000..8f7fd67
--- /dev/null
+++ b/openstack/networking/v2/extensions/requests.go
@@ -0,0 +1,27 @@
+package extensions
+
+import (
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) GetResult {
+	var res GetResult
+	_, err := perigee.Request("GET", extensionURL(c, alias), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		Results:     &res.Resp,
+		OkCodes:     []int{200},
+	})
+	res.Err = err
+	return res
+}
+
+// List returns a Pager which allows you to iterate over the full collection of
+// extensions. It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, listExtensionURL(c), func(r pagination.LastHTTPResponse) pagination.Page {
+		return ExtensionPage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/networking/v2/extensions/requests_test.go b/openstack/networking/v2/extensions/requests_test.go
new file mode 100644
index 0000000..5741280
--- /dev/null
+++ b/openstack/networking/v2/extensions/requests_test.go
@@ -0,0 +1,113 @@
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const TokenID = "123"
+
+func ServiceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: TokenID,
+		},
+		Endpoint: th.Endpoint(),
+	}
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, `
+{
+    "extensions": [
+        {
+            "updated": "2013-01-20T00:00:00-00:00",
+            "name": "Neutron Service Type Management",
+            "links": [],
+            "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+            "alias": "service-type",
+            "description": "API for retrieving service providers for Neutron advanced services"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	List(ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractExtensions(page)
+		if err != nil {
+			t.Errorf("Failed to extract extensions: %v", err)
+		}
+
+		expected := []Extension{
+			Extension{
+				Updated:     "2013-01-20T00:00:00-00:00",
+				Name:        "Neutron Service Type Management",
+				Links:       []interface{}{},
+				Namespace:   "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+				Alias:       "service-type",
+				Description: "API for retrieving service providers for Neutron advanced services",
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "extension": {
+        "updated": "2013-02-03T10:00:00-00:00",
+        "name": "agent",
+        "links": [],
+        "namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+        "alias": "agent",
+        "description": "The agent management extension."
+    }
+}
+		`)
+
+		ext, err := Get(ServiceClient(), "agent").Extract()
+		th.AssertNoErr(t, err)
+
+		th.AssertEquals(t, ext.Updated, "2013-02-03T10:00:00-00:00")
+		th.AssertEquals(t, ext.Name, "agent")
+		th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/agent/api/v2.0")
+		th.AssertEquals(t, ext.Alias, "agent")
+		th.AssertEquals(t, ext.Description, "The agent management extension.")
+	})
+}
diff --git a/openstack/networking/v2/extensions/results.go b/openstack/networking/v2/extensions/results.go
new file mode 100644
index 0000000..2b8408d
--- /dev/null
+++ b/openstack/networking/v2/extensions/results.go
@@ -0,0 +1,71 @@
+package extensions
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type GetResult struct {
+	gophercloud.CommonResult
+}
+
+func (r GetResult) Extract() (*Extension, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+
+	var res struct {
+		Extension *Extension `json:"extension"`
+	}
+
+	err := mapstructure.Decode(r.Resp, &res)
+	if err != nil {
+		return nil, fmt.Errorf("Error decoding Neutron extension: %v", err)
+	}
+
+	return res.Extension, nil
+}
+
+// Extension is a struct that represents a Neutron extension.
+type Extension struct {
+	Updated     string        `json:"updated"`
+	Name        string        `json:"name"`
+	Links       []interface{} `json:"links"`
+	Namespace   string        `json:"namespace"`
+	Alias       string        `json:"alias"`
+	Description string        `json:"description"`
+}
+
+// ExtensionPage is the page returned by a pager when traversing over a
+// collection of extensions.
+type ExtensionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an ExtensionPage struct is empty.
+func (r ExtensionPage) IsEmpty() (bool, error) {
+	is, err := ExtractExtensions(r)
+	if err != nil {
+		return true, err
+	}
+	return len(is) == 0, nil
+}
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage
+// struct, and extracts the elements into a slice of Extension structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractExtensions(page pagination.Page) ([]Extension, error) {
+	var resp struct {
+		Extensions []Extension `mapstructure:"extensions"`
+	}
+
+	err := mapstructure.Decode(page.(ExtensionPage).Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Extensions, nil
+}
diff --git a/openstack/networking/v2/extensions/urls.go b/openstack/networking/v2/extensions/urls.go
new file mode 100644
index 0000000..e31e76c
--- /dev/null
+++ b/openstack/networking/v2/extensions/urls.go
@@ -0,0 +1,13 @@
+package extensions
+
+import "github.com/rackspace/gophercloud"
+
+const version = "v2.0"
+
+func extensionURL(c *gophercloud.ServiceClient, name string) string {
+	return c.ServiceURL(version, "extensions", name)
+}
+
+func listExtensionURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(version, "extensions")
+}
diff --git a/openstack/networking/v2/extensions/urls_test.go b/openstack/networking/v2/extensions/urls_test.go
new file mode 100644
index 0000000..2a1e6a1
--- /dev/null
+++ b/openstack/networking/v2/extensions/urls_test.go
@@ -0,0 +1,26 @@
+package extensions
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestExtensionURL(t *testing.T) {
+	actual := extensionURL(endpointClient(), "agent")
+	expected := endpoint + "v2.0/extensions/agent"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestListExtensionURL(t *testing.T) {
+	actual := listExtensionURL(endpointClient())
+	expected := endpoint + "v2.0/extensions"
+	th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/networks/doc.go b/openstack/networking/v2/networks/doc.go
new file mode 100644
index 0000000..c87a7ce
--- /dev/null
+++ b/openstack/networking/v2/networks/doc.go
@@ -0,0 +1,9 @@
+// Package networks contains functionality for working with Neutron network
+// resources. A network is an isolated virtual layer-2 broadcast domain that is
+// typically reserved for the tenant who created it (unless you configure the
+// network to be shared). Tenants can create multiple networks until the
+// thresholds per-tenant quota is reached.
+//
+// In the v2.0 Networking API, the network is the main entity. Ports and subnets
+// are always associated with a network.
+package networks
diff --git a/openstack/networking/v2/networks/errors.go b/openstack/networking/v2/networks/errors.go
new file mode 100644
index 0000000..83c4a6a
--- /dev/null
+++ b/openstack/networking/v2/networks/errors.go
@@ -0,0 +1 @@
+package networks
diff --git a/openstack/networking/v2/networks/requests.go b/openstack/networking/v2/networks/requests.go
new file mode 100644
index 0000000..0d74eb5
--- /dev/null
+++ b/openstack/networking/v2/networks/requests.go
@@ -0,0 +1,182 @@
+package networks
+
+import (
+	"strconv"
+
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/utils"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type networkOpts struct {
+	AdminStateUp bool
+	Name         string
+	Shared       *bool
+	TenantID     string
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the network attributes you want to see returned. SortKey allows you to sort
+// by a particular network attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status       string
+	Name         string
+	AdminStateUp *bool
+	TenantID     string
+	Shared       *bool
+	ID           string
+	Marker       string
+	Limit        int
+	SortKey      string
+	SortDir      string
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// networks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	// Build query parameters
+	q := make(map[string]string)
+	if opts.Status != "" {
+		q["status"] = opts.Status
+	}
+	if opts.Name != "" {
+		q["name"] = opts.Name
+	}
+	if opts.AdminStateUp != nil {
+		q["admin_state_up"] = strconv.FormatBool(*opts.AdminStateUp)
+	}
+	if opts.TenantID != "" {
+		q["tenant_id"] = opts.TenantID
+	}
+	if opts.Shared != nil {
+		q["shared"] = strconv.FormatBool(*opts.Shared)
+	}
+	if opts.ID != "" {
+		q["id"] = opts.ID
+	}
+	if opts.Marker != "" {
+		q["marker"] = opts.Marker
+	}
+	if opts.Limit != 0 {
+		q["limit"] = strconv.Itoa(opts.Limit)
+	}
+	if opts.SortKey != "" {
+		q["sort_key"] = opts.SortKey
+	}
+	if opts.SortDir != "" {
+		q["sort_dir"] = opts.SortDir
+	}
+
+	u := listURL(c) + utils.BuildQuery(q)
+	return pagination.NewPager(c, u, func(r pagination.LastHTTPResponse) pagination.Page {
+		return NetworkPage{pagination.LinkedPageBase(r)}
+	})
+}
+
+// Get retrieves a specific network based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+	var res GetResult
+	_, err := perigee.Request("GET", getURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		Results:     &res.Resp,
+		OkCodes:     []int{200},
+	})
+	res.Err = err
+	return res
+}
+
+// CreateOpts represents the attributes used when creating a new network.
+type CreateOpts networkOpts
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. This operation does not actually require a request body, i.e. the
+// CreateOpts struct argument can be empty.
+//
+// The tenant ID that is contained in the URI is the tenant that creates the
+// network. An admin user, however, has the option of specifying another tenant
+// ID in the CreateOpts struct.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+	// Define structures
+	type network struct {
+		AdminStateUp bool    `json:"admin_state_up,omitempty"`
+		Name         string  `json:"name,omitempty"`
+		Shared       *bool   `json:"shared,omitempty"`
+		TenantID     *string `json:"tenant_id,omitempty"`
+	}
+	type request struct {
+		Network network `json:"network"`
+	}
+
+	// Populate request body
+	reqBody := request{Network: network{
+		AdminStateUp: opts.AdminStateUp,
+		Name:         opts.Name,
+		Shared:       opts.Shared,
+	}}
+
+	if opts.TenantID != "" {
+		reqBody.Network.TenantID = &opts.TenantID
+	}
+
+	// Send request to API
+	var res CreateResult
+	_, err := perigee.Request("POST", createURL(c), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &res.Resp,
+		OkCodes:     []int{201},
+	})
+	res.Err = err
+	return res
+}
+
+// UpdateOpts represents the attributes used when updating an existing network.
+type UpdateOpts networkOpts
+
+// Update accepts a UpdateOpts struct and updates an existing network using the
+// values provided. For more information, see the Create function.
+func Update(c *gophercloud.ServiceClient, networkID string, opts UpdateOpts) UpdateResult {
+	// Define structures
+	type network struct {
+		AdminStateUp bool   `json:"admin_state_up"`
+		Name         string `json:"name"`
+		Shared       *bool  `json:"shared,omitempty"`
+	}
+
+	type request struct {
+		Network network `json:"network"`
+	}
+
+	// Populate request body
+	reqBody := request{Network: network{
+		AdminStateUp: opts.AdminStateUp,
+		Name:         opts.Name,
+		Shared:       opts.Shared,
+	}}
+
+	// Send request to API
+	var res UpdateResult
+	_, err := perigee.Request("PUT", getURL(c, networkID), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &res.Resp,
+		OkCodes:     []int{200, 201},
+	})
+	res.Err = err
+	return res
+}
+
+// Delete accepts a unique ID and deletes the network associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) DeleteResult {
+	var res DeleteResult
+	_, err := perigee.Request("DELETE", deleteURL(c, networkID), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		OkCodes:     []int{204},
+	})
+	res.Err = err
+	return res
+}
diff --git a/openstack/networking/v2/networks/requests_test.go b/openstack/networking/v2/networks/requests_test.go
new file mode 100644
index 0000000..4c50b87
--- /dev/null
+++ b/openstack/networking/v2/networks/requests_test.go
@@ -0,0 +1,285 @@
+package networks
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const TokenID = "123"
+
+func ServiceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: TokenID,
+		},
+		Endpoint: th.Endpoint(),
+	}
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "networks": [
+        {
+            "status": "ACTIVE",
+            "subnets": [
+                "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+            ],
+            "name": "private-network",
+            "admin_state_up": true,
+            "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+            "shared": true,
+            "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+        },
+        {
+            "status": "ACTIVE",
+            "subnets": [
+                "08eae331-0402-425a-923c-34f7cfe39c1b"
+            ],
+            "name": "private",
+            "admin_state_up": true,
+            "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+            "shared": true,
+            "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324"
+        }
+    ]
+}
+			`)
+	})
+
+	client := ServiceClient()
+	count := 0
+
+	List(client, ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractNetworks(page)
+		if err != nil {
+			t.Errorf("Failed to extract networks: %v", err)
+			return false, err
+		}
+
+		expected := []Network{
+			Network{
+				Status:       "ACTIVE",
+				Subnets:      []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"},
+				Name:         "private-network",
+				AdminStateUp: true,
+				TenantID:     "4fd44f30292945e481c7b8a0c8908869",
+				Shared:       true,
+				ID:           "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+			},
+			Network{
+				Status:       "ACTIVE",
+				Subnets:      []string{"08eae331-0402-425a-923c-34f7cfe39c1b"},
+				Name:         "private",
+				AdminStateUp: true,
+				TenantID:     "26a7980765d0414dbc1fc1f88cdb7e6e",
+				Shared:       true,
+				ID:           "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [
+            "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        ],
+        "name": "private-network",
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "shared": true,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+			`)
+	})
+
+	n, err := Get(ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"})
+	th.AssertEquals(t, n.Name, "private-network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertEquals(t, n.Shared, true)
+	th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "network": {
+        "name": "sample_network",
+        "admin_state_up": true
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [],
+        "name": "net1",
+        "admin_state_up": true,
+        "tenant_id": "9bacb3c5d39d41a79512987f338cf177",
+        "shared": false,
+        "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+    }
+}
+		`)
+	})
+
+	options := CreateOpts{Name: "sample_network", AdminStateUp: true}
+	n, err := Create(ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{})
+	th.AssertEquals(t, n.Name, "net1")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.TenantID, "9bacb3c5d39d41a79512987f338cf177")
+	th.AssertEquals(t, n.Shared, false)
+	th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestCreateWithOptionalFields(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+	"network": {
+			"name": "sample_network",
+			"admin_state_up": true,
+			"shared": true,
+			"tenant_id": "12345"
+	}
+}
+		`)
+
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	shared := true
+	options := CreateOpts{Name: "sample_network", AdminStateUp: true, Shared: &shared, TenantID: "12345"}
+	_, err := Create(ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"network": {
+				"name": "new_network_name",
+				"admin_state_up": false,
+				"shared": true
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [],
+        "name": "new_network_name",
+        "admin_state_up": false,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "shared": true,
+        "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+    }
+}
+		`)
+	})
+
+	shared := true
+	options := UpdateOpts{Name: "new_network_name", AdminStateUp: false, Shared: &shared}
+	n, err := Update(ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Name, "new_network_name")
+	th.AssertEquals(t, n.AdminStateUp, false)
+	th.AssertEquals(t, n.Shared, true)
+	th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/networks/results.go b/openstack/networking/v2/networks/results.go
new file mode 100644
index 0000000..91182a4
--- /dev/null
+++ b/openstack/networking/v2/networks/results.go
@@ -0,0 +1,130 @@
+package networks
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.CommonResult
+}
+
+// Extract is a function that accepts a result and extracts a network resource.
+func (r commonResult) Extract() (*Network, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+
+	var res struct {
+		Network *Network `json:"network"`
+	}
+
+	err := mapstructure.Decode(r.Resp, &res)
+	if err != nil {
+		return nil, fmt.Errorf("Error decoding Neutron network: %v", err)
+	}
+
+	return res.Network, nil
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult commonResult
+
+// Network represents, well, a network.
+type Network struct {
+	// UUID for the network
+	ID string `mapstructure:"id" json:"id"`
+	// Human-readable name for the network. Might not be unique.
+	Name string `mapstructure:"name" json:"name"`
+	// The administrative state of network. If false (down), the network does not forward packets.
+	AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+	// Indicates whether network is currently operational. Possible values include
+	// `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+	Status string `mapstructure:"status" json:"status"`
+	// Subnets associated with this network.
+	Subnets []string `mapstructure:"subnets" json:"subnets"`
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+	// Specifies whether the network resource can be accessed by any tenant or not.
+	Shared bool `mapstructure:"shared" json:"shared"`
+}
+
+// NetworkPage is the page returned by a pager when traversing over a
+// collection of networks.
+type NetworkPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of networks has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p NetworkPage) NextPageURL() (string, error) {
+	type link struct {
+		Href string `mapstructure:"href"`
+		Rel  string `mapstructure:"rel"`
+	}
+	type resp struct {
+		Links []link `mapstructure:"networks_links"`
+	}
+
+	var r resp
+	err := mapstructure.Decode(p.Body, &r)
+	if err != nil {
+		return "", err
+	}
+
+	var url string
+	for _, l := range r.Links {
+		if l.Rel == "next" {
+			url = l.Href
+		}
+	}
+	if url == "" {
+		return "", nil
+	}
+
+	return url, nil
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (p NetworkPage) IsEmpty() (bool, error) {
+	is, err := ExtractNetworks(p)
+	if err != nil {
+		return true, nil
+	}
+	return len(is) == 0, nil
+}
+
+// ExtractNetworks accepts a Page struct, specifically a NetworkPage struct,
+// and extracts the elements into a slice of Network structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractNetworks(page pagination.Page) ([]Network, error) {
+	var resp struct {
+		Networks []Network `mapstructure:"networks" json:"networks"`
+	}
+
+	err := mapstructure.Decode(page.(NetworkPage).Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Networks, nil
+}
diff --git a/openstack/networking/v2/networks/urls.go b/openstack/networking/v2/networks/urls.go
new file mode 100644
index 0000000..80c307c
--- /dev/null
+++ b/openstack/networking/v2/networks/urls.go
@@ -0,0 +1,29 @@
+package networks
+
+import "github.com/rackspace/gophercloud"
+
+const Version = "v2.0"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(Version, "networks", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(Version, "networks")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/networks/urls_test.go b/openstack/networking/v2/networks/urls_test.go
new file mode 100644
index 0000000..713a547
--- /dev/null
+++ b/openstack/networking/v2/networks/urls_test.go
@@ -0,0 +1,38 @@
+package networks
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestGetURL(t *testing.T) {
+	actual := getURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/networks/foo"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+	actual := createURL(endpointClient())
+	expected := endpoint + "v2.0/networks"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+	actual := createURL(endpointClient())
+	expected := endpoint + "v2.0/networks"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+	actual := deleteURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/networks/foo"
+	th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/ports/doc.go b/openstack/networking/v2/ports/doc.go
new file mode 100644
index 0000000..f16a4bb
--- /dev/null
+++ b/openstack/networking/v2/ports/doc.go
@@ -0,0 +1,8 @@
+// Package ports contains functionality for working with Neutron port resources.
+// A port represents a virtual switch port on a logical network switch. Virtual
+// instances attach their interfaces into ports. The logical port also defines
+// the MAC address and the IP address(es) to be assigned to the interfaces
+// plugged into them. When IP addresses are associated to a port, this also
+// implies the port is associated with a subnet, as the IP address was taken
+// from the allocation pool for a specific subnet.
+package ports
diff --git a/openstack/networking/v2/ports/errors.go b/openstack/networking/v2/ports/errors.go
new file mode 100644
index 0000000..111d977
--- /dev/null
+++ b/openstack/networking/v2/ports/errors.go
@@ -0,0 +1,11 @@
+package ports
+
+import "fmt"
+
+func err(str string) error {
+	return fmt.Errorf("%s", str)
+}
+
+var (
+	errNetworkIDRequired = err("A Network ID is required")
+)
diff --git a/openstack/networking/v2/ports/requests.go b/openstack/networking/v2/ports/requests.go
new file mode 100644
index 0000000..3b7e605
--- /dev/null
+++ b/openstack/networking/v2/ports/requests.go
@@ -0,0 +1,248 @@
+package ports
+
+import (
+	"strconv"
+
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/utils"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the port attributes you want to see returned. SortKey allows you to sort
+// by a particular port attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status          string
+	Name            string
+	AdminStateUp    *bool
+	NetworkID       string
+	TenantID        string
+	DeviceOwner     string
+	MACAddress      string
+	ID              string
+	DeviceID        string
+	BindingHostID   string
+	BindingVIFType  string
+	BindingVNICType string
+	Limit           int
+	Marker          string
+	SortKey         string
+	SortDir         string
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// ports. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those ports that are owned by the tenant
+// who submits the request, unless the request is submitted by an user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	// Build query parameters
+	q := make(map[string]string)
+	if opts.Status != "" {
+		q["status"] = opts.Status
+	}
+	if opts.Name != "" {
+		q["name"] = opts.Name
+	}
+	if opts.AdminStateUp != nil {
+		q["admin_state_up"] = strconv.FormatBool(*opts.AdminStateUp)
+	}
+	if opts.NetworkID != "" {
+		q["network_id"] = opts.NetworkID
+	}
+	if opts.TenantID != "" {
+		q["tenant_id"] = opts.TenantID
+	}
+	if opts.DeviceOwner != "" {
+		q["device_owner"] = opts.DeviceOwner
+	}
+	if opts.MACAddress != "" {
+		q["mac_address"] = opts.MACAddress
+	}
+	if opts.ID != "" {
+		q["id"] = opts.ID
+	}
+	if opts.DeviceID != "" {
+		q["device_id"] = opts.DeviceID
+	}
+	if opts.BindingHostID != "" {
+		q["binding:host_id"] = opts.BindingHostID
+	}
+	if opts.BindingVIFType != "" {
+		q["binding:vif_type"] = opts.BindingVIFType
+	}
+	if opts.BindingVNICType != "" {
+		q["binding:vnic_type"] = opts.BindingVNICType
+	}
+	if opts.NetworkID != "" {
+		q["network_id"] = opts.NetworkID
+	}
+	if opts.Limit != 0 {
+		q["limit"] = strconv.Itoa(opts.Limit)
+	}
+	if opts.Marker != "" {
+		q["marker"] = opts.Marker
+	}
+	if opts.SortKey != "" {
+		q["sort_key"] = opts.SortKey
+	}
+	if opts.SortDir != "" {
+		q["sort_dir"] = opts.SortDir
+	}
+
+	u := listURL(c) + utils.BuildQuery(q)
+	return pagination.NewPager(c, u, func(r pagination.LastHTTPResponse) pagination.Page {
+		return PortPage{pagination.LinkedPageBase(r)}
+	})
+}
+
+// Get retrieves a specific port based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+	var res GetResult
+	_, err := perigee.Request("GET", getURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		Results:     &res.Resp,
+		OkCodes:     []int{200},
+	})
+	res.Err = err
+	return res
+}
+
+// CreateOpts represents the attributes used when creating a new port.
+type CreateOpts struct {
+	NetworkID      string
+	Name           string
+	AdminStateUp   *bool
+	MACAddress     string
+	FixedIPs       interface{}
+	DeviceID       string
+	DeviceOwner    string
+	TenantID       string
+	SecurityGroups []string
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. You must remember to provide a NetworkID value.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+	var res CreateResult
+
+	type port struct {
+		NetworkID      string      `json:"network_id"`
+		Name           *string     `json:"name,omitempty"`
+		AdminStateUp   *bool       `json:"admin_state_up,omitempty"`
+		MACAddress     *string     `json:"mac_address,omitempty"`
+		FixedIPs       interface{} `json:"fixed_ips,omitempty"`
+		DeviceID       *string     `json:"device_id,omitempty"`
+		DeviceOwner    *string     `json:"device_owner,omitempty"`
+		TenantID       *string     `json:"tenant_id,omitempty"`
+		SecurityGroups []string    `json:"security_groups,omitempty"`
+	}
+	type request struct {
+		Port port `json:"port"`
+	}
+
+	// Validate
+	if opts.NetworkID == "" {
+		res.Err = errNetworkIDRequired
+		return res
+	}
+
+	// Populate request body
+	reqBody := request{Port: port{
+		NetworkID:    opts.NetworkID,
+		Name:         gophercloud.MaybeString(opts.Name),
+		AdminStateUp: opts.AdminStateUp,
+		TenantID:     gophercloud.MaybeString(opts.TenantID),
+		MACAddress:   gophercloud.MaybeString(opts.MACAddress),
+		DeviceID:     gophercloud.MaybeString(opts.DeviceID),
+		DeviceOwner:  gophercloud.MaybeString(opts.DeviceOwner),
+	}}
+
+	if opts.FixedIPs != nil {
+		reqBody.Port.FixedIPs = opts.FixedIPs
+	}
+
+	if opts.SecurityGroups != nil {
+		reqBody.Port.SecurityGroups = opts.SecurityGroups
+	}
+
+	// Response
+	_, err := perigee.Request("POST", createURL(c), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &res.Resp,
+		OkCodes:     []int{201},
+		DumpReqJson: true,
+	})
+	res.Err = err
+	return res
+}
+
+// UpdateOpts represents the attributes used when updating an existing port.
+type UpdateOpts struct {
+	Name           string
+	AdminStateUp   *bool
+	FixedIPs       interface{}
+	DeviceID       string
+	DeviceOwner    string
+	SecurityGroups []string
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+	type port struct {
+		Name           *string     `json:"name,omitempty"`
+		AdminStateUp   *bool       `json:"admin_state_up,omitempty"`
+		FixedIPs       interface{} `json:"fixed_ips,omitempty"`
+		DeviceID       *string     `json:"device_id,omitempty"`
+		DeviceOwner    *string     `json:"device_owner,omitempty"`
+		SecurityGroups []string    `json:"security_groups,omitempty"`
+	}
+	type request struct {
+		Port port `json:"port"`
+	}
+
+	// Populate request body
+	reqBody := request{Port: port{
+		Name:         gophercloud.MaybeString(opts.Name),
+		AdminStateUp: opts.AdminStateUp,
+		DeviceID:     gophercloud.MaybeString(opts.DeviceID),
+		DeviceOwner:  gophercloud.MaybeString(opts.DeviceOwner),
+	}}
+
+	if opts.FixedIPs != nil {
+		reqBody.Port.FixedIPs = opts.FixedIPs
+	}
+
+	if opts.SecurityGroups != nil {
+		reqBody.Port.SecurityGroups = opts.SecurityGroups
+	}
+
+	// Response
+	var res UpdateResult
+	_, err := perigee.Request("PUT", updateURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &res.Resp,
+		OkCodes:     []int{200, 201},
+	})
+	res.Err = err
+	return res
+}
+
+// Delete accepts a unique ID and deletes the port associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+	var res DeleteResult
+	_, err := perigee.Request("DELETE", deleteURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		OkCodes:     []int{204},
+	})
+	res.Err = err
+	return res
+}
diff --git a/openstack/networking/v2/ports/requests_test.go b/openstack/networking/v2/ports/requests_test.go
new file mode 100644
index 0000000..ad498a2
--- /dev/null
+++ b/openstack/networking/v2/ports/requests_test.go
@@ -0,0 +1,308 @@
+package ports
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const tokenID = "123"
+
+func ServiceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{TokenID: tokenID},
+		Endpoint: th.Endpoint(),
+	}
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "ports": [
+        {
+            "status": "ACTIVE",
+            "binding:host_id": "devstack",
+            "name": "",
+            "admin_state_up": true,
+            "network_id": "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+            "tenant_id": "",
+            "device_owner": "network:router_gateway",
+            "mac_address": "fa:16:3e:58:42:ed",
+            "fixed_ips": [
+                {
+                    "subnet_id": "008ba151-0b8c-4a67-98b5-0d2b87666062",
+                    "ip_address": "172.24.4.2"
+                }
+            ],
+            "id": "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+            "security_groups": [],
+            "device_id": "9ae135f4-b6e0-4dad-9e91-3c223e385824"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractPorts(page)
+		if err != nil {
+			t.Errorf("Failed to extract subnets: %v", err)
+			return false, nil
+		}
+
+		expected := []Port{
+			Port{
+				Status:       "ACTIVE",
+				Name:         "",
+				AdminStateUp: true,
+				NetworkID:    "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+				TenantID:     "",
+				DeviceOwner:  "network:router_gateway",
+				MACAddress:   "fa:16:3e:58:42:ed",
+				FixedIPs: []IP{
+					IP{
+						SubnetID:  "008ba151-0b8c-4a67-98b5-0d2b87666062",
+						IPAddress: "172.24.4.2",
+					},
+				},
+				ID:             "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+				SecurityGroups: []string{},
+				DeviceID:       "9ae135f4-b6e0-4dad-9e91-3c223e385824",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports/46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "ACTIVE",
+        "name": "",
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "7e02058126cc4950b75f9970368ba177",
+        "device_owner": "network:router_interface",
+        "mac_address": "fa:16:3e:23:fd:d7",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.1"
+            }
+        ],
+        "id": "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2",
+        "security_groups": [],
+        "device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e"
+    }
+}
+			`)
+	})
+
+	n, err := Get(ServiceClient(), "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertEquals(t, n.Name, "")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+	th.AssertEquals(t, n.TenantID, "7e02058126cc4950b75f9970368ba177")
+	th.AssertEquals(t, n.DeviceOwner, "network:router_interface")
+	th.AssertEquals(t, n.MACAddress, "fa:16:3e:23:fd:d7")
+	th.AssertDeepEquals(t, n.FixedIPs, []IP{
+		IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.1"},
+	})
+	th.AssertEquals(t, n.ID, "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2")
+	th.AssertDeepEquals(t, n.SecurityGroups, []string{})
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertEquals(t, n.DeviceID, "5e3898d7-11be-483e-9732-b2f5eccd2b2e")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", tokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "port": {
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "name": "private-port",
+        "admin_state_up": true
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "DOWN",
+        "name": "private-port",
+        "allowed_address_pairs": [],
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+        "device_owner": "",
+        "mac_address": "fa:16:3e:c9:cb:f0",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.2"
+            }
+        ],
+        "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+        "security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ],
+        "device_id": ""
+    }
+}
+		`)
+	})
+
+	asu := true
+	options := CreateOpts{Name: "private-port", AdminStateUp: &asu, NetworkID: "a87cc70a-3e15-4acf-8205-9b711a3531b7"}
+	n, err := Create(ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "DOWN")
+	th.AssertEquals(t, n.Name, "private-port")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+	th.AssertEquals(t, n.TenantID, "d6700c0c9ffa4f1cb322cd4a1f3906fa")
+	th.AssertEquals(t, n.DeviceOwner, "")
+	th.AssertEquals(t, n.MACAddress, "fa:16:3e:c9:cb:f0")
+	th.AssertDeepEquals(t, n.FixedIPs, []IP{
+		IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+	})
+	th.AssertEquals(t, n.ID, "65c0ee9f-d634-4522-8954-51021b570b0d")
+	th.AssertDeepEquals(t, n.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", tokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"port": {
+				"name": "new_port_name",
+				"fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.3"
+            }
+        ],
+				"security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ]
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "DOWN",
+        "name": "new_port_name",
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+        "device_owner": "",
+        "mac_address": "fa:16:3e:c9:cb:f0",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.3"
+            }
+        ],
+        "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+        "security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ],
+        "device_id": ""
+    }
+}
+		`)
+	})
+
+	options := UpdateOpts{
+		Name: "new_port_name",
+		FixedIPs: []IP{
+			IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+		},
+		SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+	}
+
+	s, err := Update(ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "new_port_name")
+	th.AssertDeepEquals(t, s.FixedIPs, []IP{
+		IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+	})
+	th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", tokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/ports/results.go b/openstack/networking/v2/ports/results.go
new file mode 100644
index 0000000..91118a4
--- /dev/null
+++ b/openstack/networking/v2/ports/results.go
@@ -0,0 +1,145 @@
+package ports
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.CommonResult
+}
+
+// Extract is a function that accepts a result and extracts a port resource.
+func (r commonResult) Extract() (*Port, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+
+	var res struct {
+		Port *Port `json:"port"`
+	}
+
+	err := mapstructure.Decode(r.Resp, &res)
+	if err != nil {
+		return nil, fmt.Errorf("Error decoding Neutron port: %v", err)
+	}
+
+	return res.Port, nil
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult commonResult
+
+// IP is a sub-struct that represents an individual IP.
+type IP struct {
+	SubnetID  string `mapstructure:"subnet_id" json:"subnet_id"`
+	IPAddress string `mapstructure:"ip_address" json:"ip_address,omitempty"`
+}
+
+// Port represents a Neutron port. See package documentation for a top-level
+// description of what this is.
+type Port struct {
+	// UUID for the port.
+	ID string `mapstructure:"id" json:"id"`
+	// Network that this port is associated with.
+	NetworkID string `mapstructure:"network_id" json:"network_id"`
+	// Human-readable name for the port. Might not be unique.
+	Name string `mapstructure:"name" json:"name"`
+	// Administrative state of port. If false (down), port does not forward packets.
+	AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+	// Indicates whether network is currently operational. Possible values include
+	// `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+	Status string `mapstructure:"status" json:"status"`
+	// Mac address to use on this port.
+	MACAddress string `mapstructure:"mac_address" json:"mac_address"`
+	// Specifies IP addresses for the port thus associating the port itself with
+	// the subnets where the IP addresses are picked from
+	FixedIPs []IP `mapstructure:"fixed_ips" json:"fixed_ips"`
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+	// Identifies the entity (e.g.: dhcp agent) using this port.
+	DeviceOwner string `mapstructure:"device_owner" json:"device_owner"`
+	// Specifies the IDs of any security groups associated with a port.
+	SecurityGroups []string `mapstructure:"security_groups" json:"security_groups"`
+	// Identifies the device (e.g., virtual server) using this port.
+	DeviceID string `mapstructure:"device_id" json:"device_id"`
+}
+
+// PortPage is the page returned by a pager when traversing over a collection
+// of network ports.
+type PortPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of ports has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p PortPage) NextPageURL() (string, error) {
+	type resp struct {
+		Links []struct {
+			Href string `mapstructure:"href"`
+			Rel  string `mapstructure:"rel"`
+		} `mapstructure:"ports_links"`
+	}
+
+	var r resp
+	err := mapstructure.Decode(p.Body, &r)
+	if err != nil {
+		return "", err
+	}
+
+	var url string
+	for _, l := range r.Links {
+		if l.Rel == "next" {
+			url = l.Href
+		}
+	}
+	if url == "" {
+		return "", nil
+	}
+
+	return url, nil
+}
+
+// IsEmpty checks whether a PortPage struct is empty.
+func (p PortPage) IsEmpty() (bool, error) {
+	is, err := ExtractPorts(p)
+	if err != nil {
+		return true, nil
+	}
+	return len(is) == 0, nil
+}
+
+// ExtractPorts accepts a Page struct, specifically a PortPage struct,
+// and extracts the elements into a slice of Port structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPorts(page pagination.Page) ([]Port, error) {
+	var resp struct {
+		Ports []Port `mapstructure:"ports" json:"ports"`
+	}
+
+	err := mapstructure.Decode(page.(PortPage).Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Ports, nil
+}
diff --git a/openstack/networking/v2/ports/urls.go b/openstack/networking/v2/ports/urls.go
new file mode 100644
index 0000000..558b399
--- /dev/null
+++ b/openstack/networking/v2/ports/urls.go
@@ -0,0 +1,33 @@
+package ports
+
+import "github.com/rackspace/gophercloud"
+
+const version = "v2.0"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(version, "ports", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(version, "ports")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/ports/urls_tests.go b/openstack/networking/v2/ports/urls_tests.go
new file mode 100644
index 0000000..6fb20aa
--- /dev/null
+++ b/openstack/networking/v2/ports/urls_tests.go
@@ -0,0 +1,44 @@
+package ports
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestListURL(t *testing.T) {
+	actual := listURL(endpointClient())
+	expected := endpoint + "v2.0/ports"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+	actual := getURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/ports/foo"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+	actual := createURL(endpointClient())
+	expected := endpoint + "v2.0/ports"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+	actual := updateURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/ports/foo"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+	actual := deleteURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/ports/foo"
+	th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/subnets/doc.go b/openstack/networking/v2/subnets/doc.go
new file mode 100644
index 0000000..43e8296
--- /dev/null
+++ b/openstack/networking/v2/subnets/doc.go
@@ -0,0 +1,10 @@
+// Package subnets contains functionality for working with Neutron subnet
+// resources. A subnet represents an IP address block that can be used to
+// assign IP addresses to virtual instances. Each subnet must have a CIDR and
+// must be associated with a network. IPs can either be selected from the whole
+// subnet CIDR or from allocation pools specified by the user.
+//
+// A subnet can also have a gateway, a list of DNS name servers, and host routes.
+// This information is pushed to instances whose interfaces are associated with
+// the subnet.
+package subnets
diff --git a/openstack/networking/v2/subnets/errors.go b/openstack/networking/v2/subnets/errors.go
new file mode 100644
index 0000000..0db0a6e
--- /dev/null
+++ b/openstack/networking/v2/subnets/errors.go
@@ -0,0 +1,13 @@
+package subnets
+
+import "fmt"
+
+func err(str string) error {
+	return fmt.Errorf("%s", str)
+}
+
+var (
+	errNetworkIDRequired = err("A network ID is required")
+	errCIDRRequired      = err("A valid CIDR is required")
+	errInvalidIPType     = err("An IP type must either be 4 or 6")
+)
diff --git a/openstack/networking/v2/subnets/requests.go b/openstack/networking/v2/subnets/requests.go
new file mode 100644
index 0000000..9cbf5ab
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests.go
@@ -0,0 +1,245 @@
+package subnets
+
+import (
+	"strconv"
+
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/utils"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the subnet attributes you want to see returned. SortKey allows you to sort
+// by a particular subnet attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Name       string
+	EnableDHCP *bool
+	NetworkID  string
+	TenantID   string
+	IPVersion  int
+	GatewayIP  string
+	CIDR       string
+	ID         string
+	Limit      int
+	Marker     string
+	SortKey    string
+	SortDir    string
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// subnets. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those subnets that are owned by the tenant
+// who submits the request, unless the request is submitted by an user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	// Build query parameters
+	q := make(map[string]string)
+	if opts.Name != "" {
+		q["name"] = opts.Name
+	}
+	if opts.EnableDHCP != nil {
+		q["enable_dhcp"] = strconv.FormatBool(*opts.EnableDHCP)
+	}
+	if opts.NetworkID != "" {
+		q["network_id"] = opts.NetworkID
+	}
+	if opts.TenantID != "" {
+		q["tenant_id"] = opts.TenantID
+	}
+	if opts.IPVersion != 0 {
+		q["ip_version"] = strconv.Itoa(opts.IPVersion)
+	}
+	if opts.GatewayIP != "" {
+		q["gateway_ip"] = opts.GatewayIP
+	}
+	if opts.CIDR != "" {
+		q["cidr"] = opts.CIDR
+	}
+	if opts.ID != "" {
+		q["id"] = opts.ID
+	}
+	if opts.Limit != 0 {
+		q["limit"] = strconv.Itoa(opts.Limit)
+	}
+	if opts.Marker != "" {
+		q["marker"] = opts.Marker
+	}
+	if opts.SortKey != "" {
+		q["sort_key"] = opts.SortKey
+	}
+	if opts.SortDir != "" {
+		q["sort_dir"] = opts.SortDir
+	}
+
+	u := listURL(c) + utils.BuildQuery(q)
+	return pagination.NewPager(c, u, func(r pagination.LastHTTPResponse) pagination.Page {
+		return SubnetPage{pagination.LinkedPageBase(r)}
+	})
+}
+
+// Get retrieves a specific subnet based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+	var res GetResult
+	_, err := perigee.Request("GET", getURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		Results:     &res.Resp,
+		OkCodes:     []int{200},
+	})
+	res.Err = err
+	return res
+}
+
+// Valid IP types
+const (
+	IPv4 = 4
+	IPv6 = 6
+)
+
+// CreateOpts represents the attributes used when creating a new subnet.
+type CreateOpts struct {
+	// Required
+	NetworkID string
+	CIDR      string
+	// Optional
+	Name            string
+	TenantID        string
+	AllocationPools []AllocationPool
+	GatewayIP       string
+	IPVersion       int
+	EnableDHCP      *bool
+	DNSNameservers  []string
+	HostRoutes      []interface{}
+}
+
+// Create accepts a CreateOpts struct and creates a new subnet using the values
+// provided. You must remember to provide a valid NetworkID, CIDR and IP version.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+	var res CreateResult
+
+	// Validate required options
+	if opts.NetworkID == "" {
+		res.Err = errNetworkIDRequired
+		return res
+	}
+	if opts.CIDR == "" {
+		res.Err = errCIDRRequired
+		return res
+	}
+	if opts.IPVersion != 0 && opts.IPVersion != IPv4 && opts.IPVersion != IPv6 {
+		res.Err = errInvalidIPType
+		return res
+	}
+
+	type subnet struct {
+		NetworkID       string           `json:"network_id"`
+		CIDR            string           `json:"cidr"`
+		Name            *string          `json:"name,omitempty"`
+		TenantID        *string          `json:"tenant_id,omitempty"`
+		AllocationPools []AllocationPool `json:"allocation_pools,omitempty"`
+		GatewayIP       *string          `json:"gateway_ip,omitempty"`
+		IPVersion       int              `json:"ip_version,omitempty"`
+		EnableDHCP      *bool            `json:"enable_dhcp,omitempty"`
+		DNSNameservers  []string         `json:"dns_nameservers,omitempty"`
+		HostRoutes      []interface{}    `json:"host_routes,omitempty"`
+	}
+	type request struct {
+		Subnet subnet `json:"subnet"`
+	}
+
+	reqBody := request{Subnet: subnet{
+		NetworkID:  opts.NetworkID,
+		CIDR:       opts.CIDR,
+		Name:       gophercloud.MaybeString(opts.Name),
+		TenantID:   gophercloud.MaybeString(opts.TenantID),
+		GatewayIP:  gophercloud.MaybeString(opts.GatewayIP),
+		EnableDHCP: opts.EnableDHCP,
+	}}
+
+	if opts.IPVersion != 0 {
+		reqBody.Subnet.IPVersion = opts.IPVersion
+	}
+	if len(opts.AllocationPools) != 0 {
+		reqBody.Subnet.AllocationPools = opts.AllocationPools
+	}
+	if len(opts.DNSNameservers) != 0 {
+		reqBody.Subnet.DNSNameservers = opts.DNSNameservers
+	}
+	if len(opts.HostRoutes) != 0 {
+		reqBody.Subnet.HostRoutes = opts.HostRoutes
+	}
+
+	_, err := perigee.Request("POST", createURL(c), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &res.Resp,
+		OkCodes:     []int{201},
+	})
+	res.Err = err
+
+	return res
+}
+
+// UpdateOpts represents the attributes used when updating an existing subnet.
+type UpdateOpts struct {
+	Name           string
+	GatewayIP      string
+	DNSNameservers []string
+	HostRoutes     []interface{}
+	EnableDHCP     *bool
+}
+
+// Update accepts a UpdateOpts struct and updates an existing subnet using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+	type subnet struct {
+		Name           *string       `json:"name,omitempty"`
+		GatewayIP      *string       `json:"gateway_ip,omitempty"`
+		DNSNameservers []string      `json:"dns_nameservers,omitempty"`
+		HostRoutes     []interface{} `json:"host_routes,omitempty"`
+		EnableDHCP     *bool         `json:"enable_dhcp,omitempty"`
+	}
+	type request struct {
+		Subnet subnet `json:"subnet"`
+	}
+
+	reqBody := request{Subnet: subnet{
+		Name:       gophercloud.MaybeString(opts.Name),
+		GatewayIP:  gophercloud.MaybeString(opts.GatewayIP),
+		EnableDHCP: opts.EnableDHCP,
+	}}
+
+	if len(opts.DNSNameservers) != 0 {
+		reqBody.Subnet.DNSNameservers = opts.DNSNameservers
+	}
+
+	if len(opts.HostRoutes) != 0 {
+		reqBody.Subnet.HostRoutes = opts.HostRoutes
+	}
+
+	var res UpdateResult
+	_, err := perigee.Request("PUT", updateURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &res.Resp,
+		OkCodes:     []int{200, 201},
+	})
+	res.Err = err
+
+	return res
+}
+
+// Delete accepts a unique ID and deletes the subnet associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+	var res DeleteResult
+	_, err := perigee.Request("DELETE", deleteURL(c, id), perigee.Options{
+		MoreHeaders: c.Provider.AuthenticatedHeaders(),
+		OkCodes:     []int{204},
+	})
+	res.Err = err
+	return res
+}
diff --git a/openstack/networking/v2/subnets/requests_test.go b/openstack/networking/v2/subnets/requests_test.go
new file mode 100644
index 0000000..3c7c8b2
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests_test.go
@@ -0,0 +1,326 @@
+package subnets
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const TokenID = "123"
+
+func ServiceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: TokenID,
+		},
+		Endpoint: th.Endpoint(),
+	}
+}
+
+func TestList(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, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "subnets": [
+        {
+            "name": "private-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"
+        },
+        {
+            "name": "my_subnet",
+            "enable_dhcp": true,
+            "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+            "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+            "dns_nameservers": [],
+            "allocation_pools": [
+                {
+                    "start": "192.0.0.2",
+                    "end": "192.255.255.254"
+                }
+            ],
+            "host_routes": [],
+            "ip_version": 4,
+            "gateway_ip": "192.0.0.1",
+            "cidr": "192.0.0.0/8",
+            "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractSubnets(page)
+		if err != nil {
+			t.Errorf("Failed to extract subnets: %v", err)
+			return false, nil
+		}
+
+		expected := []Subnet{
+			Subnet{
+				Name:           "private-subnet",
+				EnableDHCP:     true,
+				NetworkID:      "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+				TenantID:       "26a7980765d0414dbc1fc1f88cdb7e6e",
+				DNSNameservers: []string{},
+				AllocationPools: []AllocationPool{
+					AllocationPool{
+						Start: "10.0.0.2",
+						End:   "10.0.0.254",
+					},
+				},
+				HostRoutes: []HostRoute{},
+				IPVersion:  4,
+				GatewayIP:  "10.0.0.1",
+				CIDR:       "10.0.0.0/24",
+				ID:         "08eae331-0402-425a-923c-34f7cfe39c1b",
+			},
+			Subnet{
+				Name:           "my_subnet",
+				EnableDHCP:     true,
+				NetworkID:      "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+				TenantID:       "4fd44f30292945e481c7b8a0c8908869",
+				DNSNameservers: []string{},
+				AllocationPools: []AllocationPool{
+					AllocationPool{
+						Start: "192.0.0.2",
+						End:   "192.255.255.254",
+					},
+				},
+				HostRoutes: []HostRoute{},
+				IPVersion:  4,
+				GatewayIP:  "192.0.0.1",
+				CIDR:       "192.0.0.0/8",
+				ID:         "54d6f61d-db07-451c-9ab3-b9609b6b6f0b",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets/54d6f61d-db07-451c-9ab3-b9609b6b6f0b", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "my_subnet",
+        "enable_dhcp": true,
+        "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "192.0.0.2",
+                "end": "192.255.255.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "192.0.0.1",
+        "cidr": "192.0.0.0/8",
+        "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+    }
+}
+			`)
+	})
+
+	s, err := Get(ServiceClient(), "54d6f61d-db07-451c-9ab3-b9609b6b6f0b").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "my_subnet")
+	th.AssertEquals(t, s.EnableDHCP, true)
+	th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+	th.AssertDeepEquals(t, s.AllocationPools, []AllocationPool{
+		AllocationPool{
+			Start: "192.0.0.2",
+			End:   "192.255.255.254",
+		},
+	})
+	th.AssertDeepEquals(t, s.HostRoutes, []HostRoute{})
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.GatewayIP, "192.0.0.1")
+	th.AssertEquals(t, s.CIDR, "192.0.0.0/8")
+	th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0b")
+}
+
+func TestCreate(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", 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-6722fc136a22",
+        "ip_version": 4,
+        "cidr": "192.168.199.0/24"
+    }
+}
+			`)
+
+		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-6722fc136a22",
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "192.168.199.2",
+                "end": "192.168.199.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "192.168.199.1",
+        "cidr": "192.168.199.0/24",
+        "id": "3b80198d-4f7b-4f77-9ef5-774d54e17126"
+    }
+}
+		`)
+	})
+
+	opts := CreateOpts{NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22", IPVersion: 4, CIDR: "192.168.199.0/24"}
+	s, err := Create(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-6722fc136a22")
+	th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+	th.AssertDeepEquals(t, s.AllocationPools, []AllocationPool{
+		AllocationPool{
+			Start: "192.168.199.2",
+			End:   "192.168.199.254",
+		},
+	})
+	th.AssertDeepEquals(t, s.HostRoutes, []HostRoute{})
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.GatewayIP, "192.168.199.1")
+	th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+	th.AssertEquals(t, s.ID, "3b80198d-4f7b-4f77-9ef5-774d54e17126")
+}
+
+func TestUpdate(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", 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"
+    }
+}
+		`)
+
+		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"
+    }
+}
+	`)
+	})
+
+	opts := UpdateOpts{Name: "my_new_subnet"}
+	s, err := Update(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")
+}
+
+func TestDelete(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, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go
new file mode 100644
index 0000000..5c5744c
--- /dev/null
+++ b/openstack/networking/v2/subnets/results.go
@@ -0,0 +1,152 @@
+package subnets
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.CommonResult
+}
+
+// Extract is a function that accepts a result and extracts a subnet resource.
+func (r commonResult) Extract() (*Subnet, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+
+	var res struct {
+		Subnet *Subnet `json:"subnet"`
+	}
+
+	err := mapstructure.Decode(r.Resp, &res)
+	if err != nil {
+		return nil, fmt.Errorf("Error decoding Neutron subnet: %v", err)
+	}
+
+	return res.Subnet, nil
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult commonResult
+
+// AllocationPool represents a sub-range of cidr available for dynamic
+// allocation to ports, e.g. {Start: "10.0.0.2", End: "10.0.0.254"}
+type AllocationPool struct {
+	Start string `json:"start"`
+	End   string `json:"end"`
+}
+
+// HostRoute represents a route that should be used by devices with IPs from
+// a subnet (not including local subnet route).
+type HostRoute struct {
+	DestinationCIDR string `json:"destination"`
+	NextHop         string `json:"nexthop"`
+}
+
+// Subnet represents a subnet. See package documentation for a top-level
+// description of what this is.
+type Subnet struct {
+	// UUID representing the subnet
+	ID string `mapstructure:"id" json:"id"`
+	// UUID of the parent network
+	NetworkID string `mapstructure:"network_id" json:"network_id"`
+	// Human-readable name for the subnet. Might not be unique.
+	Name string `mapstructure:"name" json:"name"`
+	// IP version, either `4' or `6'
+	IPVersion int `mapstructure:"ip_version" json:"ip_version"`
+	// CIDR representing IP range for this subnet, based on IP version
+	CIDR string `mapstructure:"cidr" json:"cidr"`
+	// Default gateway used by devices in this subnet
+	GatewayIP string `mapstructure:"gateway_ip" json:"gateway_ip"`
+	// DNS name servers used by hosts in this subnet.
+	DNSNameservers []string `mapstructure:"dns_nameservers" json:"dns_nameservers"`
+	// Sub-ranges of CIDR available for dynamic allocation to ports. See AllocationPool.
+	AllocationPools []AllocationPool `mapstructure:"allocation_pools" json:"allocation_pools"`
+	// Routes that should be used by devices with IPs from this subnet (not including local subnet route).
+	HostRoutes []HostRoute `mapstructure:"host_routes" json:"host_routes"`
+	// Specifies whether DHCP is enabled for this subnet or not.
+	EnableDHCP bool `mapstructure:"enable_dhcp" json:"enable_dhcp"`
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+}
+
+// SubnetPage is the page returned by a pager when traversing over a collection
+// of subnets.
+type SubnetPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of subnets has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p SubnetPage) NextPageURL() (string, error) {
+	type link struct {
+		Href string `mapstructure:"href"`
+		Rel  string `mapstructure:"rel"`
+	}
+	type resp struct {
+		Links []link `mapstructure:"subnets_links"`
+	}
+
+	var r resp
+	err := mapstructure.Decode(p.Body, &r)
+	if err != nil {
+		return "", err
+	}
+
+	var url string
+	for _, l := range r.Links {
+		if l.Rel == "next" {
+			url = l.Href
+		}
+	}
+	if url == "" {
+		return "", nil
+	}
+
+	return url, nil
+}
+
+// IsEmpty checks whether a SubnetPage struct is empty.
+func (p SubnetPage) IsEmpty() (bool, error) {
+	is, err := ExtractSubnets(p)
+	if err != nil {
+		return true, nil
+	}
+	return len(is) == 0, nil
+}
+
+// ExtractSubnets accepts a Page struct, specifically a SubnetPage struct,
+// and extracts the elements into a slice of Subnet structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractSubnets(page pagination.Page) ([]Subnet, error) {
+	var resp struct {
+		Subnets []Subnet `mapstructure:"subnets" json:"subnets"`
+	}
+
+	err := mapstructure.Decode(page.(SubnetPage).Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Subnets, nil
+}
diff --git a/openstack/networking/v2/subnets/urls.go b/openstack/networking/v2/subnets/urls.go
new file mode 100644
index 0000000..ca70b66
--- /dev/null
+++ b/openstack/networking/v2/subnets/urls.go
@@ -0,0 +1,33 @@
+package subnets
+
+import "github.com/rackspace/gophercloud"
+
+const version = "v2.0"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(version, "subnets", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(version, "subnets")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/subnets/urls_tests.go b/openstack/networking/v2/subnets/urls_tests.go
new file mode 100644
index 0000000..b04b432
--- /dev/null
+++ b/openstack/networking/v2/subnets/urls_tests.go
@@ -0,0 +1,44 @@
+package subnets
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestListURL(t *testing.T) {
+	actual := listURL(endpointClient())
+	expected := endpoint + "v2.0/subnets"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+	actual := getURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/subnets/foo"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+	actual := createURL(endpointClient())
+	expected := endpoint + "v2.0/subnets"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+	actual := updateURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/subnets/foo"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+	actual := deleteURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/subnets/foo"
+	th.AssertEquals(t, expected, actual)
+}
diff --git a/params.go b/params.go
new file mode 100644
index 0000000..f11a29d
--- /dev/null
+++ b/params.go
@@ -0,0 +1,13 @@
+package gophercloud
+
+// MaybeString takes a string that might be a zero-value, and either returns a
+// pointer to its address or a nil value (i.e. empty pointer). This is useful
+// for converting zero values in options structs when the end-user hasn't
+// defined values. Those zero values need to be nil in order for the JSON
+// serialization to ignore them.
+func MaybeString(original string) *string {
+	if original != "" {
+		return &original
+	}
+	return nil
+}
diff --git a/results.go b/results.go
new file mode 100644
index 0000000..3ca7c94
--- /dev/null
+++ b/results.go
@@ -0,0 +1,9 @@
+package gophercloud
+
+// CommonResult acts as a base struct that other results can embed. It contains
+// the deserialized JSON structure returned from the server (Resp), and any
+// errors that might have occurred during transport or deserialization.
+type CommonResult struct {
+	Resp map[string]interface{}
+	Err  error
+}