images service v2 port from rackpsace/gophercloud (#171)

* CheckByteArrayEquals funcs

* direct port from rackspace/gophercloud with minor additions to get unit tests passing

* new package for uploading and downloading image data

* updates to make imageservice v2 consistent with the rest of gophercloud/gophercloud

* add image service v2 client
diff --git a/openstack/imageservice/v2/members/requests.go b/openstack/imageservice/v2/members/requests.go
new file mode 100644
index 0000000..8c667cb
--- /dev/null
+++ b/openstack/imageservice/v2/members/requests.go
@@ -0,0 +1,77 @@
+package members
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Create member for specific image
+//
+// Preconditions
+//    The specified images must exist.
+//    You can only add a new member to an image which 'visibility' attribute is private.
+//    You must be the owner of the specified image.
+// Synchronous Postconditions
+//    With correct permissions, you can see the member status of the image as pending through API calls.
+//
+// More details here: http://developer.openstack.org/api-ref-image-v2.html#createImageMember-v2
+func Create(client *gophercloud.ServiceClient, id string, member string) (r CreateResult) {
+	b := map[string]interface{}{"member": member}
+	_, r.Err = client.Post(createMemberURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 409, 403},
+	})
+	return
+}
+
+// List members returns list of members for specifed image id
+// More details: http://developer.openstack.org/api-ref-image-v2.html#listImageMembers-v2
+func List(client *gophercloud.ServiceClient, id string) pagination.Pager {
+	return pagination.NewPager(client, listMembersURL(client, id), func(r pagination.PageResult) pagination.Page {
+		return MemberPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get image member details.
+// More details: http://developer.openstack.org/api-ref-image-v2.html#getImageMember-v2
+func Get(client *gophercloud.ServiceClient, imageID string, memberID string) (r DetailsResult) {
+	_, r.Err = client.Get(getMemberURL(client, imageID, memberID), &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+	return
+}
+
+// Delete membership for given image.
+// Callee should be image owner
+// More details: http://developer.openstack.org/api-ref-image-v2.html#deleteImageMember-v2
+func Delete(client *gophercloud.ServiceClient, imageID string, memberID string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteMemberURL(client, imageID, memberID), &gophercloud.RequestOpts{OkCodes: []int{204, 403}})
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+	ToImageMemberUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts implements UpdateOptsBuilder
+type UpdateOpts struct {
+	Status string
+}
+
+// ToMemberUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToImageMemberUpdateMap() (map[string]interface{}, error) {
+	return map[string]interface{}{
+		"status": opts.Status,
+	}, nil
+}
+
+// Update function updates member
+// More details: http://developer.openstack.org/api-ref-image-v2.html#updateImageMember-v2
+func Update(client *gophercloud.ServiceClient, imageID string, memberID string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToImageMemberUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(updateMemberURL(client, imageID, memberID), b, &r.Body,
+		&gophercloud.RequestOpts{OkCodes: []int{200}})
+	return
+}
diff --git a/openstack/imageservice/v2/members/results.go b/openstack/imageservice/v2/members/results.go
new file mode 100644
index 0000000..1d2a9c0
--- /dev/null
+++ b/openstack/imageservice/v2/members/results.go
@@ -0,0 +1,91 @@
+package members
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Member model
+type Member struct {
+	CreatedAt time.Time `json:"-"`
+	ImageID   string    `json:"image_id"`
+	MemberID  string    `json:"member_id"`
+	Schema    string    `json:"schema"`
+	Status    string    `json:"status"`
+	UpdatedAt time.Time `json:"-"`
+}
+
+func (s *Member) UnmarshalJSON(b []byte) error {
+	type tmp Member
+	var p *struct {
+		tmp
+		CreatedAt string `json:"created_at"`
+		UpdatedAt string `json:"updated_at"`
+	}
+	err := json.Unmarshal(b, &p)
+	if err != nil {
+		return err
+	}
+
+	*s = Member(p.tmp)
+	s.CreatedAt, err = time.Parse(time.RFC3339, p.CreatedAt)
+	if err != nil {
+		return err
+	}
+	s.UpdatedAt, err = time.Parse(time.RFC3339, p.UpdatedAt)
+	return err
+}
+
+// Extract Member model from request if possible
+func (r commonResult) Extract() (*Member, error) {
+	var s *Member
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// MemberPage is a single page of Members results.
+type MemberPage struct {
+	pagination.SinglePageBase
+}
+
+// ExtractMembers returns a slice of Members contained in a single page of results.
+func ExtractMembers(r pagination.Page) ([]Member, error) {
+	var s struct {
+		Members []Member `json:"members"`
+	}
+	err := r.(MemberPage).ExtractInto(&s)
+	return s.Members, err
+}
+
+// IsEmpty determines whether or not a page of Members contains any results.
+func (r MemberPage) IsEmpty() (bool, error) {
+	members, err := ExtractMembers(r)
+	return len(members) == 0, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// CreateResult result model
+type CreateResult struct {
+	commonResult
+}
+
+// DetailsResult model
+type DetailsResult struct {
+	commonResult
+}
+
+// UpdateResult model
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult model
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/imageservice/v2/members/testing/fixtures.go b/openstack/imageservice/v2/members/testing/fixtures.go
new file mode 100644
index 0000000..c08fc5e
--- /dev/null
+++ b/openstack/imageservice/v2/members/testing/fixtures.go
@@ -0,0 +1,138 @@
+package testing
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleCreateImageMemberSuccessfully setup
+func HandleCreateImageMemberSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		th.TestJSONRequest(t, r, `{"member": "8989447062e04a818baf9e073fd04fa7"}`)
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `{
+		    "created_at": "2013-09-20T19:22:19Z",
+		    "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		    "member_id": "8989447062e04a818baf9e073fd04fa7",
+		    "schema": "/v2/schemas/member",
+		    "status": "pending",
+		    "updated_at": "2013-09-20T19:25:31Z"
+			}`)
+
+	})
+}
+
+// HandleImageMemberList happy path setup
+func HandleImageMemberList(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+		    "members": [
+		        {
+		            "created_at": "2013-10-07T17:58:03Z",
+		            "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		            "member_id": "123456789",
+		            "schema": "/v2/schemas/member",
+		            "status": "pending",
+		            "updated_at": "2013-10-07T17:58:03Z"
+		        },
+		        {
+		            "created_at": "2013-10-07T17:58:55Z",
+		            "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		            "member_id": "987654321",
+		            "schema": "/v2/schemas/member",
+		            "status": "accepted",
+		            "updated_at": "2013-10-08T12:08:55Z"
+		        }
+		    ],
+		    "schema": "/v2/schemas/members"
+		}`)
+	})
+}
+
+// HandleImageMemberEmptyList happy path setup
+func HandleImageMemberEmptyList(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+		    "members": [],
+		    "schema": "/v2/schemas/members"
+		}`)
+	})
+}
+
+// HandleImageMemberDetails setup
+func HandleImageMemberDetails(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members/8989447062e04a818baf9e073fd04fa7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `{
+		    "status": "pending",
+		    "created_at": "2013-11-26T07:21:21Z",
+		    "updated_at": "2013-11-26T07:21:21Z",
+		    "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		    "member_id": "8989447062e04a818baf9e073fd04fa7",
+		    "schema": "/v2/schemas/member"
+		}`)
+	})
+}
+
+// HandleImageMemberDeleteSuccessfully setup
+func HandleImageMemberDeleteSuccessfully(t *testing.T) *CallsCounter {
+	var counter CallsCounter
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members/8989447062e04a818baf9e073fd04fa7", func(w http.ResponseWriter, r *http.Request) {
+		counter.Counter = counter.Counter + 1
+
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+	return &counter
+}
+
+// HandleImageMemberUpdate setup
+func HandleImageMemberUpdate(t *testing.T) *CallsCounter {
+	var counter CallsCounter
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members/8989447062e04a818baf9e073fd04fa7", func(w http.ResponseWriter, r *http.Request) {
+		counter.Counter = counter.Counter + 1
+
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		th.TestJSONRequest(t, r, `{"status": "accepted"}`)
+
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `{
+		    "status": "accepted",
+		    "created_at": "2013-11-26T07:21:21Z",
+		    "updated_at": "2013-11-26T07:21:21Z",
+		    "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		    "member_id": "8989447062e04a818baf9e073fd04fa7",
+		    "schema": "/v2/schemas/member"
+		}`)
+	})
+	return &counter
+}
+
+// CallsCounter for checking if request handler was called at all
+type CallsCounter struct {
+	Counter int
+}
diff --git a/openstack/imageservice/v2/members/testing/requests_test.go b/openstack/imageservice/v2/members/testing/requests_test.go
new file mode 100644
index 0000000..04624c9
--- /dev/null
+++ b/openstack/imageservice/v2/members/testing/requests_test.go
@@ -0,0 +1,172 @@
+package testing
+
+import (
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud/openstack/imageservice/v2/members"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const createdAtString = "2013-09-20T19:22:19Z"
+const updatedAtString = "2013-09-20T19:25:31Z"
+
+func TestCreateMemberSuccessfully(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleCreateImageMemberSuccessfully(t)
+	im, err := members.Create(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		"8989447062e04a818baf9e073fd04fa7").Extract()
+	th.AssertNoErr(t, err)
+
+	createdAt, err := time.Parse(time.RFC3339, createdAtString)
+	th.AssertNoErr(t, err)
+
+	updatedAt, err := time.Parse(time.RFC3339, updatedAtString)
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, members.Member{
+		CreatedAt: createdAt,
+		ImageID:   "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		MemberID:  "8989447062e04a818baf9e073fd04fa7",
+		Schema:    "/v2/schemas/member",
+		Status:    "pending",
+		UpdatedAt: updatedAt,
+	}, *im)
+
+}
+
+func TestMemberListSuccessfully(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageMemberList(t)
+
+	pager := members.List(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea")
+	t.Logf("Pager state %v", pager)
+	count, pages := 0, 0
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+		t.Logf("Page %v", page)
+		members, err := members.ExtractMembers(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, i := range members {
+			t.Logf("%s\t%s\t%s\t%s\t\n", i.ImageID, i.MemberID, i.Status, i.Schema)
+			count++
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+	th.AssertEquals(t, 2, count)
+}
+
+func TestMemberListEmpty(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageMemberEmptyList(t)
+
+	pager := members.List(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea")
+	t.Logf("Pager state %v", pager)
+	count, pages := 0, 0
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+		t.Logf("Page %v", page)
+		members, err := members.ExtractMembers(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, i := range members {
+			t.Logf("%s\t%s\t%s\t%s\t\n", i.ImageID, i.MemberID, i.Status, i.Schema)
+			count++
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 0, pages)
+	th.AssertEquals(t, 0, count)
+}
+
+func TestShowMemberDetails(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageMemberDetails(t)
+	md, err := members.Get(fakeclient.ServiceClient(),
+		"da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		"8989447062e04a818baf9e073fd04fa7").Extract()
+
+	th.AssertNoErr(t, err)
+	if md == nil {
+		t.Errorf("Expected non-nil value for md")
+	}
+
+	createdAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+	th.AssertNoErr(t, err)
+
+	updatedAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, members.Member{
+		CreatedAt: createdAt,
+		ImageID:   "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		MemberID:  "8989447062e04a818baf9e073fd04fa7",
+		Schema:    "/v2/schemas/member",
+		Status:    "pending",
+		UpdatedAt: updatedAt,
+	}, *md)
+}
+
+func TestDeleteMember(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	counter := HandleImageMemberDeleteSuccessfully(t)
+
+	result := members.Delete(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		"8989447062e04a818baf9e073fd04fa7")
+	th.AssertEquals(t, 1, counter.Counter)
+	th.AssertNoErr(t, result.Err)
+}
+
+func TestMemberUpdateSuccessfully(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	counter := HandleImageMemberUpdate(t)
+	im, err := members.Update(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		"8989447062e04a818baf9e073fd04fa7",
+		members.UpdateOpts{
+			Status: "accepted",
+		}).Extract()
+	th.AssertEquals(t, 1, counter.Counter)
+	th.AssertNoErr(t, err)
+
+	createdAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+	th.AssertNoErr(t, err)
+
+	updatedAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, members.Member{
+		CreatedAt: createdAt,
+		ImageID:   "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		MemberID:  "8989447062e04a818baf9e073fd04fa7",
+		Schema:    "/v2/schemas/member",
+		Status:    "accepted",
+		UpdatedAt: updatedAt,
+	}, *im)
+
+}
diff --git a/openstack/imageservice/v2/members/urls.go b/openstack/imageservice/v2/members/urls.go
new file mode 100644
index 0000000..0898364
--- /dev/null
+++ b/openstack/imageservice/v2/members/urls.go
@@ -0,0 +1,31 @@
+package members
+
+import "github.com/gophercloud/gophercloud"
+
+func imageMembersURL(c *gophercloud.ServiceClient, imageID string) string {
+	return c.ServiceURL("images", imageID, "members")
+}
+
+func listMembersURL(c *gophercloud.ServiceClient, imageID string) string {
+	return imageMembersURL(c, imageID)
+}
+
+func createMemberURL(c *gophercloud.ServiceClient, imageID string) string {
+	return imageMembersURL(c, imageID)
+}
+
+func imageMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+	return c.ServiceURL("images", imageID, "members", memberID)
+}
+
+func getMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+	return imageMemberURL(c, imageID, memberID)
+}
+
+func updateMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+	return imageMemberURL(c, imageID, memberID)
+}
+
+func deleteMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+	return imageMemberURL(c, imageID, memberID)
+}