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)
+ }
+}