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/README.md b/openstack/imageservice/v2/README.md
new file mode 100644
index 0000000..05c19be
--- /dev/null
+++ b/openstack/imageservice/v2/README.md
@@ -0,0 +1 @@
+This provides a Go API which wraps any service implementing the [OpenStack Image Service API, version 2](http://developer.openstack.org/api-ref-image-v2.html).
diff --git a/openstack/imageservice/v2/imagedata/requests.go b/openstack/imageservice/v2/imagedata/requests.go
new file mode 100644
index 0000000..b1aac8e
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/requests.go
@@ -0,0 +1,28 @@
+package imagedata
+
+import (
+	"io"
+	"net/http"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// Upload uploads image file
+func Upload(client *gophercloud.ServiceClient, id string, data io.ReadSeeker) (r UploadResult) {
+	_, r.Err = client.Put(uploadURL(client, id), data, nil, &gophercloud.RequestOpts{
+		MoreHeaders: map[string]string{"Content-Type": "application/octet-stream"},
+		OkCodes:     []int{204},
+	})
+	return
+}
+
+// Download retrieves file
+func Download(client *gophercloud.ServiceClient, id string) (r DownloadResult) {
+	var resp *http.Response
+	resp, r.Err = client.Get(downloadURL(client, id), nil, nil)
+	if resp != nil {
+		r.Body = resp.Body
+		r.Header = resp.Header
+	}
+	return
+}
diff --git a/openstack/imageservice/v2/imagedata/results.go b/openstack/imageservice/v2/imagedata/results.go
new file mode 100644
index 0000000..970b226
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/results.go
@@ -0,0 +1,26 @@
+package imagedata
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// UploadResult is the result of an upload image operation
+type UploadResult struct {
+	gophercloud.ErrResult
+}
+
+// DownloadResult is the result of a download image operation
+type DownloadResult struct {
+	gophercloud.Result
+}
+
+// Extract builds images model from io.Reader
+func (r DownloadResult) Extract() (io.Reader, error) {
+	if r, ok := r.Body.(io.Reader); ok {
+		return r, nil
+	}
+	return nil, fmt.Errorf("Expected io.Reader but got: %T(%#v)", r.Body, r.Body)
+}
diff --git a/openstack/imageservice/v2/imagedata/testing/fixtures.go b/openstack/imageservice/v2/imagedata/testing/fixtures.go
new file mode 100644
index 0000000..fe93fc9
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/testing/fixtures.go
@@ -0,0 +1,40 @@
+package testing
+
+import (
+	"io/ioutil"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandlePutImageDataSuccessfully setup
+func HandlePutImageDataSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		b, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("Unable to read request body: %v", err)
+		}
+
+		th.AssertByteArrayEquals(t, []byte{5, 3, 7, 24}, b)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleGetImageDataSuccessfully setup
+func HandleGetImageDataSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file", 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)
+
+		_, err := w.Write([]byte{34, 87, 0, 23, 23, 23, 56, 255, 254, 0})
+		th.AssertNoErr(t, err)
+	})
+}
diff --git a/openstack/imageservice/v2/imagedata/testing/requests_test.go b/openstack/imageservice/v2/imagedata/testing/requests_test.go
new file mode 100644
index 0000000..4ac42d0
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/testing/requests_test.go
@@ -0,0 +1,87 @@
+package testing
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/imageservice/v2/imagedata"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestUpload(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandlePutImageDataSuccessfully(t)
+
+	err := imagedata.Upload(
+		fakeclient.ServiceClient(),
+		"da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		readSeekerOfBytes([]byte{5, 3, 7, 24})).ExtractErr()
+
+	th.AssertNoErr(t, err)
+}
+
+func readSeekerOfBytes(bs []byte) io.ReadSeeker {
+	return &RS{bs: bs}
+}
+
+// implements io.ReadSeeker
+type RS struct {
+	bs     []byte
+	offset int
+}
+
+func (rs *RS) Read(p []byte) (int, error) {
+	leftToRead := len(rs.bs) - rs.offset
+
+	if 0 < leftToRead {
+		bytesToWrite := min(leftToRead, len(p))
+		for i := 0; i < bytesToWrite; i++ {
+			p[i] = rs.bs[rs.offset]
+			rs.offset++
+		}
+		return bytesToWrite, nil
+	}
+	return 0, io.EOF
+}
+
+func min(a int, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+func (rs *RS) Seek(offset int64, whence int) (int64, error) {
+	var offsetInt = int(offset)
+	if whence == 0 {
+		rs.offset = offsetInt
+	} else if whence == 1 {
+		rs.offset = rs.offset + offsetInt
+	} else if whence == 2 {
+		rs.offset = len(rs.bs) - offsetInt
+	} else {
+		return 0, fmt.Errorf("For parameter `whence`, expected value in {0,1,2} but got: %#v", whence)
+	}
+
+	return int64(rs.offset), nil
+}
+
+func TestDownload(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleGetImageDataSuccessfully(t)
+
+	rdr, err := imagedata.Download(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea").Extract()
+	th.AssertNoErr(t, err)
+
+	bs, err := ioutil.ReadAll(rdr)
+	th.AssertNoErr(t, err)
+
+	th.AssertByteArrayEquals(t, []byte{34, 87, 0, 23, 23, 23, 56, 255, 254, 0}, bs)
+}
diff --git a/openstack/imageservice/v2/imagedata/urls.go b/openstack/imageservice/v2/imagedata/urls.go
new file mode 100644
index 0000000..ccd6416
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/urls.go
@@ -0,0 +1,13 @@
+package imagedata
+
+import "github.com/gophercloud/gophercloud"
+
+// `imageDataURL(c,i)` is the URL for the binary image data for the
+// image identified by ID `i` in the service `c`.
+func uploadURL(c *gophercloud.ServiceClient, imageID string) string {
+	return c.ServiceURL("images", imageID, "file")
+}
+
+func downloadURL(c *gophercloud.ServiceClient, imageID string) string {
+	return uploadURL(c, imageID)
+}
diff --git a/openstack/imageservice/v2/images/requests.go b/openstack/imageservice/v2/images/requests.go
new file mode 100644
index 0000000..32f09ee
--- /dev/null
+++ b/openstack/imageservice/v2/images/requests.go
@@ -0,0 +1,238 @@
+package images
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToImageListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the server attributes you want to see returned. Marker and Limit are used
+// for pagination.
+//http://developer.openstack.org/api-ref-image-v2.html
+type ListOpts struct {
+	// Integer value for the limit of values to return.
+	Limit int `q:"limit"`
+
+	// UUID of the server at which you want to set a marker.
+	Marker string `q:"marker"`
+
+	Name         string            `q:"name"`
+	Visibility   ImageVisibility   `q:"visibility"`
+	MemberStatus ImageMemberStatus `q:"member_status"`
+	Owner        string            `q:"owner"`
+	Status       ImageStatus       `q:"status"`
+	SizeMin      int64             `q:"size_min"`
+	SizeMax      int64             `q:"size_max"`
+	SortKey      string            `q:"sort_key"`
+	SortDir      string            `q:"sort_dir"`
+	Tag          string            `q:"tag"`
+}
+
+// ToImageListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToImageListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List implements image list request
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(c)
+	if opts != nil {
+		query, err := opts.ToImageListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return ImagePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+// The CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+	// Returns value that can be passed to json.Marshal
+	ToImageCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts implements CreateOptsBuilder
+type CreateOpts struct {
+	// Name is the name of the new image.
+	Name string `json:"name" required:"true"`
+
+	// Id is the the image ID.
+	ID string `json:"id,omitempty"`
+
+	// Visibility defines who can see/use the image.
+	Visibility *ImageVisibility `json:"visibility,omitempty"`
+
+	// Tags is a set of image tags.
+	Tags []string `json:"tags,omitempty"`
+
+	// ContainerFormat is the format of the
+	// container. Valid values are ami, ari, aki, bare, and ovf.
+	ContainerFormat string `json:"container_format,omitempty"`
+
+	// DiskFormat is the format of the disk. If set,
+	// valid values are ami, ari, aki, vhd, vmdk, raw, qcow2, vdi,
+	// and iso.
+	DiskFormat string `json:"disk_format,omitempty"`
+
+	// MinDisk is the amount of disk space in
+	// GB that is required to boot the image.
+	MinDisk int `json:"min_disk,omitempty"`
+
+	// MinRAM is the amount of RAM in MB that
+	// is required to boot the image.
+	MinRAM int `json:"min_ram,omitempty"`
+
+	// protected is whether the image is not deletable.
+	Protected *bool `json:"protected,omitempty"`
+
+	// properties is a set of properties, if any, that
+	// are associated with the image.
+	Properties map[string]string `json:"-,omitempty"`
+}
+
+// ToImageCreateMap assembles a request body based on the contents of
+// a CreateOpts.
+func (opts CreateOpts) ToImageCreateMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	if opts.Properties != nil {
+		for k, v := range opts.Properties {
+			b[k] = v
+		}
+	}
+	return b, nil
+}
+
+// Create implements create image request
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToImageCreateMap()
+	if err != nil {
+		r.Err = err
+		return r
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{201}})
+	return
+}
+
+// Delete implements image delete request
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// Get implements image get request
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// Update implements image updated request
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToImageUpdateMap()
+	if err != nil {
+		r.Err = err
+		return r
+	}
+	_, r.Err = client.Patch(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes:     []int{200},
+		MoreHeaders: map[string]string{"Content-Type": "application/openstack-images-v2.1-json-patch"},
+	})
+	return
+}
+
+// UpdateOptsBuilder implements UpdateOptsBuilder
+type UpdateOptsBuilder interface {
+	// returns value implementing json.Marshaler which when marshaled matches the patch schema:
+	// http://specs.openstack.org/openstack/glance-specs/specs/api/v2/http-patch-image-api-v2.html
+	ToImageUpdateMap() ([]interface{}, error)
+}
+
+// UpdateOpts implements UpdateOpts
+type UpdateOpts []Patch
+
+// ToImageUpdateMap builder
+func (opts UpdateOpts) ToImageUpdateMap() ([]interface{}, error) {
+	m := make([]interface{}, len(opts))
+	for i, patch := range opts {
+		patchJSON := patch.ToImagePatchMap()
+		m[i] = patchJSON
+	}
+	return m, nil
+}
+
+// Patch represents a single update to an existing image. Multiple updates to an image can be
+// submitted at the same time.
+type Patch interface {
+	ToImagePatchMap() map[string]interface{}
+}
+
+// UpdateVisibility updated visibility
+type UpdateVisibility struct {
+	Visibility ImageVisibility
+}
+
+// ToImagePatchMap builder
+func (u UpdateVisibility) ToImagePatchMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "replace",
+		"path":  "/visibility",
+		"value": u.Visibility,
+	}
+}
+
+// ReplaceImageName implements Patch
+type ReplaceImageName struct {
+	NewName string
+}
+
+// ToImagePatchMap builder
+func (r ReplaceImageName) ToImagePatchMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "replace",
+		"path":  "/name",
+		"value": r.NewName,
+	}
+}
+
+// ReplaceImageChecksum implements Patch
+type ReplaceImageChecksum struct {
+	Checksum string
+}
+
+// ReplaceImageChecksum builder
+func (rc ReplaceImageChecksum) ToImagePatchMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "replace",
+		"path":  "/checksum",
+		"value": rc.Checksum,
+	}
+}
+
+// ReplaceImageTags implements Patch
+type ReplaceImageTags struct {
+	NewTags []string
+}
+
+// ToImagePatchMap builder
+func (r ReplaceImageTags) ToImagePatchMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "replace",
+		"path":  "/tags",
+		"value": r.NewTags,
+	}
+}
diff --git a/openstack/imageservice/v2/images/results.go b/openstack/imageservice/v2/images/results.go
new file mode 100644
index 0000000..653c68c
--- /dev/null
+++ b/openstack/imageservice/v2/images/results.go
@@ -0,0 +1,176 @@
+package images
+
+import (
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Image model
+// Does not include the literal image data; just metadata.
+// returned by listing images, and by fetching a specific image.
+type Image struct {
+	// ID is the image UUID
+	ID string `json:"id"`
+
+	// Name is the human-readable display name for the image.
+	Name string `json:"name"`
+
+	// Status is the image status. It can be "queued" or "active"
+	// See imageservice/v2/images/type.go
+	Status ImageStatus `json:"status"`
+
+	// Tags is a list of image tags. Tags are arbitrarily defined strings
+	// attached to an image.
+	Tags []string `json:"tags"`
+
+	// ContainerFormat is the format of the container.
+	// Valid values are ami, ari, aki, bare, and ovf.
+	ContainerFormat string `json:"container_format"`
+
+	// DiskFormat is the format of the disk.
+	// If set, valid values are ami, ari, aki, vhd, vmdk, raw, qcow2, vdi, and iso.
+	DiskFormat string `json:"disk_format"`
+
+	// MinDiskGigabytes is the amount of disk space in GB that is required to boot the image.
+	MinDiskGigabytes int `json:"min_disk"`
+
+	// MinRAMMegabytes [optional] is the amount of RAM in MB that is required to boot the image.
+	MinRAMMegabytes int `json:"min_ram"`
+
+	// Owner is the tenant the image belongs to.
+	Owner string `json:"owner"`
+
+	// Protected is whether the image is deletable or not.
+	Protected bool `json:"protected"`
+
+	// Visibility defines who can see/use the image.
+	Visibility ImageVisibility `json:"visibility"`
+
+	// Checksum is the checksum of the data that's associated with the image
+	Checksum string `json:"checksum"`
+
+	// SizeBytes is the size of the data that's associated with the image.
+	SizeBytes int64 `json:"size"`
+
+	// Metadata is a set of metadata associated with the image.
+	// Image metadata allow for meaningfully define the image properties
+	// and tags. See http://docs.openstack.org/developer/glance/metadefs-concepts.html.
+	Metadata map[string]string `json:"metadata"`
+
+	// Properties is a set of key-value pairs, if any, that are associated with the image.
+	Properties map[string]string `json:"properties"`
+
+	// CreatedAt is the date when the image has been created.
+	CreatedAt time.Time `json:"-"`
+
+	// UpdatedAt is the date when the last change has been made to the image or it's properties.
+	UpdatedAt time.Time `json:"-"`
+
+	// File is the trailing path after the glance endpoint that represent the location
+	// of the image or the path to retrieve it.
+	File string `json:"file"`
+
+	// Schema is the path to the JSON-schema that represent the image or image entity.
+	Schema string `json:"schema"`
+}
+
+func (s *Image) UnmarshalJSON(b []byte) error {
+	type tmp Image
+	var p *struct {
+		tmp
+		SizeBytes interface{} `json:"size"`
+		CreatedAt string      `json:"created_at"`
+		UpdatedAt string      `json:"updated_at"`
+	}
+	err := json.Unmarshal(b, &p)
+	if err != nil {
+		return err
+	}
+	*s = Image(p.tmp)
+
+	switch t := p.SizeBytes.(type) {
+	case nil:
+		return nil
+	case float32:
+		s.SizeBytes = int64(t)
+	case float64:
+		s.SizeBytes = int64(t)
+	default:
+		return fmt.Errorf("Unknown type for SizeBytes: %v (value: %v)", reflect.TypeOf(t), t)
+	}
+
+	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
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract interprets any commonResult as an Image.
+func (r commonResult) Extract() (*Image, error) {
+	var s *Image
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+	commonResult
+}
+
+//DeleteResult model
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// ImagePage represents page
+type ImagePage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Images results.
+func (r ImagePage) IsEmpty() (bool, error) {
+	images, err := ExtractImages(r)
+	return len(images) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (r ImagePage) NextPageURL() (string, error) {
+	var s struct {
+		Next string `json:"next"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return nextPageURL(r.URL.String(), s.Next), nil
+}
+
+// ExtractImages interprets the results of a single page from a List() call, producing a slice of Image entities.
+func ExtractImages(r pagination.Page) ([]Image, error) {
+	var s struct {
+		Images []Image `json:"images"`
+	}
+	err := (r.(ImagePage)).ExtractInto(&s)
+	return s.Images, err
+}
diff --git a/openstack/imageservice/v2/images/testing/fixtures.go b/openstack/imageservice/v2/images/testing/fixtures.go
new file mode 100644
index 0000000..1754407
--- /dev/null
+++ b/openstack/imageservice/v2/images/testing/fixtures.go
@@ -0,0 +1,328 @@
+package testing
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+type imageEntry struct {
+	ID   string
+	JSON string
+}
+
+// HandleImageListSuccessfully test setup
+func HandleImageListSuccessfully(t *testing.T) {
+
+	images := make([]imageEntry, 3)
+
+	images[0] = imageEntry{"cirros-0.3.4-x86_64-uec",
+		`{
+            "status": "active",
+            "name": "cirros-0.3.4-x86_64-uec",
+            "tags": [],
+            "kernel_id": "e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+            "container_format": "ami",
+            "created_at": "2015-07-15T11:43:35Z",
+            "ramdisk_id": "8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+            "disk_format": "ami",
+            "updated_at": "2015-07-15T11:43:35Z",
+            "visibility": "public",
+            "self": "/v2/images/07aa21a9-fa1a-430e-9a33-185be5982431",
+            "min_disk": 0,
+            "protected": false,
+            "id": "07aa21a9-fa1a-430e-9a33-185be5982431",
+            "size": 25165824,
+            "file": "/v2/images/07aa21a9-fa1a-430e-9a33-185be5982431/file",
+            "checksum": "eb9139e4942121f22bbc2afc0400b2a4",
+            "owner": "cba624273b8344e59dd1fd18685183b0",
+            "virtual_size": null,
+            "min_ram": 0,
+            "schema": "/v2/schemas/image"
+        }`}
+	images[1] = imageEntry{"cirros-0.3.4-x86_64-uec-ramdisk",
+		`{
+            "status": "active",
+            "name": "cirros-0.3.4-x86_64-uec-ramdisk",
+            "tags": [],
+            "container_format": "ari",
+            "created_at": "2015-07-15T11:43:32Z",
+            "size": 3740163,
+            "disk_format": "ari",
+            "updated_at": "2015-07-15T11:43:32Z",
+            "visibility": "public",
+            "self": "/v2/images/8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+            "min_disk": 0,
+            "protected": false,
+            "id": "8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+            "file": "/v2/images/8c64f48a-45a3-4eaa-adff-a8106b6c005b/file",
+            "checksum": "be575a2b939972276ef675752936977f",
+            "owner": "cba624273b8344e59dd1fd18685183b0",
+            "virtual_size": null,
+            "min_ram": 0,
+            "schema": "/v2/schemas/image"
+        }`}
+	images[2] = imageEntry{"cirros-0.3.4-x86_64-uec-kernel",
+		`{
+            "status": "active",
+            "name": "cirros-0.3.4-x86_64-uec-kernel",
+            "tags": [],
+            "container_format": "aki",
+            "created_at": "2015-07-15T11:43:29Z",
+            "size": 4979632,
+            "disk_format": "aki",
+            "updated_at": "2015-07-15T11:43:30Z",
+            "visibility": "public",
+            "self": "/v2/images/e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+            "min_disk": 0,
+            "protected": false,
+            "id": "e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+            "file": "/v2/images/e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4/file",
+            "checksum": "8a40c862b5735975d82605c1dd395796",
+            "owner": "cba624273b8344e59dd1fd18685183b0",
+            "virtual_size": null,
+            "min_ram": 0,
+            "schema": "/v2/schemas/image"
+        }`}
+
+	th.Mux.HandleFunc("/images", 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")
+
+		w.WriteHeader(http.StatusOK)
+
+		limit := 10
+		var err error
+		if r.FormValue("limit") != "" {
+			limit, err = strconv.Atoi(r.FormValue("limit"))
+			if err != nil {
+				t.Errorf("Error value for 'limit' parameter %v (error: %v)", r.FormValue("limit"), err)
+			}
+
+		}
+
+		marker := ""
+		newMarker := ""
+
+		if r.Form["marker"] != nil {
+			marker = r.Form["marker"][0]
+		}
+
+		t.Logf("limit = %v   marker = %v", limit, marker)
+
+		selected := 0
+		addNext := false
+		var imageJSON []string
+
+		fmt.Fprintf(w, `{"images": [`)
+
+		for _, i := range images {
+			if marker == "" || addNext {
+				t.Logf("Adding image %v to page", i.ID)
+				imageJSON = append(imageJSON, i.JSON)
+				newMarker = i.ID
+				selected++
+			} else {
+				if strings.Contains(i.JSON, marker) {
+					addNext = true
+				}
+			}
+
+			if selected == limit {
+				break
+			}
+		}
+		t.Logf("Writing out %v image(s)", len(imageJSON))
+		fmt.Fprintf(w, strings.Join(imageJSON, ","))
+
+		fmt.Fprintf(w, `],
+			    "next": "/images?marker=%s&limit=%v",
+			    "schema": "/schemas/images",
+			    "first": "/images?limit=%v"}`, newMarker, limit, limit)
+
+	})
+}
+
+// HandleImageCreationSuccessfully test setup
+func HandleImageCreationSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images", 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, `{
+			"id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+			"name": "Ubuntu 12.10",
+			"tags": [
+				"ubuntu",
+				"quantal"
+			]
+		}`)
+
+		w.WriteHeader(http.StatusCreated)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+			"status": "queued",
+			"name": "Ubuntu 12.10",
+			"protected": false,
+			"tags": ["ubuntu","quantal"],
+			"container_format": "bare",
+			"created_at": "2014-11-11T20:47:55Z",
+			"disk_format": "qcow2",
+			"updated_at": "2014-11-11T20:47:55Z",
+			"visibility": "private",
+			"self": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+			"min_disk": 0,
+			"protected": false,
+			"id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+			"file": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd/file",
+			"owner": "b4eedccc6fb74fa8a7ad6b08382b852b",
+			"min_ram": 0,
+			"schema": "/v2/schemas/image",
+			"size": 0,
+			"checksum": "",
+			"virtual_size": 0
+		}`)
+	})
+}
+
+// HandleImageCreationSuccessfullyNulls test setup
+// JSON null values could be also returned according to behaviour https://bugs.launchpad.net/glance/+bug/1481512
+func HandleImageCreationSuccessfullyNulls(t *testing.T) {
+	th.Mux.HandleFunc("/images", 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, `{
+			"id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+			"name": "Ubuntu 12.10",
+			"tags": [
+				"ubuntu",
+				"quantal"
+			]
+		}`)
+
+		w.WriteHeader(http.StatusCreated)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+			"status": "queued",
+			"name": "Ubuntu 12.10",
+			"protected": false,
+			"tags": ["ubuntu","quantal"],
+			"container_format": "bare",
+			"created_at": "2014-11-11T20:47:55Z",
+			"disk_format": "qcow2",
+			"updated_at": "2014-11-11T20:47:55Z",
+			"visibility": "private",
+			"self": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+			"min_disk": 0,
+			"protected": false,
+			"id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+			"file": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd/file",
+			"owner": "b4eedccc6fb74fa8a7ad6b08382b852b",
+			"min_ram": 0,
+			"schema": "/v2/schemas/image",
+			"size": null,
+			"checksum": null,
+			"virtual_size": null
+		}`)
+	})
+}
+
+// HandleImageGetSuccessfully test setup
+func HandleImageGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27", 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)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+			"status": "active",
+			"name": "cirros-0.3.2-x86_64-disk",
+			"tags": [],
+			"container_format": "bare",
+			"created_at": "2014-05-05T17:15:10Z",
+			"disk_format": "qcow2",
+			"updated_at": "2014-05-05T17:15:11Z",
+			"visibility": "public",
+			"self": "/v2/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+			"min_disk": 0,
+			"protected": false,
+			"id": "1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+			"file": "/v2/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27/file",
+			"checksum": "64d7c1cd2b6f60c92c14662941cb7913",
+			"owner": "5ef70662f8b34079a6eddb8da9d75fe8",
+			"size": 13167616,
+			"min_ram": 0,
+			"schema": "/v2/schemas/image",
+			"virtual_size": "None"
+		}`)
+	})
+}
+
+// HandleImageDeleteSuccessfully test setup
+func HandleImageDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleImageUpdateSuccessfully setup
+func HandleImageUpdateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PATCH")
+		th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+		th.TestJSONRequest(t, r, `[
+			{
+				"op": "replace",
+				"path": "/name",
+				"value": "Fedora 17"
+			},
+			{
+				"op": "replace",
+				"path": "/tags",
+				"value": [
+					"fedora",
+					"beefy"
+				]
+			}
+		]`)
+
+		th.AssertEquals(t, "application/openstack-images-v2.1-json-patch", r.Header.Get("Content-Type"))
+
+		w.WriteHeader(http.StatusOK)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+			"id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+			"name": "Fedora 17",
+			"status": "active",
+			"visibility": "public",
+			"size": 2254249,
+			"checksum": "2cec138d7dae2aa59038ef8c9aec2390",
+			"tags": [
+				"fedora",
+				"beefy"
+			],
+			"created_at": "2012-08-10T19:23:50Z",
+			"updated_at": "2012-08-12T11:11:33Z",
+			"self": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+			"file": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file",
+			"schema": "/v2/schemas/image",
+			"owner": "",
+			"min_ram": 0,
+			"min_disk": 0,
+			"disk_format": "",
+			"virtual_size": 0,
+			"container_format": ""
+		}`)
+	})
+}
diff --git a/openstack/imageservice/v2/images/testing/requests_test.go b/openstack/imageservice/v2/images/testing/requests_test.go
new file mode 100644
index 0000000..788eec4
--- /dev/null
+++ b/openstack/imageservice/v2/images/testing/requests_test.go
@@ -0,0 +1,259 @@
+package testing
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/imageservice/v2/images"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageListSuccessfully(t)
+
+	t.Logf("Test setup %+v\n", th.Server)
+
+	t.Logf("Id\tName\tOwner\tChecksum\tSizeBytes")
+
+	pager := images.List(fakeclient.ServiceClient(), images.ListOpts{Limit: 1})
+	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)
+		images, err := images.ExtractImages(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, i := range images {
+			t.Logf("%s\t%s\t%s\t%s\t%v\t\n", i.ID, i.Name, i.Owner, i.Checksum, i.SizeBytes)
+			count++
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	t.Logf("--------\n%d images listed on %d pages.\n", count, pages)
+	th.AssertEquals(t, 3, pages)
+	th.AssertEquals(t, 3, count)
+}
+
+func TestCreateImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageCreationSuccessfully(t)
+
+	id := "e7db3b45-8db7-47ad-8109-3fb55c2c24fd"
+	name := "Ubuntu 12.10"
+
+	actualImage, err := images.Create(fakeclient.ServiceClient(), images.CreateOpts{
+		ID:   id,
+		Name: name,
+		Tags: []string{"ubuntu", "quantal"},
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	containerFormat := "bare"
+	diskFormat := "qcow2"
+	owner := "b4eedccc6fb74fa8a7ad6b08382b852b"
+	minDiskGigabytes := 0
+	minRAMMegabytes := 0
+	file := actualImage.File
+	createdDate := actualImage.CreatedAt
+	lastUpdate := actualImage.UpdatedAt
+	schema := "/v2/schemas/image"
+
+	expectedImage := images.Image{
+		ID:   "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+		Name: "Ubuntu 12.10",
+		Tags: []string{"ubuntu", "quantal"},
+
+		Status: images.ImageStatusQueued,
+
+		ContainerFormat: containerFormat,
+		DiskFormat:      diskFormat,
+
+		MinDiskGigabytes: minDiskGigabytes,
+		MinRAMMegabytes:  minRAMMegabytes,
+
+		Owner: owner,
+
+		Visibility: images.ImageVisibilityPrivate,
+		File:       file,
+		CreatedAt:  createdDate,
+		UpdatedAt:  lastUpdate,
+		Schema:     schema,
+	}
+
+	th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestCreateImageNulls(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageCreationSuccessfullyNulls(t)
+
+	id := "e7db3b45-8db7-47ad-8109-3fb55c2c24fd"
+	name := "Ubuntu 12.10"
+
+	actualImage, err := images.Create(fakeclient.ServiceClient(), images.CreateOpts{
+		ID:   id,
+		Name: name,
+		Tags: []string{"ubuntu", "quantal"},
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	containerFormat := "bare"
+	diskFormat := "qcow2"
+	owner := "b4eedccc6fb74fa8a7ad6b08382b852b"
+	minDiskGigabytes := 0
+	minRAMMegabytes := 0
+	file := actualImage.File
+	createdDate := actualImage.CreatedAt
+	lastUpdate := actualImage.UpdatedAt
+	schema := "/v2/schemas/image"
+
+	expectedImage := images.Image{
+		ID:   "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+		Name: "Ubuntu 12.10",
+		Tags: []string{"ubuntu", "quantal"},
+
+		Status: images.ImageStatusQueued,
+
+		ContainerFormat: containerFormat,
+		DiskFormat:      diskFormat,
+
+		MinDiskGigabytes: minDiskGigabytes,
+		MinRAMMegabytes:  minRAMMegabytes,
+
+		Owner: owner,
+
+		Visibility: images.ImageVisibilityPrivate,
+		File:       file,
+		CreatedAt:  createdDate,
+		UpdatedAt:  lastUpdate,
+		Schema:     schema,
+	}
+
+	th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestGetImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageGetSuccessfully(t)
+
+	actualImage, err := images.Get(fakeclient.ServiceClient(), "1bea47ed-f6a9-463b-b423-14b9cca9ad27").Extract()
+
+	th.AssertNoErr(t, err)
+
+	checksum := "64d7c1cd2b6f60c92c14662941cb7913"
+	sizeBytes := int64(13167616)
+	containerFormat := "bare"
+	diskFormat := "qcow2"
+	minDiskGigabytes := 0
+	minRAMMegabytes := 0
+	owner := "5ef70662f8b34079a6eddb8da9d75fe8"
+	file := actualImage.File
+	createdDate := actualImage.CreatedAt
+	lastUpdate := actualImage.UpdatedAt
+	schema := "/v2/schemas/image"
+
+	expectedImage := images.Image{
+		ID:   "1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+		Name: "cirros-0.3.2-x86_64-disk",
+		Tags: []string{},
+
+		Status: images.ImageStatusActive,
+
+		ContainerFormat: containerFormat,
+		DiskFormat:      diskFormat,
+
+		MinDiskGigabytes: minDiskGigabytes,
+		MinRAMMegabytes:  minRAMMegabytes,
+
+		Owner: owner,
+
+		Protected:  false,
+		Visibility: images.ImageVisibilityPublic,
+
+		Checksum:  checksum,
+		SizeBytes: sizeBytes,
+		File:      file,
+		CreatedAt: createdDate,
+		UpdatedAt: lastUpdate,
+		Schema:    schema,
+	}
+
+	th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestDeleteImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageDeleteSuccessfully(t)
+
+	result := images.Delete(fakeclient.ServiceClient(), "1bea47ed-f6a9-463b-b423-14b9cca9ad27")
+	th.AssertNoErr(t, result.Err)
+}
+
+func TestUpdateImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleImageUpdateSuccessfully(t)
+
+	actualImage, err := images.Update(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea", images.UpdateOpts{
+		images.ReplaceImageName{NewName: "Fedora 17"},
+		images.ReplaceImageTags{NewTags: []string{"fedora", "beefy"}},
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	sizebytes := int64(2254249)
+	checksum := "2cec138d7dae2aa59038ef8c9aec2390"
+	file := actualImage.File
+	createdDate := actualImage.CreatedAt
+	lastUpdate := actualImage.UpdatedAt
+	schema := "/v2/schemas/image"
+
+	expectedImage := images.Image{
+		ID:         "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+		Name:       "Fedora 17",
+		Status:     images.ImageStatusActive,
+		Visibility: images.ImageVisibilityPublic,
+
+		SizeBytes: sizebytes,
+		Checksum:  checksum,
+
+		Tags: []string{
+			"fedora",
+			"beefy",
+		},
+
+		Owner:            "",
+		MinRAMMegabytes:  0,
+		MinDiskGigabytes: 0,
+
+		DiskFormat:      "",
+		ContainerFormat: "",
+		File:            file,
+		CreatedAt:       createdDate,
+		UpdatedAt:       lastUpdate,
+		Schema:          schema,
+	}
+
+	th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
diff --git a/openstack/imageservice/v2/images/types.go b/openstack/imageservice/v2/images/types.go
new file mode 100644
index 0000000..086e7e5
--- /dev/null
+++ b/openstack/imageservice/v2/images/types.go
@@ -0,0 +1,75 @@
+package images
+
+// ImageStatus image statuses
+// http://docs.openstack.org/developer/glance/statuses.html
+type ImageStatus string
+
+const (
+	// ImageStatusQueued is a status for an image which identifier has
+	// been reserved for an image in the image registry.
+	ImageStatusQueued ImageStatus = "queued"
+
+	// ImageStatusSaving denotes that an image’s raw data is currently being uploaded to Glance
+	ImageStatusSaving ImageStatus = "saving"
+
+	// ImageStatusActive denotes an image that is fully available in Glance.
+	ImageStatusActive ImageStatus = "active"
+
+	// ImageStatusKilled denotes that an error occurred during the uploading
+	// of an image’s data, and that the image is not readable.
+	ImageStatusKilled ImageStatus = "killed"
+
+	// ImageStatusDeleted is used for an image that is no longer available to use.
+	// The image information is retained in the image registry.
+	ImageStatusDeleted ImageStatus = "deleted"
+
+	// ImageStatusPendingDelete is similar to Delete, but the image is not yet deleted.
+	ImageStatusPendingDelete ImageStatus = "pending_delete"
+
+	// ImageStatusDeactivated denotes that access to image data is not allowed to any non-admin user.
+	ImageStatusDeactivated ImageStatus = "deactivated"
+)
+
+// ImageVisibility denotes an image that is fully available in Glance.
+// This occurs when the image data is uploaded, or the image size
+// is explicitly set to zero on creation.
+// According to design
+// https://wiki.openstack.org/wiki/Glance-v2-community-image-visibility-design
+type ImageVisibility string
+
+const (
+	// ImageVisibilityPublic all users
+	ImageVisibilityPublic ImageVisibility = "public"
+
+	// ImageVisibilityPrivate users with tenantId == tenantId(owner)
+	ImageVisibilityPrivate ImageVisibility = "private"
+
+	// ImageVisibilityShared images are visible to:
+	// - users with tenantId == tenantId(owner)
+	// - users with tenantId in the member-list of the image
+	// - users with tenantId in the member-list with member_status == 'accepted'
+	ImageVisibilityShared ImageVisibility = "shared"
+
+	// ImageVisibilityCommunity images:
+	// - all users can see and boot it
+	// - users with tenantId in the member-list of the image with member_status == 'accepted'
+	//   have this image in their default image-list
+	ImageVisibilityCommunity ImageVisibility = "community"
+)
+
+// MemberStatus is a status for adding a new member (tenant) to an image member list.
+type ImageMemberStatus string
+
+const (
+	// ImageMemberStatusAccepted is the status for an accepted image member.
+	ImageMemberStatusAccepted ImageMemberStatus = "accepted"
+
+	// ImageMemberStatusPending shows that the member addition is pending
+	ImageMemberStatusPending ImageMemberStatus = "pending"
+
+	// ImageMemberStatusAccepted is the status for a rejected image member
+	ImageMemberStatusRejected ImageMemberStatus = "rejected"
+
+	// ImageMemberStatusAll
+	ImageMemberStatusAll ImageMemberStatus = "all"
+)
diff --git a/openstack/imageservice/v2/images/urls.go b/openstack/imageservice/v2/images/urls.go
new file mode 100644
index 0000000..58cb8f7
--- /dev/null
+++ b/openstack/imageservice/v2/images/urls.go
@@ -0,0 +1,44 @@
+package images
+
+import (
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// `listURL` is a pure function. `listURL(c)` is a URL for which a GET
+// request will respond with a list of images in the service `c`.
+func listURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("images")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("images")
+}
+
+// `imageURL(c,i)` is the URL for the image identified by ID `i` in
+// the service `c`.
+func imageURL(c *gophercloud.ServiceClient, imageID string) string {
+	return c.ServiceURL("images", imageID)
+}
+
+// `getURL(c,i)` is a URL for which a GET request will respond with
+// information about the image identified by ID `i` in the service
+// `c`.
+func getURL(c *gophercloud.ServiceClient, imageID string) string {
+	return imageURL(c, imageID)
+}
+
+func updateURL(c *gophercloud.ServiceClient, imageID string) string {
+	return imageURL(c, imageID)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, imageID string) string {
+	return imageURL(c, imageID)
+}
+
+// builds next page full url based on current url
+func nextPageURL(currentURL string, next string) string {
+	base := currentURL[:strings.Index(currentURL, "/images")]
+	return base + next
+}
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)
+}