Skip to content

Commit

Permalink
Allow specifying a version in the API url
Browse files Browse the repository at this point in the history
If a versioned url is used, the normal version negotiation will be
skipped.
  • Loading branch information
babbageclunk committed Apr 3, 2017
1 parent 34256e9 commit c8e3e67
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 33 deletions.
15 changes: 15 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,21 @@ func AddAPIVersionToURL(BaseURL, apiVersion string) string {
return fmt.Sprintf("%sapi/%s/", baseurl, apiVersion)
}

var apiVersionPattern = regexp.MustCompile(`^(?P<base>.*/)api/(?P<version>\d+\.\d+)/?$`)

//splitVersionedURL splits a versioned API URL (like
//http://maas.server/MAAS/api/2.0/) into a base URL
//(http://maas.server/MAAS/) and API version (2.0). If the URL doesn't
//include a version component the bool return value will be false.
func splitVersionedURL(url string) (string, string, bool) {
if !apiVersionPattern.MatchString(url) {
return url, "", false
}
version := apiVersionPattern.ReplaceAllString(url, "$version")
baseURL := apiVersionPattern.ReplaceAllString(url, "$base")
return baseURL, version, true
}

// NewAnonymousClient creates a client that issues anonymous requests.
// BaseURL should refer to the root of the MAAS server path, e.g.
// http://my.maas.server.example.com/MAAS/
Expand Down
19 changes: 16 additions & 3 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,20 @@ func (suite *ClientSuite) TestNewAuthenticatedClientFailsIfInvalidKey(c *gc.C) {
}

func (suite *ClientSuite) TestAddAPIVersionToURL(c *gc.C) {
apiurl := AddAPIVersionToURL("http://example.com/MAAS", "1.0")
expectedURL := "http://example.com/MAAS/api/1.0/"
c.Assert(expectedURL, jc.DeepEquals, apiurl)
addVersion := AddAPIVersionToURL
c.Assert(addVersion("http://example.com/MAAS", "1.0"), gc.Equals, "http://example.com/MAAS/api/1.0/")
c.Assert(addVersion("http://example.com/MAAS/", "2.0"), gc.Equals, "http://example.com/MAAS/api/2.0/")
}

func (suite *ClientSuite) TestSplitVersionedURL(c *gc.C) {
check := func(url, expectedBase, expectedVersion string, expectedResult bool) {
base, version, ok := splitVersionedURL(url)
c.Check(ok, gc.Equals, expectedResult)
c.Check(base, gc.Equals, expectedBase)
c.Check(version, gc.Equals, expectedVersion)
}
check("http://maas.server/MAAS", "http://maas.server/MAAS", "", false)
check("http://maas.server/MAAS/api/3.0", "http://maas.server/MAAS/", "3.0", true)
check("http://maas.server/MAAS/api/3.0/", "http://maas.server/MAAS/", "3.0", true)
check("http://maas.server/MAAS/api/maas", "http://maas.server/MAAS/api/maas", "", false)
}
98 changes: 68 additions & 30 deletions controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,47 +44,85 @@ type ControllerArgs struct {
APIKey string
}

// NewController creates an authenticated client to the MAAS API, and checks
// the capabilities of the server.
// NewController creates an authenticated client to the MAAS API, and
// checks the capabilities of the server. If the BaseURL specified
// includes the API version, that version of the API will be used,
// otherwise the controller will use the highest supported version
// available.
//
// If the APIKey is not valid, a NotValid error is returned.
// If the credentials are incorrect, a PermissionError is returned.
func NewController(args ControllerArgs) (Controller, error) {
base, apiVersion, includesVersion := splitVersionedURL(args.BaseURL)
if includesVersion {
if !supportedVersion(apiVersion) {
return nil, NewUnsupportedVersionError("version %s", apiVersion)
}
return newControllerWithVersion(base, apiVersion, args.APIKey)
}
return newControllerUnknownVersion(args)
}

func supportedVersion(value string) bool {
for _, version := range supportedAPIVersions {
if value == version {
return true
}
}
return false
}

func newControllerWithVersion(baseURL, apiVersion, apiKey string) (Controller, error) {
major, minor, err := version.ParseMajorMinor(apiVersion)
// We should not get an error here. See the test.
if err != nil {
return nil, errors.Errorf("bad version defined in supported versions: %q", apiVersion)
}
client, err := NewAuthenticatedClient(AddAPIVersionToURL(baseURL, apiVersion), apiKey)
if err != nil {
// If the credentials aren't valid, return now.
if errors.IsNotValid(err) {
return nil, errors.Trace(err)
}
// Any other error attempting to create the authenticated client
// is an unexpected error and return now.
return nil, NewUnexpectedError(err)
}
controllerVersion := version.Number{
Major: major,
Minor: minor,
}
controller := &controller{client: client, apiVersion: controllerVersion}
// The controllerVersion returned from the function will include any patch version.
controller.capabilities, err = controller.readAPIVersionInfo()
if err != nil {
logger.Debugf("read version failed: %#v", err)
return nil, NewBadVersionInfoError(err)
}

if err := controller.checkCreds(); err != nil {
return nil, errors.Trace(err)
}
return controller, nil
}

func newControllerUnknownVersion(args ControllerArgs) (Controller, error) {
// For now we don't need to test multiple versions. It is expected that at
// some time in the future, we will try the most up to date version and then
// work our way backwards.
for _, apiVersion := range supportedAPIVersions {
major, minor, err := version.ParseMajorMinor(apiVersion)
// We should not get an error here. See the test.
if err != nil {
return nil, errors.Errorf("bad version defined in supported versions: %q", apiVersion)
}
client, err := NewAuthenticatedClient(AddAPIVersionToURL(args.BaseURL, apiVersion), args.APIKey)
if err != nil {
// If the credentials aren't valid, return now.
if errors.IsNotValid(err) {
return nil, errors.Trace(err)
}
// Any other error attempting to create the authenticated client
// is an unexpected error and return now.
return nil, NewUnexpectedError(err)
}
controllerVersion := version.Number{
Major: major,
Minor: minor,
}
controller := &controller{client: client, apiVersion: controllerVersion}
// The controllerVersion returned from the function will include any patch version.
controller.capabilities, err = controller.readAPIVersionInfo()
if err != nil {
logger.Debugf("read version failed: %#v", err)
controller, err := newControllerWithVersion(args.BaseURL, apiVersion, args.APIKey)
switch {
case err == nil:
return controller, nil
case IsBadVersionInfoError(err):
// TODO(babbageclunk): this is bad - it treats transient
// network errors the same as version mismatches. See
// lp:1667095
continue
}

if err := controller.checkCreds(); err != nil {
default:
return nil, errors.Trace(err)
}
return controller, nil
}

return nil, NewUnsupportedVersionError("controller at %s does not support any of %s", args.BaseURL, supportedAPIVersions)
Expand Down
30 changes: 30 additions & 0 deletions controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,36 @@ func (s *controllerSuite) TestNewControllerUnexpected(c *gc.C) {
c.Assert(err, jc.Satisfies, IsUnexpectedError)
}

func (s *controllerSuite) TestNewControllerKnownVersion(c *gc.C) {
// Using a server URL including the version should work.
officialController, err := NewController(ControllerArgs{
BaseURL: s.server.URL + "/api/2.0/",
APIKey: "fake:as:key",
})
c.Assert(err, jc.ErrorIsNil)
rawController, ok := officialController.(*controller)
c.Assert(ok, jc.IsTrue)
c.Assert(rawController.apiVersion, gc.Equals, version.Number{
Major: 2,
Minor: 0,
})
}

func (s *controllerSuite) TestNewControllerUnsupportedVersionSpecified(c *gc.C) {
// Ensure the server would actually respond to the version if it
// was asked.
s.server.AddGetResponse("/api/3.0/users/?op=whoami", http.StatusOK, `"captain awesome"`)
s.server.AddGetResponse("/api/3.0/version/", http.StatusOK, versionResponse)
// Using a server URL including a version that isn't in the known
// set should be denied.
controller, err := NewController(ControllerArgs{
BaseURL: s.server.URL + "/api/3.0/",
APIKey: "fake:as:key",
})
c.Assert(controller, gc.IsNil)
c.Assert(err, jc.Satisfies, IsUnsupportedVersionError)
}

func (s *controllerSuite) TestBootResources(c *gc.C) {
controller := s.getController(c)
resources, err := controller.BootResources()
Expand Down
21 changes: 21 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ func IsUnsupportedVersionError(err error) bool {
return ok
}

// BadVersionInfoError is more specific than UnsupportedVersionError -
// it means that we couldn't read the version info endpoint, so the
// API version is probably wrong. Used in version selection when
// creating a controller.
type BadVersionInfoError struct {
errors.Err
}

// NewBadVersionInfoError constructs a new BadVersionInfoError and sets the location.
func NewBadVersionInfoError(err error) error {
uerr := &BadVersionInfoError{Err: errors.NewErr("bad version info: %v", err)}
uerr.SetLocation(1)
return errors.Wrap(err, uerr)
}

// IsBadVersionInfoError returns true if err is an BadVersionInfoError.
func IsBadVersionInfoError(err error) bool {
_, ok := errors.Cause(err).(*BadVersionInfoError)
return ok
}

// DeserializationError types are returned when the returned JSON data from
// the controller doesn't match the code's expectations.
type DeserializationError struct {
Expand Down
8 changes: 8 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ func (*errorTypesSuite) TestUnexpectedError(c *gc.C) {
c.Assert(err.Error(), gc.Equals, "unexpected: wat")
}

func (*errorTypesSuite) TestNewBadVersionInfoError(c *gc.C) {
err := errors.New("wat")
err = NewBadVersionInfoError(err)
c.Assert(err, gc.NotNil)
c.Assert(err, jc.Satisfies, IsBadVersionInfoError)
c.Assert(err.Error(), gc.Equals, "bad version info: wat")
}

func (*errorTypesSuite) TestUnsupportedVersionError(c *gc.C) {
err := NewUnsupportedVersionError("foo %d", 42)
c.Assert(err, gc.NotNil)
Expand Down

0 comments on commit c8e3e67

Please sign in to comment.