Repository: gophercloud/gophercloud

Issue:
Add support of Networking TaaS TapMirrors
- [ ] Create https://github.com/gophercloud/gophercloud/pull/3483
- [ ] Read
- [ ] Update
- [ ] Delete

OpenStack API docs https://docs.openstack.org/api-ref/network/v2/index.html#tap-mirrors

Client implementation in OpenStack https://github.com/openstack/tap-as-a-service/blob/master/neutron_taas/taas_client/osc/tap_mirror.py

I'll try to implement it.

Discussion:

Solution patch:
diff --git a/internal/acceptance/openstack/networking/v2/extensions/taas/taas.go b/internal/acceptance/openstack/networking/v2/extensions/taas/taas.go
new file mode 100644
index 000000000..3d4f3979f
--- /dev/null
+++ b/internal/acceptance/openstack/networking/v2/extensions/taas/taas.go
@@ -0,0 +1,57 @@
+package taas
+
+import (
+	"context"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/v2"
+	"github.com/gophercloud/gophercloud/v2/internal/acceptance/tools"
+	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/taas/tapmirrors"
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+// CreateTapMirror will create a Tap Mirror with the specified portID and remoteIP. An error
+// will be returned if the Tap Mirror could not be created.
+func CreateTapMirror(t *testing.T, client *gophercloud.ServiceClient, portID string, remoteIP string) (*tapmirrors.TapMirror, error) {
+	mirrorName := tools.RandomString("TESTACC-", 8)
+	mirrorDescription := tools.RandomString("TESTACC-DESC-", 8)
+	mirrorDirectionIN := tools.RandomInt(1, 1000000)
+	t.Logf("Attempting to create tap mirror: %s", mirrorName)
+
+	createopts := tapmirrors.CreateOpts{
+		Name:        mirrorName,
+		Description: mirrorDescription,
+		PortID:      portID,
+		MirrorType:  tapmirrors.MirrorTypeErspanv1,
+		RemoteIP:    remoteIP,
+		Directions: tapmirrors.Directions{
+			In:  mirrorDirectionIN,
+			Out: mirrorDirectionIN + 1,
+		},
+	}
+
+	mirror, err := tapmirrors.Create(context.TODO(), client, createopts).Extract()
+	if err != nil {
+		return nil, err
+	}
+
+	th.AssertEquals(t, mirrorName, mirror.Name)
+	th.AssertEquals(t, mirrorDescription, mirror.Description)
+
+	t.Logf("Created Tap Mirror: %s", mirror.ID)
+	return mirror, nil
+}
+
+// DeleteTapMirror will delete a Tap Mirror with a specified ID. A fatal error will
+// occur if the delete was not successful. This works best when used as a
+// deferred function.
+func DeleteTapMirror(t *testing.T, client *gophercloud.ServiceClient, mirrorID string) {
+	t.Logf("Attempting to delete Tap Mirror: %s", mirrorID)
+
+	err := tapmirrors.Delete(context.TODO(), client, mirrorID).ExtractErr()
+	if err != nil {
+		t.Fatalf("Unable to delete Tap Mirror %s: %v", mirrorID, err)
+	}
+
+	t.Logf("Deleted Tap Mirror: %s", mirrorID)
+}
diff --git a/openstack/networking/v2/extensions/taas/tapmirrors/doc.go b/openstack/networking/v2/extensions/taas/tapmirrors/doc.go
new file mode 100644
index 000000000..46e1d9966
--- /dev/null
+++ b/openstack/networking/v2/extensions/taas/tapmirrors/doc.go
@@ -0,0 +1,63 @@
+/*
+Package tapmirrors manages and retrieves Tap Mirrors in the OpenStack Networking Service.
+
+Example to Create a Tap Mirror
+
+	createopts := tapmirrors.CreateOpts{
+		Name:        "tapmirror1",
+		Description: "Description of tapmirror1",
+		PortID:      "a25290e9-1a54-4c26-a5b3-34458d122acc",
+		MirrorType:  tapmirrors.MirrorTypeErspanv1,
+		RemoteIP:    "192.168.54.217",
+		Directions: tapmirrors.Directions{
+			In:  "1",
+			Out: "2",
+		},
+	}
+
+	mirror, err := tapmirrors.Create(context.TODO(), networkClient, createopts).Extract()
+	if err != nil {
+		panic(err)
+	}
+
+Example to Show the details of a specific Tap Mirror by ID
+
+	tapMirror, err := tapmirrors.Get(context.TODO(), networkClient, "f2b08c1e-aa81-4668-8ae1-1401bcb0576c").Extract()
+	if err != nil {
+		panic(err)
+	}
+
+Example to Delete a Tap Mirror
+
+	err = tapmirrors.Delete(context.TODO(), networkClient, "5291b189-fd84-46e5-84bd-78f40c05d69c").ExtractErr()
+	if err != nil {
+		panic(err)
+	}
+
+Example to Update an Tap Mirror
+
+	name := "updated name"
+	description := "updated description"
+	updateOps := tapmirrors.UpdateOpts{
+		Description: &description,
+		Name:        &name,
+	}
+
+	updatedTapMirror, err := tapmirrors.Update(context.TODO(), networkClient, "5c561d9d-eaea-45f6-ae3e-08d1a7080828", updateOps).Extract()
+	if err != nil {
+		panic(err)
+	}
+
+Example to List Tap Mirrors
+
+	allPages, err := tapmirrors.List(networkClient, nil).AllPages(context.TODO())
+	if err != nil {
+		panic(err)
+	}
+
+	allTapMirrors, err := tapmirrors.ExtractTapMirrors(allPages)
+	if err != nil {
+		panic(err)
+	}
+*/
+package tapmirrors
diff --git a/openstack/networking/v2/extensions/taas/tapmirrors/requests.go b/openstack/networking/v2/extensions/taas/tapmirrors/requests.go
new file mode 100644
index 000000000..7b236374b
--- /dev/null
+++ b/openstack/networking/v2/extensions/taas/tapmirrors/requests.go
@@ -0,0 +1,156 @@
+package tapmirrors
+
+import (
+	"context"
+
+	"github.com/gophercloud/gophercloud/v2"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+)
+
+type MirrorType string
+
+const (
+	MirrorTypeErspanv1 MirrorType = "erspanv1"
+	MirrorTypeGre      MirrorType = "gre"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToTapMirrorCreateMap() (map[string]any, error)
+}
+
+// CreateOpts contains all the values needed to create a new tap mirror
+type CreateOpts struct {
+	// The name of the Tap Mirror.
+	Name string `json:"name"`
+
+	// A human-readable description of the Tap Mirror.
+	Description string `json:"description,omitempty"`
+
+	// The ID of the project. The caller must have an admin role in
+	// order to set this. Otherwise, this field is left unset
+	// and the caller will be the owner.
+	TenantID string `json:"tenant_id,omitempty"`
+
+	// The Port ID of the Tap Mirror, this will be the source of the mirrored traffic,
+	// and this traffic will be tunneled into the GRE or ERSPAN v1 tunnel.
+	// The tunnel itself is not starting from this port.
+	PortID string `json:"port_id"`
+
+	// The type of the mirroring, it can be gre or erspanv1.
+	MirrorType MirrorType `json:"mirror_type"`
+
+	// The remote IP of the Tap Mirror, this will be the remote end of the GRE or ERSPAN v1 tunnel.
+	RemoteIP string `json:"remote_ip"`
+
+	// A dictionary of direction and tunnel_id. Directions are In and Out. In specifies
+	// ingress traffic to the port will be mirrored, Out specifies egress traffic will be mirrored.
+	// The values of the directions are the identifiers of the ERSPAN or GRE session between
+	// the source and destination, these must be unique within the project.
+	Directions Directions `json:"directions"`
+}
+
+// ToTapMirrorCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToTapMirrorCreateMap() (map[string]any, error) {
+	return gophercloud.BuildRequestBody(opts, "tap_mirror")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new Tap Mirror.
+func Create(ctx context.Context, c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToTapMirrorCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	resp, err := c.Post(ctx, rootURL(c), b, &r.Body, nil)
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// Get retrieves a particular Tap Mirror on its ID.
+func Get(ctx context.Context, c *gophercloud.ServiceClient, id string) (r GetResult) {
+	resp, err := c.Get(ctx, resourceURL(c, id), &r.Body, nil)
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToTapMirrorListQuery() (string, error)
+}
+
+// ListOpts allows the filtering of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the Endpoint group attributes you want to see returned.
+type ListOpts struct {
+	ProjectID   string     `q:"project_id"`
+	Name        string     `q:"name"`
+	Description string     `q:"description"`
+	TenantID    string     `q:"tenant_id"`
+	PortID      string     `q:"port_id"`
+	MirrorType  MirrorType `q:"mirror_type"`
+	RemoteIP    string     `q:"remote_ip"`
+}
+
+// ToTapMirrorListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToTapMirrorListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// Tap Mirrors. It accepts a ListOpts struct, which allows you to filter
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := rootURL(c)
+	if opts != nil {
+		query, err := opts.ToTapMirrorListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return TapMirrorPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Delete will permanently delete a Tap Mirror based on its ID.
+func Delete(ctx context.Context, c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	resp, err := c.Delete(ctx, resourceURL(c, id), nil)
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+	ToTapMirrorUpdateMap() (map[string]any, error)
+}
+
+// UpdateOpts contains the values used when updating a Tap Mirror.
+type UpdateOpts struct {
+	Description *string `json:"description,omitempty"`
+	Name        *string `json:"name,omitempty"`
+}
+
+// ToTapMirrorUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToTapMirrorUpdateMap() (map[string]any, error) {
+	return gophercloud.BuildRequestBody(opts, "tap_mirror")
+}
+
+// Update allows Tap Mirrors to be updated.
+func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToTapMirrorUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	resp, err := c.Put(ctx, resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
diff --git a/openstack/networking/v2/extensions/taas/tapmirrors/results.go b/openstack/networking/v2/extensions/taas/tapmirrors/results.go
new file mode 100644
index 000000000..f6ca8b1aa
--- /dev/null
+++ b/openstack/networking/v2/extensions/taas/tapmirrors/results.go
@@ -0,0 +1,129 @@
+package tapmirrors
+
+import (
+	"github.com/gophercloud/gophercloud/v2"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+)
+
+// TapMirror represents a Tap Mirror of the networking service taas extension
+type TapMirror struct {
+	// The ID of the Tap Mirror.
+	ID string `json:"id"`
+
+	// The name of the Tap Mirror.
+	Name string `json:"name"`
+
+	// A human-readable description of the Tap Mirror.
+	Description string `json:"description"`
+
+	// The ID of the tenant.
+	TenantID string `json:"tenant_id"`
+
+	// The ID of the project.
+	ProjectID string `json:"project_id"`
+
+	// The Port ID of the Tap Mirror, this will be the source of the mirrored traffic,
+	// and this traffic will be tunneled into the GRE or ERSPAN v1 tunnel.
+	// The tunnel itself is not starting from this port.
+	PortID string `json:"port_id"`
+
+	// The type of the mirroring, it can be gre or erspanv1.
+	MirrorType string `json:"mirror_type"`
+
+	// The remote IP of the Tap Mirror, this will be the remote end of the GRE or ERSPAN v1 tunnel.
+	RemoteIP string `json:"remote_ip"`
+
+	// A dictionary of direction and tunnel_id. Directions are In and Out. In specifies
+	// ingress traffic to the port will be mirrored, Out specifies egress traffic will be mirrored.
+	// The values of the directions are the identifiers of the ERSPAN or GRE session between
+	// the source and destination, these must be unique within the project.
+	Directions Directions `json:"directions"`
+}
+
+type Directions struct {
+	// Unique identifier of the tunnel with ingress traffic. Omit to not capture ingress traffic.
+	// Encoded as JSON string to be compatible with python tap-as-a-service client.
+	In int `json:"IN,omitempty,string"`
+
+	// Unique identifier of the tunnel with egress traffic. Omit to not capture egress traffic.
+	// Encoded as JSON string to be compatible with python tap-as-a-service client.
+	Out int `json:"OUT,omitempty,string"`
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a Tap Mirror.
+func (r commonResult) Extract() (*TapMirror, error) {
+	var s struct {
+		TapMirror *TapMirror `json:"tap_mirror"`
+	}
+	err := r.ExtractInto(&s)
+	return s.TapMirror, err
+}
+
+// TapMirrorPage is the page returned by a pager when traversing over a
+// collection of Policies.
+type TapMirrorPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of Endpoint groups has
+// reached the end of a page and the pager seeks to traverse over a new one.
+// In order to do this, it needs to construct the next page's URL.
+func (r TapMirrorPage) NextPageURL(endpointURL string) (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"tap_mirrors_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether an TapMirrorPage struct is empty.
+func (r TapMirrorPage) IsEmpty() (bool, error) {
+	if r.StatusCode == 204 {
+		return true, nil
+	}
+
+	is, err := ExtractTapMirrors(r)
+	return len(is) == 0, err
+}
+
+// ExtractTapMirrors accepts a Page struct, specifically an TapMirrorPage struct,
+// and extracts the elements into a slice of Tap Mirror structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractTapMirrors(r pagination.Page) ([]TapMirror, error) {
+	var s struct {
+		TapMirrors []TapMirror `json:"tap_mirrors"`
+	}
+	err := (r.(TapMirrorPage)).ExtractInto(&s)
+	return s.TapMirrors, err
+}
+
+// CreateResult represents the result of a create operation. Call its Extract
+// method to interpret it as a Tap Mirror.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation. Call its Extract
+// method to interpret it as a TapMirror.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult represents the results of a Delete operation. Call its ExtractErr method
+// to determine whether the operation succeeded or failed.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// UpdateResult represents the result of an update operation. Call its Extract method
+// to interpret it as a TapMirror.
+type UpdateResult struct {
+	commonResult
+}
diff --git a/openstack/networking/v2/extensions/taas/tapmirrors/urls.go b/openstack/networking/v2/extensions/taas/tapmirrors/urls.go
new file mode 100644
index 000000000..1b38a7cec
--- /dev/null
+++ b/openstack/networking/v2/extensions/taas/tapmirrors/urls.go
@@ -0,0 +1,16 @@
+package tapmirrors
+
+import "github.com/gophercloud/gophercloud/v2"
+
+const (
+	rootPath     = "taas"
+	resourcePath = "tap_mirrors"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}



Test patch:
diff --git a/internal/acceptance/openstack/networking/v2/extensions/taas/tapmirrors_test.go b/internal/acceptance/openstack/networking/v2/extensions/taas/tapmirrors_test.go
new file mode 100644
index 000000000..b46d5ae58
--- /dev/null
+++ b/internal/acceptance/openstack/networking/v2/extensions/taas/tapmirrors_test.go
@@ -0,0 +1,77 @@
+//go:build acceptance || networking || taas
+
+package taas
+
+import (
+	"context"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/v2/internal/acceptance/clients"
+	networking "github.com/gophercloud/gophercloud/v2/internal/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/v2/internal/acceptance/tools"
+	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/taas/tapmirrors"
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+func TestTapMirrorList(t *testing.T) {
+	client, err := clients.NewNetworkV2Client()
+	th.AssertNoErr(t, err)
+
+	// Skip these tests if we don't have the required extension
+	networking.RequireNeutronExtension(t, client, "taas")
+
+	allPages, err := tapmirrors.List(client, nil).AllPages(context.TODO())
+	th.AssertNoErr(t, err)
+
+	allMirrors, err := tapmirrors.ExtractTapMirrors(allPages)
+	th.AssertNoErr(t, err)
+
+	for _, mirror := range allMirrors {
+		tools.PrintResource(t, mirror)
+	}
+}
+
+func TestTapMirrorCRUD(t *testing.T) {
+	client, err := clients.NewNetworkV2Client()
+	th.AssertNoErr(t, err)
+
+	// Skip these tests if we don't have the required extension
+	networking.RequireNeutronExtension(t, client, "taas")
+
+	// Create Port
+	network, err := networking.CreateNetwork(t, client)
+	th.AssertNoErr(t, err)
+	defer networking.DeleteNetwork(t, client, network.ID)
+
+	subnet, err := networking.CreateSubnet(t, client, network.ID)
+	th.AssertNoErr(t, err)
+	defer networking.DeleteSubnet(t, client, subnet.ID)
+
+	port, err := networking.CreatePort(t, client, network.ID, subnet.ID)
+	th.AssertNoErr(t, err)
+	defer networking.DeletePort(t, client, port.ID)
+
+	// Create and defer Delete Tap Mirror
+	mirror, err := CreateTapMirror(t, client, port.ID, port.FixedIPs[0].IPAddress)
+	th.AssertNoErr(t, err)
+	defer DeleteTapMirror(t, client, mirror.ID)
+
+	tools.PrintResource(t, mirror)
+
+	// Get Tap Mirror
+	newmirror, err := tapmirrors.Get(context.TODO(), client, mirror.ID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, mirror, newmirror)
+
+	// Update Tap Mirror
+	updatedName := "TESTACC-updated name"
+	updatedDescription := "TESTACC-updated mirror description"
+	updateOpts := tapmirrors.UpdateOpts{
+		Name:        &updatedName,
+		Description: &updatedDescription,
+	}
+	updatedmirror, err := tapmirrors.Update(context.TODO(), client, mirror.ID, updateOpts).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, updatedName, updatedmirror.Name)
+	th.AssertEquals(t, updatedDescription, updatedmirror.Description)
+}
diff --git a/openstack/networking/v2/extensions/taas/tapmirrors/testing/requests_test.go b/openstack/networking/v2/extensions/taas/tapmirrors/testing/requests_test.go
new file mode 100644
index 000000000..7d98c403b
--- /dev/null
+++ b/openstack/networking/v2/extensions/taas/tapmirrors/testing/requests_test.go
@@ -0,0 +1,291 @@
+package testing
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/taas/tapmirrors"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+func TestCreate(t *testing.T) {
+	fakeServer := th.SetupHTTP()
+	defer fakeServer.Teardown()
+
+	fakeServer.Mux.HandleFunc("/v2.0/taas/tap_mirrors", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "tap_mirror": {
+        "description": "description",
+        "directions": {
+            "IN": "1",
+            "OUT": "2"
+        },
+        "mirror_type": "erspanv1",
+        "name": "test",
+        "port_id": "a25290e9-1a54-4c26-a5b3-34458d122acc",
+        "remote_ip": "192.168.54.217"
+    }
+}
+		`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprint(w, `
+{
+    "tap_mirror": {
+        "id": "bd64a6e3-12b8-4092-a348-6fc7e27c298a",
+        "project_id": "6776f022d64443a898ee3fab89dc8c05",
+        "name": "test",
+        "description": "description",
+        "port_id": "a25290e9-1a54-4c26-a5b3-34458d122acc",
+        "directions": {
+            "IN": "1",
+            "OUT": "2"
+        },
+        "remote_ip": "192.168.54.217",
+        "mirror_type": "erspanv1",
+        "tenant_id": "6776f022d64443a898ee3fab89dc8c05"
+    }
+}
+    `)
+	})
+
+	options := tapmirrors.CreateOpts{
+		Name:        "test",
+		Description: "description",
+		PortID:      "a25290e9-1a54-4c26-a5b3-34458d122acc",
+		MirrorType:  tapmirrors.MirrorTypeErspanv1,
+		RemoteIP:    "192.168.54.217",
+		Directions: tapmirrors.Directions{
+			In:  1,
+			Out: 2,
+		},
+	}
+	actual, err := tapmirrors.Create(context.TODO(), fake.ServiceClient(fakeServer), options).Extract()
+	th.AssertNoErr(t, err)
+	expected := tapmirrors.TapMirror{
+		ID:          "bd64a6e3-12b8-4092-a348-6fc7e27c298a",
+		TenantID:    "6776f022d64443a898ee3fab89dc8c05",
+		ProjectID:   "6776f022d64443a898ee3fab89dc8c05",
+		Name:        "test",
+		Description: "description",
+		PortID:      "a25290e9-1a54-4c26-a5b3-34458d122acc",
+		MirrorType:  "erspanv1",
+		RemoteIP:    "192.168.54.217",
+		Directions: tapmirrors.Directions{
+			In:  1,
+			Out: 2,
+		},
+	}
+	th.AssertDeepEquals(t, expected, *actual)
+}
+
+func TestGet(t *testing.T) {
+	fakeServer := th.SetupHTTP()
+	defer fakeServer.Teardown()
+
+	fakeServer.Mux.HandleFunc("/v2.0/taas/tap_mirrors/0837b488-f0e2-4689-99b3-e3ed531f9b10", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprint(w, `
+{
+    "tap_mirror": {
+        "id": "0837b488-f0e2-4689-99b3-e3ed531f9b10",
+        "project_id": "6776f022d64443a898ee3fab89dc8c05",
+        "name": "test",
+        "description": "description",
+        "port_id": "a25290e9-1a54-4c26-a5b3-34458d122acc",
+        "directions": {
+            "IN": "1",
+            "OUT": "2"
+        },
+        "remote_ip": "192.168.54.217",
+        "mirror_type": "erspanv1",
+        "tenant_id": "6776f022d64443a898ee3fab89dc8c05"
+    }
+}
+        `)
+	})
+
+	actual, err := tapmirrors.Get(context.TODO(), fake.ServiceClient(fakeServer), "0837b488-f0e2-4689-99b3-e3ed531f9b10").Extract()
+	th.AssertNoErr(t, err)
+	expected := tapmirrors.TapMirror{
+		ID:          "0837b488-f0e2-4689-99b3-e3ed531f9b10",
+		TenantID:    "6776f022d64443a898ee3fab89dc8c05",
+		ProjectID:   "6776f022d64443a898ee3fab89dc8c05",
+		Name:        "test",
+		Description: "description",
+		PortID:      "a25290e9-1a54-4c26-a5b3-34458d122acc",
+		MirrorType:  "erspanv1",
+		RemoteIP:    "192.168.54.217",
+		Directions: tapmirrors.Directions{
+			In:  1,
+			Out: 2,
+		},
+	}
+	th.AssertDeepEquals(t, expected, *actual)
+}
+
+func TestDelete(t *testing.T) {
+	fakeServer := th.SetupHTTP()
+	defer fakeServer.Teardown()
+
+	fakeServer.Mux.HandleFunc("/v2.0/taas/tap_mirrors/0837b488-f0e2-4689-99b3-e3ed531f9b10", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := tapmirrors.Delete(context.TODO(), fake.ServiceClient(fakeServer), "0837b488-f0e2-4689-99b3-e3ed531f9b10")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestList(t *testing.T) {
+	fakeServer := th.SetupHTTP()
+	defer fakeServer.Teardown()
+
+	fakeServer.Mux.HandleFunc("/v2.0/taas/tap_mirrors", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprint(w, `
+{
+    "tap_mirrors": [
+        {
+            "id": "0837b488-f0e2-4689-99b3-e3ed531f9b10",
+            "project_id": "6776f022d64443a898ee3fab89dc8c05",
+            "name": "test",
+            "description": "description",
+            "port_id": "a25290e9-1a54-4c26-a5b3-34458d122acc",
+            "directions": {
+                "IN": "1",
+                "OUT": "2"
+            },
+            "remote_ip": "192.168.54.217",
+            "mirror_type": "erspanv1",
+            "tenant_id": "6776f022d64443a898ee3fab89dc8c05"
+        }
+    ]
+}
+	  `)
+	})
+
+	count := 0
+
+	err := tapmirrors.List(fake.ServiceClient(fakeServer), tapmirrors.ListOpts{}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
+		count++
+		actual, err := tapmirrors.ExtractTapMirrors(page)
+		if err != nil {
+			t.Errorf("Failed to extract members: %v", err)
+			return false, err
+		}
+		expected := []tapmirrors.TapMirror{
+			{
+				ID:          "0837b488-f0e2-4689-99b3-e3ed531f9b10",
+				TenantID:    "6776f022d64443a898ee3fab89dc8c05",
+				ProjectID:   "6776f022d64443a898ee3fab89dc8c05",
+				Name:        "test",
+				Description: "description",
+				PortID:      "a25290e9-1a54-4c26-a5b3-34458d122acc",
+				MirrorType:  "erspanv1",
+				RemoteIP:    "192.168.54.217",
+				Directions: tapmirrors.Directions{
+					In:  1,
+					Out: 2,
+				},
+			},
+		}
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	fakeServer := th.SetupHTTP()
+	defer fakeServer.Teardown()
+
+	fakeServer.Mux.HandleFunc("/v2.0/taas/tap_mirrors/d031da31-fb9b-4bd9-8d37-aaf04a12d45f", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "tap_mirror": {
+        "name": "new name",
+        "description": "new description"
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprint(w, `
+{
+    "tap_mirror": {
+        "id": "d031da31-fb9b-4bd9-8d37-aaf04a12d45f",
+        "project_id": "6776f022d64443a898ee3fab89dc8c05",
+        "name": "new name",
+        "description": "new description",
+        "port_id": "a25290e9-1a54-4c26-a5b3-34458d122acc",
+        "directions": {
+            "IN": "1",
+            "OUT": "2"
+        },
+        "remote_ip": "192.168.54.217",
+        "mirror_type": "erspanv1",
+        "tenant_id": "6776f022d64443a898ee3fab89dc8c05"
+    }
+}
+`)
+	})
+
+	updatedName := "new name"
+	updatedDescription := "new description"
+	options := tapmirrors.UpdateOpts{
+		Name:        &updatedName,
+		Description: &updatedDescription,
+	}
+
+	actual, err := tapmirrors.Update(context.TODO(), fake.ServiceClient(fakeServer), "d031da31-fb9b-4bd9-8d37-aaf04a12d45f", options).Extract()
+	th.AssertNoErr(t, err)
+	expected := tapmirrors.TapMirror{
+		ID:          "d031da31-fb9b-4bd9-8d37-aaf04a12d45f",
+		TenantID:    "6776f022d64443a898ee3fab89dc8c05",
+		ProjectID:   "6776f022d64443a898ee3fab89dc8c05",
+		Name:        "new name",
+		Description: "new description",
+		PortID:      "a25290e9-1a54-4c26-a5b3-34458d122acc",
+		MirrorType:  "erspanv1",
+		RemoteIP:    "192.168.54.217",
+		Directions: tapmirrors.Directions{
+			In:  1,
+			Out: 2,
+		},
+	}
+	th.AssertDeepEquals(t, expected, *actual)
+}