Merge pull request #224 from jrperritt/v0.2.0-consistency-checks

V0.2.0 consistency checks
diff --git a/.travis.yml b/.travis.yml
index 9c37aef..cf4f8ca 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,10 +1,11 @@
 language: go
 install:
-  - go get -v ./...
+  - go get -v -tags 'fixtures acceptance' ./...
 go:
   - 1.1
   - 1.2
   - tip
+script: script/cibuild
 after_success:
   - go get code.google.com/p/go.tools/cmd/cover
   - go get github.com/axw/gocov/gocov
diff --git a/acceptance/rackspace/client_test.go b/acceptance/rackspace/client_test.go
new file mode 100644
index 0000000..e68aef8
--- /dev/null
+++ b/acceptance/rackspace/client_test.go
@@ -0,0 +1,29 @@
+// +build acceptance
+
+package rackspace
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/utils"
+	"github.com/rackspace/gophercloud/rackspace"
+)
+
+func TestAuthenticatedClient(t *testing.T) {
+	// Obtain credentials from the environment.
+	ao, err := utils.AuthOptions()
+	if err != nil {
+		t.Fatalf("Unable to acquire credentials: %v", err)
+	}
+
+	client, err := rackspace.AuthenticatedClient(ao)
+	if err != nil {
+		t.Fatalf("Unable to authenticate: %v", err)
+	}
+
+	if client.TokenID == "" {
+		t.Errorf("No token ID assigned to the client")
+	}
+
+	t.Logf("Client successfully acquired a token: %v", client.TokenID)
+}
diff --git a/acceptance/rackspace/identity/v2/extension_test.go b/acceptance/rackspace/identity/v2/extension_test.go
new file mode 100644
index 0000000..a50e015
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/extension_test.go
@@ -0,0 +1,54 @@
+// +build acceptance
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/pagination"
+	extensions2 "github.com/rackspace/gophercloud/rackspace/identity/v2/extensions"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestExtensions(t *testing.T) {
+	service := authenticatedClient(t)
+
+	t.Logf("Extensions available on this identity endpoint:")
+	count := 0
+	var chosen string
+	err := extensions2.List(service).EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page %02d ---", count)
+
+		extensions, err := extensions2.ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+
+		for i, ext := range extensions {
+			if chosen == "" {
+				chosen = ext.Alias
+			}
+
+			t.Logf("[%02d] name=[%s] namespace=[%s]", i, ext.Name, ext.Namespace)
+			t.Logf("     alias=[%s] updated=[%s]", ext.Alias, ext.Updated)
+			t.Logf("     description=[%s]", ext.Description)
+		}
+
+		count++
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	if chosen == "" {
+		t.Logf("No extensions found.")
+		return
+	}
+
+	ext, err := extensions2.Get(service, chosen).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Detail for extension [%s]:", chosen)
+	t.Logf("        name=[%s]", ext.Name)
+	t.Logf("   namespace=[%s]", ext.Namespace)
+	t.Logf("       alias=[%s]", ext.Alias)
+	t.Logf("     updated=[%s]", ext.Updated)
+	t.Logf(" description=[%s]", ext.Description)
+}
diff --git a/acceptance/rackspace/identity/v2/identity_test.go b/acceptance/rackspace/identity/v2/identity_test.go
new file mode 100644
index 0000000..019a9e6
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/identity_test.go
@@ -0,0 +1,51 @@
+// +build acceptance
+
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/rackspace"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func rackspaceAuthOptions(t *testing.T) gophercloud.AuthOptions {
+	// Obtain credentials from the environment.
+	options := gophercloud.AuthOptions{
+		Username: os.Getenv("RS_USERNAME"),
+		APIKey:   os.Getenv("RS_APIKEY"),
+	}
+
+	if options.Username == "" {
+		t.Fatal("Please provide a Rackspace username as RS_USERNAME.")
+	}
+	if options.APIKey == "" {
+		t.Fatal("Please provide a Rackspace API key as RS_APIKEY.")
+	}
+
+	return options
+}
+
+func createClient(t *testing.T, auth bool) *gophercloud.ServiceClient {
+	ao := rackspaceAuthOptions(t)
+
+	provider, err := rackspace.NewClient(ao.IdentityEndpoint)
+	th.AssertNoErr(t, err)
+
+	if auth {
+		err = rackspace.Authenticate(provider, ao)
+		th.AssertNoErr(t, err)
+	}
+
+	return rackspace.NewIdentityV2(provider)
+}
+
+func unauthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+	return createClient(t, false)
+}
+
+func authenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+	return createClient(t, true)
+}
diff --git a/acceptance/rackspace/identity/v2/tenant_test.go b/acceptance/rackspace/identity/v2/tenant_test.go
new file mode 100644
index 0000000..6081a49
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/tenant_test.go
@@ -0,0 +1,37 @@
+// +build acceptance
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/pagination"
+	rstenants "github.com/rackspace/gophercloud/rackspace/identity/v2/tenants"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTenants(t *testing.T) {
+	service := authenticatedClient(t)
+
+	t.Logf("Tenants available to the currently issued token:")
+	count := 0
+	err := rstenants.List(service, nil).EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page %02d ---", count)
+
+		tenants, err := rstenants.ExtractTenants(page)
+		th.AssertNoErr(t, err)
+
+		for i, tenant := range tenants {
+			t.Logf("[%02d]      id=[%s]", i, tenant.ID)
+			t.Logf("        name=[%s] enabled=[%v]", i, tenant.Name, tenant.Enabled)
+			t.Logf(" description=[%s]", tenant.Description)
+		}
+
+		count++
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	if count == 0 {
+		t.Errorf("No tenants listed for your current token.")
+	}
+}
diff --git a/acceptance/rackspace/pkg.go b/acceptance/rackspace/pkg.go
new file mode 100644
index 0000000..5d17b32
--- /dev/null
+++ b/acceptance/rackspace/pkg.go
@@ -0,0 +1 @@
+package rackspace
diff --git a/openstack/client.go b/openstack/client.go
index f3638be..97556d6 100644
--- a/openstack/client.go
+++ b/openstack/client.go
@@ -3,15 +3,11 @@
 import (
 	"fmt"
 	"net/url"
-	"strings"
 
 	"github.com/rackspace/gophercloud"
 	tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
-	endpoints3 "github.com/rackspace/gophercloud/openstack/identity/v3/endpoints"
-	services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
 	tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
 	"github.com/rackspace/gophercloud/openstack/utils"
-	"github.com/rackspace/gophercloud/pagination"
 )
 
 const (
@@ -32,8 +28,8 @@
 	u.Path, u.RawQuery, u.Fragment = "", "", ""
 	base := u.String()
 
-	endpoint = normalizeURL(endpoint)
-	base = normalizeURL(base)
+	endpoint = gophercloud.NormalizeURL(endpoint)
+	base = gophercloud.NormalizeURL(base)
 
 	if hadPath {
 		return &gophercloud.ProviderClient{
@@ -99,7 +95,7 @@
 		v2Client.Endpoint = endpoint
 	}
 
-	result := tokens2.Create(v2Client, options)
+	result := tokens2.Create(v2Client, tokens2.AuthOptions{AuthOptions: options})
 
 	token, err := result.ExtractToken()
 	if err != nil {
@@ -113,50 +109,12 @@
 
 	client.TokenID = token.ID
 	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
-		return v2endpointLocator(catalog, opts)
+		return V2EndpointURL(catalog, opts)
 	}
 
 	return nil
 }
 
-func v2endpointLocator(catalog *tokens2.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
-	// Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
-	var endpoints = make([]tokens2.Endpoint, 0, 1)
-	for _, entry := range catalog.Entries {
-		if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
-			for _, endpoint := range entry.Endpoints {
-				if opts.Region == "" || endpoint.Region == opts.Region {
-					endpoints = append(endpoints, endpoint)
-				}
-			}
-		}
-	}
-
-	// Report an error if the options were ambiguous.
-	if len(endpoints) == 0 {
-		return "", gophercloud.ErrEndpointNotFound
-	}
-	if len(endpoints) > 1 {
-		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
-	}
-
-	// Extract the appropriate URL from the matching Endpoint.
-	for _, endpoint := range endpoints {
-		switch opts.Availability {
-		case gophercloud.AvailabilityPublic:
-			return normalizeURL(endpoint.PublicURL), nil
-		case gophercloud.AvailabilityInternal:
-			return normalizeURL(endpoint.InternalURL), nil
-		case gophercloud.AvailabilityAdmin:
-			return normalizeURL(endpoint.AdminURL), nil
-		default:
-			return "", fmt.Errorf("Unexpected availability in endpoint query: %s", opts.Availability)
-		}
-	}
-
-	return "", gophercloud.ErrEndpointNotFound
-}
-
 // AuthenticateV3 explicitly authenticates against the identity v3 service.
 func AuthenticateV3(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
 	return v3auth(client, "", options)
@@ -176,85 +134,12 @@
 	client.TokenID = token.ID
 
 	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
-		return v3endpointLocator(v3Client, opts)
+		return V3EndpointURL(v3Client, opts)
 	}
 
 	return nil
 }
 
-func v3endpointLocator(v3Client *gophercloud.ServiceClient, opts gophercloud.EndpointOpts) (string, error) {
-	// Discover the service we're interested in.
-	var services = make([]services3.Service, 0, 1)
-	servicePager := services3.List(v3Client, services3.ListOpts{ServiceType: opts.Type})
-	err := servicePager.EachPage(func(page pagination.Page) (bool, error) {
-		part, err := services3.ExtractServices(page)
-		if err != nil {
-			return false, err
-		}
-
-		for _, service := range part {
-			if service.Name == opts.Name {
-				services = append(services, service)
-			}
-		}
-
-		return true, nil
-	})
-	if err != nil {
-		return "", err
-	}
-
-	if len(services) == 0 {
-		return "", gophercloud.ErrServiceNotFound
-	}
-	if len(services) > 1 {
-		return "", fmt.Errorf("Discovered %d matching services: %#v", len(services), services)
-	}
-	service := services[0]
-
-	// Enumerate the endpoints available for this service.
-	var endpoints []endpoints3.Endpoint
-	endpointPager := endpoints3.List(v3Client, endpoints3.ListOpts{
-		Availability: opts.Availability,
-		ServiceID:    service.ID,
-	})
-	err = endpointPager.EachPage(func(page pagination.Page) (bool, error) {
-		part, err := endpoints3.ExtractEndpoints(page)
-		if err != nil {
-			return false, err
-		}
-
-		for _, endpoint := range part {
-			if opts.Region == "" || endpoint.Region == opts.Region {
-				endpoints = append(endpoints, endpoint)
-			}
-		}
-
-		return true, nil
-	})
-	if err != nil {
-		return "", err
-	}
-
-	if len(endpoints) == 0 {
-		return "", gophercloud.ErrEndpointNotFound
-	}
-	if len(endpoints) > 1 {
-		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
-	}
-	endpoint := endpoints[0]
-
-	return normalizeURL(endpoint.URL), nil
-}
-
-// normalizeURL ensures that each endpoint URL has a closing `/`, as expected by ServiceClient.
-func normalizeURL(url string) string {
-	if !strings.HasSuffix(url, "/") {
-		return url + "/"
-	}
-	return url
-}
-
 // NewIdentityV2 creates a ServiceClient that may be used to interact with the v2 identity service.
 func NewIdentityV2(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
 	v2Endpoint := client.IdentityBase + "v2.0/"
diff --git a/openstack/common/extensions/fixtures.go b/openstack/common/extensions/fixtures.go
new file mode 100644
index 0000000..0ed7de9
--- /dev/null
+++ b/openstack/common/extensions/fixtures.go
@@ -0,0 +1,91 @@
+// +build fixtures
+
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Extension results.
+const ListOutput = `
+{
+	"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"
+		}
+	]
+}`
+
+// GetOutput provides a single Extension result.
+const GetOutput = `
+{
+	"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."
+	}
+}
+`
+
+// ListedExtension is the Extension that should be parsed from ListOutput.
+var ListedExtension = 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",
+}
+
+// ExpectedExtensions is a slice containing the Extension that should be parsed from ListOutput.
+var ExpectedExtensions = []Extension{ListedExtension}
+
+// SingleExtension is the Extension that should be parsed from GetOutput.
+var SingleExtension = &Extension{
+	Updated:     "2013-02-03T10:00:00-00:00",
+	Name:        "agent",
+	Links:       []interface{}{},
+	Namespace:   "http://docs.openstack.org/ext/agent/api/v2.0",
+	Alias:       "agent",
+	Description: "The agent management extension.",
+}
+
+// HandleListExtensionsSuccessfully creates an HTTP handler at `/extensions` on the test handler
+// mux that response with a list containing a single tenant.
+func HandleListExtensionsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetExtensionSuccessfully creates an HTTP handler at `/extensions/agent` that responds with
+// a JSON payload corresponding to SingleExtension.
+func HandleGetExtensionSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, GetOutput)
+	})
+}
diff --git a/openstack/common/extensions/requests_test.go b/openstack/common/extensions/requests_test.go
index b0f655a..6550283 100644
--- a/openstack/common/extensions/requests_test.go
+++ b/openstack/common/extensions/requests_test.go
@@ -1,113 +1,38 @@
 package extensions
 
 import (
-	"fmt"
-	"net/http"
 	"testing"
 
-	"github.com/rackspace/gophercloud"
 	"github.com/rackspace/gophercloud/pagination"
 	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
 )
 
-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("/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"
-		}
-	]
-}
-			`)
-	})
+	HandleListExtensionsSuccessfully(t)
 
 	count := 0
 
-	List(ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+	List(client.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)
+		th.AssertNoErr(t, err)
+		th.AssertDeepEquals(t, ExpectedExtensions, actual)
 
 		return true, nil
 	})
 
-	if count != 1 {
-		t.Errorf("Expected 1 page, got %d", count)
-	}
+	th.CheckEquals(t, 1, count)
 }
 
 func TestGet(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
+	HandleGetExtensionSuccessfully(t)
 
-	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.")
-	})
+	actual, err := Get(client.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, SingleExtension, actual)
 }
diff --git a/openstack/common/extensions/results.go b/openstack/common/extensions/results.go
index e98e5d6..4827072 100755
--- a/openstack/common/extensions/results.go
+++ b/openstack/common/extensions/results.go
@@ -29,12 +29,12 @@
 
 // Extension is a struct that represents an OpenStack 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"`
+	Updated     string        `json:"updated" mapstructure:"updated"`
+	Name        string        `json:"name" mapstructure:"name"`
+	Links       []interface{} `json:"links" mapstructure:"links"`
+	Namespace   string        `json:"namespace" mapstructure:"namespace"`
+	Alias       string        `json:"alias" mapstructure:"alias"`
+	Description string        `json:"description" mapstructure:"description"`
 }
 
 // ExtensionPage is the page returned by a pager when traversing over a collection of extensions.
diff --git a/openstack/endpoint_location.go b/openstack/endpoint_location.go
new file mode 100644
index 0000000..5a311e4
--- /dev/null
+++ b/openstack/endpoint_location.go
@@ -0,0 +1,124 @@
+package openstack
+
+import (
+	"fmt"
+
+	"github.com/rackspace/gophercloud"
+	tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+	endpoints3 "github.com/rackspace/gophercloud/openstack/identity/v3/endpoints"
+	services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// V2EndpointURL discovers the endpoint URL for a specific service from a ServiceCatalog acquired
+// during the v2 identity service. The specified EndpointOpts are used to identify a unique,
+// unambiguous endpoint to return. It's an error both when multiple endpoints match the provided
+// criteria and when none do. The minimum that can be specified is a Type, but you will also often
+// need to specify a Name and/or a Region depending on what's available on your OpenStack
+// deployment.
+func V2EndpointURL(catalog *tokens2.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
+	// Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
+	var endpoints = make([]tokens2.Endpoint, 0, 1)
+	for _, entry := range catalog.Entries {
+		if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
+			for _, endpoint := range entry.Endpoints {
+				if opts.Region == "" || endpoint.Region == opts.Region {
+					endpoints = append(endpoints, endpoint)
+				}
+			}
+		}
+	}
+
+	// Report an error if the options were ambiguous.
+	if len(endpoints) > 1 {
+		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
+	}
+
+	// Extract the appropriate URL from the matching Endpoint.
+	for _, endpoint := range endpoints {
+		switch opts.Availability {
+		case gophercloud.AvailabilityPublic:
+			return gophercloud.NormalizeURL(endpoint.PublicURL), nil
+		case gophercloud.AvailabilityInternal:
+			return gophercloud.NormalizeURL(endpoint.InternalURL), nil
+		case gophercloud.AvailabilityAdmin:
+			return gophercloud.NormalizeURL(endpoint.AdminURL), nil
+		default:
+			return "", fmt.Errorf("Unexpected availability in endpoint query: %s", opts.Availability)
+		}
+	}
+
+	// Report an error if there were no matching endpoints.
+	return "", gophercloud.ErrEndpointNotFound
+}
+
+// V3EndpointURL discovers the endpoint URL for a specific service using multiple calls against
+// an identity v3 service endpoint. The specified EndpointOpts are used to identify a unique,
+// unambiguous endpoint to return. It's an error both when multiple endpoints match the provided
+// criteria and when none do. The minimum that can be specified is a Type, but you will also often
+// need to specify a Name and/or a Region depending on what's available on your OpenStack
+// deployment.
+func V3EndpointURL(v3Client *gophercloud.ServiceClient, opts gophercloud.EndpointOpts) (string, error) {
+	// Discover the service we're interested in.
+	var services = make([]services3.Service, 0, 1)
+	servicePager := services3.List(v3Client, services3.ListOpts{ServiceType: opts.Type})
+	err := servicePager.EachPage(func(page pagination.Page) (bool, error) {
+		part, err := services3.ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, service := range part {
+			if service.Name == opts.Name {
+				services = append(services, service)
+			}
+		}
+
+		return true, nil
+	})
+	if err != nil {
+		return "", err
+	}
+
+	if len(services) == 0 {
+		return "", gophercloud.ErrServiceNotFound
+	}
+	if len(services) > 1 {
+		return "", fmt.Errorf("Discovered %d matching services: %#v", len(services), services)
+	}
+	service := services[0]
+
+	// Enumerate the endpoints available for this service.
+	var endpoints []endpoints3.Endpoint
+	endpointPager := endpoints3.List(v3Client, endpoints3.ListOpts{
+		Availability: opts.Availability,
+		ServiceID:    service.ID,
+	})
+	err = endpointPager.EachPage(func(page pagination.Page) (bool, error) {
+		part, err := endpoints3.ExtractEndpoints(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, endpoint := range part {
+			if opts.Region == "" || endpoint.Region == opts.Region {
+				endpoints = append(endpoints, endpoint)
+			}
+		}
+
+		return true, nil
+	})
+	if err != nil {
+		return "", err
+	}
+
+	if len(endpoints) == 0 {
+		return "", gophercloud.ErrEndpointNotFound
+	}
+	if len(endpoints) > 1 {
+		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
+	}
+	endpoint := endpoints[0]
+
+	return gophercloud.NormalizeURL(endpoint.URL), nil
+}
diff --git a/openstack/endpoint_location_test.go b/openstack/endpoint_location_test.go
new file mode 100644
index 0000000..4e0569a
--- /dev/null
+++ b/openstack/endpoint_location_test.go
@@ -0,0 +1,225 @@
+package openstack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+	th "github.com/rackspace/gophercloud/testhelper"
+	fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// Service catalog fixtures take too much vertical space!
+var catalog2 = tokens2.ServiceCatalog{
+	Entries: []tokens2.CatalogEntry{
+		tokens2.CatalogEntry{
+			Type: "same",
+			Name: "same",
+			Endpoints: []tokens2.Endpoint{
+				tokens2.Endpoint{
+					Region:      "same",
+					PublicURL:   "https://public.correct.com/",
+					InternalURL: "https://internal.correct.com/",
+					AdminURL:    "https://admin.correct.com/",
+				},
+				tokens2.Endpoint{
+					Region:    "different",
+					PublicURL: "https://badregion.com/",
+				},
+			},
+		},
+		tokens2.CatalogEntry{
+			Type: "same",
+			Name: "different",
+			Endpoints: []tokens2.Endpoint{
+				tokens2.Endpoint{
+					Region:    "same",
+					PublicURL: "https://badname.com/",
+				},
+				tokens2.Endpoint{
+					Region:    "different",
+					PublicURL: "https://badname.com/+badregion",
+				},
+			},
+		},
+		tokens2.CatalogEntry{
+			Type: "different",
+			Name: "different",
+			Endpoints: []tokens2.Endpoint{
+				tokens2.Endpoint{
+					Region:    "same",
+					PublicURL: "https://badtype.com/+badname",
+				},
+				tokens2.Endpoint{
+					Region:    "different",
+					PublicURL: "https://badtype.com/+badregion+badname",
+				},
+			},
+		},
+	},
+}
+
+func TestV2EndpointExact(t *testing.T) {
+	expectedURLs := map[gophercloud.Availability]string{
+		gophercloud.AvailabilityPublic:   "https://public.correct.com/",
+		gophercloud.AvailabilityAdmin:    "https://admin.correct.com/",
+		gophercloud.AvailabilityInternal: "https://internal.correct.com/",
+	}
+
+	for availability, expected := range expectedURLs {
+		actual, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+			Type:         "same",
+			Name:         "same",
+			Region:       "same",
+			Availability: availability,
+		})
+		th.AssertNoErr(t, err)
+		th.CheckEquals(t, expected, actual)
+	}
+}
+
+func TestV2EndpointNone(t *testing.T) {
+	_, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+		Type:         "nope",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	th.CheckEquals(t, gophercloud.ErrEndpointNotFound, err)
+}
+
+func TestV2EndpointMultiple(t *testing.T) {
+	_, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+		Type:         "same",
+		Region:       "same",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	if !strings.HasPrefix(err.Error(), "Discovered 2 matching endpoints:") {
+		t.Errorf("Received unexpected error: %v", err)
+	}
+}
+
+func TestV2EndpointBadAvailability(t *testing.T) {
+	_, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+		Type:         "same",
+		Name:         "same",
+		Region:       "same",
+		Availability: "wat",
+	})
+	th.CheckEquals(t, err.Error(), "Unexpected availability in endpoint query: wat")
+}
+
+func setupV3Responses(t *testing.T) {
+	// Mock the service query.
+	th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"links": {
+					"next": null,
+					"previous": null
+				},
+				"services": [
+					{
+						"description": "Correct",
+						"id": "1234",
+						"name": "same",
+						"type": "same"
+					},
+					{
+						"description": "Bad Name",
+						"id": "9876",
+						"name": "different",
+						"type": "same"
+					}
+				]
+			}
+		`)
+	})
+
+	// Mock the endpoint query.
+	th.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestFormValues(t, r, map[string]string{
+			"service_id": "1234",
+			"interface":  "public",
+		})
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"endpoints": [
+					{
+						"id": "12",
+						"interface": "public",
+						"name": "the-right-one",
+						"region": "same",
+						"service_id": "1234",
+						"url": "https://correct:9000/"
+					},
+					{
+						"id": "14",
+						"interface": "public",
+						"name": "bad-region",
+						"region": "different",
+						"service_id": "1234",
+						"url": "https://bad-region:9001/"
+					}
+				],
+				"links": {
+					"next": null,
+					"previous": null
+				}
+			}
+    `)
+	})
+}
+
+func TestV3EndpointExact(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	setupV3Responses(t)
+
+	actual, err := V3EndpointURL(fake.ServiceClient(), gophercloud.EndpointOpts{
+		Type:         "same",
+		Name:         "same",
+		Region:       "same",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, actual, "https://correct:9000/")
+}
+
+func TestV3EndpointNoService(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+      {
+        "links": {
+          "next": null,
+          "previous": null
+        },
+        "services": []
+      }
+    `)
+	})
+
+	_, err := V3EndpointURL(fake.ServiceClient(), gophercloud.EndpointOpts{
+		Type:         "nope",
+		Name:         "same",
+		Region:       "same",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	th.CheckEquals(t, gophercloud.ErrServiceNotFound, err)
+}
diff --git a/openstack/identity/v2/extensions/delegate.go b/openstack/identity/v2/extensions/delegate.go
index 1992a2c..cee275f 100644
--- a/openstack/identity/v2/extensions/delegate.go
+++ b/openstack/identity/v2/extensions/delegate.go
@@ -7,16 +7,6 @@
 	"github.com/rackspace/gophercloud/pagination"
 )
 
-// Extension is a single OpenStack extension.
-type Extension struct {
-	common.Extension
-}
-
-// GetResult wraps a GetResult from common.
-type GetResult struct {
-	common.GetResult
-}
-
 // ExtensionPage is a single page of Extension results.
 type ExtensionPage struct {
 	common.ExtensionPage
@@ -33,53 +23,30 @@
 
 // ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
 // elements into a slice of Extension structs.
-func ExtractExtensions(page pagination.Page) ([]Extension, error) {
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
 	// Identity v2 adds an intermediate "values" object.
 
-	type extension struct {
-		Updated     string        `mapstructure:"updated"`
-		Name        string        `mapstructure:"name"`
-		Namespace   string        `mapstructure:"namespace"`
-		Alias       string        `mapstructure:"alias"`
-		Description string        `mapstructure:"description"`
-		Links       []interface{} `mapstructure:"links"`
-	}
-
 	var resp struct {
 		Extensions struct {
-			Values []extension `mapstructure:"values"`
+			Values []common.Extension `mapstructure:"values"`
 		} `mapstructure:"extensions"`
 	}
 
 	err := mapstructure.Decode(page.(ExtensionPage).Body, &resp)
-	if err != nil {
-		return nil, err
-	}
-
-	exts := make([]Extension, len(resp.Extensions.Values))
-	for i, original := range resp.Extensions.Values {
-		exts[i] = Extension{common.Extension{
-			Updated:     original.Updated,
-			Name:        original.Name,
-			Namespace:   original.Namespace,
-			Alias:       original.Alias,
-			Description: original.Description,
-			Links:       original.Links,
-		}}
-	}
-
-	return exts, err
+	return resp.Extensions.Values, err
 }
 
 // Get retrieves information for a specific extension using its alias.
-func Get(c *gophercloud.ServiceClient, alias string) GetResult {
-	return GetResult{common.Get(c, alias)}
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+	return common.Get(c, alias)
 }
 
 // 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, common.ListExtensionURL(c), func(r pagination.LastHTTPResponse) pagination.Page {
-		return ExtensionPage{common.ExtensionPage{SinglePageBase: pagination.SinglePageBase(r)}}
+	return common.List(c).WithPageCreator(func(r pagination.LastHTTPResponse) pagination.Page {
+		return ExtensionPage{
+			ExtensionPage: common.ExtensionPage{SinglePageBase: pagination.SinglePageBase(r)},
+		}
 	})
 }
diff --git a/openstack/identity/v2/extensions/delegate_test.go b/openstack/identity/v2/extensions/delegate_test.go
index 90eb21b..504118a 100644
--- a/openstack/identity/v2/extensions/delegate_test.go
+++ b/openstack/identity/v2/extensions/delegate_test.go
@@ -1,76 +1,25 @@
 package extensions
 
 import (
-	"fmt"
-	"net/http"
 	"testing"
 
-	"github.com/rackspace/gophercloud"
 	common "github.com/rackspace/gophercloud/openstack/common/extensions"
 	"github.com/rackspace/gophercloud/pagination"
 	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
 )
 
-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("/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": {
-		"values": [
-			{
-				"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"
-			}
-		]
-	}
-}
-		`)
-	})
+	HandleListExtensionsSuccessfully(t)
 
 	count := 0
-
-	err := List(ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
 		count++
 		actual, err := ExtractExtensions(page)
 		th.AssertNoErr(t, err)
-
-		expected := []Extension{
-			Extension{
-				common.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)
+		th.CheckDeepEquals(t, common.ExpectedExtensions, actual)
 
 		return true, nil
 	})
@@ -81,34 +30,9 @@
 func TestGet(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
+	common.HandleGetExtensionSuccessfully(t)
 
-	th.Mux.HandleFunc("/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.")
-	})
+	actual, err := Get(client.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, common.SingleExtension, actual)
 }
diff --git a/openstack/identity/v2/extensions/fixtures.go b/openstack/identity/v2/extensions/fixtures.go
new file mode 100644
index 0000000..96cb7d2
--- /dev/null
+++ b/openstack/identity/v2/extensions/fixtures.go
@@ -0,0 +1,60 @@
+// +build fixtures
+
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single Extension result. It differs from the delegated implementation
+// by the introduction of an intermediate "values" member.
+const ListOutput = `
+{
+	"extensions": {
+		"values": [
+			{
+				"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"
+			}
+		]
+	}
+}
+`
+
+// HandleListExtensionsSuccessfully creates an HTTP handler that returns ListOutput for a List
+// call.
+func HandleListExtensionsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, `
+{
+  "extensions": {
+    "values": [
+      {
+        "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"
+      }
+    ]
+  }
+}
+    `)
+	})
+
+}
diff --git a/openstack/identity/v2/tenants/fixtures.go b/openstack/identity/v2/tenants/fixtures.go
new file mode 100644
index 0000000..7f044ac
--- /dev/null
+++ b/openstack/identity/v2/tenants/fixtures.go
@@ -0,0 +1,65 @@
+// +build fixtures
+
+package tenants
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Tenant results.
+const ListOutput = `
+{
+	"tenants": [
+		{
+			"id": "1234",
+			"name": "Red Team",
+			"description": "The team that is red",
+			"enabled": true
+		},
+		{
+			"id": "9876",
+			"name": "Blue Team",
+			"description": "The team that is blue",
+			"enabled": false
+		}
+	]
+}
+`
+
+// RedTeam is a Tenant fixture.
+var RedTeam = Tenant{
+	ID:          "1234",
+	Name:        "Red Team",
+	Description: "The team that is red",
+	Enabled:     true,
+}
+
+// BlueTeam is a Tenant fixture.
+var BlueTeam = Tenant{
+	ID:          "9876",
+	Name:        "Blue Team",
+	Description: "The team that is blue",
+	Enabled:     false,
+}
+
+// ExpectedTenantSlice is the slice of tenants expected to be returned from ListOutput.
+var ExpectedTenantSlice = []Tenant{RedTeam, BlueTeam}
+
+// HandleListTenantsSuccessfully creates an HTTP handler at `/tenants` on the test handler mux that
+// responds with a list of two tenants.
+func HandleListTenantsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/tenants", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, ListOutput)
+	})
+}
diff --git a/openstack/identity/v2/tenants/requests_test.go b/openstack/identity/v2/tenants/requests_test.go
index 685bf96..e8f172d 100644
--- a/openstack/identity/v2/tenants/requests_test.go
+++ b/openstack/identity/v2/tenants/requests_test.go
@@ -1,76 +1,26 @@
 package tenants
 
 import (
-	"fmt"
-	"net/http"
 	"testing"
 
-	"github.com/rackspace/gophercloud"
 	"github.com/rackspace/gophercloud/pagination"
 	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
 )
 
-const tokenID = "1234123412341234"
-
 func TestListTenants(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
-
-	th.Mux.HandleFunc("/tenants", func(w http.ResponseWriter, r *http.Request) {
-		th.TestMethod(t, r, "GET")
-		th.TestHeader(t, r, "Accept", "application/json")
-		th.TestHeader(t, r, "X-Auth-Token", tokenID)
-
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(http.StatusOK)
-		fmt.Fprintf(w, `
-{
-  "tenants": [
-    {
-      "id": "1234",
-      "name": "Red Team",
-      "description": "The team that is red",
-      "enabled": true
-    },
-    {
-      "id": "9876",
-      "name": "Blue Team",
-      "description": "The team that is blue",
-      "enabled": false
-    }
-  ]
-}
-    `)
-	})
-
-	client := &gophercloud.ServiceClient{
-		Provider: &gophercloud.ProviderClient{TokenID: tokenID},
-		Endpoint: th.Endpoint(),
-	}
+	HandleListTenantsSuccessfully(t)
 
 	count := 0
-	err := List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+	err := List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
 		count++
 
 		actual, err := ExtractTenants(page)
 		th.AssertNoErr(t, err)
 
-		expected := []Tenant{
-			Tenant{
-				ID:          "1234",
-				Name:        "Red Team",
-				Description: "The team that is red",
-				Enabled:     true,
-			},
-			Tenant{
-				ID:          "9876",
-				Name:        "Blue Team",
-				Description: "The team that is blue",
-				Enabled:     false,
-			},
-		}
-
-		th.CheckDeepEquals(t, expected, actual)
+		th.CheckDeepEquals(t, ExpectedTenantSlice, actual)
 
 		return true, nil
 	})
diff --git a/openstack/identity/v2/tokens/errors.go b/openstack/identity/v2/tokens/errors.go
index 244db1b..3a9172e 100644
--- a/openstack/identity/v2/tokens/errors.go
+++ b/openstack/identity/v2/tokens/errors.go
@@ -9,6 +9,9 @@
 	// ErrUserIDProvided is returned if you attempt to authenticate with a UserID.
 	ErrUserIDProvided = unacceptedAttributeErr("UserID")
 
+	// ErrAPIKeyProvided is returned if you attempt to authenticate with an APIKey.
+	ErrAPIKeyProvided = unacceptedAttributeErr("APIKey")
+
 	// ErrDomainIDProvided is returned if you attempt to authenticate with a DomainID.
 	ErrDomainIDProvided = unacceptedAttributeErr("DomainID")
 
@@ -18,8 +21,8 @@
 	// ErrUsernameRequired is returned if you attempt ot authenticate without a Username.
 	ErrUsernameRequired = errors.New("You must supply a Username in your AuthOptions.")
 
-	// ErrPasswordOrAPIKey is returned if you provide both a password and an API key.
-	ErrPasswordOrAPIKey = errors.New("Please supply exactly one of Password or APIKey in your AuthOptions.")
+	// ErrPasswordRequired is returned if you don't provide a password.
+	ErrPasswordRequired = errors.New("Please supply a Password in your AuthOptions.")
 )
 
 func unacceptedAttributeErr(attribute string) error {
diff --git a/openstack/identity/v2/tokens/fixtures.go b/openstack/identity/v2/tokens/fixtures.go
new file mode 100644
index 0000000..1cb0d05
--- /dev/null
+++ b/openstack/identity/v2/tokens/fixtures.go
@@ -0,0 +1,128 @@
+// +build fixtures
+
+package tokens
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+// ExpectedToken is the token that should be parsed from TokenCreationResponse.
+var ExpectedToken = &Token{
+	ID:        "aaaabbbbccccdddd",
+	ExpiresAt: time.Date(2014, time.January, 31, 15, 30, 58, 0, time.UTC),
+	Tenant: tenants.Tenant{
+		ID:          "fc394f2ab2df4114bde39905f800dc57",
+		Name:        "test",
+		Description: "There are many tenants. This one is yours.",
+		Enabled:     true,
+	},
+}
+
+// ExpectedServiceCatalog is the service catalog that should be parsed from TokenCreationResponse.
+var ExpectedServiceCatalog = &ServiceCatalog{
+	Entries: []CatalogEntry{
+		CatalogEntry{
+			Name: "inscrutablewalrus",
+			Type: "something",
+			Endpoints: []Endpoint{
+				Endpoint{
+					PublicURL: "http://something0:1234/v2/",
+					Region:    "region0",
+				},
+				Endpoint{
+					PublicURL: "http://something1:1234/v2/",
+					Region:    "region1",
+				},
+			},
+		},
+		CatalogEntry{
+			Name: "arbitrarypenguin",
+			Type: "else",
+			Endpoints: []Endpoint{
+				Endpoint{
+					PublicURL: "http://else0:4321/v3/",
+					Region:    "region0",
+				},
+			},
+		},
+	},
+}
+
+// TokenCreationResponse is a JSON response that contains ExpectedToken and ExpectedServiceCatalog.
+const TokenCreationResponse = `
+{
+	"access": {
+		"token": {
+			"issued_at": "2014-01-30T15:30:58.000000Z",
+			"expires": "2014-01-31T15:30:58Z",
+			"id": "aaaabbbbccccdddd",
+			"tenant": {
+				"description": "There are many tenants. This one is yours.",
+				"enabled": true,
+				"id": "fc394f2ab2df4114bde39905f800dc57",
+				"name": "test"
+			}
+		},
+		"serviceCatalog": [
+			{
+				"endpoints": [
+					{
+						"publicURL": "http://something0:1234/v2/",
+						"region": "region0"
+					},
+					{
+						"publicURL": "http://something1:1234/v2/",
+						"region": "region1"
+					}
+				],
+				"type": "something",
+				"name": "inscrutablewalrus"
+			},
+			{
+				"endpoints": [
+					{
+						"publicURL": "http://else0:4321/v3/",
+						"region": "region0"
+					}
+				],
+				"type": "else",
+				"name": "arbitrarypenguin"
+			}
+		]
+	}
+}
+`
+
+// HandleTokenPost expects a POST against a /tokens handler, ensures that the request body has been
+// constructed properly given certain auth options, and returns the result.
+func HandleTokenPost(t *testing.T, requestJSON string) {
+	th.Mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		if requestJSON != "" {
+			th.TestJSONRequest(t, r, requestJSON)
+		}
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, TokenCreationResponse)
+	})
+}
+
+// IsSuccessful ensures that a CreateResult was successful and contains the correct token and
+// service catalog.
+func IsSuccessful(t *testing.T, result CreateResult) {
+	token, err := result.ExtractToken()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedToken, token)
+
+	serviceCatalog, err := result.ExtractServiceCatalog()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedServiceCatalog, serviceCatalog)
+}
diff --git a/openstack/identity/v2/tokens/requests.go b/openstack/identity/v2/tokens/requests.go
index 5bd5a70..c25a72b 100644
--- a/openstack/identity/v2/tokens/requests.go
+++ b/openstack/identity/v2/tokens/requests.go
@@ -5,73 +5,80 @@
 	"github.com/rackspace/gophercloud"
 )
 
+// AuthOptionsBuilder describes any argument that may be passed to the Create call.
+type AuthOptionsBuilder interface {
+
+	// ToTokenCreateMap assembles the Create request body, returning an error if parameters are
+	// missing or inconsistent.
+	ToTokenCreateMap() (map[string]interface{}, error)
+}
+
+// AuthOptions wraps a gophercloud AuthOptions in order to adhere to the AuthOptionsBuilder
+// interface.
+type AuthOptions struct {
+	gophercloud.AuthOptions
+}
+
+// WrapOptions embeds a root AuthOptions struct in a package-specific one.
+func WrapOptions(original gophercloud.AuthOptions) AuthOptions {
+	return AuthOptions{AuthOptions: original}
+}
+
+// ToTokenCreateMap converts AuthOptions into nested maps that can be serialized into a JSON
+// request.
+func (auth AuthOptions) ToTokenCreateMap() (map[string]interface{}, error) {
+	// Error out if an unsupported auth option is present.
+	if auth.UserID != "" {
+		return nil, ErrUserIDProvided
+	}
+	if auth.APIKey != "" {
+		return nil, ErrAPIKeyProvided
+	}
+	if auth.DomainID != "" {
+		return nil, ErrDomainIDProvided
+	}
+	if auth.DomainName != "" {
+		return nil, ErrDomainNameProvided
+	}
+
+	// Username and Password are always required.
+	if auth.Username == "" {
+		return nil, ErrUsernameRequired
+	}
+	if auth.Password == "" {
+		return nil, ErrPasswordRequired
+	}
+
+	// Populate the request map.
+	authMap := make(map[string]interface{})
+
+	authMap["passwordCredentials"] = map[string]interface{}{
+		"username": auth.Username,
+		"password": auth.Password,
+	}
+
+	if auth.TenantID != "" {
+		authMap["tenantId"] = auth.TenantID
+	}
+	if auth.TenantName != "" {
+		authMap["tenantName"] = auth.TenantName
+	}
+
+	return map[string]interface{}{"auth": authMap}, nil
+}
+
 // Create authenticates to the identity service and attempts to acquire a Token.
 // If successful, the CreateResult
 // Generally, rather than interact with this call directly, end users should call openstack.AuthenticatedClient(),
 // which abstracts all of the gory details about navigating service catalogs and such.
-func Create(client *gophercloud.ServiceClient, auth gophercloud.AuthOptions) CreateResult {
-	type passwordCredentials struct {
-		Username string `json:"username"`
-		Password string `json:"password"`
+func Create(client *gophercloud.ServiceClient, auth AuthOptionsBuilder) CreateResult {
+	request, err := auth.ToTokenCreateMap()
+	if err != nil {
+		return CreateResult{gophercloud.CommonResult{Err: err}}
 	}
 
-	type apiKeyCredentials struct {
-		Username string `json:"username"`
-		APIKey   string `json:"apiKey"`
-	}
-
-	var request struct {
-		Auth struct {
-			PasswordCredentials *passwordCredentials `json:"passwordCredentials,omitempty"`
-			APIKeyCredentials   *apiKeyCredentials   `json:"RAX-KSKEY:apiKeyCredentials,omitempty"`
-			TenantID            string               `json:"tenantId,omitempty"`
-			TenantName          string               `json:"tenantName,omitempty"`
-		} `json:"auth"`
-	}
-
-	// Error out if an unsupported auth option is present.
-	if auth.UserID != "" {
-		return createErr(ErrUserIDProvided)
-	}
-	if auth.DomainID != "" {
-		return createErr(ErrDomainIDProvided)
-	}
-	if auth.DomainName != "" {
-		return createErr(ErrDomainNameProvided)
-	}
-
-	// Username is always required.
-	if auth.Username == "" {
-		return createErr(ErrUsernameRequired)
-	}
-
-	// Populate either PasswordCredentials or APIKeyCredentials
-	if auth.Password != "" {
-		if auth.APIKey != "" {
-			return createErr(ErrPasswordOrAPIKey)
-		}
-
-		// Username + Password
-		request.Auth.PasswordCredentials = &passwordCredentials{
-			Username: auth.Username,
-			Password: auth.Password,
-		}
-	} else if auth.APIKey != "" {
-		// API key authentication.
-		request.Auth.APIKeyCredentials = &apiKeyCredentials{
-			Username: auth.Username,
-			APIKey:   auth.APIKey,
-		}
-	} else {
-		return createErr(ErrPasswordOrAPIKey)
-	}
-
-	// Populate the TenantName or TenantID, if provided.
-	request.Auth.TenantID = auth.TenantID
-	request.Auth.TenantName = auth.TenantName
-
 	var result CreateResult
-	_, result.Err = perigee.Request("POST", listURL(client), perigee.Options{
+	_, result.Err = perigee.Request("POST", CreateURL(client), perigee.Options{
 		ReqBody: &request,
 		Results: &result.Resp,
 		OkCodes: []int{200, 203},
diff --git a/openstack/identity/v2/tokens/requests_test.go b/openstack/identity/v2/tokens/requests_test.go
index 0e6269f..2f02825 100644
--- a/openstack/identity/v2/tokens/requests_test.go
+++ b/openstack/identity/v2/tokens/requests_test.go
@@ -1,153 +1,37 @@
 package tokens
 
 import (
-	"fmt"
-	"net/http"
 	"testing"
-	"time"
 
 	"github.com/rackspace/gophercloud"
-	"github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
 	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
 )
 
-var expectedToken = &Token{
-	ID:        "aaaabbbbccccdddd",
-	ExpiresAt: time.Date(2014, time.January, 31, 15, 30, 58, 0, time.UTC),
-	Tenant: tenants.Tenant{
-		ID:          "fc394f2ab2df4114bde39905f800dc57",
-		Name:        "test",
-		Description: "There are many tenants. This one is yours.",
-		Enabled:     true,
-	},
-}
-
-var expectedServiceCatalog = &ServiceCatalog{
-	Entries: []CatalogEntry{
-		CatalogEntry{
-			Name: "inscrutablewalrus",
-			Type: "something",
-			Endpoints: []Endpoint{
-				Endpoint{
-					PublicURL: "http://something0:1234/v2/",
-					Region:    "region0",
-				},
-				Endpoint{
-					PublicURL: "http://something1:1234/v2/",
-					Region:    "region1",
-				},
-			},
-		},
-		CatalogEntry{
-			Name: "arbitrarypenguin",
-			Type: "else",
-			Endpoints: []Endpoint{
-				Endpoint{
-					PublicURL: "http://else0:4321/v3/",
-					Region:    "region0",
-				},
-			},
-		},
-	},
-}
-
 func tokenPost(t *testing.T, options gophercloud.AuthOptions, requestJSON string) CreateResult {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
+	HandleTokenPost(t, requestJSON)
 
-	client := gophercloud.ServiceClient{Endpoint: th.Endpoint()}
-
-	th.Mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
-		th.TestMethod(t, r, "POST")
-		th.TestHeader(t, r, "Content-Type", "application/json")
-		th.TestHeader(t, r, "Accept", "application/json")
-		th.TestJSONRequest(t, r, requestJSON)
-
-		w.WriteHeader(http.StatusOK)
-		fmt.Fprintf(w, `
-{
-  "access": {
-    "token": {
-      "issued_at": "2014-01-30T15:30:58.000000Z",
-      "expires": "2014-01-31T15:30:58Z",
-      "id": "aaaabbbbccccdddd",
-      "tenant": {
-        "description": "There are many tenants. This one is yours.",
-        "enabled": true,
-        "id": "fc394f2ab2df4114bde39905f800dc57",
-        "name": "test"
-      }
-    },
-    "serviceCatalog": [
-      {
-        "endpoints": [
-          {
-            "publicURL": "http://something0:1234/v2/",
-            "region": "region0"
-          },
-          {
-            "publicURL": "http://something1:1234/v2/",
-            "region": "region1"
-          }
-        ],
-        "type": "something",
-        "name": "inscrutablewalrus"
-      },
-      {
-        "endpoints": [
-          {
-            "publicURL": "http://else0:4321/v3/",
-            "region": "region0"
-          }
-        ],
-        "type": "else",
-        "name": "arbitrarypenguin"
-      }
-    ]
-  }
-}
-    `)
-	})
-
-	return Create(&client, options)
+	return Create(client.ServiceClient(), AuthOptions{options})
 }
 
 func tokenPostErr(t *testing.T, options gophercloud.AuthOptions, expectedErr error) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
+	HandleTokenPost(t, "")
 
-	client := gophercloud.ServiceClient{Endpoint: th.Endpoint()}
-
-	th.Mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
-		th.TestMethod(t, r, "POST")
-		th.TestHeader(t, r, "Content-Type", "application/json")
-		th.TestHeader(t, r, "Accept", "application/json")
-
-		w.WriteHeader(http.StatusOK)
-		fmt.Fprintf(w, `{}`)
-	})
-
-	actualErr := Create(&client, options).Err
+	actualErr := Create(client.ServiceClient(), AuthOptions{options}).Err
 	th.CheckEquals(t, expectedErr, actualErr)
 }
 
-func isSuccessful(t *testing.T, result CreateResult) {
-	token, err := result.ExtractToken()
-	th.AssertNoErr(t, err)
-	th.CheckDeepEquals(t, expectedToken, token)
-
-	serviceCatalog, err := result.ExtractServiceCatalog()
-	th.AssertNoErr(t, err)
-	th.CheckDeepEquals(t, expectedServiceCatalog, serviceCatalog)
-}
-
 func TestCreateWithPassword(t *testing.T) {
 	options := gophercloud.AuthOptions{
 		Username: "me",
 		Password: "swordfish",
 	}
 
-	isSuccessful(t, tokenPost(t, options, `
+	IsSuccessful(t, tokenPost(t, options, `
     {
       "auth": {
         "passwordCredentials": {
@@ -159,24 +43,6 @@
   `))
 }
 
-func TestCreateTokenWithAPIKey(t *testing.T) {
-	options := gophercloud.AuthOptions{
-		Username: "me",
-		APIKey:   "1234567890abcdef",
-	}
-
-	isSuccessful(t, tokenPost(t, options, `
-    {
-      "auth": {
-        "RAX-KSKEY:apiKeyCredentials": {
-          "username": "me",
-          "apiKey": "1234567890abcdef"
-        }
-      }
-    }
-  `))
-}
-
 func TestCreateTokenWithTenantID(t *testing.T) {
 	options := gophercloud.AuthOptions{
 		Username: "me",
@@ -184,7 +50,7 @@
 		TenantID: "fc394f2ab2df4114bde39905f800dc57",
 	}
 
-	isSuccessful(t, tokenPost(t, options, `
+	IsSuccessful(t, tokenPost(t, options, `
     {
       "auth": {
         "tenantId": "fc394f2ab2df4114bde39905f800dc57",
@@ -204,7 +70,7 @@
 		TenantName: "demo",
 	}
 
-	isSuccessful(t, tokenPost(t, options, `
+	IsSuccessful(t, tokenPost(t, options, `
     {
       "auth": {
         "tenantName": "demo",
@@ -223,15 +89,27 @@
 		UserID:   "1234",
 		Password: "thing",
 	}
+
 	tokenPostErr(t, options, ErrUserIDProvided)
 }
 
+func TestProhibitAPIKey(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Username: "me",
+		Password: "thing",
+		APIKey:   "123412341234",
+	}
+
+	tokenPostErr(t, options, ErrAPIKeyProvided)
+}
+
 func TestProhibitDomainID(t *testing.T) {
 	options := gophercloud.AuthOptions{
 		Username: "me",
 		Password: "thing",
 		DomainID: "1234",
 	}
+
 	tokenPostErr(t, options, ErrDomainIDProvided)
 }
 
@@ -241,6 +119,7 @@
 		Password:   "thing",
 		DomainName: "wat",
 	}
+
 	tokenPostErr(t, options, ErrDomainNameProvided)
 }
 
@@ -248,21 +127,14 @@
 	options := gophercloud.AuthOptions{
 		Password: "thing",
 	}
+
 	tokenPostErr(t, options, ErrUsernameRequired)
 }
 
-func TestProhibitBothPasswordAndAPIKey(t *testing.T) {
+func TestRequirePassword(t *testing.T) {
 	options := gophercloud.AuthOptions{
 		Username: "me",
-		Password: "thing",
-		APIKey:   "123412341234",
 	}
-	tokenPostErr(t, options, ErrPasswordOrAPIKey)
-}
 
-func TestRequirePasswordOrAPIKey(t *testing.T) {
-	options := gophercloud.AuthOptions{
-		Username: "me",
-	}
-	tokenPostErr(t, options, ErrPasswordOrAPIKey)
+	tokenPostErr(t, options, ErrPasswordRequired)
 }
diff --git a/openstack/identity/v2/tokens/urls.go b/openstack/identity/v2/tokens/urls.go
index 86d19f2..cd4c696 100644
--- a/openstack/identity/v2/tokens/urls.go
+++ b/openstack/identity/v2/tokens/urls.go
@@ -2,6 +2,7 @@
 
 import "github.com/rackspace/gophercloud"
 
-func listURL(client *gophercloud.ServiceClient) string {
+// CreateURL generates the URL used to create new Tokens.
+func CreateURL(client *gophercloud.ServiceClient) string {
 	return client.ServiceURL("tokens")
 }
diff --git a/pagination/pager.go b/pagination/pager.go
index 22d6d84..75fe408 100644
--- a/pagination/pager.go
+++ b/pagination/pager.go
@@ -29,10 +29,10 @@
 
 // Pager knows how to advance through a specific resource collection, one page at a time.
 type Pager struct {
-	initialURL string
-
 	client *gophercloud.ServiceClient
 
+	initialURL string
+
 	createPage func(r LastHTTPResponse) Page
 
 	Err error
@@ -45,8 +45,18 @@
 // Supply the URL for the first page, a function that requests a specific page given a URL, and a function that counts a page.
 func NewPager(client *gophercloud.ServiceClient, initialURL string, createPage func(r LastHTTPResponse) Page) Pager {
 	return Pager{
-		initialURL: initialURL,
 		client:     client,
+		initialURL: initialURL,
+		createPage: createPage,
+	}
+}
+
+// WithPageCreator returns a new Pager that substitutes a different page creation function. This is
+// useful for overriding List functions in delegation.
+func (p Pager) WithPageCreator(createPage func(r LastHTTPResponse) Page) Pager {
+	return Pager{
+		client:     p.client,
+		initialURL: p.initialURL,
 		createPage: createPage,
 	}
 }
diff --git a/rackspace/client.go b/rackspace/client.go
new file mode 100644
index 0000000..d8c4a19
--- /dev/null
+++ b/rackspace/client.go
@@ -0,0 +1,115 @@
+package rackspace
+
+import (
+	"fmt"
+
+	"github.com/rackspace/gophercloud"
+	os "github.com/rackspace/gophercloud/openstack"
+	"github.com/rackspace/gophercloud/openstack/utils"
+	tokens2 "github.com/rackspace/gophercloud/rackspace/identity/v2/tokens"
+)
+
+const (
+	// RackspaceUSIdentity is an identity endpoint located in the United States.
+	RackspaceUSIdentity = "https://identity.api.rackspacecloud.com/v2.0/"
+
+	// RackspaceUKIdentity is an identity endpoint located in the UK.
+	RackspaceUKIdentity = "https://lon.identity.api.rackspacecloud.com/v2.0/"
+)
+
+const (
+	v20 = "v2.0"
+)
+
+// NewClient creates a client that's prepared to communicate with the Rackspace API, but is not
+// yet authenticated. Most users will probably prefer using the AuthenticatedClient function
+// instead.
+//
+// Provide the base URL of the identity endpoint you wish to authenticate against as "endpoint".
+// Often, this will be either RackspaceUSIdentity or RackspaceUKIdentity.
+func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
+	if endpoint == "" {
+		return os.NewClient(RackspaceUSIdentity)
+	}
+	return os.NewClient(endpoint)
+}
+
+// AuthenticatedClient logs in to Rackspace with the provided credentials and constructs a
+// ProviderClient that's ready to operate.
+//
+// If the provided AuthOptions does not specify an explicit IdentityEndpoint, it will default to
+// the canonical, production Rackspace US identity endpoint.
+func AuthenticatedClient(options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) {
+	client, err := NewClient(options.IdentityEndpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	err = Authenticate(client, options)
+	if err != nil {
+		return nil, err
+	}
+	return client, nil
+}
+
+// Authenticate or re-authenticate against the most recent identity service supported at the
+// provided endpoint.
+func Authenticate(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+	versions := []*utils.Version{
+		&utils.Version{ID: v20, Priority: 20, Suffix: "/v2.0/"},
+	}
+
+	chosen, endpoint, err := utils.ChooseVersion(client.IdentityBase, client.IdentityEndpoint, versions)
+	if err != nil {
+		return err
+	}
+
+	switch chosen.ID {
+	case v20:
+		return v2auth(client, endpoint, options)
+	default:
+		// The switch statement must be out of date from the versions list.
+		return fmt.Errorf("Unrecognized identity version: %s", chosen.ID)
+	}
+}
+
+// AuthenticateV2 explicitly authenticates with v2 of the identity service.
+func AuthenticateV2(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+	return v2auth(client, "", options)
+}
+
+func v2auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+	v2Client := NewIdentityV2(client)
+	if endpoint != "" {
+		v2Client.Endpoint = endpoint
+	}
+
+	result := tokens2.Create(v2Client, tokens2.WrapOptions(options))
+
+	token, err := result.ExtractToken()
+	if err != nil {
+		return err
+	}
+
+	catalog, err := result.ExtractServiceCatalog()
+	if err != nil {
+		return err
+	}
+
+	client.TokenID = token.ID
+	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+		return os.V2EndpointURL(catalog, opts)
+	}
+
+	return nil
+}
+
+// NewIdentityV2 creates a ServiceClient that may be used to access the v2 identity service.
+func NewIdentityV2(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+	v2Endpoint := client.IdentityBase + "v2.0/"
+
+	return &gophercloud.ServiceClient{
+		Provider: client,
+		Endpoint: v2Endpoint,
+	}
+}
diff --git a/rackspace/client_test.go b/rackspace/client_test.go
new file mode 100644
index 0000000..73b1c88
--- /dev/null
+++ b/rackspace/client_test.go
@@ -0,0 +1,38 @@
+package rackspace
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClientV2(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/tokens", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+      {
+        "access": {
+          "token": {
+            "id": "01234567890",
+            "expires": "2014-10-01T10:00:00.000000Z"
+          },
+          "serviceCatalog": []
+        }
+      }
+    `)
+	})
+
+	options := gophercloud.AuthOptions{
+		Username:         "me",
+		APIKey:           "09876543210",
+		IdentityEndpoint: th.Endpoint() + "v2.0/",
+	}
+	client, err := AuthenticatedClient(options)
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, "01234567890", client.TokenID)
+}
diff --git a/rackspace/identity/v2/extensions/delegate.go b/rackspace/identity/v2/extensions/delegate.go
new file mode 100644
index 0000000..fc547cd
--- /dev/null
+++ b/rackspace/identity/v2/extensions/delegate.go
@@ -0,0 +1,24 @@
+package extensions
+
+import (
+	"github.com/rackspace/gophercloud"
+	common "github.com/rackspace/gophercloud/openstack/common/extensions"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
+// elements into a slice of os.Extension structs.
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
+	return common.ExtractExtensions(page)
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+	return common.Get(c, alias)
+}
+
+// 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 common.List(c)
+}
diff --git a/rackspace/identity/v2/extensions/delegate_test.go b/rackspace/identity/v2/extensions/delegate_test.go
new file mode 100644
index 0000000..e30f794
--- /dev/null
+++ b/rackspace/identity/v2/extensions/delegate_test.go
@@ -0,0 +1,39 @@
+package extensions
+
+import (
+	"testing"
+
+	common "github.com/rackspace/gophercloud/openstack/common/extensions"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+	fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	common.HandleListExtensionsSuccessfully(t)
+
+	count := 0
+
+	err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+		th.AssertDeepEquals(t, common.ExpectedExtensions, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	common.HandleGetExtensionSuccessfully(t)
+
+	actual, err := Get(fake.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, common.SingleExtension, actual)
+}
diff --git a/rackspace/identity/v2/tenants/delegate.go b/rackspace/identity/v2/tenants/delegate.go
new file mode 100644
index 0000000..6cdd0cf
--- /dev/null
+++ b/rackspace/identity/v2/tenants/delegate.go
@@ -0,0 +1,17 @@
+package tenants
+
+import (
+	"github.com/rackspace/gophercloud"
+	os "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractTenants interprets a page of List results as a more usable slice of Tenant structs.
+func ExtractTenants(page pagination.Page) ([]os.Tenant, error) {
+	return os.ExtractTenants(page)
+}
+
+// List enumerates the tenants to which the current token grants access.
+func List(client *gophercloud.ServiceClient, opts *os.ListOpts) pagination.Pager {
+	return os.List(client, opts)
+}
diff --git a/rackspace/identity/v2/tenants/delegate_test.go b/rackspace/identity/v2/tenants/delegate_test.go
new file mode 100644
index 0000000..eccbfe2
--- /dev/null
+++ b/rackspace/identity/v2/tenants/delegate_test.go
@@ -0,0 +1,28 @@
+package tenants
+
+import (
+	"testing"
+
+	os "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+	fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListTenants(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	os.HandleListTenantsSuccessfully(t)
+
+	count := 0
+	err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+		actual, err := ExtractTenants(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, os.ExpectedTenantSlice, actual)
+
+		count++
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
diff --git a/rackspace/identity/v2/tokens/delegate.go b/rackspace/identity/v2/tokens/delegate.go
new file mode 100644
index 0000000..4f9885a
--- /dev/null
+++ b/rackspace/identity/v2/tokens/delegate.go
@@ -0,0 +1,60 @@
+package tokens
+
+import (
+	"errors"
+
+	"github.com/rackspace/gophercloud"
+	os "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+)
+
+var (
+	// ErrPasswordProvided is returned if both a password and an API key are provided to Create.
+	ErrPasswordProvided = errors.New("Please provide either a password or an API key.")
+)
+
+// AuthOptions wraps the OpenStack AuthOptions struct to be able to customize the request body
+// when API key authentication is used.
+type AuthOptions struct {
+	os.AuthOptions
+}
+
+// WrapOptions embeds a root AuthOptions struct in a package-specific one.
+func WrapOptions(original gophercloud.AuthOptions) AuthOptions {
+	return AuthOptions{AuthOptions: os.WrapOptions(original)}
+}
+
+// ToTokenCreateMap serializes an AuthOptions into a request body. If an API key is provided, it
+// will be used, otherwise
+func (auth AuthOptions) ToTokenCreateMap() (map[string]interface{}, error) {
+	if auth.APIKey == "" {
+		return auth.AuthOptions.ToTokenCreateMap()
+	}
+
+	// Verify that other required attributes are present.
+	if auth.Username == "" {
+		return nil, os.ErrUsernameRequired
+	}
+
+	authMap := make(map[string]interface{})
+
+	authMap["RAX-KSKEY:apiKeyCredentials"] = map[string]interface{}{
+		"username": auth.Username,
+		"apiKey":   auth.APIKey,
+	}
+
+	if auth.TenantID != "" {
+		authMap["tenantId"] = auth.TenantID
+	}
+	if auth.TenantName != "" {
+		authMap["tenantName"] = auth.TenantName
+	}
+
+	return map[string]interface{}{"auth": authMap}, nil
+}
+
+// Create authenticates to Rackspace's identity service and attempts to acquire a Token. Rather
+// than interact with this service directly, users should generally call
+// rackspace.AuthenticatedClient().
+func Create(client *gophercloud.ServiceClient, auth AuthOptions) os.CreateResult {
+	return os.Create(client, auth)
+}
diff --git a/rackspace/identity/v2/tokens/delegate_test.go b/rackspace/identity/v2/tokens/delegate_test.go
new file mode 100644
index 0000000..6678ff4
--- /dev/null
+++ b/rackspace/identity/v2/tokens/delegate_test.go
@@ -0,0 +1,36 @@
+package tokens
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	os "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func tokenPost(t *testing.T, options gophercloud.AuthOptions, requestJSON string) os.CreateResult {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	os.HandleTokenPost(t, requestJSON)
+
+	return Create(client.ServiceClient(), WrapOptions(options))
+}
+
+func TestCreateTokenWithAPIKey(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Username: "me",
+		APIKey:   "1234567890abcdef",
+	}
+
+	os.IsSuccessful(t, tokenPost(t, options, `
+    {
+      "auth": {
+        "RAX-KSKEY:apiKeyCredentials": {
+          "username": "me",
+          "apiKey": "1234567890abcdef"
+        }
+      }
+    }
+  `))
+}
diff --git a/script/acceptancetest b/script/acceptancetest
new file mode 100755
index 0000000..d8039ae
--- /dev/null
+++ b/script/acceptancetest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the acceptance tests.
+
+exec go test -tags 'acceptance fixtures' ./acceptance/... $@
diff --git a/scripts/create-environment.sh b/script/bootstrap
old mode 100644
new mode 100755
similarity index 100%
rename from scripts/create-environment.sh
rename to script/bootstrap
diff --git a/script/cibuild b/script/cibuild
new file mode 100755
index 0000000..1cb389e
--- /dev/null
+++ b/script/cibuild
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Test script to be invoked by Travis.
+
+exec script/unittest -v
diff --git a/script/test b/script/test
new file mode 100755
index 0000000..1e03dff
--- /dev/null
+++ b/script/test
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run all the tests.
+
+exec go test -tags 'acceptance fixtures' ./... $@
diff --git a/script/unittest b/script/unittest
new file mode 100755
index 0000000..d3440a9
--- /dev/null
+++ b/script/unittest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the unit tests.
+
+exec go test -tags fixtures ./... $@
diff --git a/scripts/test-all.sh b/scripts/test-all.sh
deleted file mode 100755
index 096736f..0000000
--- a/scripts/test-all.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-#
-# This script is responsible for executing all the acceptance tests found in
-# the acceptance/ directory.
-
-# Find where _this_ script is running from.
-SCRIPTS=$(dirname $0)
-SCRIPTS=$(cd $SCRIPTS; pwd)
-
-# Locate the acceptance test / examples directory.
-ACCEPTANCE=$(cd $SCRIPTS/../acceptance; pwd)
-
-# Go workspace path
-WS=$(cd $SCRIPTS/..; pwd)
-
-# In order to run Go code interactively, we need the GOPATH environment
-# to be set.
-if [ "x$GOPATH" == "x" ]; then
-  export GOPATH=$WS
-  echo "WARNING: You didn't have your GOPATH environment variable set."
-  echo "         I'm assuming $GOPATH as its value."
-fi
-
-# Run all acceptance tests sequentially.
-# If any test fails, we fail fast.
-LIBS=$(ls $ACCEPTANCE/lib*.go)
-for T in $(ls -1 $ACCEPTANCE/[0-9][0-9]*.go); do
-  if ! [ -x $T ]; then
-    CMD="go run $T $LIBS -quiet"
-    echo "$CMD ..."
-    if ! $CMD ; then
-      echo "- FAILED.  Try re-running w/out the -quiet option to see output."
-      exit 1
-    fi
-  fi
-done
-
diff --git a/util.go b/util.go
index c424759..1715458 100644
--- a/util.go
+++ b/util.go
@@ -2,6 +2,7 @@
 
 import (
 	"fmt"
+	"strings"
 	"time"
 )
 
@@ -20,3 +21,11 @@
 	}
 	return fmt.Errorf("Time out in WaitFor.")
 }
+
+// NormalizeURL ensures that each endpoint URL has a closing `/`, as expected by ServiceClient.
+func NormalizeURL(url string) string {
+	if !strings.HasSuffix(url, "/") {
+		return url + "/"
+	}
+	return url
+}