← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/the-thing-from-maas into lp:~maas-maintainers/maas/gomaasapi

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/the-thing-from-maas into lp:~maas-maintainers/maas/gomaasapi.

Commit message:
Handle JSON returned by the MAAS API.

Requested reviews:
  Raphaël Badin (rvb)

For more details, see:
https://code.launchpad.net/~jtv/maas/the-thing-from-maas/+merge/144882

The MAAS API returns JSON blobs (“the Thing from MAAS”) that are typically either MAAS model objects or arrays of same.

Go's static type system makes it awkward to deal with these ad-hoc data structures.  There are no generics and the only kind of dynamic dispatch is based on which object type an interface value refers to.

I worked with that type system to let you request items from a JSON blob on-demand, specifying types as you go.  So for example, to parse a JSON blob consisting of a single number:

        value, err := Parse(client, []bytes{"2139"})
        // value implements JSONObject.  Its actual type is a wrapper for float64.
        number, err := value.GetFloat64()
        // value is a float64: 2139.0.
        fmt.Println(number)

Here you retrieve the value as a float64.  You must know the type of the JSON value you're accessing; call value.Type() if you're not sure.

To decode an array, you use GetArray() instead of GetFloat64():

        value, err := Parse(client, []bytes{"[1, 2, 3]"})
        // value implements JSONObject.  Its actual type is a wrapper for []JSONObject.
        array, err := value.GetArray()
        // array is an []JSONObject: {1, 2, 3}.
        number, err := array[1].GetFloat64()
        fmt.Println(number)

Similar for maps (which you can extract as map[string]JSONObject), bools (which work much like float64), and as a special case, MAASModel.  MAASModel can be acessed like a map, but it also defines methods to retrieve the object's resource URL, to issue an http GET on the object, and so on.  This is why Parse() needs to know your Client.


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/the-thing-from-maas/+merge/144882
Your team MAAS Maintainers is subscribed to branch lp:~maas-maintainers/maas/gomaasapi.
=== renamed file 'maasobject.go' => 'jsonobject.go'
--- maasobject.go	2013-01-23 10:10:06 +0000
+++ jsonobject.go	2013-01-25 10:14:25 +0000
@@ -3,18 +3,198 @@
 
 package gomaasapi
 
-// MAASObject is a wrapper around a JSON structure which provides
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+)
+
+
+// JSONObject is a wrapper around a JSON structure which provides
 // methods to extract data from that structure.
-type MAASObject struct {
-}
-
-func NewMAASObject(json []byte) (*MAASObject, error) {
-	// Not implemented.
-	return &MAASObject{}, nil
-}
-
-func NewMAASObjectList(json []byte) ([]*MAASObject, error) {
-	// Not implemented.
-	list := []*MAASObject{&MAASObject{}}
-	return list, nil
-}
+// A JSONObject provides a simple structure consisting of the data types
+// defined in JSON: string, number, object, list, and bool.  To get the
+// value you want out of a JSONObject, you must know (or figure out) which
+// kind of value you have, and then call the appropriate Get*() method to
+// get at it.  Reading an item as the wrong type will return an error.
+// For instance, if your JSONObject consists of a number, call GetFloat64()
+// to get the value as a float64.  If it's a list, call GetArray() to get
+// a slice of JSONObjects.  To read any given item from the slice, you'll
+// need to "Get" that as the right type as well.
+// There is one exception: a MAASModel is really a special kind of map,
+// so you can read it as either.
+// Reading a null item is also an error.  So before you try obj.Get*(),
+// first check that obj != nil.
+type JSONObject interface {
+	// Type of this value:
+	// "string", "float64", "map", "model", "array", or "bool".
+	Type() string
+	// Read as string.
+	GetString() (string, error)
+	// Read number as float64.
+	GetFloat64() (float64, error)
+	// Read object as map.
+	GetMap() (map[string]JSONObject, error)
+	// Read object as MAAS model object.
+	GetModel() (MAASModel, error)
+	// Read list as array.
+	GetArray() ([]JSONObject, error)
+	// Read as bool.
+	GetBool() (bool, error)
+}
+
+
+// Internally, each JSONObject already knows what type it is.  It just
+// can't tell the caller yet because the caller may not have the right
+// hard-coded variable type.
+// So for each JSON type, there is a separate implementation of JSONObject
+// that converts only to that type.  Any other conversion is an error.
+// One type is special: maasModel is a model object.  It behaves just like
+// a jsonMap if you want it to, but it also implements MAASModel.
+type jsonString string
+type jsonFloat64 float64
+type jsonMap map[string]JSONObject
+type jsonArray []JSONObject
+type jsonBool bool
+
+
+// Our JSON processor distinguishes a MAASModel from a jsonMap by the fact
+// that it contains a key "resource_uri".  (A regular map might contain the
+// same key through sheer coincide, but never mind: you can still treat it
+// as a jsonMap and never notice the difference.)
+const resource_uri = "resource_uri"
+
+
+// Internal: turn a completely untyped json.Unmarshal result into a
+// JSONObject (with the appropriate implementation of course).
+// This function is recursive.  Maps and arrays are deep-copied, with each
+// individual value being converted to a JSONObject type.
+func maasify(client Client, value interface{}) JSONObject {
+	if value == nil {
+		return nil
+	}
+	switch value.(type) {
+	case string:
+		return jsonString(value.(string))
+	case float64:
+		return jsonFloat64(value.(float64))
+	case map[string]interface{}:
+		original := value.(map[string]interface{})
+		result := make(map[string]JSONObject, len(original))
+		for key, value := range original {
+			result[key] = maasify(client, value)
+		}
+		if _, ok := result[resource_uri]; ok {
+			// If the map contains "resource-uri", we can treat
+			// it as a model object.
+			return maasModel{result, client}
+		}
+		return jsonMap(result)
+	case []interface{}:
+		original := value.([]interface{})
+		result := make([]JSONObject, len(original))
+		for index, value := range original {
+			result[index] = maasify(client, value)
+		}
+		return jsonArray(result)
+	case bool:
+		return jsonBool(value.(bool))
+	}
+	msg := fmt.Sprintf("Unknown JSON type, can't be converted to JSONObject: %v", value)
+	panic(msg)
+}
+
+
+// Parse a JSON blob into a JSONObject.
+func Parse(client Client, input []byte) (JSONObject, error) {
+	var obj interface{}
+	err := json.Unmarshal(input, &obj)
+	if err != nil {
+		return nil, err
+	}
+	return maasify(client, obj), nil
+}
+
+
+// Return error value for failed type conversion.
+func failConversion(wanted_type string, obj JSONObject) error {
+	msg := fmt.Sprintf("Requested %v, got %v.", wanted_type, obj.Type())
+	return errors.New(msg)
+}
+
+
+// Error return values for failure to convert to string.
+func failString(obj JSONObject) (string, error) {
+	return "", failConversion("string", obj)
+}
+// Error return values for failure to convert to float64.
+func failFloat64(obj JSONObject) (float64, error) {
+	return 0.0, failConversion("float64", obj)
+}
+// Error return values for failure to convert to map.
+func failMap(obj JSONObject) (map[string]JSONObject, error) {
+	return make(map[string]JSONObject, 0), failConversion("map", obj)
+}
+// Error return values for failure to convert to model.
+func failModel(obj JSONObject) (MAASModel, error) {
+	return maasModel{}, failConversion("model", obj)
+}
+// Error return values for failure to convert to array.
+func failArray(obj JSONObject) ([]JSONObject, error) {
+	return make([]JSONObject, 0), failConversion("array", obj)
+}
+// Error return values for failure to convert to bool.
+func failBool(obj JSONObject) (bool, error) {
+	return false, failConversion("bool", obj)
+}
+
+
+// JSONObject implementation for jsonString.
+func (jsonString) Type() string { return "string" }
+func (obj jsonString) GetString() (string, error) { return string(obj), nil }
+func (obj jsonString) GetFloat64() (float64, error) { return failFloat64(obj) }
+func (obj jsonString) GetMap() (map[string]JSONObject, error) { return failMap(obj) }
+func (obj jsonString) GetModel() (MAASModel, error) { return failModel(obj) }
+func (obj jsonString) GetArray() ([]JSONObject, error) { return failArray(obj) }
+func (obj jsonString) GetBool() (bool, error) { return failBool(obj) }
+
+// JSONObject implementation for jsonFloat64.
+func (jsonFloat64) Type() string { return "float64" }
+func (obj jsonFloat64) GetString() (string, error) { return failString(obj) }
+func (obj jsonFloat64) GetFloat64() (float64, error) { return float64(obj), nil }
+func (obj jsonFloat64) GetMap() (map[string]JSONObject, error) { return failMap(obj) }
+func (obj jsonFloat64) GetModel() (MAASModel, error) { return failModel(obj) }
+func (obj jsonFloat64) GetArray() ([]JSONObject, error) { return failArray(obj) }
+func (obj jsonFloat64) GetBool() (bool, error) { return failBool(obj) }
+
+// JSONObject implementation for jsonMap.
+func (jsonMap) Type() string { return "map" }
+func (obj jsonMap) GetString() (string, error) { return failString(obj) }
+func (obj jsonMap) GetFloat64() (float64, error) { return failFloat64(obj) }
+func (obj jsonMap) GetMap() (map[string]JSONObject, error) {
+	return (map[string]JSONObject)(obj), nil
+}
+func (obj jsonMap) GetModel() (MAASModel, error) { return failModel(obj) }
+func (obj jsonMap) GetArray() ([]JSONObject, error) { return failArray(obj) }
+func (obj jsonMap) GetBool() (bool, error) { return failBool(obj) }
+
+
+// JSONObject implementation for jsonArray.
+func (jsonArray) Type() string { return "array" }
+func (obj jsonArray) GetString() (string, error) { return failString(obj) }
+func (obj jsonArray) GetFloat64() (float64, error) { return failFloat64(obj) }
+func (obj jsonArray) GetMap() (map[string]JSONObject, error) { return failMap(obj) }
+func (obj jsonArray) GetModel() (MAASModel, error) { return failModel(obj) }
+func (obj jsonArray) GetArray() ([]JSONObject, error) {
+	return ([]JSONObject)(obj), nil
+}
+func (obj jsonArray) GetBool() (bool, error) { return failBool(obj) }
+
+// JSONObject implementation for jsonBool.
+func (jsonBool) Type() string { return "bool" }
+func (obj jsonBool) GetString() (string, error) { return failString(obj) }
+func (obj jsonBool) GetFloat64() (float64, error) { return failFloat64(obj) }
+func (obj jsonBool) GetMap() (map[string]JSONObject, error) { return failMap(obj) }
+func (obj jsonBool) GetModel() (MAASModel, error) { return failModel(obj) }
+func (obj jsonBool) GetArray() ([]JSONObject, error) { return failArray(obj) }
+func (obj jsonBool) GetBool() (bool, error) { return bool(obj), nil }

=== added file 'jsonobject_test.go'
--- jsonobject_test.go	1970-01-01 00:00:00 +0000
+++ jsonobject_test.go	2013-01-25 10:14:25 +0000
@@ -0,0 +1,123 @@
+// Copyright 2013 Canonical Ltd.  This software is licensed under the
+// GNU Lesser General Public License version 3 (see the file COPYING).
+
+package gomaasapi
+
+import (
+	"launchpad.net/gocheck"
+)
+
+
+// maasify() converts nil.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsNil(c *gocheck.C) {
+	c.Check(maasify(nil, nil), gocheck.Equals, nil)
+}
+
+
+// maasify() converts strings.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsString(c *gocheck.C) {
+	const text = "Hello"
+	c.Check(string(maasify(nil, text).(jsonString)), gocheck.Equals, text)
+}
+
+
+// maasify() converts float64 numbers.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsNumber(c *gocheck.C) {
+	const number = 3.1415926535
+	c.Check(float64(maasify(nil, number).(jsonFloat64)), gocheck.Equals, number)
+}
+
+
+// maasify() converts array slices.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsArray(c *gocheck.C) {
+	original := []interface{}{3.0, 2.0, 1.0}
+	output := maasify(nil, original).(jsonArray)
+	c.Check(len(output), gocheck.Equals, len(original))
+}
+
+
+// When maasify() converts an array slice, the result contains JSONObjects.
+func (suite *GomaasapiTestSuite) TestMaasifyArrayContainsJSONObjects(c *gocheck.C) {
+	arr := maasify(nil, []interface{}{9.9}).(jsonArray)
+	var entry JSONObject
+	entry = arr[0]
+	c.Check((float64)(entry.(jsonFloat64)), gocheck.Equals, 9.9)
+}
+
+
+// maasify() converts maps.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsMap(c *gocheck.C) {
+	original := map[string]interface{}{"1": "one", "2": "two", "3": "three"}
+	output := maasify(nil, original).(jsonMap)
+	c.Check(len(output), gocheck.Equals, len(original))
+}
+
+
+// When maasify() converts a map, the result contains JSONObjects.
+func (suite *GomaasapiTestSuite) TestMaasifyMapContainsJSONObjects(c *gocheck.C) {
+	mp := maasify(nil, map[string]interface{}{"key": "value"}).(jsonMap)
+	var entry JSONObject
+	entry = mp["key"]
+	c.Check((string)(entry.(jsonString)), gocheck.Equals, "value")
+}
+
+
+// maasify() converts MAAS model objects.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsModel(c *gocheck.C) {
+	original := map[string]interface{}{
+		"resource_uri": "http://example.com/foo";,
+		"size": "3",
+	}
+	output := maasify(nil, original).(maasModel)
+	c.Check(len(output.jsonMap), gocheck.Equals, len(original))
+	c.Check((string)(output.jsonMap["size"].(jsonString)), gocheck.Equals, "3")
+}
+
+
+// maasify() passes its Client to a MAASModel it creates.
+func (suite *GomaasapiTestSuite) TestMaasifyPassesClientToModel(c *gocheck.C) {
+	client := &genericClient{}
+	original := map[string]interface{}{"resource_uri": "http://example.com/foo"}
+	output := maasify(client, original).(maasModel)
+	c.Check(output.client, gocheck.Equals, client)
+}
+
+
+// maasify() passes its Client into an array of MAASModels it creates.
+func (suite *GomaasapiTestSuite) TestMaasifyPassesClientIntoArray(c *gocheck.C) {
+	client := &genericClient{}
+	obj := map[string]interface{}{"resource_uri": "http://example.com/foo"}
+	list := []interface{}{obj}
+	output := maasify(client, list).(jsonArray)
+	c.Check(output[0].(maasModel).client, gocheck.Equals, client)
+}
+
+
+// maasify() passes its Client into a map of MAASModels it creates.
+func (suite *GomaasapiTestSuite) TestMaasifyPassesClientIntoMap(c *gocheck.C) {
+	client := &genericClient{}
+	obj := map[string]interface{}{"resource_uri": "http://example.com/foo"}
+	mp := map[string]interface{}{"key": obj}
+	output := maasify(client, mp).(jsonMap)
+	c.Check(output["key"].(maasModel).client, gocheck.Equals, client)
+}
+
+
+// maasify() passes its Client all the way down into any MAASModels in the
+// object structure it creates.
+func (suite *GomaasapiTestSuite) TestMaasifyPassesClientAllTheWay(c *gocheck.C) {
+	client := &genericClient{}
+	obj := map[string]interface{}{"resource_uri": "http://example.com/foo"}
+	mp := map[string]interface{}{"key": obj}
+	list := []interface{}{mp}
+	output := maasify(client, list).(jsonArray)
+	model := output[0].(jsonMap)["key"]
+	c.Check(model.(maasModel).client, gocheck.Equals, client)
+}
+
+
+// maasify() converts Booleans.
+func (suite *GomaasapiTestSuite) TestMaasifyConvertsBool(c *gocheck.C) {
+	c.Check(bool(maasify(nil, true).(jsonBool)), gocheck.Equals, true)
+	c.Check(bool(maasify(nil, false).(jsonBool)), gocheck.Equals, false)
+}

=== added file 'maasmodel.go'
--- maasmodel.go	1970-01-01 00:00:00 +0000
+++ maasmodel.go	2013-01-25 10:14:25 +0000
@@ -0,0 +1,91 @@
+// Copyright 2013 Canonical Ltd.  This software is licensed under the
+// GNU Lesser General Public License version 3 (see the file COPYING).
+
+package gomaasapi
+
+import (
+	"errors"
+	"net/url"
+)
+
+
+// MAASModel represents a model object as returned by the MAAS API.  This is
+// a special kind of JSONObject.  A MAAS API call will usually return either
+// a MAASModel or a list of MAASModels.  (The list itself will be wrapped in
+// a JSONObject).
+//
+type MAASModel interface {
+	// Resource URI for this object.
+	URL() string
+	// Retrieve this model object.
+	Get() (MAASModel, error)
+	// Write this model object.
+	Post(params url.Values) (MAASModel, error)
+	// Update this model object with the given values.
+	Update(params url.Values) (MAASModel, error)
+	// Delete this model object.
+	Delete() error
+	// Invoke a GET-based method on this model object.
+	CallGet(operation string, params url.Values) (JSONObject, error)
+	// Invoke a POST-based method on this model object.
+	CallPost(operation string, params url.Values) (JSONObject, error)
+}
+
+// JSONObject implementation for a MAAS model object.  From a decoding
+// perspective, a maasModel is just like a jsonMap except it contains a key
+// "resource_uri", and it keeps track of the Client you got it from so that
+// you can invoke API methods directly on their model object.
+// maasModel implements both JSONObject and MAASModel.
+type maasModel struct {
+	jsonMap
+	client Client
+}
+
+
+// JSONObject implementation for maasModel.
+func (maasModel) Type() string { return "model" }
+func (obj maasModel) GetString() (string, error) { return failString(obj) }
+func (obj maasModel) GetFloat64() (float64, error) { return failFloat64(obj) }
+func (obj maasModel) GetMap() (map[string]JSONObject, error) { return obj.jsonMap.GetMap() }
+func (obj maasModel) GetModel() (MAASModel, error) { return obj, nil }
+func (obj maasModel) GetArray() ([]JSONObject, error) { return failArray(obj) }
+func (obj maasModel) GetBool() (bool, error) { return failBool(obj) }
+
+
+// MAASModel implementation for maasModel.
+
+func (obj maasModel) URL() string {
+	contents, err := obj.GetMap()
+	if err != nil {
+		panic("Unexpected failure converting maasModel to maasMap.")
+	}
+	url, err := contents[resource_uri].GetString()
+	if err != nil {
+		panic("Unexpected failure reading maasModel's URL.")
+	}
+	return url
+}
+
+var NotImplemented = errors.New("Not implemented")
+
+func (obj maasModel) Get() (MAASModel, error) {
+	return maasModel{}, NotImplemented
+}
+
+func (obj maasModel) Post(params url.Values) (MAASModel, error) {
+	return maasModel{}, NotImplemented
+}
+
+func (obj maasModel) Update(params url.Values) (MAASModel, error) {
+	return maasModel{}, NotImplemented
+}
+
+func (obj maasModel) Delete() error { return NotImplemented }
+
+func (obj maasModel) CallGet(operation string, params url.Values) (JSONObject, error) {
+	return nil, NotImplemented
+}
+
+func (obj maasModel) CallPost(operation string, params url.Values) (JSONObject, error) {
+	return nil, NotImplemented
+}

=== added file 'maasmodel_test.go'
--- maasmodel_test.go	1970-01-01 00:00:00 +0000
+++ maasmodel_test.go	2013-01-25 10:14:25 +0000
@@ -0,0 +1,29 @@
+// Copyright 2013 Canonical Ltd.  This software is licensed under the
+// GNU Lesser General Public License version 3 (see the file COPYING).
+
+package gomaasapi
+
+import (
+	"fmt"
+	"launchpad.net/gocheck"
+	"math/rand"
+)
+
+
+func makeFakeResourceURI() string {
+	return "http://example.com/"; + fmt.Sprint(rand.Int31())
+}
+
+
+func makeFakeModel() maasModel {
+	attrs := make(map[string]JSONObject)
+	attrs[resource_uri] = jsonString(makeFakeResourceURI())
+	return maasModel{jsonMap: jsonMap(attrs)}
+}
+
+
+func (suite *GomaasapiTestSuite) TestImplementsInterfaces(c *gocheck.C) {
+	obj := makeFakeModel()
+	_ = JSONObject(obj)
+	_ = MAASModel(obj)
+}

=== modified file 'server.go'
--- server.go	2013-01-23 10:10:06 +0000
+++ server.go	2013-01-25 10:14:25 +0000
@@ -12,7 +12,7 @@
 	client *Client
 }
 
-func (server *Server) listNodes() ([]*MAASObject, error) {
+func (server *Server) listNodes() ([]JSONObject, error) {
 	// Do something like (warning, completely untested code):
 	listURL := server.URL + "nodes/"
 	result, err := (*server.client).Get(listURL, nil)
@@ -20,10 +20,10 @@
 		log.Println(err)
 		return nil, err
 	}
-	list, errJson := NewMAASObjectList(result)
-	if errJson != nil {
-		log.Println(errJson)
-		return nil, errJson
+	jsonobj, err := Parse(*server.client, result)
+	if err != nil {
+		log.Println(err)
+		return nil, err
 	}
-	return list, nil
+	return jsonobj.GetArray()
 }


Follow ups