Implement Access.FirstEndpointUrlByCriteria
The FirstEndpointUrlByCriteria method is a key enabler for writing API
provider interface constructors. A simple, albeit incomplete, use-case
for Cloud Servers API demonstrates how it's used internally.
See squashed commit history below for more detailed rationale behind the
API design.
Squashed commit of the following:
commit 625c31f754dcdcd2d348cf4cf5499a03ba6b2de1
Author: Samuel A. Falvo II <sam.falvo@rackspace.com>
Date: Tue Jul 2 18:21:36 2013 -0700
Fix service name typo
commit c6abcbe20bfe31a8c9399e78c186dca64d050140
Author: Samuel A. Falvo II <sam.falvo@rackspace.com>
Date: Tue Jul 2 18:15:41 2013 -0700
Added decision logic to FFEBC function.
commit bccf7178464c5071a81d63ef16fd20d7a241146f
Author: Samuel A. Falvo II <sam.falvo@rackspace.com>
Date: Tue Jul 2 17:18:14 2013 -0700
Added ListServers and its dependencies.
In order to list servers, we need access to a cloud server API. This is
the job of the ComputeApi() function.
ComputeApi(), in turn, tries hard not to contrain the user in choosing
an endpoint, while still offering an interface optimized for the common
case of using an existing service provider's endpoints. Otherwise, the
user will end up having to use nested functions and bizarre predicate
sequences like this:
func(ce *CatalogEntry, ee *EntryEndpoint) bool {
if ce != nil {
return ce.Name == "cloudComputeOpenStack"
}
if ee != nil {
return ee.Region == "DFW" && ee.VersionId == "2"
}
return false
}
The current interface just encapsulates this kind of logic into a simple
structure, taking 66% fewer lines, and zero chance for error:
ApiCriteria{
Name: "cloudComputeOpenStack",
Region: "DFW",
VersionId: "2",
}
FindFirstEndpointByConstraint() is invoked (via
AccessProvider.FirstEndpointUrlByConstraint()) to actually look for a
matching endpoint in the provider's service catalog. This interprets
the ApiCriteria structure settings, except for UrlChoice. If it finds a
candidate endpoint, the user may select public or private endpoints via
the ApiCriteria.UrlChoice setting (which the
FirstEndpointUrlByCriteria() function interprets). If nothing is found,
an ErrEndpoint error will be returned to the caller. Of course, this
being a brand new implementation, it just returns the default of
"nothing found" for all queries anyway.
If not specified, a criteria's UrlChoice defaults to PublicURL.
commit 9549f0b30e0736962dad55f3f38f88124e076fb9
Author: Samuel A. Falvo II <sam.falvo@rackspace.com>
Date: Tue Jul 2 17:10:14 2013 -0700
Removed VIM temp swap file
commit 8e00ad5ac3466cbec3c539e8b21bea6d23ab37f7
Author: Samuel A. Falvo II <sam.falvo@rackspace.com>
Date: Tue Jul 2 16:20:22 2013 -0700
Add ApiCriteria to API
commit 6f3b41929a496c6a0734221bf12ef27035b71e39
Author: Samuel A. Falvo II <sam.falvo@rackspace.com>
Date: Tue Jul 2 16:18:49 2013 -0700
Add acceptance test for list servers
diff --git a/acceptance/02-list-servers.go b/acceptance/02-list-servers.go
new file mode 100644
index 0000000..b33a15c
--- /dev/null
+++ b/acceptance/02-list-servers.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "fmt"
+ "github.com/rackspace/gophercloud"
+ "os"
+)
+
+func main() {
+ provider := os.Getenv("SDK_PROVIDER")
+ username := os.Getenv("SDK_USERNAME")
+ password := os.Getenv("SDK_PASSWORD")
+
+ if (provider == "") || (username == "") || (password == "") {
+ fmt.Fprintf(os.Stderr, "One or more of the following environment variables aren't set:\n")
+ fmt.Fprintf(os.Stderr, " SDK_PROVIDER=\"%s\"\n", provider)
+ fmt.Fprintf(os.Stderr, " SDK_USERNAME=\"%s\"\n", username)
+ fmt.Fprintf(os.Stderr, " SDK_PASSWORD=\"%s\"\n", password)
+ os.Exit(1)
+ }
+
+ acc, err := gophercloud.Authenticate(
+ provider,
+ gophercloud.AuthOptions{
+ Username: username,
+ Password: password,
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ api, err := gophercloud.ComputeApi(acc, gophercloud.ApiCriteria{
+ Name: "cloudServersOpenStack",
+ Region: "DFW",
+ VersionId: "2",
+ UrlChoice: gophercloud.PublicURL,
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ servers, err := api.ListServers()
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Println("Server ID")
+ fmt.Println("----------------------------------------")
+ for _, s := range servers {
+ fmt.Printf(" %s\n", s.Id)
+ }
+}
diff --git a/api.go b/api.go
index 73b3a8c..069d4c9 100644
--- a/api.go
+++ b/api.go
@@ -1,5 +1,7 @@
package gophercloud
+import "strings"
+
// globalContext is the, well, "global context."
// Most of this SDK is written in a manner to facilitate easier testing,
// which doesn't require all the configuration a real-world application would require.
@@ -41,3 +43,113 @@
func Authenticate(provider string, options AuthOptions) (*Access, error) {
return globalContext.Authenticate(provider, options)
}
+
+// ApiCriteria provides one or more criteria for the SDK to look for appropriate endpoints.
+// Fields left unspecified or otherwise set to their zero-values are assumed to not be
+// relevant, and do not participate in the endpoint search.
+type ApiCriteria struct {
+ // Name specifies the desired service catalog entry name.
+ Name string
+
+ // Region specifies the desired endpoint region.
+ Region string
+
+ // VersionId specifies the desired version of the endpoint.
+ // Note that this field is matched exactly, and is (at present)
+ // opaque to Gophercloud. Thus, requesting a version 2
+ // endpoint will _not_ match a version 3 endpoint.
+ VersionId string
+
+ // The UrlChoice field inidicates whether or not gophercloud
+ // should use the public or internal endpoint URL if a
+ // candidate endpoint is found.
+ UrlChoice int
+}
+
+// The choices available for UrlChoice. See the ApiCriteria structure for details.
+const (
+ PublicURL = iota
+ InternalURL
+)
+
+// ComputeProvider instances encapsulate a Cloud Servers API, should one exist in the service catalog
+// for your provider.
+type ComputeProvider interface {
+ ListServers() ([]Server, error)
+}
+
+// AccessProvider instances encapsulate a Keystone authentication interface.
+type AccessProvider interface {
+ // FirstEndpointUrlByCriteria searches through the service catalog for the first
+ // matching entry endpoint fulfilling the provided criteria. If nothing found,
+ // return "". Otherwise, return either the public or internal URL for the
+ // endpoint, depending on both its existence and the setting of the ApiCriteria.UrlChoice
+ // field.
+ FirstEndpointUrlByCriteria(ApiCriteria) string
+}
+
+// genericCloudProvider structures provide the implementation for generic OpenStack-compatible
+// ComputeProvider interfaces.
+type genericCloudProvider struct {
+ // endpoint refers to the provider's API endpoint base URL. This will be used to construct
+ // and issue queries.
+ endpoint string
+}
+
+func ComputeApi(acc AccessProvider, criteria ApiCriteria) (ComputeProvider, error) {
+ url := acc.FirstEndpointUrlByCriteria(criteria)
+ if url == "" {
+ return nil, ErrEndpoint
+ }
+
+ gcp := &genericCloudProvider{
+ endpoint: url,
+ }
+
+ return gcp, nil
+}
+
+// See AccessProvider interface definition for details.
+func (a *Access) FirstEndpointUrlByCriteria(ac ApiCriteria) string {
+ ep := FindFirstEndpointByCriteria(a.ServiceCatalog, ac)
+ urls := []string{ep.PublicURL, ep.InternalURL}
+ return urls[ac.UrlChoice]
+}
+
+// Given a set of criteria to match on, locate the first candidate endpoint
+// in the provided service catalog.
+//
+// If nothing found, the result will be a zero-valued EntryEndpoint (all URLs
+// set to "").
+func FindFirstEndpointByCriteria(entries []CatalogEntry, ac ApiCriteria) EntryEndpoint {
+ rgn := strings.ToUpper(ac.Region)
+
+ for _, entry := range entries {
+ if (ac.Name != "") && (ac.Name != entry.Name) {
+ continue
+ }
+
+ for _, endpoint := range entry.Endpoints {
+ if (ac.Region != "") && (rgn != strings.ToUpper(endpoint.Region)) {
+ continue
+ }
+
+ if (ac.VersionId != "") && (ac.VersionId != endpoint.VersionId) {
+ continue
+ }
+
+ return endpoint
+ }
+ }
+ return EntryEndpoint{}
+}
+
+// See the ComputeProvider interface for details.
+func (gcp *genericCloudProvider) ListServers() ([]Server, error) {
+ return nil, nil
+}
+
+// Server structures provide data about a server running in your provider's cloud.
+type Server struct {
+ Id string
+}
diff --git a/api_test.go b/api_test.go
new file mode 100644
index 0000000..4c0bc25
--- /dev/null
+++ b/api_test.go
@@ -0,0 +1,136 @@
+package gophercloud
+
+import (
+ "testing"
+)
+
+type testAccess struct {
+ public, internal string
+ calledFirstEndpointByCriteria int
+}
+
+func (ta *testAccess) FirstEndpointUrlByCriteria(ac ApiCriteria) string {
+ ta.calledFirstEndpointByCriteria++
+ urls := []string{ta.public, ta.internal}
+ return urls[ac.UrlChoice]
+}
+
+func TestGettingComputeApi(t *testing.T) {
+ acc := &testAccess{
+ public: "http://localhost:8080",
+ internal: "http://localhost:8086",
+ }
+
+ _, err := ComputeApi(acc, ApiCriteria{
+ Name: "cloudComputeOpenStack",
+ Region: "dfw",
+ VersionId: "2",
+ })
+
+ if err != nil {
+ t.Error(err)
+ return
+ }
+
+ if acc.calledFirstEndpointByCriteria != 1 {
+ t.Error("Expected FirstEndpointByCriteria to be called")
+ return
+ }
+}
+
+func TestFindFirstEndpointByCriteria(t *testing.T) {
+ endpoint := FindFirstEndpointByCriteria([]CatalogEntry{}, ApiCriteria{Name: "test"})
+ if endpoint.PublicURL != "" {
+ t.Error("Not expecting to find anything in an empty service catalog.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ []CatalogEntry{
+ CatalogEntry{Name: "test"},
+ },
+ ApiCriteria{Name: "test"},
+ )
+ if endpoint.PublicURL != "" {
+ t.Error("Even though we have a matching entry, no endpoints exist")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "", ""),
+ ApiCriteria{Name: "test"},
+ )
+ if endpoint.PublicURL != "http://localhost" {
+ t.Error("Looking for an endpoint by name but without region or version ID should match first entry endpoint.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "", ""),
+ ApiCriteria{Name: "test", Region: "RGN"},
+ )
+ if endpoint.PublicURL != "" {
+ t.Error("If provided, the Region qualifier must exclude endpoints with missing or mismatching regions.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "rgn", ""),
+ ApiCriteria{Name: "test", Region: "RGN"},
+ )
+ if endpoint.PublicURL != "http://localhost" {
+ t.Error("Regions are case insensitive.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "rgn", ""),
+ ApiCriteria{Name: "test", Region: "RGN", VersionId: "2"},
+ )
+ if endpoint.PublicURL != "" {
+ t.Error("Missing version ID means no match.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "rgn", "3"),
+ ApiCriteria{Name: "test", Region: "RGN", VersionId: "2"},
+ )
+ if endpoint.PublicURL != "" {
+ t.Error("Mismatched version ID means no match.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "rgn", "2"),
+ ApiCriteria{Name: "test", Region: "RGN", VersionId: "2"},
+ )
+ if endpoint.PublicURL != "http://localhost" {
+ t.Error("All search criteria met; endpoint expected.")
+ return
+ }
+
+ endpoint = FindFirstEndpointByCriteria(
+ catalog("test", "http://localhost", "ord", "2"),
+ ApiCriteria{Name: "test", VersionId: "2"},
+ )
+ if endpoint.PublicURL != "http://localhost" {
+ t.Error("Sometimes, you might not care what region your stuff is in.")
+ return
+ }
+}
+
+func catalog(name, url, region, version string) []CatalogEntry {
+ return []CatalogEntry{
+ CatalogEntry{
+ Name: name,
+ Endpoints: []EntryEndpoint{
+ EntryEndpoint{
+ PublicURL: url,
+ Region: region,
+ VersionId: version,
+ },
+ },
+ },
+ }
+}
diff --git a/context.go b/context.go
index 70d70a7..daa5001 100644
--- a/context.go
+++ b/context.go
@@ -31,7 +31,7 @@
// UseCustomClient configures the context to use a customized HTTP client
// instance. By default, TestContext() will return a Context which uses
-// the net/http package's default client instance.
+// the net/http package's default client instance.
func (c *Context) UseCustomClient(hc *http.Client) {
c.httpClient = hc
}
diff --git a/errors.go b/errors.go
index 58a898c..f113446 100644
--- a/errors.go
+++ b/errors.go
@@ -25,3 +25,8 @@
// for authentication; if this endpoint isn't specified, you may receive
// this error when attempting to register it against a context.
var ErrConfiguration = fmt.Errorf("Missing or incomplete configuration")
+
+// ErrEndpoint errors happen when no endpoint with the desired characteristics
+// exists in the service catalog. This can also happen if your tenant lacks
+// adequate permissions to access a given endpoint.
+var ErrEndpoint = fmt.Errorf("Missing endpoint, or insufficient privileges to access endpoint")