Merge pull request #200 from smashwilson/pagination-improvements-question-mark

[wip] Alternative pagination idiom
diff --git a/acceptance/openstack/identity/v3/endpoint_test.go b/acceptance/openstack/identity/v3/endpoint_test.go
index 14783b5..5c0e774 100644
--- a/acceptance/openstack/identity/v3/endpoint_test.go
+++ b/acceptance/openstack/identity/v3/endpoint_test.go
@@ -18,15 +18,16 @@
 	}
 
 	// Use the service to list all available endpoints.
-	results, err := endpoints3.List(serviceClient, endpoints3.ListOpts{})
-	if err != nil {
-		t.Fatalf("Unexpected error while listing endpoints: %v", err)
-	}
-
-	err = gophercloud.EachPage(results, func(page gophercloud.Collection) bool {
+	pager := endpoints3.List(serviceClient, endpoints3.ListOpts{})
+	err := pager.EachPage(func(page gophercloud.Page) (bool, error) {
 		t.Logf("--- Page ---")
 
-		for _, endpoint := range endpoints3.AsEndpoints(page) {
+		endpoints, err := endpoints3.ExtractEndpoints(page)
+		if err != nil {
+			t.Fatalf("Error extracting endpoings: %v", err)
+		}
+
+		for _, endpoint := range endpoints {
 			t.Logf("Endpoint: %8s %10s %9s %s",
 				endpoint.ID,
 				endpoint.Availability,
@@ -34,7 +35,7 @@
 				endpoint.URL)
 		}
 
-		return true
+		return true, nil
 	})
 	if err != nil {
 		t.Errorf("Unexpected error while iterating endpoint pages: %v", err)
@@ -45,43 +46,62 @@
 	// Create a service client.
 	client := createAuthenticatedClient(t)
 
+	var compute *services3.Service
+	var endpoint *endpoints3.Endpoint
+
 	// Discover the service we're interested in.
-	computeResults, err := services3.List(client, services3.ListOpts{ServiceType: "compute"})
+	servicePager := services3.List(client, services3.ListOpts{ServiceType: "compute"})
+	err := servicePager.EachPage(func(page gophercloud.Page) (bool, error) {
+		part, err := services3.ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
+		if compute != nil {
+			t.Fatalf("Expected one service, got more than one page")
+			return false, nil
+		}
+		if len(part) != 1 {
+			t.Fatalf("Expected one service, got %d", len(part))
+			return false, nil
+		}
+
+		compute = &part[0]
+		return true, nil
+	})
 	if err != nil {
-		t.Fatalf("Unexpected error while listing services: %v", err)
+		t.Fatalf("Unexpected error iterating pages: %v", err)
 	}
 
-	allServices, err := gophercloud.AllPages(computeResults)
-	if err != nil {
-		t.Fatalf("Unexpected error while traversing service results: %v", err)
+	if compute == nil {
+		t.Fatalf("No compute service found.")
 	}
 
-	computeServices := services3.AsServices(allServices)
-
-	if len(computeServices) != 1 {
-		t.Logf("%d compute services are available at this endpoint.", len(computeServices))
-		return
-	}
-	computeService := computeServices[0]
-
 	// Enumerate the endpoints available for this service.
-	endpointResults, err := endpoints3.List(client, endpoints3.ListOpts{
+	computePager := endpoints3.List(client, endpoints3.ListOpts{
 		Availability: gophercloud.AvailabilityPublic,
-		ServiceID:    computeService.ID,
+		ServiceID:    compute.ID,
+	})
+	err = computePager.EachPage(func(page gophercloud.Page) (bool, error) {
+		part, err := endpoints3.ExtractEndpoints(page)
+		if err != nil {
+			return false, err
+		}
+		if endpoint != nil {
+			t.Fatalf("Expected one endpoint, got more than one page")
+			return false, nil
+		}
+		if len(part) != 1 {
+			t.Fatalf("Expected one endpoint, got %d", len(part))
+			return false, nil
+		}
+
+		endpoint = &part[0]
+		return true, nil
 	})
 
-	allEndpoints, err := gophercloud.AllPages(endpointResults)
-	if err != nil {
-		t.Fatalf("Unexpected error while listing endpoints: %v", err)
+	if endpoint == nil {
+		t.Fatalf("No endpoint found.")
 	}
 
-	endpoints := endpoints3.AsEndpoints(allEndpoints)
-
-	if len(endpoints) != 1 {
-		t.Logf("%d endpoints are available for the service %v.", len(endpoints), computeService.Name)
-		return
-	}
-
-	endpoint := endpoints[0]
 	t.Logf("Success. The compute endpoint is at %s.", endpoint.URL)
 }
diff --git a/acceptance/openstack/identity/v3/service_test.go b/acceptance/openstack/identity/v3/service_test.go
index 00375e2..11f039d 100644
--- a/acceptance/openstack/identity/v3/service_test.go
+++ b/acceptance/openstack/identity/v3/service_test.go
@@ -17,17 +17,18 @@
 	}
 
 	// Use the client to list all available services.
-	results, err := services3.List(serviceClient, services3.ListOpts{})
-	if err != nil {
-		t.Fatalf("Unable to list services: %v", err)
-	}
+	pager := services3.List(serviceClient, services3.ListOpts{})
+	err := pager.EachPage(func(page gophercloud.Page) (bool, error) {
+		parts, err := services3.ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
 
-	err = gophercloud.EachPage(results, func(page gophercloud.Collection) bool {
 		t.Logf("--- Page ---")
-		for _, service := range services3.AsServices(page) {
+		for _, service := range parts {
 			t.Logf("Service: %32s %15s %10s %s", service.ID, service.Type, service.Name, *service.Description)
 		}
-		return true
+		return true, nil
 	})
 	if err != nil {
 		t.Errorf("Unexpected error traversing pages: %v", err)
diff --git a/collections.go b/collections.go
deleted file mode 100644
index 443a000..0000000
--- a/collections.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package gophercloud
-
-import (
-	"errors"
-
-	"github.com/racker/perigee"
-)
-
-var (
-	// ErrPageNotAvailable is returned from a Pager when a next or previous page is requested, but does not exist.
-	ErrPageNotAvailable = errors.New("The requested Collection page does not exist.")
-)
-
-// Collection describes the minimum functionality that any collection resource must implement to be able to use
-// the global paging and iteration functions.
-// Every resource that returns a list of multiple results must implement this functionality, whether or not it is paged.
-// In addition to the methods provided here, each collection should also provide an AsItem(Page) method that
-// casts the Page to its more specific type and returns the Page's contents as a slice.
-type Collection interface {
-
-	// Pager returns one of the concrete Pager implementations from this package, or a custom one.
-	// The style of Pager returned determines how the collection is paged.
-	Pager() Pager
-
-	// Concat the contents of another collection on to the end of this one.
-	// Return a new collection that contains elements from both.
-	Concat(Collection) Collection
-}
-
-// EachPage iterates through a Collection one page at a time.
-// The handler function will be invoked with a Collection containing each page.
-// If the handler returns true, iteration will continue. If it returns false, no more pages will be fetched.
-func EachPage(first Collection, handler func(Collection) bool) error {
-	p := first.Pager()
-	var err error
-	current := first
-
-	for {
-		if !handler(current) {
-			return nil
-		}
-
-		if !p.HasNextPage() {
-			return nil
-		}
-
-		current, err = p.NextPage()
-		if err != nil {
-			return err
-		}
-	}
-}
-
-// AllPages consolidates all pages reachable from a provided starting point into a single mega-Page.
-// Use this only when you know that the full set will always fit within memory.
-func AllPages(first Collection) (Collection, error) {
-	megaPage := first
-	isFirst := true
-
-	err := EachPage(first, func(page Collection) bool {
-		if isFirst {
-			isFirst = false
-		} else {
-			megaPage = megaPage.Concat(page)
-		}
-		return true
-	})
-
-	return megaPage, err
-}
-
-// Pager describes a specific paging idiom for a Collection resource.
-// Generally, to use a Pager, the Collection must also implement a more specialized interface than Collection.
-// Clients should not generally interact with Pagers directly.
-// Instead, use the more convenient collection traversal methods: AllPages and EachPage.
-type Pager interface {
-
-	// HasNextPage returns true if a call to NextPage will return an additional Page of results.
-	HasNextPage() bool
-
-	// NextPage returns the next Page in the sequence.
-	// Panics if no page is available, so always check HasNextPage first.
-	NextPage() (Collection, error)
-}
-
-// SinglePager is used by collections that are not actually paged.
-// It has no additional interface requirements for its host Page.
-type SinglePager struct{}
-
-// HasNextPage always reports false.
-func (p SinglePager) HasNextPage() bool {
-	return false
-}
-
-// NextPage always returns an ErrPageNotAvailable.
-func (p SinglePager) NextPage() (Collection, error) {
-	return nil, ErrPageNotAvailable
-}
-
-// PaginationLinks stores the `next` and `previous` links that are provided by some (but not all) paginated resources.
-type PaginationLinks struct {
-
-	// Next is the full URL to the next page of results, or nil if this is the last page.
-	Next *string `json:"next,omitempty"`
-
-	// Previous is the full URL to the previous page of results, or nil if this is the first page.
-	Previous *string `json:"previous,omitempty"`
-}
-
-// LinkCollection must be satisfied by a Page that uses a LinkPager.
-type LinkCollection interface {
-	Collection
-
-	// Service returns the client used to make further requests.
-	Service() *ServiceClient
-
-	// Links returns the pagination links from a single page.
-	Links() PaginationLinks
-
-	// Interpret an arbitrary JSON result as a new LinkCollection.
-	Interpret(interface{}) (LinkCollection, error)
-}
-
-// LinkPager implements paging for collections that provide a link structure in their response JSON.
-// It follows explicit `next` links and stops when the `next` link is "null".
-type LinkPager struct {
-	current LinkCollection
-}
-
-// NewLinkPager creates and initializes a pager for a LinkCollection.
-func NewLinkPager(first LinkCollection) *LinkPager {
-	return &LinkPager{current: first}
-}
-
-// HasNextPage checks the `next` link in the pagination data.
-func (p *LinkPager) HasNextPage() bool {
-	return p.current.Links().Next != nil
-}
-
-// NextPage follows the `next` link to construct the next page of data.
-func (p *LinkPager) NextPage() (Collection, error) {
-	url := p.current.Links().Next
-	if url == nil {
-		return nil, ErrPageNotAvailable
-	}
-
-	var response interface{}
-	_, err := perigee.Request("GET", *url, perigee.Options{
-		MoreHeaders: p.current.Service().Provider.AuthenticatedHeaders(),
-		Results:     &response,
-		OkCodes:     []int{200},
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	interpreted, err := p.current.Interpret(response)
-	if err != nil {
-		return nil, err
-	}
-
-	p.current = interpreted
-	return interpreted, nil
-}
diff --git a/collections_test.go b/collections_test.go
deleted file mode 100644
index 4b0fdc5..0000000
--- a/collections_test.go
+++ /dev/null
@@ -1,228 +0,0 @@
-package gophercloud
-
-import (
-	"errors"
-	"fmt"
-	"net/http"
-	"reflect"
-	"testing"
-
-	"github.com/rackspace/gophercloud/testhelper"
-)
-
-// SinglePage sample and test cases.
-
-type SinglePageCollection struct {
-	results []int
-}
-
-func (c SinglePageCollection) Pager() Pager {
-	return SinglePager{}
-}
-
-func (c SinglePageCollection) Concat(other Collection) Collection {
-	panic("Concat should never be called on a single-paged collection.")
-}
-
-func AsSingleInts(c Collection) []int {
-	return c.(SinglePageCollection).results
-}
-
-var single = SinglePageCollection{
-	results: []int{1, 2, 3},
-}
-
-func TestEnumerateSinglePaged(t *testing.T) {
-	callCount := 0
-	EachPage(single, func(page Collection) bool {
-		callCount++
-
-		expected := []int{1, 2, 3}
-		actual := AsSingleInts(page)
-		if !reflect.DeepEqual(expected, actual) {
-			t.Errorf("Expected %v, but was %v", expected, actual)
-		}
-		return true
-	})
-
-	if callCount != 1 {
-		t.Errorf("Callback was invoked %d times", callCount)
-	}
-}
-
-func TestAllSinglePaged(t *testing.T) {
-	r, err := AllPages(single)
-	if err != nil {
-		t.Fatalf("Unexpected error when iterating pages: %v", err)
-	}
-
-	expected := []int{1, 2, 3}
-	actual := AsSingleInts(r)
-	if !reflect.DeepEqual(expected, actual) {
-		t.Errorf("Expected %v, but was %v", expected, actual)
-	}
-}
-
-// LinkedPager sample and test cases.
-
-type LinkedCollection struct {
-	PaginationLinks
-
-	service *ServiceClient
-	results []int
-}
-
-func (c LinkedCollection) Pager() Pager {
-	return NewLinkPager(c)
-}
-
-func (c LinkedCollection) Concat(other Collection) Collection {
-	return LinkedCollection{
-		service: c.service,
-		results: append(c.results, AsLinkedInts(other)...),
-	}
-}
-
-func (c LinkedCollection) Links() PaginationLinks {
-	return c.PaginationLinks
-}
-
-func (c LinkedCollection) Service() *ServiceClient {
-	return c.service
-}
-
-func (c LinkedCollection) Interpret(response interface{}) (LinkCollection, error) {
-	casted, ok := response.([]interface{})
-	if ok {
-		asInts := make([]int, len(casted))
-		for index, item := range casted {
-			f := item.(float64)
-			asInts[index] = int(f)
-		}
-
-		var nextURL *string
-		switch asInts[0] {
-		case 4:
-			u := testhelper.Server.URL + "/foo?page=3&perPage=3"
-			nextURL = &u
-		case 7:
-			// Leave nextURL as nil.
-		default:
-			return nil, fmt.Errorf("Unexpected resultset: %#v", asInts)
-		}
-
-		result := LinkedCollection{
-			PaginationLinks: PaginationLinks{Next: nextURL},
-			service:         c.service,
-			results:         asInts,
-		}
-		return result, nil
-	}
-	return nil, errors.New("Wat")
-}
-
-func AsLinkedInts(results Collection) []int {
-	return results.(LinkedCollection).results
-}
-
-func createLinked() LinkedCollection {
-	nextURL := testhelper.Server.URL + "/foo?page=2&perPage=3"
-	return LinkedCollection{
-		PaginationLinks: PaginationLinks{Next: &nextURL},
-		service: &ServiceClient{
-			Provider: &ProviderClient{TokenID: "1234"},
-			Endpoint: testhelper.Endpoint(),
-		},
-		results: []int{1, 2, 3},
-	}
-}
-
-func setupLinkedResponses(t *testing.T) {
-	testhelper.Mux.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) {
-		testhelper.TestMethod(t, r, "GET")
-		testhelper.TestHeader(t, r, "X-Auth-Token", "1234")
-		w.Header().Add("Content-Type", "application/json")
-
-		r.ParseForm()
-
-		pages := r.Form["page"]
-		if len(pages) != 1 {
-			t.Errorf("Endpoint called with unexpected page: %#v", r.Form)
-		}
-
-		switch pages[0] {
-		case "2":
-			fmt.Fprintf(w, `[4, 5, 6]`)
-		case "3":
-			fmt.Fprintf(w, `[7, 8, 9]`)
-		default:
-			t.Errorf("Endpoint called with unexpected page: %s", pages[0])
-		}
-	})
-}
-
-func TestEnumerateLinked(t *testing.T) {
-	testhelper.SetupHTTP()
-	defer testhelper.TeardownHTTP()
-
-	setupLinkedResponses(t)
-	lc := createLinked()
-
-	callCount := 0
-	err := EachPage(lc, func(page Collection) bool {
-		actual := AsLinkedInts(page)
-		t.Logf("Handler invoked with %v", actual)
-
-		var expected []int
-		switch callCount {
-		case 0:
-			expected = []int{1, 2, 3}
-		case 1:
-			expected = []int{4, 5, 6}
-		case 2:
-			expected = []int{7, 8, 9}
-		default:
-			t.Fatalf("Unexpected call count: %d", callCount)
-			return false
-		}
-
-		if !reflect.DeepEqual(expected, actual) {
-			t.Errorf("Call %d: Expected %#v, but was %#v", callCount, expected, actual)
-		}
-
-		callCount++
-		return true
-	})
-	if err != nil {
-		t.Errorf("Unexpected error for page iteration: %v", err)
-	}
-
-	if callCount != 3 {
-		t.Errorf("Expected 3 calls, but was %d", callCount)
-	}
-}
-
-func TestAllLinked(t *testing.T) {
-	testhelper.SetupHTTP()
-	defer testhelper.TeardownHTTP()
-
-	setupLinkedResponses(t)
-	lc := createLinked()
-
-	all, err := AllPages(lc)
-	if err != nil {
-		t.Fatalf("Unexpected error collection all linked pages: %v", err)
-	}
-
-	actual := AsLinkedInts(all)
-	expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
-
-	if !reflect.DeepEqual(expected, actual) {
-		t.Errorf("Expected %v, but was %v", expected, actual)
-	}
-
-	original := []int{1, 2, 3}
-	if !reflect.DeepEqual(AsLinkedInts(lc), original) {
-		t.Errorf("AllPages modified the original page, and now it contains: %v", lc)
-	}
-}
diff --git a/openstack/client.go b/openstack/client.go
index 39d39a8..a5d0bc8 100644
--- a/openstack/client.go
+++ b/openstack/client.go
@@ -192,58 +192,56 @@
 
 func v3endpointLocator(v3Client *gophercloud.ServiceClient, opts gophercloud.EndpointOpts) (string, error) {
 	// Discover the service we're interested in.
-	serviceResults, err := services3.List(v3Client, services3.ListOpts{ServiceType: opts.Type})
-	if err != nil {
-		return "", err
-	}
+	var services = make([]services3.Service, 0, 1)
+	servicePager := services3.List(v3Client, services3.ListOpts{ServiceType: opts.Type})
+	err := servicePager.EachPage(func(page gophercloud.Page) (bool, error) {
+		part, err := services3.ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
 
-	allServiceResults, err := gophercloud.AllPages(serviceResults)
-	if err != nil {
-		return "", err
-	}
-	allServices := services3.AsServices(allServiceResults)
-
-	if opts.Name != "" {
-		filtered := make([]services3.Service, 0, 1)
-		for _, service := range allServices {
+		for _, service := range part {
 			if service.Name == opts.Name {
-				filtered = append(filtered, service)
+				services = append(services, service)
 			}
 		}
-		allServices = filtered
-	}
 
-	if len(allServices) == 0 {
-		return "", gophercloud.ErrServiceNotFound
-	}
-	if len(allServices) > 1 {
-		return "", fmt.Errorf("Discovered %d matching services: %#v", len(allServices), allServices)
-	}
-
-	service := allServices[0]
-
-	// Enumerate the endpoints available for this service.
-	endpointResults, err := endpoints3.List(v3Client, endpoints3.ListOpts{
-		Availability: opts.Availability,
-		ServiceID:    service.ID,
+		return true, nil
 	})
 	if err != nil {
 		return "", err
 	}
-	allEndpoints, err := gophercloud.AllPages(endpointResults)
-	if err != nil {
-		return "", err
-	}
-	endpoints := endpoints3.AsEndpoints(allEndpoints)
 
-	if opts.Name != "" {
-		filtered := make([]endpoints3.Endpoint, 0, 1)
-		for _, endpoint := range endpoints {
+	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 gophercloud.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 {
-				filtered = append(filtered, endpoint)
+				endpoints = append(endpoints, endpoint)
 			}
 		}
-		endpoints = filtered
+
+		return true, nil
+	})
+	if err != nil {
+		return "", err
 	}
 
 	if len(endpoints) == 0 {
@@ -252,7 +250,6 @@
 	if len(endpoints) > 1 {
 		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
 	}
-
 	endpoint := endpoints[0]
 
 	return normalizeURL(endpoint.URL), nil
diff --git a/openstack/identity/v3/endpoints/requests.go b/openstack/identity/v3/endpoints/requests.go
index a990049..7fc4544 100644
--- a/openstack/identity/v3/endpoints/requests.go
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -94,7 +94,7 @@
 }
 
 // List enumerates endpoints in a paginated collection, optionally filtered by ListOpts criteria.
-func List(client *gophercloud.ServiceClient, opts ListOpts) (*EndpointList, error) {
+func List(client *gophercloud.ServiceClient, opts ListOpts) gophercloud.Pager {
 	q := make(map[string]string)
 	if opts.Availability != "" {
 		q["interface"] = string(opts.Availability)
@@ -110,18 +110,7 @@
 	}
 
 	u := getListURL(client) + utils.BuildQuery(q)
-
-	var respBody EndpointList
-	_, err := perigee.Request("GET", u, perigee.Options{
-		MoreHeaders: client.Provider.AuthenticatedHeaders(),
-		Results:     &respBody,
-		OkCodes:     []int{200},
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	return &respBody, nil
+	return gophercloud.NewLinkedPager(client, u)
 }
 
 // Update changes an existing endpoint with new data.
diff --git a/openstack/identity/v3/endpoints/requests_test.go b/openstack/identity/v3/endpoints/requests_test.go
index 272860e..f988770 100644
--- a/openstack/identity/v3/endpoints/requests_test.go
+++ b/openstack/identity/v3/endpoints/requests_test.go
@@ -127,13 +127,16 @@
 
 	client := serviceClient()
 
-	actual, err := List(client, ListOpts{})
-	if err != nil {
-		t.Fatalf("Unexpected error listing endpoints: %v", err)
-	}
+	count := 0
+	List(client, ListOpts{}).EachPage(func(page gophercloud.Page) (bool, error) {
+		count++
+		actual, err := ExtractEndpoints(page)
+		if err != nil {
+			t.Errorf("Failed to extract endpoints: %v", err)
+			return false, err
+		}
 
-	expected := &EndpointList{
-		Endpoints: []Endpoint{
+		expected := []Endpoint{
 			Endpoint{
 				ID:           "12",
 				Availability: gophercloud.AvailabilityPublic,
@@ -150,11 +153,16 @@
 				ServiceID:    "asdfasdfasdfasdf",
 				URL:          "https://1.2.3.4:9001/",
 			},
-		},
-	}
+		}
 
-	if !reflect.DeepEqual(expected, actual) {
-		t.Errorf("Expected %#v, got %#v", expected, actual)
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Expected %#v, got %#v", expected, actual)
+		}
+
+		return true, nil
+	})
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
 	}
 }
 
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
index bd7a013..4d7bfbc 100644
--- a/openstack/identity/v3/endpoints/results.go
+++ b/openstack/identity/v3/endpoints/results.go
@@ -1,70 +1,29 @@
 package endpoints
 
 import (
-	"fmt"
-
 	"github.com/mitchellh/mapstructure"
 	"github.com/rackspace/gophercloud"
 )
 
 // Endpoint describes the entry point for another service's API.
 type Endpoint struct {
-	ID           string                   `json:"id"`
-	Availability gophercloud.Availability `json:"interface"`
-	Name         string                   `json:"name"`
-	Region       string                   `json:"region"`
-	ServiceID    string                   `json:"service_id"`
-	URL          string                   `json:"url"`
+	ID           string                   `mapstructure:"id" json:"id"`
+	Availability gophercloud.Availability `mapstructure:"interface" json:"interface"`
+	Name         string                   `mapstructure:"name" json:"name"`
+	Region       string                   `mapstructure:"region" json:"region"`
+	ServiceID    string                   `mapstructure:"service_id" json:"service_id"`
+	URL          string                   `mapstructure:"url" json:"url"`
 }
 
-// EndpointList contains a page of Endpoint results.
-type EndpointList struct {
-	gophercloud.PaginationLinks `json:"links"`
-
-	client    *gophercloud.ServiceClient
-	Endpoints []Endpoint `json:"endpoints"`
-}
-
-// Pager marks EndpointList as paged by links.
-func (list EndpointList) Pager() gophercloud.Pager {
-	return gophercloud.NewLinkPager(list)
-}
-
-// Concat adds the contents of another Collection to this one.
-func (list EndpointList) Concat(other gophercloud.Collection) gophercloud.Collection {
-	return EndpointList{
-		client:    list.client,
-		Endpoints: append(list.Endpoints, AsEndpoints(other)...),
-	}
-}
-
-// Service returns the ServiceClient used to acquire this list.
-func (list EndpointList) Service() *gophercloud.ServiceClient {
-	return list.client
-}
-
-// Links accesses pagination information for the current page.
-func (list EndpointList) Links() gophercloud.PaginationLinks {
-	return list.PaginationLinks
-}
-
-// Interpret parses a follow-on JSON response as an additional page.
-func (list EndpointList) Interpret(json interface{}) (gophercloud.LinkCollection, error) {
-	mapped, ok := json.(map[string]interface{})
-	if !ok {
-		return nil, fmt.Errorf("Unexpected JSON response: %#v", json)
+// ExtractEndpoints extracts an Endpoint slice from a Page.
+func ExtractEndpoints(page gophercloud.Page) ([]Endpoint, error) {
+	var response struct {
+		Endpoints []Endpoint `mapstructure:"endpoints"`
 	}
 
-	var result EndpointList
-	err := mapstructure.Decode(mapped, &result)
+	err := mapstructure.Decode(page.(gophercloud.LinkedPage).Body, &response)
 	if err != nil {
 		return nil, err
 	}
-	return result, nil
-}
-
-// AsEndpoints extracts an Endpoint slice from a Collection.
-// Panics if `list` was not returned from a List call.
-func AsEndpoints(list gophercloud.Collection) []Endpoint {
-	return list.(*EndpointList).Endpoints
+	return response.Endpoints, nil
 }
diff --git a/openstack/identity/v3/services/requests.go b/openstack/identity/v3/services/requests.go
index e261192..34df63f 100644
--- a/openstack/identity/v3/services/requests.go
+++ b/openstack/identity/v3/services/requests.go
@@ -42,7 +42,7 @@
 }
 
 // List enumerates the services available to a specific user.
-func List(client *gophercloud.ServiceClient, opts ListOpts) (*ServiceList, error) {
+func List(client *gophercloud.ServiceClient, opts ListOpts) gophercloud.Pager {
 	q := make(map[string]string)
 	if opts.ServiceType != "" {
 		q["type"] = opts.ServiceType
@@ -55,17 +55,7 @@
 	}
 	u := getListURL(client) + utils.BuildQuery(q)
 
-	var resp ServiceList
-	_, err := perigee.Request("GET", u, perigee.Options{
-		MoreHeaders: client.Provider.AuthenticatedHeaders(),
-		Results:     &resp,
-		OkCodes:     []int{200},
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	return &resp, nil
+	return gophercloud.NewLinkedPager(client, u)
 }
 
 // Get returns additional information about a service, given its ID.
diff --git a/openstack/identity/v3/services/requests_test.go b/openstack/identity/v3/services/requests_test.go
index 9f43db3..59cefe3 100644
--- a/openstack/identity/v3/services/requests_test.go
+++ b/openstack/identity/v3/services/requests_test.go
@@ -98,33 +98,42 @@
 
 	client := serviceClient()
 
-	result, err := List(client, ListOpts{})
+	count := 0
+	err := List(client, ListOpts{}).EachPage(func(page gophercloud.Page) (bool, error) {
+		count++
+		actual, err := ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
+
+		desc0 := "Service One"
+		desc1 := "Service Two"
+		expected := []Service{
+			Service{
+				Description: &desc0,
+				ID:          "1234",
+				Name:        "service-one",
+				Type:        "identity",
+			},
+			Service{
+				Description: &desc1,
+				ID:          "9876",
+				Name:        "service-two",
+				Type:        "compute",
+			},
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Expected %#v, got %#v", expected, actual)
+		}
+
+		return true, nil
+	})
 	if err != nil {
-		t.Fatalf("Error listing services: %v", err)
+		t.Errorf("Unexpected error while paging: %v", err)
 	}
-
-	collection, err := gophercloud.AllPages(result)
-	actual := AsServices(collection)
-
-	desc0 := "Service One"
-	desc1 := "Service Two"
-	expected := []Service{
-		Service{
-			Description: &desc0,
-			ID:          "1234",
-			Name:        "service-one",
-			Type:        "identity",
-		},
-		Service{
-			Description: &desc1,
-			ID:          "9876",
-			Name:        "service-two",
-			Type:        "compute",
-		},
-	}
-
-	if !reflect.DeepEqual(expected, actual) {
-		t.Errorf("Expected %#v, got %#v", expected, actual)
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
 	}
 }
 
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
index 88510b4..537ea2e 100644
--- a/openstack/identity/v3/services/results.go
+++ b/openstack/identity/v3/services/results.go
@@ -1,10 +1,9 @@
 package services
 
 import (
-	"fmt"
+	"github.com/rackspace/gophercloud"
 
 	"github.com/mitchellh/mapstructure"
-	"github.com/rackspace/gophercloud"
 )
 
 // Service is the result of a list or information query.
@@ -15,54 +14,12 @@
 	Type        string  `json:"type"`
 }
 
-// ServiceList is a collection of Services.
-type ServiceList struct {
-	gophercloud.PaginationLinks `json:"links"`
-
-	client   *gophercloud.ServiceClient
-	Services []Service `json:"services"`
-}
-
-// Pager indicates that the ServiceList is paginated by next and previous links.
-func (list ServiceList) Pager() gophercloud.Pager {
-	return gophercloud.NewLinkPager(list)
-}
-
-// Concat returns a new collection that's the result of appending a new collection at the end of this one.
-func (list ServiceList) Concat(other gophercloud.Collection) gophercloud.Collection {
-	return ServiceList{
-		client:   list.client,
-		Services: append(list.Services, AsServices(other)...),
-	}
-}
-
-// Service returns the ServiceClient used to acquire this list.
-func (list ServiceList) Service() *gophercloud.ServiceClient {
-	return list.client
-}
-
-// Links accesses pagination information for the current page.
-func (list ServiceList) Links() gophercloud.PaginationLinks {
-	return list.PaginationLinks
-}
-
-// Interpret parses a follow-on JSON response as an additional page.
-func (list ServiceList) Interpret(json interface{}) (gophercloud.LinkCollection, error) {
-	mapped, ok := json.(map[string]interface{})
-	if !ok {
-		return nil, fmt.Errorf("Unexpected JSON response: %#v", json)
+// ExtractServices extracts a slice of Services from a Collection acquired from List.
+func ExtractServices(page gophercloud.Page) ([]Service, error) {
+	var response struct {
+		Services []Service `mapstructure:"services"`
 	}
 
-	var result ServiceList
-	err := mapstructure.Decode(mapped, &result)
-	if err != nil {
-		return nil, err
-	}
-	return result, nil
-}
-
-// AsServices extracts a slice of Services from a Collection acquired from List.
-// It panics if the Collection does not actually contain Services.
-func AsServices(results gophercloud.Collection) []Service {
-	return results.(*ServiceList).Services
+	err := mapstructure.Decode(page.(gophercloud.LinkedPage).Body, &response)
+	return response.Services, err
 }
diff --git a/pagination.go b/pagination.go
new file mode 100644
index 0000000..004c542
--- /dev/null
+++ b/pagination.go
@@ -0,0 +1,187 @@
+package gophercloud
+
+import (
+	"encoding/json"
+	"errors"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/racker/perigee"
+)
+
+var (
+	// ErrPageNotAvailable is returned from a Pager when a next or previous page is requested, but does not exist.
+	ErrPageNotAvailable = errors.New("The requested Collection page does not exist.")
+)
+
+// LastHTTPResponse stores generic information derived from an HTTP response.
+type LastHTTPResponse struct {
+	http.Header
+	Body interface{}
+}
+
+// RememberHTTPResponse parses an HTTP response as JSON and returns a LastHTTPResponse containing the results.
+// The main reason to do this instead of holding the response directly is that a response body can only be read once.
+// Also, this centralizes the JSON decoding.
+func RememberHTTPResponse(resp http.Response) (LastHTTPResponse, error) {
+	var parsedBody interface{}
+
+	defer resp.Body.Close()
+	rawBody, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return LastHTTPResponse{}, err
+	}
+	err = json.Unmarshal(rawBody, &parsedBody)
+	if err != nil {
+		return LastHTTPResponse{}, err
+	}
+
+	return LastHTTPResponse{Header: resp.Header, Body: parsedBody}, err
+}
+
+// request performs a Perigee request and extracts the http.Response from the result.
+func request(client *ServiceClient, url string) (http.Response, error) {
+	resp, err := perigee.Request("GET", url, perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return http.Response{}, err
+	}
+	return resp.HttpResponse, nil
+}
+
+// Page must be satisfied by the result type of any resource collection.
+// It allows clients to interact with the resource uniformly, regardless of whether or not or how it's paginated.
+type Page interface {
+
+	// NextPageURL generates the URL for the page of data that follows this collection.
+	// Return "" if no such page exists.
+	NextPageURL() (string, error)
+}
+
+// SinglePage is a page that contains all of the results from an operation.
+type SinglePage LastHTTPResponse
+
+// NextPageURL always returns "" to indicate that there are no more pages to return.
+func (current SinglePage) NextPageURL() (string, error) {
+	return "", nil
+}
+
+// LinkedPage is a page in a collection that provides navigational "Next" and "Previous" links within its result.
+type LinkedPage LastHTTPResponse
+
+// NextPageURL extracts the pagination structure from a JSON response and returns the "next" link, if one is present.
+func (current LinkedPage) NextPageURL() (string, error) {
+	type response struct {
+		Links struct {
+			Next *string `mapstructure:"next,omitempty"`
+		} `mapstructure:"links"`
+	}
+
+	var r response
+	err := mapstructure.Decode(current.Body, &r)
+	if err != nil {
+		return "", err
+	}
+
+	if r.Links.Next == nil {
+		return "", nil
+	}
+
+	return *r.Links.Next, nil
+}
+
+// Pager knows how to advance through a specific resource collection, one page at a time.
+type Pager struct {
+	initialURL string
+
+	fetchNextPage func(string) (Page, error)
+}
+
+// NewPager constructs a manually-configured pager.
+// Supply the URL for the first page and a function that requests a specific page given a URL.
+func NewPager(initialURL string, fetchNextPage func(string) (Page, error)) Pager {
+	return Pager{
+		initialURL:    initialURL,
+		fetchNextPage: fetchNextPage,
+	}
+}
+
+// NewSinglePager constructs a Pager that "iterates" over a single Page.
+// Supply the URL to request.
+func NewSinglePager(client *ServiceClient, onlyURL string) Pager {
+	consumed := false
+	single := func(_ string) (Page, error) {
+		if !consumed {
+			consumed = true
+			resp, err := request(client, onlyURL)
+			if err != nil {
+				return SinglePage{}, err
+			}
+
+			cp, err := RememberHTTPResponse(resp)
+			if err != nil {
+				return SinglePage{}, err
+			}
+			return SinglePage(cp), nil
+		}
+		return SinglePage{}, ErrPageNotAvailable
+	}
+
+	return Pager{
+		initialURL:    "",
+		fetchNextPage: single,
+	}
+}
+
+// NewLinkedPager creates a Pager that uses a "links" element in the JSON response to locate the next page.
+func NewLinkedPager(client *ServiceClient, initialURL string) Pager {
+	fetchNextPage := func(url string) (Page, error) {
+		resp, err := request(client, url)
+		if err != nil {
+			return nil, err
+		}
+
+		cp, err := RememberHTTPResponse(resp)
+		if err != nil {
+			return nil, err
+		}
+
+		return LinkedPage(cp), nil
+	}
+
+	return Pager{
+		initialURL:    initialURL,
+		fetchNextPage: fetchNextPage,
+	}
+}
+
+// EachPage iterates over each page returned by a Pager, yielding one at a time to a handler function.
+// Return "false" from the handler to prematurely stop iterating.
+func (p Pager) EachPage(handler func(Page) (bool, error)) error {
+	currentURL := p.initialURL
+	for {
+		currentPage, err := p.fetchNextPage(currentURL)
+		if err != nil {
+			return err
+		}
+
+		ok, err := handler(currentPage)
+		if err != nil {
+			return err
+		}
+		if !ok {
+			return nil
+		}
+
+		currentURL, err = currentPage.NextPageURL()
+		if err != nil {
+			return err
+		}
+		if currentURL == "" {
+			return nil
+		}
+	}
+}
diff --git a/pagination_test.go b/pagination_test.go
new file mode 100644
index 0000000..d215c33
--- /dev/null
+++ b/pagination_test.go
@@ -0,0 +1,148 @@
+package gophercloud
+
+import (
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+func createClient() *ServiceClient {
+	return &ServiceClient{
+		Provider: &ProviderClient{TokenID: "abc123"},
+		Endpoint: testhelper.Endpoint(),
+	}
+}
+
+// SinglePage sample and test cases.
+
+func ExtractSingleInts(page Page) ([]int, error) {
+	var response struct {
+		Ints []int `mapstructure:"ints"`
+	}
+
+	err := mapstructure.Decode(page.(SinglePage).Body, &response)
+	if err != nil {
+		return nil, err
+	}
+
+	return response.Ints, nil
+}
+
+func setupSinglePaged() Pager {
+	testhelper.SetupHTTP()
+	client := createClient()
+
+	testhelper.Mux.HandleFunc("/only", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `{ "ints": [1, 2, 3] }`)
+	})
+
+	return NewSinglePager(client, testhelper.Server.URL+"/only")
+}
+
+func TestEnumerateSinglePaged(t *testing.T) {
+	callCount := 0
+	pager := setupSinglePaged()
+	defer testhelper.TeardownHTTP()
+
+	err := pager.EachPage(func(page Page) (bool, error) {
+		callCount++
+
+		expected := []int{1, 2, 3}
+		actual, err := ExtractSingleInts(page)
+		if err != nil {
+			return false, err
+		}
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Expected %v, but was %v", expected, actual)
+		}
+		return true, nil
+	})
+	if err != nil {
+		t.Fatalf("Unexpected error calling EachPage: %v", err)
+	}
+
+	if callCount != 1 {
+		t.Errorf("Callback was invoked %d times", callCount)
+	}
+}
+
+// LinkedPager sample and test cases.
+
+func ExtractLinkedInts(page Page) ([]int, error) {
+	var response struct {
+		Ints []int `mapstructure:"ints"`
+	}
+
+	err := mapstructure.Decode(page.(LinkedPage).Body, &response)
+	if err != nil {
+		return nil, err
+	}
+
+	return response.Ints, nil
+}
+
+func createLinked(t *testing.T) Pager {
+	testhelper.SetupHTTP()
+
+	testhelper.Mux.HandleFunc("/page1", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `{ "ints": [1, 2, 3], "links": { "next": "%s/page2" } }`, testhelper.Server.URL)
+	})
+
+	testhelper.Mux.HandleFunc("/page2", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `{ "ints": [4, 5, 6], "links": { "next": "%s/page3" } }`, testhelper.Server.URL)
+	})
+
+	testhelper.Mux.HandleFunc("/page3", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `{ "ints": [7, 8, 9], "links": { "next": null } }`)
+	})
+
+	client := createClient()
+
+	return NewLinkedPager(client, testhelper.Server.URL+"/page1")
+}
+
+func TestEnumerateLinked(t *testing.T) {
+	pager := createLinked(t)
+	defer testhelper.TeardownHTTP()
+
+	callCount := 0
+	err := pager.EachPage(func(page Page) (bool, error) {
+		actual, err := ExtractLinkedInts(page)
+		if err != nil {
+			return false, err
+		}
+
+		t.Logf("Handler invoked with %v", actual)
+
+		var expected []int
+		switch callCount {
+		case 0:
+			expected = []int{1, 2, 3}
+		case 1:
+			expected = []int{4, 5, 6}
+		case 2:
+			expected = []int{7, 8, 9}
+		default:
+			t.Fatalf("Unexpected call count: %d", callCount)
+			return false, nil
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Call %d: Expected %#v, but was %#v", callCount, expected, actual)
+		}
+
+		callCount++
+		return true, nil
+	})
+	if err != nil {
+		t.Errorf("Unexpected error for page iteration: %v", err)
+	}
+
+	if callCount != 3 {
+		t.Errorf("Expected 3 calls, but was %d", callCount)
+	}
+}