Epic refactoring to improve testability.
It all started when I wanted to write ListServers(), but doing that
meant acquiring the test context, so that I could intercept server-side
communications. However, that couldn't be easily acquired with the old
software configuration. In fact, it was patently impossible to do,
without breaking a lot of encapsulation and conflating concerns.
Additionally, I knew I didn't want to make ComputeApi() a method of an
AccessProvider interface; considering how similar the OpenStack APIs
are, I was banking on that design decision causing a lot of duplicate
code, even if said code was simple. Not only that, but it conflated
concerns; again, something I wanted to avoid.
So, I needed to do a couple of things.
1) Realize that module-global functions are delegators to the global
context. My original implementation of ComputeApi() wasn't, which meant
that I had zero access to any contexts created in unit testing.
2) Realize that the Context interface is the true Gophercloud global
API. This meant I had to make a ComputeApi() method on the Context
interface, and implement it. This proved very convenient -- it granted
me access automatically to all test contexts.
As a checklist bullet point, whenever adding a new global-level function
to gophercloud, do it in at least these steps: a) add the function as a
method on Context. Seriously -- this is the only real way to make it
testable. b) Add a very dumb delegator function in global_context.go
which dispatches to its eponymously-named method on globalContext.
3) Making this simple change was sufficient to start to test an
implementation of ListServers(). However, invoking "c := TestContext();
c.foo(); c.bar();" was becoming repetitive and error-prone. So, I
refactored that into a Java-style DSL. These things aren't terribly Go
idiomatic, but for my needs here, they work nicely.
4) I refactored the two different implementations of custom transports
into a single "transport-double" type. This type will supports both
canned responses and some basic request validation. It's expandable by
simply adding more configuration fields and DSL methods of its own.
5) api.go is no more -- it previously served two separate purposes, each
of which has its own source file now. interfaces.go holds the
definition of all publicly visible APIs, while global_context.go
provides the default global Context, its initialization, and the
module-global entry points that delegate to the global context.
With these changes having been made, *now* I'm ready to actually start
testing ListServers() development! It only took 24 hours and 4
refreshes of the feature branch just to get this far. :-)
The nice thing is, though, that going forward, these changes should
contribute to making future endpoint binding implementations
significantly easier than what I had to do before.
diff --git a/context.go b/context.go
index daa5001..71ff757 100644
--- a/context.go
+++ b/context.go
@@ -4,6 +4,14 @@
"net/http"
)
+// Provider structures exist for each tangible provider of OpenStack service.
+// For example, Rackspace, Hewlett-Packard, and NASA might have their own instance of this structure.
+//
+// At a minimum, a provider must expose an authentication endpoint.
+type Provider struct {
+ AuthEndpoint string
+}
+
// Context structures encapsulate Gophercloud-global state in a manner which
// facilitates easier unit testing. As a user of this SDK, you'll never
// have to use this structure, except when contributing new code to the SDK.
@@ -32,6 +40,54 @@
// 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.
-func (c *Context) UseCustomClient(hc *http.Client) {
+func (c *Context) UseCustomClient(hc *http.Client) *Context {
c.httpClient = hc
+ return c
+}
+
+// RegisterProvider allows a unit test to register a mythical provider convenient for testing.
+// If the provider structure lacks adequate configuration, or the configuration given has some
+// detectable error, an ErrConfiguration error will result.
+func (c *Context) RegisterProvider(name string, p Provider) error {
+ if p.AuthEndpoint == "" {
+ return ErrConfiguration
+ }
+
+ c.providerMap[name] = p
+ return nil
+}
+
+// WithProvider offers convenience for unit tests.
+func (c *Context) WithProvider(name string, p Provider) *Context {
+ err := c.RegisterProvider(name, p)
+ if err != nil {
+ panic(err)
+ }
+ return c
+}
+
+// ProviderByName will locate a provider amongst those previously registered, if it exists.
+// If the named provider has not been registered, an ErrProvider error will result.
+func (c *Context) ProviderByName(name string) (p Provider, err error) {
+ for provider, descriptor := range c.providerMap {
+ if name == provider {
+ return descriptor, nil
+ }
+ }
+ return Provider{}, ErrProvider
+}
+
+// Instantiates a Cloud Servers object for the provider given.
+func (c *Context) ComputeApi(acc AccessProvider, criteria ApiCriteria) (ComputeProvider, error) {
+ url := acc.FirstEndpointUrlByCriteria(criteria)
+ if url == "" {
+ return nil, ErrEndpoint
+ }
+
+ gcp := &genericCloudProvider{
+ endpoint: url,
+ context: c,
+ }
+
+ return gcp, nil
}