Merge remote-tracking branch 'upstream/v0.2.0' into identity-v3
Conflicts:
acceptance/openstack/compute/tools_test.go
diff --git a/acceptance/openstack/client_test.go b/acceptance/openstack/client_test.go
new file mode 100644
index 0000000..9b12337
--- /dev/null
+++ b/acceptance/openstack/client_test.go
@@ -0,0 +1,38 @@
+// +build acceptance
+
+package openstack
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+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 := openstack.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)
+
+ // Find the storage service in the service catalog.
+ storage, err := openstack.NewStorageV1(client, os.Getenv("OS_REGION_NAME"))
+ if err != nil {
+ t.Errorf("Unable to locate a storage service: %v", err)
+ } else {
+ t.Logf("Located a storage service at endpoint: [%s]", storage.Endpoint)
+ }
+}
diff --git a/acceptance/openstack/compute/tools_test.go b/acceptance/openstack/compute/tools_test.go
index 100ea5e..1acb824 100644
--- a/acceptance/openstack/compute/tools_test.go
+++ b/acceptance/openstack/compute/tools_test.go
@@ -9,6 +9,7 @@
"text/tabwriter"
"time"
+ "github.com/rackspace/gophercloud"
"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
"github.com/rackspace/gophercloud/openstack/utils"
@@ -17,7 +18,7 @@
var errTimeout = fmt.Errorf("Timeout.")
type testState struct {
- o identity.AuthOptions
+ o gophercloud.AuthOptions
a identity.AuthResults
sc *identity.ServiceCatalog
eps []identity.Endpoint
@@ -45,7 +46,8 @@
return ts, err
}
- ts.a, err = identity.Authenticate(ts.o)
+ client := &gophercloud.ServiceClient{Endpoint: ts.o.IdentityEndpoint}
+ ts.a, err = identity.Authenticate(client, ts.o)
if err != nil {
return ts, err
}
diff --git a/acceptance/openstack/identity/v3/endpoint_test.go b/acceptance/openstack/identity/v3/endpoint_test.go
new file mode 100644
index 0000000..b8c1dcc
--- /dev/null
+++ b/acceptance/openstack/identity/v3/endpoint_test.go
@@ -0,0 +1,87 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ endpoints3 "github.com/rackspace/gophercloud/openstack/identity/v3/endpoints"
+ services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+)
+
+func TestListEndpoints(t *testing.T) {
+ // Create a service client.
+ serviceClient := createAuthenticatedClient(t)
+ if serviceClient == nil {
+ return
+ }
+
+ // 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 {
+ t.Logf("--- Page ---")
+
+ for _, endpoint := range endpoints3.AsEndpoints(page) {
+ t.Logf("Endpoint: %8s %10s %9s %s",
+ endpoint.ID,
+ endpoint.Interface,
+ endpoint.Name,
+ endpoint.URL)
+ }
+
+ return true
+ })
+ if err != nil {
+ t.Errorf("Unexpected error while iterating endpoint pages: %v", err)
+ }
+}
+
+func TestNavigateCatalog(t *testing.T) {
+ // Create a service client.
+ client := createAuthenticatedClient(t)
+
+ // Discover the service we're interested in.
+ computeResults, err := services3.List(client, services3.ListOpts{ServiceType: "compute"})
+ if err != nil {
+ t.Fatalf("Unexpected error while listing services: %v", err)
+ }
+
+ allServices, err := gophercloud.AllPages(computeResults)
+ if err != nil {
+ t.Fatalf("Unexpected error while traversing service results: %v", err)
+ }
+
+ 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{
+ Interface: gophercloud.InterfacePublic,
+ ServiceID: computeService.ID,
+ })
+
+ allEndpoints, err := gophercloud.AllPages(endpointResults)
+ if err != nil {
+ t.Fatalf("Unexpected error while listing endpoints: %v", err)
+ }
+
+ 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/identity_test.go b/acceptance/openstack/identity/v3/identity_test.go
new file mode 100644
index 0000000..ec184a0
--- /dev/null
+++ b/acceptance/openstack/identity/v3/identity_test.go
@@ -0,0 +1,39 @@
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+func createAuthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+ // Obtain credentials from the environment.
+ ao, err := utils.AuthOptions()
+ if err != nil {
+ t.Fatalf("Unable to acquire credentials: %v", err)
+ }
+
+ // Trim out unused fields.
+ ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+
+ if ao.UserID == "" {
+ t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+ return nil
+ }
+
+ // Create a client and manually authenticate against v3.
+ providerClient, err := openstack.NewClient(ao.IdentityEndpoint)
+ if err != nil {
+ t.Fatalf("Unable to instantiate client: %v", err)
+ }
+
+ err = openstack.AuthenticateV3(providerClient, ao)
+ if err != nil {
+ t.Fatalf("Unable to authenticate against identity v3: %v", err)
+ }
+
+ // Create a service client.
+ return openstack.NewIdentityV3(providerClient)
+}
diff --git a/acceptance/openstack/identity/v3/service_test.go b/acceptance/openstack/identity/v3/service_test.go
new file mode 100644
index 0000000..00375e2
--- /dev/null
+++ b/acceptance/openstack/identity/v3/service_test.go
@@ -0,0 +1,35 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+)
+
+func TestListServices(t *testing.T) {
+ // Create a service client.
+ serviceClient := createAuthenticatedClient(t)
+ if serviceClient == nil {
+ return
+ }
+
+ // 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)
+ }
+
+ err = gophercloud.EachPage(results, func(page gophercloud.Collection) bool {
+ t.Logf("--- Page ---")
+ for _, service := range services3.AsServices(page) {
+ t.Logf("Service: %32s %15s %10s %s", service.ID, service.Type, service.Name, *service.Description)
+ }
+ return true
+ })
+ if err != nil {
+ t.Errorf("Unexpected error traversing pages: %v", err)
+ }
+}
diff --git a/acceptance/openstack/identity/v3/token_test.go b/acceptance/openstack/identity/v3/token_test.go
new file mode 100644
index 0000000..d5f9ea6
--- /dev/null
+++ b/acceptance/openstack/identity/v3/token_test.go
@@ -0,0 +1,48 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack"
+ tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+func TestGetToken(t *testing.T) {
+ // Obtain credentials from the environment.
+ ao, err := utils.AuthOptions()
+ if err != nil {
+ t.Fatalf("Unable to acquire credentials: %v", err)
+ }
+
+ // Trim out unused fields. Skip if we don't have a UserID.
+ ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+ if ao.UserID == "" {
+ t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+ return
+ }
+
+ // Create an unauthenticated client.
+ provider, err := openstack.NewClient(ao.IdentityEndpoint)
+ if err != nil {
+ t.Fatalf("Unable to instantiate client: %v", err)
+ }
+
+ // Create a service client.
+ service := openstack.NewIdentityV3(provider)
+
+ // Use the service to create a token.
+ result, err := tokens3.Create(service, ao, nil)
+ if err != nil {
+ t.Fatalf("Unable to get token: %v", err)
+ }
+
+ token, err := result.TokenID()
+ if err != nil {
+ t.Fatalf("Unable to extract token from response: %v", err)
+ }
+
+ t.Logf("Acquired token: %s", token)
+}
diff --git a/acceptance/openstack/identity_test.go b/acceptance/openstack/identity_test.go
index f9c26ae..8b6035d 100644
--- a/acceptance/openstack/identity_test.go
+++ b/acceptance/openstack/identity_test.go
@@ -4,11 +4,13 @@
import (
"fmt"
- identity "github.com/rackspace/gophercloud/openstack/identity/v2"
- "github.com/rackspace/gophercloud/openstack/utils"
"os"
"testing"
"text/tabwriter"
+
+ "github.com/rackspace/gophercloud"
+ identity "github.com/rackspace/gophercloud/openstack/identity/v2"
+ "github.com/rackspace/gophercloud/openstack/utils"
)
type extractor func(*identity.Token) string
@@ -23,7 +25,8 @@
}
// Attempt to authenticate with them.
- r, err := identity.Authenticate(ao)
+ client := &gophercloud.ServiceClient{Endpoint: ao.IdentityEndpoint + "/"}
+ r, err := identity.Authenticate(client, ao)
if err != nil {
t.Error(err)
return
@@ -39,7 +42,7 @@
// Authentication tokens have a variety of fields which might be of some interest.
// Let's print a few of them out.
table := map[string]extractor{
- "ID": func(t *identity.Token) string { return tok.Id },
+ "ID": func(t *identity.Token) string { return tok.ID },
"Expires": func(t *identity.Token) string { return tok.Expires },
}
@@ -75,7 +78,7 @@
fmt.Printf("Endpoints for %s/%s\n", ce.Name, ce.Type)
fmt.Fprintln(w, "Version\tRegion\tTenant\tPublic URL\tInternal URL\t")
for _, ep := range ce.Endpoints {
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t\n", ep.VersionId, ep.Region, ep.TenantId, ep.PublicURL, ep.InternalURL)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t\n", ep.VersionID, ep.Region, ep.TenantID, ep.PublicURL, ep.InternalURL)
}
w.Flush()
}
@@ -91,7 +94,8 @@
}
// Attempt to query extensions.
- exts, err := identity.GetExtensions(ao)
+ client := &gophercloud.ServiceClient{Endpoint: ao.IdentityEndpoint + "/"}
+ exts, err := identity.GetExtensions(client, ao)
if err != nil {
t.Error(err)
return
diff --git a/acceptance/tools/tools.go b/acceptance/tools/tools.go
index 5891c7f..594bfba 100644
--- a/acceptance/tools/tools.go
+++ b/acceptance/tools/tools.go
@@ -5,18 +5,20 @@
import (
"crypto/rand"
"fmt"
- "github.com/rackspace/gophercloud/openstack/compute/servers"
- identity "github.com/rackspace/gophercloud/openstack/identity/v2"
- "github.com/rackspace/gophercloud/openstack/utils"
"os"
"text/tabwriter"
"time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/servers"
+ identity "github.com/rackspace/gophercloud/openstack/identity/v2"
+ "github.com/rackspace/gophercloud/openstack/utils"
)
var errTimeout = fmt.Errorf("Timeout.")
type testState struct {
- O identity.AuthOptions
+ O gophercloud.AuthOptions
A identity.AuthResults
SC *identity.ServiceCatalog
EPs []identity.Endpoint
@@ -44,7 +46,8 @@
return ts, err
}
- ts.A, err = identity.Authenticate(ts.O)
+ client := &gophercloud.ServiceClient{Endpoint: ts.O.IdentityEndpoint + "/"}
+ ts.A, err = identity.Authenticate(client, ts.O)
if err != nil {
return ts, err
}
diff --git a/auth_options.go b/auth_options.go
new file mode 100644
index 0000000..f9e6ee5
--- /dev/null
+++ b/auth_options.go
@@ -0,0 +1,36 @@
+package gophercloud
+
+// AuthOptions lets anyone calling Authenticate() supply the required access credentials.
+// Its fields are the union of those recognized by each identity implementation and provider.
+type AuthOptions struct {
+
+ // IdentityEndpoint specifies the HTTP endpoint offering the Identity API of the appropriate version.
+ // Required by the identity services, but often populated by a provider Client.
+ IdentityEndpoint string
+
+ // Username is required if using Identity V2 API.
+ // Consult with your provider's control panel to discover your account's username.
+ // In Identity V3, either UserID or a combination of Username and DomainID or DomainName.
+ Username, UserID string
+
+ // Exactly one of Password or ApiKey is required for the Identity V2 and V3 APIs.
+ // Consult with your provider's control panel to discover your account's preferred method of authentication.
+ Password, APIKey string
+
+ // At most one of DomainID and DomainName must be provided if using Username with Identity V3.
+ // Otherwise, either are optional.
+ DomainID, DomainName string
+
+ // The TenantID and TenantName fields are optional for the Identity V2 API.
+ // Some providers allow you to specify a TenantName instead of the TenantId.
+ // Some require both. Your provider's authentication policies will determine
+ // how these fields influence authentication.
+ TenantID, TenantName string
+
+ // AllowReauth should be set to true if you grant permission for Gophercloud to
+ // cache your credentials in memory, and to allow Gophercloud to attempt to
+ // re-authenticate automatically if/when your token expires. If you set it to
+ // false, it will not cache these settings, but re-authentication will not be
+ // possible. This setting defaults to false.
+ AllowReauth bool
+}
diff --git a/auth_results.go b/auth_results.go
new file mode 100644
index 0000000..07e0fc7
--- /dev/null
+++ b/auth_results.go
@@ -0,0 +1,16 @@
+package gophercloud
+
+import "time"
+
+// AuthResults encapsulates the raw results from an authentication request. As OpenStack allows
+// extensions to influence the structure returned in ways that Gophercloud cannot predict at
+// compile-time, you should use type-safe accessors to work with the data represented by this type,
+// such as ServiceCatalog() and TokenID().
+type AuthResults interface {
+
+ // Retrieve the authentication token's value from the authentication response.
+ TokenID() (string, error)
+
+ // ExpiresAt retrieves the token's expiration time.
+ ExpiresAt() (time.Time, error)
+}
diff --git a/collections.go b/collections.go
new file mode 100644
index 0000000..443a000
--- /dev/null
+++ b/collections.go
@@ -0,0 +1,164 @@
+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
new file mode 100644
index 0000000..4b0fdc5
--- /dev/null
+++ b/collections_test.go
@@ -0,0 +1,228 @@
+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/endpoint_search.go b/endpoint_search.go
new file mode 100644
index 0000000..061dbaa
--- /dev/null
+++ b/endpoint_search.go
@@ -0,0 +1,51 @@
+package gophercloud
+
+import "errors"
+
+var (
+ // ErrServiceNotFound is returned when no service matches the EndpointOpts.
+ ErrServiceNotFound = errors.New("No suitable service could be found in the service catalog.")
+
+ // ErrEndpointNotFound is returned when no available endpoints match the provided EndpointOpts.
+ ErrEndpointNotFound = errors.New("No suitable endpoint could be found in the service catalog.")
+)
+
+// Interface describes the accessibility of a specific service endpoint.
+type Interface string
+
+const (
+ // InterfaceAdmin makes an endpoint only available to administrators.
+ InterfaceAdmin Interface = "admin"
+
+ // InterfacePublic makes an endpoint available to everyone.
+ InterfacePublic Interface = "public"
+
+ // InterfaceInternal makes an endpoint only available within the cluster.
+ InterfaceInternal Interface = "internal"
+)
+
+// EndpointOpts contains options for finding an endpoint for an Openstack client.
+type EndpointOpts struct {
+
+ // Type is the service type for the client (e.g., "compute", "object-store").
+ // Type is a required field.
+ Type string
+
+ // Name is the service name for the client (e.g., "nova").
+ // Name is not a required field, but it is used if present.
+ // Services can have the same Type but a different Name, which is one example of when both Type and Name are needed.
+ Name string
+
+ // Region is the region in which the service resides.
+ // Region must be specified for services that span multiple regions.
+ Region string
+
+ // Interface is the visibility of the endpoint to be returned: InterfacePublic, InterfaceInternal, or InterfaceAdmin
+ // Interface is not required, and defaults to InterfacePublic.
+ // Not all interface types are accepted by all providers or identity services.
+ Interface Interface
+}
+
+// EndpointLocator is a function that describes how to locate a single endpoint from a service catalog for a specific ProviderClient.
+// It should be set during ProviderClient authentication and used to discover related ServiceClients.
+type EndpointLocator func(EndpointOpts) (string, error)
diff --git a/openstack/client.go b/openstack/client.go
new file mode 100644
index 0000000..3bbff12
--- /dev/null
+++ b/openstack/client.go
@@ -0,0 +1,297 @@
+package openstack
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+ identity2 "github.com/rackspace/gophercloud/openstack/identity/v2"
+ 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"
+)
+
+const (
+ v20 = "v2.0"
+ v30 = "v3.0"
+)
+
+// NewClient prepares an unauthenticated ProviderClient instance.
+// Most users will probably prefer using the AuthenticatedClient function instead.
+// This is useful if you wish to explicitly control the version of the identity service that's used for authentication explicitly,
+// for example.
+func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
+ u, err := url.Parse(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ hadPath := u.Path != ""
+ u.Path, u.RawQuery, u.Fragment = "", "", ""
+ base := u.String()
+
+ if !strings.HasSuffix(endpoint, "/") {
+ endpoint = endpoint + "/"
+ }
+ if !strings.HasSuffix(base, "/") {
+ base = base + "/"
+ }
+
+ if hadPath {
+ return &gophercloud.ProviderClient{
+ IdentityBase: base,
+ IdentityEndpoint: endpoint,
+ }, nil
+ }
+
+ return &gophercloud.ProviderClient{
+ IdentityBase: base,
+ IdentityEndpoint: "",
+ }, nil
+}
+
+// AuthenticatedClient logs in to an OpenStack cloud found at the identity endpoint specified by options, acquires a token, and
+// returns a Client instance that's ready to operate.
+// It first queries the root identity endpoint to determine which versions of the identity service are supported, then chooses
+// the most recent identity service available to proceed.
+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/"},
+ &utils.Version{ID: v30, Priority: 30, Suffix: "/v3/"},
+ }
+
+ 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)
+ case v30:
+ return v3auth(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 against the identity v2 endpoint.
+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, err := identity2.Authenticate(v2Client, options)
+ if err != nil {
+ return err
+ }
+
+ token, err := identity2.GetToken(result)
+ if err != nil {
+ return err
+ }
+
+ client.TokenID = token.ID
+ client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+ return v2endpointLocator(result, opts)
+ }
+
+ return nil
+}
+
+func v2endpointLocator(authResults identity2.AuthResults, opts gophercloud.EndpointOpts) (string, error) {
+ catalog, err := identity2.GetServiceCatalog(authResults)
+ if err != nil {
+ return "", err
+ }
+
+ entries, err := catalog.CatalogEntries()
+ if err != nil {
+ return "", err
+ }
+
+ // Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
+ var endpoints = make([]identity2.Endpoint, 0, 1)
+ for _, entry := range 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.Interface {
+ case gophercloud.InterfacePublic:
+ return endpoint.PublicURL, nil
+ case gophercloud.InterfaceInternal:
+ return endpoint.InternalURL, nil
+ default:
+ return "", fmt.Errorf("Unexpected interface in endpoint query: %s", opts.Interface)
+ }
+ }
+
+ return "", gophercloud.ErrEndpointNotFound
+}
+
+// AuthenticateV3 explicitly authenticates against the identity v3 service.
+func AuthenticateV3(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+ return v3auth(client, "", options)
+}
+
+func v3auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+ // Override the generated service endpoint with the one returned by the version endpoint.
+ v3Client := NewIdentityV3(client)
+ if endpoint != "" {
+ v3Client.Endpoint = endpoint
+ }
+
+ result, err := tokens3.Create(v3Client, options, nil)
+ if err != nil {
+ return err
+ }
+
+ client.TokenID, err = result.TokenID()
+ if err != nil {
+ return err
+ }
+ client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+ return v3endpointLocator(v3Client, opts)
+ }
+
+ return nil
+}
+
+func v3endpointLocator(v3Client *gophercloud.ServiceClient, opts gophercloud.EndpointOpts) (string, error) {
+ // Default Interface to InterfacePublic, if it isn't provided.
+ if opts.Interface == "" {
+ opts.Interface = gophercloud.InterfacePublic
+ }
+
+ // Discover the service we're interested in.
+ serviceResults, err := services3.List(v3Client, services3.ListOpts{ServiceType: opts.Type})
+ if err != nil {
+ return "", 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 {
+ if service.Name == opts.Name {
+ filtered = append(filtered, 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{
+ Interface: opts.Interface,
+ ServiceID: service.ID,
+ })
+ 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 opts.Region == "" || endpoint.Region == opts.Region {
+ filtered = append(filtered, endpoint)
+ }
+ }
+ endpoints = filtered
+ }
+
+ 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 endpoint.URL, nil
+}
+
+// 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/"
+
+ return &gophercloud.ServiceClient{
+ Provider: client,
+ Endpoint: v2Endpoint,
+ }
+}
+
+// NewIdentityV3 creates a ServiceClient that may be used to access the v3 identity service.
+func NewIdentityV3(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+ v3Endpoint := client.IdentityBase + "v3/"
+
+ return &gophercloud.ServiceClient{
+ Provider: client,
+ Endpoint: v3Endpoint,
+ }
+}
+
+// NewStorageV1 creates a ServiceClient that may be used with the v1 object storage package.
+func NewStorageV1(client *gophercloud.ProviderClient, region string) (*gophercloud.ServiceClient, error) {
+ url, err := client.EndpointLocator(gophercloud.EndpointOpts{Type: "object-store", Name: "swift"})
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{Provider: client, Endpoint: url}, nil
+}
diff --git a/openstack/client_test.go b/openstack/client_test.go
new file mode 100644
index 0000000..dd39e77
--- /dev/null
+++ b/openstack/client_test.go
@@ -0,0 +1,173 @@
+package openstack
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClientV3(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ const ID = "0123456789"
+
+ testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "versions": {
+ "values": [
+ {
+ "status": "stable",
+ "id": "v3.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ },
+ {
+ "status": "stable",
+ "id": "v2.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ }
+ ]
+ }
+ }
+ `, testhelper.Endpoint()+"v3/", testhelper.Endpoint()+"v2.0/")
+ })
+
+ testhelper.Mux.HandleFunc("/v3/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("X-Subject-Token", ID)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{ "token": { "expires_at": "2013-02-02T18:30:59.000000Z" } }`)
+ })
+
+ options := gophercloud.AuthOptions{
+ UserID: "me",
+ Password: "secret",
+ IdentityEndpoint: testhelper.Endpoint(),
+ }
+ client, err := AuthenticatedClient(options)
+
+ if err != nil {
+ t.Fatalf("Unexpected error from AuthenticatedClient: %s", err)
+ }
+
+ if client.TokenID != ID {
+ t.Errorf("Expected token ID to be [%s], but was [%s]", ID, client.TokenID)
+ }
+}
+
+func TestAuthenticatedClientV2(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "versions": {
+ "values": [
+ {
+ "status": "experimental",
+ "id": "v3.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ },
+ {
+ "status": "stable",
+ "id": "v2.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ }
+ ]
+ }
+ }
+ `, testhelper.Endpoint()+"v3/", testhelper.Endpoint()+"v2.0/")
+ })
+
+ testhelper.Mux.HandleFunc("/v2.0/tokens", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+ {
+ "access": {
+ "token": {
+ "id": "01234567890"
+ },
+ "serviceCatalog": [
+ {
+ "name": "Cloud Servers",
+ "type": "compute",
+ "endpoints": [
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://compute.north.host.com/v1/t1000",
+ "internalURL": "https://compute.north.internal/v1/t1000",
+ "region": "North",
+ "versionId": "1",
+ "versionInfo": "https://compute.north.host.com/v1/",
+ "versionList": "https://compute.north.host.com/"
+ },
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://compute.north.host.com/v1.1/t1000",
+ "internalURL": "https://compute.north.internal/v1.1/t1000",
+ "region": "North",
+ "versionId": "1.1",
+ "versionInfo": "https://compute.north.host.com/v1.1/",
+ "versionList": "https://compute.north.host.com/"
+ }
+ ],
+ "endpoints_links": []
+ },
+ {
+ "name": "Cloud Files",
+ "type": "object-store",
+ "endpoints": [
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://storage.north.host.com/v1/t1000",
+ "internalURL": "https://storage.north.internal/v1/t1000",
+ "region": "North",
+ "versionId": "1",
+ "versionInfo": "https://storage.north.host.com/v1/",
+ "versionList": "https://storage.north.host.com/"
+ },
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://storage.south.host.com/v1/t1000",
+ "internalURL": "https://storage.south.internal/v1/t1000",
+ "region": "South",
+ "versionId": "1",
+ "versionInfo": "https://storage.south.host.com/v1/",
+ "versionList": "https://storage.south.host.com/"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ `)
+ })
+
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "secret",
+ IdentityEndpoint: testhelper.Endpoint(),
+ }
+ client, err := AuthenticatedClient(options)
+
+ if err != nil {
+ t.Fatalf("Unexpected error from AuthenticatedClient: %s", err)
+ }
+
+ if client.TokenID != "01234567890" {
+ t.Errorf("Expected token ID to be [01234567890], but was [%s]", client.TokenID)
+ }
+}
diff --git a/openstack/compute/v2/flavors/client.go b/openstack/compute/v2/flavors/client.go
index 5676d36..edeec66 100644
--- a/openstack/compute/v2/flavors/client.go
+++ b/openstack/compute/v2/flavors/client.go
@@ -2,18 +2,20 @@
import (
"fmt"
- identity "github.com/rackspace/gophercloud/openstack/identity/v2"
"net/url"
"strconv"
+
+ "github.com/rackspace/gophercloud"
+ identity "github.com/rackspace/gophercloud/openstack/identity/v2"
)
type Client struct {
endpoint string
authority identity.AuthResults
- options identity.AuthOptions
+ options gophercloud.AuthOptions
}
-func NewClient(e string, a identity.AuthResults, ao identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, ao gophercloud.AuthOptions) *Client {
return &Client{
endpoint: e,
authority: a,
@@ -56,6 +58,6 @@
}
return map[string]string{
- "X-Auth-Token": t.Id,
+ "X-Auth-Token": t.ID,
}, nil
}
diff --git a/openstack/compute/v2/images/client.go b/openstack/compute/v2/images/client.go
index 3390b99..6322b9c 100644
--- a/openstack/compute/v2/images/client.go
+++ b/openstack/compute/v2/images/client.go
@@ -1,16 +1,17 @@
package images
import (
+ "github.com/rackspace/gophercloud"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
)
type Client struct {
endpoint string
authority identity.AuthResults
- options identity.AuthOptions
+ options gophercloud.AuthOptions
}
-func NewClient(e string, a identity.AuthResults, ao identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, ao gophercloud.AuthOptions) *Client {
return &Client{
endpoint: e,
authority: a,
@@ -29,6 +30,6 @@
}
return map[string]string{
- "X-Auth-Token": t.Id,
+ "X-Auth-Token": t.ID,
}, nil
}
diff --git a/openstack/compute/v2/servers/client.go b/openstack/compute/v2/servers/client.go
index 611ff22..8c79f94 100644
--- a/openstack/compute/v2/servers/client.go
+++ b/openstack/compute/v2/servers/client.go
@@ -2,6 +2,8 @@
import (
"fmt"
+
+ "github.com/rackspace/gophercloud"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
)
@@ -9,12 +11,12 @@
type Client struct {
endpoint string
authority identity.AuthResults
- options identity.AuthOptions
+ options gophercloud.AuthOptions
token *identity.Token
}
// NewClient creates a new Client structure to use when issuing requests to the server.
-func NewClient(e string, a identity.AuthResults, o identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, o gophercloud.AuthOptions) *Client {
return &Client{
endpoint: e,
authority: a,
@@ -87,5 +89,5 @@
}
}
- return c.token.Id, err
+ return c.token.ID, err
}
diff --git a/openstack/identity/v2/common_test.go b/openstack/identity/v2/common_test.go
index 18c5340..0260424 100644
--- a/openstack/identity/v2/common_test.go
+++ b/openstack/identity/v2/common_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
// Taken from: http://docs.openstack.org/api/openstack-identity-service/2.0/content/POST_authenticate_v2.0_tokens_.html
const authResultsOK = `{
@@ -160,20 +160,20 @@
}
],
"namespace": "http://docs.openstack.org/identity/api/ext/OS-SIMPLE-CERT/v1.0",
- "alias": "OS-SIMPLE-CERT",
- "description": "OpenStack simple certificate retrieval extension"
- },
+ "alias": "OS-SIMPLE-CERT",
+ "description": "OpenStack simple certificate retrieval extension"
+ },
{
- "updated": "2013-07-07T12:00:0-00:00",
+ "updated": "2013-07-07T12:00:0-00:00",
"name": "OpenStack EC2 API",
"links": [
{
- "href": "https://github.com/openstack/identity-api",
- "type": "text/html",
- "rel": "describedby"
- }
+ "href": "https://github.com/openstack/identity-api",
+ "type": "text/html",
+ "rel": "describedby"
+ }
],
- "namespace": "http://docs.openstack.org/identity/api/ext/OS-EC2/v1.0",
+ "namespace": "http://docs.openstack.org/identity/api/ext/OS-EC2/v1.0",
"alias": "OS-EC2",
"description": "OpenStack EC2 Credentials backend."
}
diff --git a/openstack/identity/v2/doc.go b/openstack/identity/v2/doc.go
index 081950d..e8ab21b 100644
--- a/openstack/identity/v2/doc.go
+++ b/openstack/identity/v2/doc.go
@@ -1,5 +1,5 @@
/*
-The Identity package provides convenient OpenStack Identity V2 API client access.
+Package v2 identity provides convenient OpenStack Identity V2 API client access.
This package currently doesn't support the administrative access endpoints, but may appear in the future based on demand.
Authentication
@@ -117,4 +117,4 @@
}
}
*/
-package identity
+package v2
diff --git a/openstack/identity/v2/errors.go b/openstack/identity/v2/errors.go
index efa7c85..0dd1172 100644
--- a/openstack/identity/v2/errors.go
+++ b/openstack/identity/v2/errors.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import "fmt"
diff --git a/openstack/identity/v2/extensions.go b/openstack/identity/v2/extensions.go
index 9cf9c78..df05a36 100644
--- a/openstack/identity/v2/extensions.go
+++ b/openstack/identity/v2/extensions.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import (
"github.com/mitchellh/mapstructure"
diff --git a/openstack/identity/v2/extensions_test.go b/openstack/identity/v2/extensions_test.go
index 3000fc0..47f7e06 100644
--- a/openstack/identity/v2/extensions_test.go
+++ b/openstack/identity/v2/extensions_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import (
"encoding/json"
diff --git a/openstack/identity/v2/requests.go b/openstack/identity/v2/requests.go
index dbd367e..bb068b6 100644
--- a/openstack/identity/v2/requests.go
+++ b/openstack/identity/v2/requests.go
@@ -1,7 +1,8 @@
-package identity
+package v2
import (
"github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
)
// AuthResults encapsulates the raw results from an authentication request.
@@ -11,60 +12,25 @@
// such as ServiceCatalog() and Token().
type AuthResults map[string]interface{}
-// AuthOptions lets anyone calling Authenticate() supply the required access
-// credentials. At present, only Identity V2 API support exists; therefore,
-// only Username, Password, and optionally, TenantId are provided. If future
-// Identity API versions become available, alternative fields unique to those
-// versions may appear here.
-//
-// Endpoint specifies the HTTP endpoint offering the Identity V2 API.
-// Required.
-//
-// Username is required if using Identity V2 API. Consult with your provider's
-// control panel to discover your account's username.
-//
-// At most one of Password or ApiKey is required if using Identity V2 API.
-// Consult with your provider's control panel to discover your account's
-// preferred method of authentication.
-//
-// The TenantId and TenantName fields are optional for the Identity V2 API.
-// Some providers allow you to specify a TenantName instead of the TenantId.
-// Some require both. Your provider's authentication policies will determine
-// how these fields influence authentication.
-//
-// AllowReauth should be set to true if you grant permission for Gophercloud to
-// cache your credentials in memory, and to allow Gophercloud to attempt to
-// re-authenticate automatically if/when your token expires. If you set it to
-// false, it will not cache these settings, but re-authentication will not be
-// possible. This setting defaults to false.
-type AuthOptions struct {
- Endpoint string
- Username string
- Password, ApiKey string
- TenantId string
- TenantName string
- AllowReauth bool
-}
-
// Authenticate passes the supplied credentials to the OpenStack provider for authentication.
// If successful, the caller may use Token() to retrieve the authentication token,
// and ServiceCatalog() to retrieve the set of services available to the API user.
-func Authenticate(options AuthOptions) (AuthResults, error) {
+func Authenticate(c *gophercloud.ServiceClient, options gophercloud.AuthOptions) (AuthResults, error) {
type AuthContainer struct {
Auth auth `json:"auth"`
}
var ar AuthResults
- if options.Endpoint == "" {
+ if c.Endpoint == "" {
return nil, ErrEndpoint
}
- if (options.Username == "") || (options.Password == "" && options.ApiKey == "") {
+ if (options.Username == "") || (options.Password == "" && options.APIKey == "") {
return nil, ErrCredentials
}
- url := options.Endpoint + "/tokens"
+ url := c.Endpoint + "tokens"
err := perigee.Post(url, perigee.Options{
ReqBody: &AuthContainer{
Auth: getAuthCredentials(options),
@@ -74,8 +40,8 @@
return ar, err
}
-func getAuthCredentials(options AuthOptions) auth {
- if options.ApiKey == "" {
+func getAuthCredentials(options gophercloud.AuthOptions) auth {
+ if options.APIKey == "" {
return auth{
PasswordCredentials: &struct {
Username string `json:"username"`
@@ -84,35 +50,35 @@
Username: options.Username,
Password: options.Password,
},
- TenantId: options.TenantId,
+ TenantID: options.TenantID,
TenantName: options.TenantName,
}
- } else {
- return auth{
- ApiKeyCredentials: &struct {
- Username string `json:"username"`
- ApiKey string `json:"apiKey"`
- }{
- Username: options.Username,
- ApiKey: options.ApiKey,
- },
- TenantId: options.TenantId,
- TenantName: options.TenantName,
- }
+ }
+ return auth{
+ APIKeyCredentials: &struct {
+ Username string `json:"username"`
+ APIKey string `json:"apiKey"`
+ }{
+ Username: options.Username,
+ APIKey: options.APIKey,
+ },
+ TenantID: options.TenantID,
+ TenantName: options.TenantName,
}
}
type auth struct {
PasswordCredentials interface{} `json:"passwordCredentials,omitempty"`
- ApiKeyCredentials interface{} `json:"RAX-KSKEY:apiKeyCredentials,omitempty"`
- TenantId string `json:"tenantId,omitempty"`
+ APIKeyCredentials interface{} `json:"RAX-KSKEY:apiKeyCredentials,omitempty"`
+ TenantID string `json:"tenantId,omitempty"`
TenantName string `json:"tenantName,omitempty"`
}
-func GetExtensions(options AuthOptions) (ExtensionsResult, error) {
+// GetExtensions returns the OpenStack extensions available from this service.
+func GetExtensions(c *gophercloud.ServiceClient, options gophercloud.AuthOptions) (ExtensionsResult, error) {
var exts ExtensionsResult
- url := options.Endpoint + "/extensions"
+ url := c.Endpoint + "extensions"
err := perigee.Get(url, perigee.Options{
Results: &exts,
})
diff --git a/openstack/identity/v2/service_catalog.go b/openstack/identity/v2/service_catalog.go
index 035f671..22d0d7b 100644
--- a/openstack/identity/v2/service_catalog.go
+++ b/openstack/identity/v2/service_catalog.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import "github.com/mitchellh/mapstructure"
@@ -40,11 +40,11 @@
//
// In all cases, fields which aren't supported by the provider and service combined will assume a zero-value ("").
type Endpoint struct {
- TenantId string
+ TenantID string
PublicURL string
InternalURL string
Region string
- VersionId string
+ VersionID string
VersionInfo string
VersionList string
}
diff --git a/openstack/identity/v2/service_catalog_test.go b/openstack/identity/v2/service_catalog_test.go
index f810609..143b48f 100644
--- a/openstack/identity/v2/service_catalog_test.go
+++ b/openstack/identity/v2/service_catalog_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import (
"encoding/json"
@@ -45,8 +45,8 @@
return
}
for _, ep := range eps {
- if strNotInStrList(ep.VersionId, "1", "1.1", "1.1") {
- t.Errorf("Expected versionId field of compute resource to be one of 1 or 1.1")
+ if strNotInStrList(ep.VersionID, "1", "1.1", "1.1") {
+ t.Errorf("Expected versionID field of compute resource to be one of 1 or 1.1")
return
}
}
@@ -57,7 +57,7 @@
return
}
for _, ep := range eps {
- if ep.VersionId != "1" {
+ if ep.VersionID != "1" {
t.Errorf("Expected only version 1 object store API version")
return
}
@@ -68,7 +68,7 @@
t.Errorf("Expected 1 endpoint for DNS-as-a-Service service")
return
}
- if eps[0].VersionId != "2.0" {
+ if eps[0].VersionID != "2.0" {
t.Errorf("Expected version 2.0 of DNS-as-a-Service service")
return
}
diff --git a/openstack/identity/v2/token.go b/openstack/identity/v2/token.go
index d50cce0..df67d76 100644
--- a/openstack/identity/v2/token.go
+++ b/openstack/identity/v2/token.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import (
"github.com/mitchellh/mapstructure"
@@ -21,21 +21,21 @@
//
// TenantName provides a human-readable tenant name corresponding to the TenantId.
type Token struct {
- Id, Expires string
- TenantId, TenantName string
+ ID, Expires string
+ TenantID, TenantName string
}
-// GetToken, if successful, yields an unpacked collection of fields related to the user's access credentials, called a "token."
+// GetToken yields an unpacked collection of fields related to the user's access credentials, called a "token", if successful.
// See the Token structure for more details.
func GetToken(m AuthResults) (*Token, error) {
type (
Tenant struct {
- Id string
+ ID string
Name string
}
TokenDesc struct {
- Id string `mapstructure:"id"`
+ ID string `mapstructure:"id"`
Expires string `mapstructure:"expires"`
Tenant
}
@@ -55,9 +55,9 @@
return nil, err
}
td := &Token{
- Id: t.Id,
+ ID: t.ID,
Expires: t.Expires,
- TenantId: t.Tenant.Id,
+ TenantID: t.Tenant.ID,
TenantName: t.Tenant.Name,
}
return td, nil
diff --git a/openstack/identity/v2/token_test.go b/openstack/identity/v2/token_test.go
index 5e96496..9770ed5 100644
--- a/openstack/identity/v2/token_test.go
+++ b/openstack/identity/v2/token_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
import (
"encoding/json"
@@ -18,8 +18,8 @@
t.Error(err)
return
}
- if tok.Id != "ab48a9efdfedb23ty3494" {
- t.Errorf("Expected token \"ab48a9efdfedb23ty3494\"; got \"%s\" instead", tok.Id)
+ if tok.ID != "ab48a9efdfedb23ty3494" {
+ t.Errorf("Expected token \"ab48a9efdfedb23ty3494\"; got \"%s\" instead", tok.ID)
return
}
}
diff --git a/openstack/identity/v3/endpoints/doc.go b/openstack/identity/v3/endpoints/doc.go
new file mode 100644
index 0000000..7d38ee3
--- /dev/null
+++ b/openstack/identity/v3/endpoints/doc.go
@@ -0,0 +1,3 @@
+// Package endpoints queries and manages service endpoints.
+// Reference: http://developer.openstack.org/api-ref-identity-v3.html#endpoints-v3
+package endpoints
diff --git a/openstack/identity/v3/endpoints/errors.go b/openstack/identity/v3/endpoints/errors.go
new file mode 100644
index 0000000..a8b00d4
--- /dev/null
+++ b/openstack/identity/v3/endpoints/errors.go
@@ -0,0 +1,21 @@
+package endpoints
+
+import "fmt"
+
+func requiredAttribute(attribute string) error {
+ return fmt.Errorf("You must specify %s for this endpoint.", attribute)
+}
+
+var (
+ // ErrInterfaceRequired is reported if an Endpoint is created without an Interface.
+ ErrInterfaceRequired = requiredAttribute("an interface")
+
+ // ErrNameRequired is reported if an Endpoint is created without a Name.
+ ErrNameRequired = requiredAttribute("a name")
+
+ // ErrURLRequired is reported if an Endpoint is created without a URL.
+ ErrURLRequired = requiredAttribute("a URL")
+
+ // ErrServiceIDRequired is reported if an Endpoint is created without a ServiceID.
+ ErrServiceIDRequired = requiredAttribute("a serviceID")
+)
diff --git a/openstack/identity/v3/endpoints/requests.go b/openstack/identity/v3/endpoints/requests.go
new file mode 100644
index 0000000..65d505a
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -0,0 +1,174 @@
+package endpoints
+
+import (
+ "strconv"
+
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+// maybeString returns nil for empty strings and nil for empty.
+func maybeString(original string) *string {
+ if original != "" {
+ return &original
+ }
+ return nil
+}
+
+// EndpointOpts contains the subset of Endpoint attributes that should be used to create or update an Endpoint.
+type EndpointOpts struct {
+ Interface gophercloud.Interface
+ Name string
+ Region string
+ URL string
+ ServiceID string
+}
+
+// Create inserts a new Endpoint into the service catalog.
+// Within EndpointOpts, Region may be omitted by being left as "", but all other fields are required.
+func Create(client *gophercloud.ServiceClient, opts EndpointOpts) (*Endpoint, error) {
+ // Redefined so that Region can be re-typed as a *string, which can be omitted from the JSON output.
+ type endpoint struct {
+ Interface string `json:"interface"`
+ Name string `json:"name"`
+ Region *string `json:"region,omitempty"`
+ URL string `json:"url"`
+ ServiceID string `json:"service_id"`
+ }
+
+ type request struct {
+ Endpoint endpoint `json:"endpoint"`
+ }
+
+ type response struct {
+ Endpoint Endpoint `json:"endpoint"`
+ }
+
+ // Ensure that EndpointOpts is fully populated.
+ if opts.Interface == "" {
+ return nil, ErrInterfaceRequired
+ }
+ if opts.Name == "" {
+ return nil, ErrNameRequired
+ }
+ if opts.URL == "" {
+ return nil, ErrURLRequired
+ }
+ if opts.ServiceID == "" {
+ return nil, ErrServiceIDRequired
+ }
+
+ // Populate the request body.
+ reqBody := request{
+ Endpoint: endpoint{
+ Interface: string(opts.Interface),
+ Name: opts.Name,
+ URL: opts.URL,
+ ServiceID: opts.ServiceID,
+ },
+ }
+ reqBody.Endpoint.Region = maybeString(opts.Region)
+
+ var respBody response
+ _, err := perigee.Request("POST", getListURL(client), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ ReqBody: &reqBody,
+ Results: &respBody,
+ OkCodes: []int{201},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &respBody.Endpoint, nil
+}
+
+// ListOpts allows finer control over the the endpoints returned by a List call.
+// All fields are optional.
+type ListOpts struct {
+ Interface gophercloud.Interface
+ ServiceID string
+ Page int
+ PerPage int
+}
+
+// List enumerates endpoints in a paginated collection, optionally filtered by ListOpts criteria.
+func List(client *gophercloud.ServiceClient, opts ListOpts) (*EndpointList, error) {
+ q := make(map[string]string)
+ if opts.Interface != "" {
+ q["interface"] = string(opts.Interface)
+ }
+ if opts.ServiceID != "" {
+ q["service_id"] = opts.ServiceID
+ }
+ if opts.Page != 0 {
+ q["page"] = strconv.Itoa(opts.Page)
+ }
+ if opts.PerPage != 0 {
+ q["per_page"] = strconv.Itoa(opts.Page)
+ }
+
+ 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
+}
+
+// Update changes an existing endpoint with new data.
+// All fields are optional in the provided EndpointOpts.
+func Update(client *gophercloud.ServiceClient, endpointID string, opts EndpointOpts) (*Endpoint, error) {
+ type endpoint struct {
+ Interface *string `json:"interface,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Region *string `json:"region,omitempty"`
+ URL *string `json:"url,omitempty"`
+ ServiceID *string `json:"service_id,omitempty"`
+ }
+
+ type request struct {
+ Endpoint endpoint `json:"endpoint"`
+ }
+
+ type response struct {
+ Endpoint Endpoint `json:"endpoint"`
+ }
+
+ reqBody := request{Endpoint: endpoint{}}
+ reqBody.Endpoint.Interface = maybeString(string(opts.Interface))
+ reqBody.Endpoint.Name = maybeString(opts.Name)
+ reqBody.Endpoint.Region = maybeString(opts.Region)
+ reqBody.Endpoint.URL = maybeString(opts.URL)
+ reqBody.Endpoint.ServiceID = maybeString(opts.ServiceID)
+
+ var respBody response
+ _, err := perigee.Request("PATCH", getEndpointURL(client, endpointID), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ ReqBody: &reqBody,
+ Results: &respBody,
+ OkCodes: []int{200},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &respBody.Endpoint, nil
+}
+
+// Delete removes an endpoint from the service catalog.
+func Delete(client *gophercloud.ServiceClient, endpointID string) error {
+ _, err := perigee.Request("DELETE", getEndpointURL(client, endpointID), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ OkCodes: []int{204},
+ })
+ return err
+}
diff --git a/openstack/identity/v3/endpoints/requests_test.go b/openstack/identity/v3/endpoints/requests_test.go
new file mode 100644
index 0000000..a7088bb
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests_test.go
@@ -0,0 +1,233 @@
+package endpoints
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+const tokenID = "abcabcabcabc"
+
+func serviceClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{TokenID: tokenID},
+ Endpoint: testhelper.Endpoint(),
+ }
+}
+
+func TestCreateSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "POST")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+ testhelper.TestJSONRequest(t, r, `
+ {
+ "endpoint": {
+ "interface": "public",
+ "name": "the-endiest-of-points",
+ "region": "underground",
+ "url": "https://1.2.3.4:9000/",
+ "service_id": "asdfasdfasdfasdf"
+ }
+ }
+ `)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+ {
+ "endpoint": {
+ "id": "12",
+ "interface": "public",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/12"
+ },
+ "name": "the-endiest-of-points",
+ "region": "underground",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9000/"
+ }
+ }
+ `)
+ })
+
+ client := serviceClient()
+
+ result, err := Create(client, EndpointOpts{
+ Interface: gophercloud.InterfacePublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ URL: "https://1.2.3.4:9000/",
+ ServiceID: "asdfasdfasdfasdf",
+ })
+ if err != nil {
+ t.Fatalf("Unable to create an endpoint: %v", err)
+ }
+
+ expected := &Endpoint{
+ ID: "12",
+ Interface: gophercloud.InterfacePublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ }
+
+ if !reflect.DeepEqual(result, expected) {
+ t.Errorf("Expected %#v, was %#v", expected, result)
+ }
+}
+
+func TestListEndpoints(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+ fmt.Fprintf(w, `
+ {
+ "endpoints": [
+ {
+ "id": "12",
+ "interface": "public",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/12"
+ },
+ "name": "the-endiest-of-points",
+ "region": "underground",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9000/"
+ },
+ {
+ "id": "13",
+ "interface": "internal",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/13"
+ },
+ "name": "shhhh",
+ "region": "underground",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9001/"
+ }
+ ],
+ "links": {
+ "next": null,
+ "previous": null
+ }
+ }
+ `)
+ })
+
+ client := serviceClient()
+
+ actual, err := List(client, ListOpts{})
+ if err != nil {
+ t.Fatalf("Unexpected error listing endpoints: %v", err)
+ }
+
+ expected := &EndpointList{
+ Endpoints: []Endpoint{
+ Endpoint{
+ ID: "12",
+ Interface: gophercloud.InterfacePublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ },
+ Endpoint{
+ ID: "13",
+ Interface: gophercloud.InterfaceInternal,
+ Name: "shhhh",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9001/",
+ },
+ },
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, got %#v", expected, actual)
+ }
+}
+
+func TestUpdateEndpoint(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "PATCH")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+ testhelper.TestJSONRequest(t, r, `
+ {
+ "endpoint": {
+ "name": "renamed",
+ "region": "somewhere-else"
+ }
+ }
+ `)
+
+ fmt.Fprintf(w, `
+ {
+ "endpoint": {
+ "id": "12",
+ "interface": "public",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/12"
+ },
+ "name": "renamed",
+ "region": "somewhere-else",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9000/"
+ }
+ }
+ `)
+ })
+
+ client := serviceClient()
+ actual, err := Update(client, "12", EndpointOpts{
+ Name: "renamed",
+ Region: "somewhere-else",
+ })
+ if err != nil {
+ t.Fatalf("Unexpected error from Update: %v", err)
+ }
+
+ expected := &Endpoint{
+ ID: "12",
+ Interface: gophercloud.InterfacePublic,
+ Name: "renamed",
+ Region: "somewhere-else",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ }
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, was %#v", expected, actual)
+ }
+}
+
+func TestDeleteEndpoint(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints/34", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "DELETE")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ client := serviceClient()
+
+ err := Delete(client, "34")
+ if err != nil {
+ t.Fatalf("Unexpected error from Delete: %v", err)
+ }
+}
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
new file mode 100644
index 0000000..940eebc
--- /dev/null
+++ b/openstack/identity/v3/endpoints/results.go
@@ -0,0 +1,70 @@
+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"`
+ Interface gophercloud.Interface `json:"interface"`
+ Name string `json:"name"`
+ Region string `json:"region"`
+ ServiceID string `json:"service_id"`
+ URL string `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)
+ }
+
+ var result EndpointList
+ err := mapstructure.Decode(mapped, &result)
+ 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
+}
diff --git a/openstack/identity/v3/endpoints/urls.go b/openstack/identity/v3/endpoints/urls.go
new file mode 100644
index 0000000..011cc01
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls.go
@@ -0,0 +1,11 @@
+package endpoints
+
+import "github.com/rackspace/gophercloud"
+
+func getListURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("endpoints")
+}
+
+func getEndpointURL(client *gophercloud.ServiceClient, endpointID string) string {
+ return client.ServiceURL("endpoints", endpointID)
+}
diff --git a/openstack/identity/v3/endpoints/urls_test.go b/openstack/identity/v3/endpoints/urls_test.go
new file mode 100644
index 0000000..fe1fb4a
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls_test.go
@@ -0,0 +1,23 @@
+package endpoints
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func TestGetListURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := getListURL(&client)
+ if url != "http://localhost:5000/v3/endpoints" {
+ t.Errorf("Unexpected list URL generated: [%s]", url)
+ }
+}
+
+func TestGetEndpointURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := getEndpointURL(&client, "1234")
+ if url != "http://localhost:5000/v3/endpoints/1234" {
+ t.Errorf("Unexpected service URL generated: [%s]", url)
+ }
+}
diff --git a/openstack/identity/v3/services/doc.go b/openstack/identity/v3/services/doc.go
new file mode 100644
index 0000000..c4772c0
--- /dev/null
+++ b/openstack/identity/v3/services/doc.go
@@ -0,0 +1,4 @@
+/*
+Package services queries and manages the service catalog.
+*/
+package services
diff --git a/openstack/identity/v3/services/requests.go b/openstack/identity/v3/services/requests.go
new file mode 100644
index 0000000..e261192
--- /dev/null
+++ b/openstack/identity/v3/services/requests.go
@@ -0,0 +1,115 @@
+package services
+
+import (
+ "strconv"
+
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+type response struct {
+ Service Service `json:"service"`
+}
+
+// Create adds a new service of the requested type to the catalog.
+func Create(client *gophercloud.ServiceClient, serviceType string) (*Service, error) {
+ type request struct {
+ Type string `json:"type"`
+ }
+
+ req := request{Type: serviceType}
+ var resp response
+
+ _, err := perigee.Request("POST", getListURL(client), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ ReqBody: &req,
+ Results: &resp,
+ OkCodes: []int{201},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &resp.Service, nil
+}
+
+// ListOpts allows you to query the List method.
+type ListOpts struct {
+ ServiceType string
+ PerPage int
+ Page int
+}
+
+// List enumerates the services available to a specific user.
+func List(client *gophercloud.ServiceClient, opts ListOpts) (*ServiceList, error) {
+ q := make(map[string]string)
+ if opts.ServiceType != "" {
+ q["type"] = opts.ServiceType
+ }
+ if opts.Page != 0 {
+ q["page"] = strconv.Itoa(opts.Page)
+ }
+ if opts.PerPage != 0 {
+ q["perPage"] = strconv.Itoa(opts.PerPage)
+ }
+ 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
+}
+
+// Get returns additional information about a service, given its ID.
+func Get(client *gophercloud.ServiceClient, serviceID string) (*Service, error) {
+ var resp response
+ _, err := perigee.Request("GET", getServiceURL(client, serviceID), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ Results: &resp,
+ OkCodes: []int{200},
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &resp.Service, nil
+}
+
+// Update changes the service type of an existing service.s
+func Update(client *gophercloud.ServiceClient, serviceID string, serviceType string) (*Service, error) {
+ type request struct {
+ Type string `json:"type"`
+ }
+
+ req := request{Type: serviceType}
+
+ var resp response
+ _, err := perigee.Request("PATCH", getServiceURL(client, serviceID), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ ReqBody: &req,
+ Results: &resp,
+ OkCodes: []int{200},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &resp.Service, nil
+}
+
+// Delete removes an existing service.
+// It either deletes all associated endpoints, or fails until all endpoints are deleted.
+func Delete(client *gophercloud.ServiceClient, serviceID string) error {
+ _, err := perigee.Request("DELETE", getServiceURL(client, serviceID), perigee.Options{
+ MoreHeaders: client.Provider.AuthenticatedHeaders(),
+ OkCodes: []int{204},
+ })
+ return err
+}
diff --git a/openstack/identity/v3/services/requests_test.go b/openstack/identity/v3/services/requests_test.go
new file mode 100644
index 0000000..9f43db3
--- /dev/null
+++ b/openstack/identity/v3/services/requests_test.go
@@ -0,0 +1,222 @@
+package services
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+const tokenID = "111111"
+
+func serviceClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{
+ TokenID: tokenID,
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+}
+
+func TestCreateSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "POST")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+ testhelper.TestJSONRequest(t, r, `{ "type": "compute" }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{
+ "service": {
+ "description": "Here's your service",
+ "id": "1234",
+ "name": "InscrutableOpenStackProjectName",
+ "type": "compute"
+ }
+ }`)
+ })
+
+ client := serviceClient()
+
+ result, err := Create(client, "compute")
+ if err != nil {
+ t.Fatalf("Unexpected error from Create: %v", err)
+ }
+
+ if result.Description == nil || *result.Description != "Here's your service" {
+ t.Errorf("Service description was unexpected [%s]", result.Description)
+ }
+ if result.ID != "1234" {
+ t.Errorf("Service ID was unexpected [%s]", result.ID)
+ }
+ if result.Name != "InscrutableOpenStackProjectName" {
+ t.Errorf("Service name was unexpected [%s]", result.Name)
+ }
+ if result.Type != "compute" {
+ t.Errorf("Service type was unexpected [%s]", result.Type)
+ }
+}
+
+func TestListSinglePage(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "links": {
+ "next": null,
+ "previous": null
+ },
+ "services": [
+ {
+ "description": "Service One",
+ "id": "1234",
+ "name": "service-one",
+ "type": "identity"
+ },
+ {
+ "description": "Service Two",
+ "id": "9876",
+ "name": "service-two",
+ "type": "compute"
+ }
+ ]
+ }
+ `)
+ })
+
+ client := serviceClient()
+
+ result, err := List(client, ListOpts{})
+ if err != nil {
+ t.Fatalf("Error listing services: %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)
+ }
+}
+
+func TestGetSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "service": {
+ "description": "Service One",
+ "id": "12345",
+ "name": "service-one",
+ "type": "identity"
+ }
+ }
+ `)
+ })
+
+ client := serviceClient()
+
+ result, err := Get(client, "12345")
+ if err != nil {
+ t.Fatalf("Error fetching service information: %v", err)
+ }
+
+ if result.ID != "12345" {
+ t.Errorf("Unexpected service ID: %s", result.ID)
+ }
+ if *result.Description != "Service One" {
+ t.Errorf("Unexpected service description: [%s]", *result.Description)
+ }
+ if result.Name != "service-one" {
+ t.Errorf("Unexpected service name: [%s]", result.Name)
+ }
+ if result.Type != "identity" {
+ t.Errorf("Unexpected service type: [%s]", result.Type)
+ }
+}
+
+func TestUpdateSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "PATCH")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+ testhelper.TestJSONRequest(t, r, `{ "type": "lasermagic" }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "service": {
+ "id": "12345",
+ "type": "lasermagic"
+ }
+ }
+ `)
+ })
+
+ client := serviceClient()
+
+ result, err := Update(client, "12345", "lasermagic")
+ if err != nil {
+ t.Fatalf("Unable to update service: %v", err)
+ }
+
+ if result.ID != "12345" {
+
+ }
+}
+
+func TestDeleteSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "DELETE")
+ testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ client := serviceClient()
+
+ err := Delete(client, "12345")
+ if err != nil {
+ t.Fatalf("Unable to delete service: %v", err)
+ }
+}
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
new file mode 100644
index 0000000..88510b4
--- /dev/null
+++ b/openstack/identity/v3/services/results.go
@@ -0,0 +1,68 @@
+package services
+
+import (
+ "fmt"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+)
+
+// Service is the result of a list or information query.
+type Service struct {
+ Description *string `json:"description,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ 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)
+ }
+
+ 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
+}
diff --git a/openstack/identity/v3/services/urls.go b/openstack/identity/v3/services/urls.go
new file mode 100644
index 0000000..3556238
--- /dev/null
+++ b/openstack/identity/v3/services/urls.go
@@ -0,0 +1,11 @@
+package services
+
+import "github.com/rackspace/gophercloud"
+
+func getListURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("services")
+}
+
+func getServiceURL(client *gophercloud.ServiceClient, serviceID string) string {
+ return client.ServiceURL("services", serviceID)
+}
diff --git a/openstack/identity/v3/services/urls_test.go b/openstack/identity/v3/services/urls_test.go
new file mode 100644
index 0000000..deded69
--- /dev/null
+++ b/openstack/identity/v3/services/urls_test.go
@@ -0,0 +1,23 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func TestGetListURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := getListURL(&client)
+ if url != "http://localhost:5000/v3/services" {
+ t.Errorf("Unexpected list URL generated: [%s]", url)
+ }
+}
+
+func TestGetServiceURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := getServiceURL(&client, "1234")
+ if url != "http://localhost:5000/v3/services/1234" {
+ t.Errorf("Unexpected service URL generated: [%s]", url)
+ }
+}
diff --git a/openstack/identity/v3/tokens/doc.go b/openstack/identity/v3/tokens/doc.go
new file mode 100644
index 0000000..02fce0d
--- /dev/null
+++ b/openstack/identity/v3/tokens/doc.go
@@ -0,0 +1,6 @@
+/*
+Package tokens defines operations performed on the token resource.
+
+Documentation: http://developer.openstack.org/api-ref-identity-v3.html#tokens-v3
+*/
+package tokens
diff --git a/openstack/identity/v3/tokens/errors.go b/openstack/identity/v3/tokens/errors.go
new file mode 100644
index 0000000..4476109
--- /dev/null
+++ b/openstack/identity/v3/tokens/errors.go
@@ -0,0 +1,72 @@
+package tokens
+
+import (
+ "errors"
+ "fmt"
+)
+
+func unacceptedAttributeErr(attribute string) error {
+ return fmt.Errorf("The base Identity V3 API does not accept authentication by %s", attribute)
+}
+
+func redundantWithTokenErr(attribute string) error {
+ return fmt.Errorf("%s may not be provided when authenticating with a TokenID", attribute)
+}
+
+func redundantWithUserID(attribute string) error {
+ return fmt.Errorf("%s may not be provided when authenticating with a UserID", attribute)
+}
+
+var (
+ // ErrAPIKeyProvided indicates that an APIKey was provided but can't be used.
+ ErrAPIKeyProvided = unacceptedAttributeErr("APIKey")
+
+ // ErrTenantIDProvided indicates that a TenantID was provided but can't be used.
+ ErrTenantIDProvided = unacceptedAttributeErr("TenantID")
+
+ // ErrTenantNameProvided indicates that a TenantName was provided but can't be used.
+ ErrTenantNameProvided = unacceptedAttributeErr("TenantName")
+
+ // ErrUsernameWithToken indicates that a Username was provided, but token authentication is being used instead.
+ ErrUsernameWithToken = redundantWithTokenErr("Username")
+
+ // ErrUserIDWithToken indicates that a UserID was provided, but token authentication is being used instead.
+ ErrUserIDWithToken = redundantWithTokenErr("UserID")
+
+ // ErrDomainIDWithToken indicates that a DomainID was provided, but token authentication is being used instead.
+ ErrDomainIDWithToken = redundantWithTokenErr("DomainID")
+
+ // ErrDomainNameWithToken indicates that a DomainName was provided, but token authentication is being used instead.s
+ ErrDomainNameWithToken = redundantWithTokenErr("DomainName")
+
+ // ErrUsernameOrUserID indicates that neither username nor userID are specified, or both are at once.
+ ErrUsernameOrUserID = errors.New("Exactly one of Username and UserID must be provided for password authentication")
+
+ // ErrDomainIDWithUserID indicates that a DomainID was provided, but unnecessary because a UserID is being used.
+ ErrDomainIDWithUserID = redundantWithUserID("DomainID")
+
+ // ErrDomainNameWithUserID indicates that a DomainName was provided, but unnecessary because a UserID is being used.
+ ErrDomainNameWithUserID = redundantWithUserID("DomainName")
+
+ // ErrDomainIDOrDomainName indicates that a username was provided, but no domain to scope it.
+ // It may also indicate that both a DomainID and a DomainName were provided at once.
+ ErrDomainIDOrDomainName = errors.New("You must provide exactly one of DomainID or DomainName to authenticate by Username")
+
+ // ErrMissingPassword indicates that no password was provided and no token is available.
+ ErrMissingPassword = errors.New("You must provide a password to authenticate")
+
+ // ErrScopeDomainIDOrDomainName indicates that a domain ID or Name was required in a Scope, but not present.
+ ErrScopeDomainIDOrDomainName = errors.New("You must provide exactly one of DomainID or DomainName in a Scope with ProjectName")
+
+ // ErrScopeProjectIDOrProjectName indicates that both a ProjectID and a ProjectName were provided in a Scope.
+ ErrScopeProjectIDOrProjectName = errors.New("You must provide at most one of ProjectID or ProjectName in a Scope")
+
+ // ErrScopeProjectIDAlone indicates that a ProjectID was provided with other constraints in a Scope.
+ ErrScopeProjectIDAlone = errors.New("ProjectID must be supplied alone in a Scope")
+
+ // ErrScopeDomainName indicates that a DomainName was provided alone in a Scope.
+ ErrScopeDomainName = errors.New("DomainName must be supplied with a ProjectName or ProjectID in a Scope.")
+
+ // ErrScopeEmpty indicates that no credentials were provided in a Scope.
+ ErrScopeEmpty = errors.New("You must provide either a Project or Domain in a Scope")
+)
diff --git a/openstack/identity/v3/tokens/requests.go b/openstack/identity/v3/tokens/requests.go
new file mode 100644
index 0000000..ab4ae05
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests.go
@@ -0,0 +1,291 @@
+package tokens
+
+import (
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+)
+
+// Scope allows a created token to be limited to a specific domain or project.
+type Scope struct {
+ ProjectID string
+ ProjectName string
+ DomainID string
+ DomainName string
+}
+
+func subjectTokenHeaders(c *gophercloud.ServiceClient, subjectToken string) map[string]string {
+ h := c.Provider.AuthenticatedHeaders()
+ h["X-Subject-Token"] = subjectToken
+ return h
+}
+
+// Create authenticates and either generates a new token, or changes the Scope of an existing token.
+func Create(c *gophercloud.ServiceClient, options gophercloud.AuthOptions, scope *Scope) (gophercloud.AuthResults, error) {
+ type domainReq struct {
+ ID *string `json:"id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ }
+
+ type projectReq struct {
+ Domain *domainReq `json:"domain,omitempty"`
+ Name *string `json:"name,omitempty"`
+ ID *string `json:"id,omitempty"`
+ }
+
+ type userReq struct {
+ ID *string `json:"id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Password string `json:"password"`
+ Domain *domainReq `json:"domain,omitempty"`
+ }
+
+ type passwordReq struct {
+ User userReq `json:"user"`
+ }
+
+ type tokenReq struct {
+ ID string `json:"id"`
+ }
+
+ type identityReq struct {
+ Methods []string `json:"methods"`
+ Password *passwordReq `json:"password,omitempty"`
+ Token *tokenReq `json:"token,omitempty"`
+ }
+
+ type scopeReq struct {
+ Domain *domainReq `json:"domain,omitempty"`
+ Project *projectReq `json:"project,omitempty"`
+ }
+
+ type authReq struct {
+ Identity identityReq `json:"identity"`
+ Scope *scopeReq `json:"scope,omitempty"`
+ }
+
+ type request struct {
+ Auth authReq `json:"auth"`
+ }
+
+ // Populate the request structure based on the provided arguments. Create and return an error
+ // if insufficient or incompatible information is present.
+ var req request
+
+ // Test first for unrecognized arguments.
+ if options.APIKey != "" {
+ return nil, ErrAPIKeyProvided
+ }
+ if options.TenantID != "" {
+ return nil, ErrTenantIDProvided
+ }
+ if options.TenantName != "" {
+ return nil, ErrTenantNameProvided
+ }
+
+ if options.Password == "" {
+ if c.Provider.TokenID != "" {
+ // Because we aren't using password authentication, it's an error to also provide any of the user-based authentication
+ // parameters.
+ if options.Username != "" {
+ return nil, ErrUsernameWithToken
+ }
+ if options.UserID != "" {
+ return nil, ErrUserIDWithToken
+ }
+ if options.DomainID != "" {
+ return nil, ErrDomainIDWithToken
+ }
+ if options.DomainName != "" {
+ return nil, ErrDomainNameWithToken
+ }
+
+ // Configure the request for Token authentication.
+ req.Auth.Identity.Methods = []string{"token"}
+ req.Auth.Identity.Token = &tokenReq{
+ ID: c.Provider.TokenID,
+ }
+ } else {
+ // If no password or token ID are available, authentication can't continue.
+ return nil, ErrMissingPassword
+ }
+ } else {
+ // Password authentication.
+ req.Auth.Identity.Methods = []string{"password"}
+
+ // At least one of Username and UserID must be specified.
+ if options.Username == "" && options.UserID == "" {
+ return nil, ErrUsernameOrUserID
+ }
+
+ if options.Username != "" {
+ // If Username is provided, UserID may not be provided.
+ if options.UserID != "" {
+ return nil, ErrUsernameOrUserID
+ }
+
+ // Either DomainID or DomainName must also be specified.
+ if options.DomainID == "" && options.DomainName == "" {
+ return nil, ErrDomainIDOrDomainName
+ }
+
+ if options.DomainID != "" {
+ if options.DomainName != "" {
+ return nil, ErrDomainIDOrDomainName
+ }
+
+ // Configure the request for Username and Password authentication with a DomainID.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{
+ Name: &options.Username,
+ Password: options.Password,
+ Domain: &domainReq{ID: &options.DomainID},
+ },
+ }
+ }
+
+ if options.DomainName != "" {
+ // Configure the request for Username and Password authentication with a DomainName.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{
+ Name: &options.Username,
+ Password: options.Password,
+ Domain: &domainReq{Name: &options.DomainName},
+ },
+ }
+ }
+ }
+
+ if options.UserID != "" {
+ // If UserID is specified, neither DomainID nor DomainName may be.
+ if options.DomainID != "" {
+ return nil, ErrDomainIDWithUserID
+ }
+ if options.DomainName != "" {
+ return nil, ErrDomainNameWithUserID
+ }
+
+ // Configure the request for UserID and Password authentication.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{ID: &options.UserID, Password: options.Password},
+ }
+ }
+ }
+
+ // Add a "scope" element if a Scope has been provided.
+ if scope != nil {
+ if scope.ProjectName != "" {
+ // ProjectName provided: either DomainID or DomainName must also be supplied.
+ // ProjectID may not be supplied.
+ if scope.DomainID == "" && scope.DomainName == "" {
+ return nil, ErrScopeDomainIDOrDomainName
+ }
+ if scope.ProjectID != "" {
+ return nil, ErrScopeProjectIDOrProjectName
+ }
+
+ if scope.DomainID != "" {
+ // ProjectName + DomainID
+ req.Auth.Scope = &scopeReq{
+ Project: &projectReq{
+ Name: &scope.ProjectName,
+ Domain: &domainReq{ID: &scope.DomainID},
+ },
+ }
+ }
+
+ if scope.DomainName != "" {
+ // ProjectName + DomainName
+ req.Auth.Scope = &scopeReq{
+ Project: &projectReq{
+ Name: &scope.ProjectName,
+ Domain: &domainReq{Name: &scope.DomainName},
+ },
+ }
+ }
+ } else if scope.ProjectID != "" {
+ // ProjectID provided. ProjectName, DomainID, and DomainName may not be provided.
+ if scope.DomainID != "" {
+ return nil, ErrScopeProjectIDAlone
+ }
+ if scope.DomainName != "" {
+ return nil, ErrScopeProjectIDAlone
+ }
+
+ // ProjectID
+ req.Auth.Scope = &scopeReq{
+ Project: &projectReq{ID: &scope.ProjectID},
+ }
+ } else if scope.DomainID != "" {
+ // DomainID provided. ProjectID, ProjectName, and DomainName may not be provided.
+ if scope.DomainName != "" {
+ return nil, ErrScopeDomainIDOrDomainName
+ }
+
+ // DomainID
+ req.Auth.Scope = &scopeReq{
+ Domain: &domainReq{ID: &scope.DomainID},
+ }
+ } else if scope.DomainName != "" {
+ return nil, ErrScopeDomainName
+ } else {
+ return nil, ErrScopeEmpty
+ }
+ }
+
+ var result TokenCreateResult
+ response, err := perigee.Request("POST", getTokenURL(c), perigee.Options{
+ ReqBody: &req,
+ Results: &result.response,
+ OkCodes: []int{201},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract the token ID from the response, if present.
+ result.tokenID = response.HttpResponse.Header.Get("X-Subject-Token")
+
+ return &result, nil
+}
+
+// Get validates and retrieves information about another token.
+func Get(c *gophercloud.ServiceClient, token string) (*TokenCreateResult, error) {
+ var result TokenCreateResult
+
+ response, err := perigee.Request("GET", getTokenURL(c), perigee.Options{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ Results: &result.response,
+ OkCodes: []int{200, 203},
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract the token ID from the response, if present.
+ result.tokenID = response.HttpResponse.Header.Get("X-Subject-Token")
+
+ return &result, nil
+}
+
+// Validate determines if a specified token is valid or not.
+func Validate(c *gophercloud.ServiceClient, token string) (bool, error) {
+ response, err := perigee.Request("HEAD", getTokenURL(c), perigee.Options{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ OkCodes: []int{204, 404},
+ })
+ if err != nil {
+ return false, err
+ }
+
+ return response.StatusCode == 204, nil
+}
+
+// Revoke immediately makes specified token invalid.
+func Revoke(c *gophercloud.ServiceClient, token string) error {
+ _, err := perigee.Request("DELETE", getTokenURL(c), perigee.Options{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ OkCodes: []int{204},
+ })
+ return err
+}
diff --git a/openstack/identity/v3/tokens/requests_test.go b/openstack/identity/v3/tokens/requests_test.go
new file mode 100644
index 0000000..989c1c4
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests_test.go
@@ -0,0 +1,514 @@
+package tokens
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+// authTokenPost verifies that providing certain AuthOptions and Scope results in an expected JSON structure.
+func authTokenPost(t *testing.T, options gophercloud.AuthOptions, scope *Scope, requestJSON string) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{
+ TokenID: "12345abcdef",
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "POST")
+ testhelper.TestHeader(t, r, "Content-Type", "application/json")
+ testhelper.TestHeader(t, r, "Accept", "application/json")
+ testhelper.TestJSONRequest(t, r, requestJSON)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{}`)
+ })
+
+ _, err := Create(&client, options, scope)
+ if err != nil {
+ t.Errorf("Create returned an error: %v", err)
+ }
+}
+
+func authTokenPostErr(t *testing.T, options gophercloud.AuthOptions, scope *Scope, includeToken bool, expectedErr error) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{},
+ Endpoint: testhelper.Endpoint(),
+ }
+ if includeToken {
+ client.Provider.TokenID = "abcdef123456"
+ }
+
+ _, err := Create(&client, options, scope)
+ if err == nil {
+ t.Errorf("Create did NOT return an error")
+ }
+ if err != expectedErr {
+ t.Errorf("Create returned an unexpected error: wanted %v, got %v", expectedErr, err)
+ }
+}
+
+func TestCreateUserIDAndPassword(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{UserID: "me", Password: "squirrel!"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": { "id": "me", "password": "squirrel!" }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateUsernameDomainIDPassword(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{Username: "fakey", Password: "notpassword", DomainID: "abc123"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "domain": {
+ "id": "abc123"
+ },
+ "name": "fakey",
+ "password": "notpassword"
+ }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateUsernameDomainNamePassword(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{Username: "frank", Password: "swordfish", DomainName: "spork.net"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "domain": {
+ "name": "spork.net"
+ },
+ "name": "frank",
+ "password": "swordfish"
+ }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateTokenID(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["token"],
+ "token": {
+ "id": "12345abcdef"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectIDScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{ProjectID: "123456"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "project": {
+ "id": "123456"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateDomainIDScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{DomainID: "1000"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "domain": {
+ "id": "1000"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectNameAndDomainIDScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{ProjectName: "world-domination", DomainID: "1000"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "project": {
+ "domain": {
+ "id": "1000"
+ },
+ "name": "world-domination"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectNameAndDomainNameScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{ProjectName: "world-domination", DomainName: "evil-plans"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "project": {
+ "domain": {
+ "name": "evil-plans"
+ },
+ "name": "world-domination"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateExtractsTokenFromResponse(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{},
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("X-Subject-Token", "aaa111")
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{}`)
+ })
+
+ options := gophercloud.AuthOptions{UserID: "me", Password: "shhh"}
+ result, err := Create(&client, options, nil)
+ if err != nil {
+ t.Errorf("Create returned an error: %v", err)
+ }
+
+ token, _ := result.TokenID()
+ if token != "aaa111" {
+ t.Errorf("Expected token to be aaa111, but was %s", token)
+ }
+}
+
+func TestCreateFailureEmptyAuth(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{}, nil, false, ErrMissingPassword)
+}
+
+func TestCreateFailureAPIKey(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{APIKey: "something"}, nil, false, ErrAPIKeyProvided)
+}
+
+func TestCreateFailureTenantID(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{TenantID: "something"}, nil, false, ErrTenantIDProvided)
+}
+
+func TestCreateFailureTenantName(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{TenantName: "something"}, nil, false, ErrTenantNameProvided)
+}
+
+func TestCreateFailureTokenIDUsername(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{Username: "something"}, nil, true, ErrUsernameWithToken)
+}
+
+func TestCreateFailureTokenIDUserID(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{UserID: "something"}, nil, true, ErrUserIDWithToken)
+}
+
+func TestCreateFailureTokenIDDomainID(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{DomainID: "something"}, nil, true, ErrDomainIDWithToken)
+}
+
+func TestCreateFailureTokenIDDomainName(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{DomainName: "something"}, nil, true, ErrDomainNameWithToken)
+}
+
+func TestCreateFailureMissingUser(t *testing.T) {
+ options := gophercloud.AuthOptions{Password: "supersecure"}
+ authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID)
+}
+
+func TestCreateFailureBothUser(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "supersecure",
+ Username: "oops",
+ UserID: "redundancy",
+ }
+ authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID)
+}
+
+func TestCreateFailureMissingDomain(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "supersecure",
+ Username: "notuniqueenough",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName)
+}
+
+func TestCreateFailureBothDomain(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "supersecure",
+ Username: "someone",
+ DomainID: "hurf",
+ DomainName: "durf",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName)
+}
+
+func TestCreateFailureUserIDDomainID(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ UserID: "100",
+ Password: "stuff",
+ DomainID: "oops",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainIDWithUserID)
+}
+
+func TestCreateFailureUserIDDomainName(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ UserID: "100",
+ Password: "sssh",
+ DomainName: "oops",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainNameWithUserID)
+}
+
+func TestCreateFailureScopeProjectNameAlone(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectName: "notenough"}
+ authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName)
+}
+
+func TestCreateFailureScopeProjectNameAndID(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectName: "whoops", ProjectID: "toomuch", DomainID: "1234"}
+ authTokenPostErr(t, options, scope, false, ErrScopeProjectIDOrProjectName)
+}
+
+func TestCreateFailureScopeProjectIDAndDomainID(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectID: "toomuch", DomainID: "notneeded"}
+ authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone)
+}
+
+func TestCreateFailureScopeProjectIDAndDomainNAme(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectID: "toomuch", DomainName: "notneeded"}
+ authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone)
+}
+
+func TestCreateFailureScopeDomainIDAndDomainName(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{DomainID: "toomuch", DomainName: "notneeded"}
+ authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName)
+}
+
+func TestCreateFailureScopeDomainNameAlone(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{DomainName: "notenough"}
+ authTokenPostErr(t, options, scope, false, ErrScopeDomainName)
+}
+
+func TestCreateFailureEmptyScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{}
+ authTokenPostErr(t, options, scope, false, ErrScopeEmpty)
+}
+
+func TestGetRequest(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{
+ TokenID: "12345abcdef",
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "Content-Type", "application/json")
+ testhelper.TestHeader(t, r, "Accept", "application/json")
+ testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+ testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ { "token": { "expires_at": "2014-08-29T13:10:01.000000Z" } }
+ `)
+ })
+
+ result, err := Get(&client, "abcdef12345")
+ if err != nil {
+ t.Errorf("Info returned an error: %v", err)
+ }
+
+ expires, err := result.ExpiresAt()
+ if err != nil {
+ t.Errorf("Error extracting token expiration time: %v", err)
+ }
+
+ expected, _ := time.Parse(time.UnixDate, "Fri Aug 29 13:10:01 UTC 2014")
+ if expires != expected {
+ t.Errorf("Expected expiration time %s, but was %s", expected.Format(time.UnixDate), expires.Format(time.UnixDate))
+ }
+}
+
+func prepareAuthTokenHandler(t *testing.T, expectedMethod string, status int) gophercloud.ServiceClient {
+ client := gophercloud.ServiceClient{
+ Provider: &gophercloud.ProviderClient{
+ TokenID: "12345abcdef",
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, expectedMethod)
+ testhelper.TestHeader(t, r, "Content-Type", "application/json")
+ testhelper.TestHeader(t, r, "Accept", "application/json")
+ testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+ testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+ w.WriteHeader(status)
+ })
+
+ return client
+}
+
+func TestValidateRequestSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "HEAD", http.StatusNoContent)
+
+ ok, err := Validate(&client, "abcdef12345")
+ if err != nil {
+ t.Errorf("Unexpected error from Validate: %v", err)
+ }
+
+ if !ok {
+ t.Errorf("Validate returned false for a valid token")
+ }
+}
+
+func TestValidateRequestFailure(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "HEAD", http.StatusNotFound)
+
+ ok, err := Validate(&client, "abcdef12345")
+ if err != nil {
+ t.Errorf("Unexpected error from Validate: %v", err)
+ }
+
+ if ok {
+ t.Errorf("Validate returned true for an invalid token")
+ }
+}
+
+func TestValidateRequestError(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "HEAD", http.StatusUnauthorized)
+
+ _, err := Validate(&client, "abcdef12345")
+ if err == nil {
+ t.Errorf("Missing expected error from Validate")
+ }
+}
+
+func TestRevokeRequestSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "DELETE", http.StatusNoContent)
+
+ err := Revoke(&client, "abcdef12345")
+ if err != nil {
+ t.Errorf("Unexpected error from Revoke: %v", err)
+ }
+}
+
+func TestRevokeRequestError(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "DELETE", http.StatusNotFound)
+
+ err := Revoke(&client, "abcdef12345")
+ if err == nil {
+ t.Errorf("Missing expected error from Revoke")
+ }
+}
diff --git a/openstack/identity/v3/tokens/results.go b/openstack/identity/v3/tokens/results.go
new file mode 100644
index 0000000..8e0f018
--- /dev/null
+++ b/openstack/identity/v3/tokens/results.go
@@ -0,0 +1,46 @@
+package tokens
+
+import (
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// RFC3339Milli describes the time format used by identity API responses.
+const RFC3339Milli = "2006-01-02T15:04:05.999999Z"
+
+// TokenCreateResult contains the document structure returned from a Create call.
+type TokenCreateResult struct {
+ response map[string]interface{}
+ tokenID string
+}
+
+// TokenID retrieves a token generated by a Create call from an token creation response.
+func (r *TokenCreateResult) TokenID() (string, error) {
+ return r.tokenID, nil
+}
+
+// ExpiresAt retrieves the token expiration time.
+func (r *TokenCreateResult) ExpiresAt() (time.Time, error) {
+ type tokenResp struct {
+ ExpiresAt string `mapstructure:"expires_at"`
+ }
+
+ type response struct {
+ Token tokenResp `mapstructure:"token"`
+ }
+
+ var resp response
+ err := mapstructure.Decode(r.response, &resp)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ // Attempt to parse the timestamp.
+ ts, err := time.Parse(RFC3339Milli, resp.Token.ExpiresAt)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ return ts, nil
+}
diff --git a/openstack/identity/v3/tokens/results_test.go b/openstack/identity/v3/tokens/results_test.go
new file mode 100644
index 0000000..669db61
--- /dev/null
+++ b/openstack/identity/v3/tokens/results_test.go
@@ -0,0 +1,37 @@
+package tokens
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTokenID(t *testing.T) {
+ result := TokenCreateResult{tokenID: "1234"}
+
+ token, _ := result.TokenID()
+ if token != "1234" {
+ t.Errorf("Expected tokenID of 1234, got %s", token)
+ }
+}
+
+func TestExpiresAt(t *testing.T) {
+ resp := map[string]interface{}{
+ "token": map[string]string{
+ "expires_at": "2013-02-02T18:30:59.000000Z",
+ },
+ }
+
+ result := TokenCreateResult{
+ tokenID: "1234",
+ response: resp,
+ }
+
+ expected, _ := time.Parse(time.UnixDate, "Sat Feb 2 18:30:59 UTC 2013")
+ actual, err := result.ExpiresAt()
+ if err != nil {
+ t.Errorf("Error extraction expiration time: %v", err)
+ }
+ if actual != expected {
+ t.Errorf("Expected expiration time %s, but was %s", expected.Format(time.UnixDate), actual.Format(time.UnixDate))
+ }
+}
diff --git a/openstack/identity/v3/tokens/urls.go b/openstack/identity/v3/tokens/urls.go
new file mode 100644
index 0000000..5b47c02
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls.go
@@ -0,0 +1,7 @@
+package tokens
+
+import "github.com/rackspace/gophercloud"
+
+func getTokenURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("auth", "tokens")
+}
diff --git a/openstack/identity/v3/tokens/urls_test.go b/openstack/identity/v3/tokens/urls_test.go
new file mode 100644
index 0000000..5ff8bc6
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls_test.go
@@ -0,0 +1,21 @@
+package tokens
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTokenURL(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{Endpoint: testhelper.Endpoint()}
+
+ expected := testhelper.Endpoint() + "auth/tokens"
+ actual := getTokenURL(&client)
+ if actual != expected {
+ t.Errorf("Expected URL %s, but was %s", expected, actual)
+ }
+}
diff --git a/openstack/storage/v1/client.go b/openstack/storage/v1/client.go
index a457a74..51312eb 100644
--- a/openstack/storage/v1/client.go
+++ b/openstack/storage/v1/client.go
@@ -2,6 +2,8 @@
import (
"fmt"
+
+ "github.com/rackspace/gophercloud"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
)
@@ -9,12 +11,12 @@
type Client struct {
endpoint string
authority identity.AuthResults
- options identity.AuthOptions
+ options gophercloud.AuthOptions
token *identity.Token
}
// NewClient creates and returns a *Client.
-func NewClient(e string, a identity.AuthResults, o identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, o gophercloud.AuthOptions) *Client {
return &Client{
endpoint: e,
authority: a,
@@ -64,5 +66,5 @@
}
}
- return c.token.Id, err
+ return c.token.ID, err
}
diff --git a/openstack/utils/choose_version.go b/openstack/utils/choose_version.go
new file mode 100644
index 0000000..753f8f8
--- /dev/null
+++ b/openstack/utils/choose_version.go
@@ -0,0 +1,114 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/racker/perigee"
+)
+
+// Version is a supported API version, corresponding to a vN package within the appropriate service.
+type Version struct {
+ ID string
+ Suffix string
+ Priority int
+}
+
+var goodStatus = map[string]bool{
+ "current": true,
+ "supported": true,
+ "stable": true,
+}
+
+// ChooseVersion queries the base endpoint of a API to choose the most recent non-experimental alternative from a service's
+// published versions.
+// It returns the highest-Priority Version among the alternatives that are provided, as well as its corresponding endpoint.
+func ChooseVersion(identityBase string, identityEndpoint string, recognized []*Version) (*Version, string, error) {
+ type linkResp struct {
+ Href string `json:"href"`
+ Rel string `json:"rel"`
+ }
+
+ type valueResp struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Links []linkResp `json:"links"`
+ }
+
+ type versionsResp struct {
+ Values []valueResp `json:"values"`
+ }
+
+ type response struct {
+ Versions versionsResp `json:"versions"`
+ }
+
+ normalize := func(endpoint string) string {
+ if !strings.HasSuffix(endpoint, "/") {
+ return endpoint + "/"
+ }
+ return endpoint
+ }
+ identityEndpoint = normalize(identityEndpoint)
+
+ // If a full endpoint is specified, check version suffixes for a match first.
+ for _, v := range recognized {
+ if strings.HasSuffix(identityEndpoint, v.Suffix) {
+ return v, identityEndpoint, nil
+ }
+ }
+
+ var resp response
+ _, err := perigee.Request("GET", identityBase, perigee.Options{
+ Results: &resp,
+ OkCodes: []int{200, 300},
+ })
+
+ if err != nil {
+ return nil, "", err
+ }
+
+ byID := make(map[string]*Version)
+ for _, version := range recognized {
+ byID[version.ID] = version
+ }
+
+ var highest *Version
+ var endpoint string
+
+ for _, value := range resp.Versions.Values {
+ href := ""
+ for _, link := range value.Links {
+ if link.Rel == "self" {
+ href = normalize(link.Href)
+ }
+ }
+
+ if matching, ok := byID[value.ID]; ok {
+ // Prefer a version that exactly matches the provided endpoint.
+ if href == identityEndpoint {
+ if href == "" {
+ return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", value.ID, identityBase)
+ }
+ return matching, href, nil
+ }
+
+ // Otherwise, find the highest-priority version with a whitelisted status.
+ if goodStatus[strings.ToLower(value.Status)] {
+ if highest == nil || matching.Priority > highest.Priority {
+ highest = matching
+ endpoint = href
+ }
+ }
+ }
+ }
+
+ if highest == nil {
+ return nil, "", fmt.Errorf("No supported version available from endpoint %s", identityBase)
+ }
+ if endpoint == "" {
+ return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", highest.ID, identityBase)
+ }
+
+ return highest, endpoint, nil
+}
diff --git a/openstack/utils/choose_version_test.go b/openstack/utils/choose_version_test.go
new file mode 100644
index 0000000..9552696
--- /dev/null
+++ b/openstack/utils/choose_version_test.go
@@ -0,0 +1,105 @@
+package utils
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func setupVersionHandler() {
+ testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "versions": {
+ "values": [
+ {
+ "status": "stable",
+ "id": "v3.0",
+ "links": [
+ { "href": "%s/v3.0", "rel": "self" }
+ ]
+ },
+ {
+ "status": "stable",
+ "id": "v2.0",
+ "links": [
+ { "href": "%s/v2.0", "rel": "self" }
+ ]
+ }
+ ]
+ }
+ }
+ `, testhelper.Server.URL, testhelper.Server.URL)
+ })
+}
+
+func TestChooseVersion(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ setupVersionHandler()
+
+ v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "blarg"}
+ v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "hargl"}
+
+ v, endpoint, err := ChooseVersion(testhelper.Endpoint(), "", []*Version{v2, v3})
+
+ if err != nil {
+ t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+ }
+
+ if v != v3 {
+ t.Errorf("Expected %#v to win, but %#v did instead", v3, v)
+ }
+
+ expected := testhelper.Endpoint() + "v3.0/"
+ if endpoint != expected {
+ t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+ }
+}
+
+func TestChooseVersionOpinionatedLink(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ setupVersionHandler()
+
+ v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "nope"}
+ v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "northis"}
+
+ v, endpoint, err := ChooseVersion(testhelper.Endpoint(), testhelper.Endpoint()+"v2.0/", []*Version{v2, v3})
+ if err != nil {
+ t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+ }
+
+ if v != v2 {
+ t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+ }
+
+ expected := testhelper.Endpoint() + "v2.0/"
+ if endpoint != expected {
+ t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+ }
+}
+
+func TestChooseVersionFromSuffix(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "/v2.0/"}
+ v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "/v3.0/"}
+
+ v, endpoint, err := ChooseVersion(testhelper.Endpoint(), testhelper.Endpoint()+"v2.0/", []*Version{v2, v3})
+ if err != nil {
+ t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+ }
+
+ if v != v2 {
+ t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+ }
+
+ expected := testhelper.Endpoint() + "v2.0/"
+ if endpoint != expected {
+ t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+ }
+}
diff --git a/openstack/utils/client.go b/openstack/utils/client.go
index d1f0359..f8a6c57 100644
--- a/openstack/utils/client.go
+++ b/openstack/utils/client.go
@@ -2,6 +2,8 @@
import (
"fmt"
+
+ "github.com/rackspace/gophercloud"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
)
@@ -12,7 +14,7 @@
// Authority holds the results of authenticating against the Endpoint.
Authority identity.AuthResults
// Options holds the authentication options. Useful for auto-reauthentication.
- Options identity.AuthOptions
+ Options gophercloud.AuthOptions
}
// EndpointOpts contains options for finding an endpoint for an Openstack Client.
@@ -40,12 +42,13 @@
// Name: "nova",
// })
// serversClient := servers.NewClient(c.Endpoint, c.Authority, c.Options)
-func NewClient(ao identity.AuthOptions, eo EndpointOpts) (Client, error) {
+func NewClient(ao gophercloud.AuthOptions, eo EndpointOpts) (Client, error) {
client := Client{
Options: ao,
}
- ar, err := identity.Authenticate(ao)
+ c := &gophercloud.ServiceClient{Endpoint: ao.IdentityEndpoint + "/"}
+ ar, err := identity.Authenticate(c, ao)
if err != nil {
return client, err
}
diff --git a/openstack/utils/utils.go b/openstack/utils/utils.go
index 99b0977..1d09d9e 100644
--- a/openstack/utils/utils.go
+++ b/openstack/utils/utils.go
@@ -1,18 +1,19 @@
-// This package contains utilities which eases working with Gophercloud's OpenStack APIs.
+// Package utils contains utilities which eases working with Gophercloud's OpenStack APIs.
package utils
import (
"fmt"
- identity "github.com/rackspace/gophercloud/openstack/identity/v2"
"os"
+
+ "github.com/rackspace/gophercloud"
)
-var nilOptions = identity.AuthOptions{}
+var nilOptions = gophercloud.AuthOptions{}
// ErrNoAuthUrl, ErrNoUsername, and ErrNoPassword errors indicate of the required OS_AUTH_URL, OS_USERNAME, or OS_PASSWORD
// environment variables, respectively, remain undefined. See the AuthOptions() function for more details.
var (
- ErrNoAuthUrl = fmt.Errorf("Environment variable OS_AUTH_URL needs to be set.")
+ ErrNoAuthURL = fmt.Errorf("Environment variable OS_AUTH_URL needs to be set.")
ErrNoUsername = fmt.Errorf("Environment variable OS_USERNAME needs to be set.")
ErrNoPassword = fmt.Errorf("Environment variable OS_PASSWORD needs to be set.")
)
@@ -21,18 +22,21 @@
// OS_* environment variables. The following variables provide sources of truth: OS_AUTH_URL, OS_USERNAME,
// OS_PASSWORD, OS_TENANT_ID, and OS_TENANT_NAME. Of these, OS_USERNAME, OS_PASSWORD, and OS_AUTH_URL must
// have settings, or an error will result. OS_TENANT_ID and OS_TENANT_NAME are optional.
-func AuthOptions() (identity.AuthOptions, error) {
- authUrl := os.Getenv("OS_AUTH_URL")
+func AuthOptions() (gophercloud.AuthOptions, error) {
+ authURL := os.Getenv("OS_AUTH_URL")
username := os.Getenv("OS_USERNAME")
+ userID := os.Getenv("OS_USERID")
password := os.Getenv("OS_PASSWORD")
- tenantId := os.Getenv("OS_TENANT_ID")
+ tenantID := os.Getenv("OS_TENANT_ID")
tenantName := os.Getenv("OS_TENANT_NAME")
+ domainID := os.Getenv("OS_DOMAIN_ID")
+ domainName := os.Getenv("OS_DOMAIN_NAME")
- if authUrl == "" {
- return nilOptions, ErrNoAuthUrl
+ if authURL == "" {
+ return nilOptions, ErrNoAuthURL
}
- if username == "" {
+ if username == "" && userID == "" {
return nilOptions, ErrNoUsername
}
@@ -40,18 +44,26 @@
return nilOptions, ErrNoPassword
}
- ao := identity.AuthOptions{
- Endpoint: authUrl,
- Username: username,
- Password: password,
- TenantId: tenantId,
- TenantName: tenantName,
+ ao := gophercloud.AuthOptions{
+ IdentityEndpoint: authURL,
+ UserID: userID,
+ Username: username,
+ Password: password,
+ TenantID: tenantID,
+ TenantName: tenantName,
+ DomainID: domainID,
+ DomainName: domainName,
}
return ao, nil
}
+// BuildQuery constructs the query section of a URI from a map.
func BuildQuery(params map[string]string) string {
+ if len(params) == 0 {
+ return ""
+ }
+
query := "?"
for k, v := range params {
query += k + "=" + v + "&"
diff --git a/provider_client.go b/provider_client.go
new file mode 100644
index 0000000..2be665e
--- /dev/null
+++ b/provider_client.go
@@ -0,0 +1,29 @@
+package gophercloud
+
+// ProviderClient stores details that are required to interact with any services within a specific provider's API.
+//
+// Generally, you acquire a ProviderClient by calling the `NewClient()` method in the appropriate provider's child package,
+// providing whatever authentication credentials are required.
+type ProviderClient struct {
+
+ // IdentityBase is the front door to an openstack provider.
+ // Generally this will be populated when you authenticate.
+ // It should be the *root* resource of the identity service, not of a specific identity version.
+ IdentityBase string
+
+ // IdentityEndpoint is the originally requested identity endpoint.
+ // This may be a specific version of the identity service, in which case that endpoint is used rather than querying the
+ // version-negotiation endpoint.
+ IdentityEndpoint string
+
+ // TokenID is the most recently valid token issued.
+ TokenID string
+
+ // EndpointLocator describes how this provider discovers the endpoints for its constituent services.
+ EndpointLocator EndpointLocator
+}
+
+// AuthenticatedHeaders returns a map of HTTP headers that are common for all authenticated service requests.
+func (client *ProviderClient) AuthenticatedHeaders() map[string]string {
+ return map[string]string{"X-Auth-Token": client.TokenID}
+}
diff --git a/rackspace/monitoring/common.go b/rackspace/monitoring/common.go
index 42c31ca..900c86e 100644
--- a/rackspace/monitoring/common.go
+++ b/rackspace/monitoring/common.go
@@ -1,11 +1,12 @@
package monitoring
import (
+ "github.com/rackspace/gophercloud"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
)
type Options struct {
Endpoint string
- AuthOptions identity.AuthOptions
+ AuthOptions gophercloud.AuthOptions
Authentication identity.AuthResults
}
diff --git a/rackspace/monitoring/notificationPlans/requests.go b/rackspace/monitoring/notificationPlans/requests.go
index 37fb40b..d4878c4 100644
--- a/rackspace/monitoring/notificationPlans/requests.go
+++ b/rackspace/monitoring/notificationPlans/requests.go
@@ -2,6 +2,7 @@
import (
"fmt"
+
"github.com/racker/perigee"
identity "github.com/rackspace/gophercloud/openstack/identity/v2"
"github.com/rackspace/gophercloud/rackspace/monitoring"
@@ -33,7 +34,7 @@
Results: &dr,
OkCodes: []int{204},
MoreHeaders: map[string]string{
- "X-Auth-Token": tok.Id,
+ "X-Auth-Token": tok.ID,
},
})
return dr, err
diff --git a/service_client.go b/service_client.go
new file mode 100644
index 0000000..83ad69b
--- /dev/null
+++ b/service_client.go
@@ -0,0 +1,19 @@
+package gophercloud
+
+import "strings"
+
+// ServiceClient stores details required to interact with a specific service API implemented by a provider.
+// Generally, you'll acquire these by calling the appropriate `New` method on a ProviderClient.
+type ServiceClient struct {
+ // Provider is a reference to the provider that implements this service.
+ Provider *ProviderClient
+
+ // Endpoint is the base URL of the service's API, acquired from a service catalog.
+ // It MUST end with a /.
+ Endpoint string
+}
+
+// ServiceURL constructs a URL for a resource belonging to this provider.
+func (client *ServiceClient) ServiceURL(parts ...string) string {
+ return client.Endpoint + strings.Join(parts, "/")
+}
diff --git a/testhelper/doc.go b/testhelper/doc.go
new file mode 100644
index 0000000..25b4dfe
--- /dev/null
+++ b/testhelper/doc.go
@@ -0,0 +1,4 @@
+/*
+Package testhelper container methods that are useful for writing unit tests.
+*/
+package testhelper
diff --git a/testhelper/http_responses.go b/testhelper/http_responses.go
new file mode 100644
index 0000000..481a833
--- /dev/null
+++ b/testhelper/http_responses.go
@@ -0,0 +1,113 @@
+package testhelper
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "testing"
+)
+
+var (
+ // Mux is a multiplexer that can be used to register handlers.
+ Mux *http.ServeMux
+
+ // Server is an in-memory HTTP server for testing.
+ Server *httptest.Server
+)
+
+// SetupHTTP prepares the Mux and Server.
+func SetupHTTP() {
+ Mux = http.NewServeMux()
+ Server = httptest.NewServer(Mux)
+}
+
+// TeardownHTTP releases HTTP-related resources.
+func TeardownHTTP() {
+ Server.Close()
+}
+
+// Endpoint returns a fake endpoint that will actually target the Mux.
+func Endpoint() string {
+ return Server.URL + "/"
+}
+
+// TestFormValues ensures that all the URL parameters given to the http.Request are the same as values.
+func TestFormValues(t *testing.T, r *http.Request, values map[string]string) {
+ want := url.Values{}
+ for k, v := range values {
+ want.Add(k, v)
+ }
+
+ r.ParseForm()
+ if !reflect.DeepEqual(want, r.Form) {
+ t.Errorf("Request parameters = %v, want %v", r.Form, want)
+ }
+}
+
+// TestMethod checks that the Request has the expected method (e.g. GET, POST).
+func TestMethod(t *testing.T, r *http.Request, expected string) {
+ if expected != r.Method {
+ t.Errorf("Request method = %v, expected %v", r.Method, expected)
+ }
+}
+
+// TestHeader checks that the header on the http.Request matches the expected value.
+func TestHeader(t *testing.T, r *http.Request, header string, expected string) {
+ if actual := r.Header.Get(header); expected != actual {
+ t.Errorf("Header %s = %s, expected %s", header, actual, expected)
+ }
+}
+
+// TestBody verifies that the request body matches an expected body.
+func TestBody(t *testing.T, r *http.Request, expected string) {
+ b, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("Unable to read body: %v", err)
+ }
+ str := string(b)
+ if expected != str {
+ t.Errorf("Body = %s, expected %s", str, expected)
+ }
+}
+
+// TestJSONRequest verifies that the JSON payload of a request matches an expected structure, without asserting things about
+// whitespace or ordering.
+func TestJSONRequest(t *testing.T, r *http.Request, expected string) {
+ b, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("Unable to read request body: %v", err)
+ }
+
+ var expectedJSON interface{}
+ err = json.Unmarshal([]byte(expected), &expectedJSON)
+ if err != nil {
+ t.Errorf("Unable to parse expected value as JSON: %v", err)
+ }
+
+ var actualJSON interface{}
+ err = json.Unmarshal(b, &actualJSON)
+ if err != nil {
+ t.Errorf("Unable to parse request body as JSON: %v", err)
+ }
+
+ if !reflect.DeepEqual(expectedJSON, actualJSON) {
+ prettyExpected, err := json.MarshalIndent(expectedJSON, "", " ")
+ if err != nil {
+ t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expected)
+ } else {
+ t.Logf("Expected JSON:\n%s", prettyExpected)
+ }
+
+ prettyActual, err := json.MarshalIndent(actualJSON, "", " ")
+ if err != nil {
+ t.Logf("Unable to pretty-print actual JSON: %v\n%s", err, b)
+ } else {
+ t.Logf("Actual JSON:\n%s", prettyActual)
+ }
+
+ t.Errorf("Response body did not contain the correct JSON.")
+ }
+}