Repository: gophercloud/gophercloud

Issue:
KeystoneV3: Adding support for option field in Roles 
Added description field in previous PR. The role resource in keystone API also offers option as a field. Good idea to add support for this field as well in gophercloud.

reference: [click me](https://docs.openstack.org/api-ref/identity/v3/index.html#create-role:~:text=about%20the%20role.-,options%20(Optional),-body)


Hints from issue comments:
@dlaw4608: Will do this issue myself.

Solution patch:
diff --git a/openstack/identity/v3/roles/requests.go b/openstack/identity/v3/roles/requests.go
index 2e40e93d6..0552dc285 100644
--- a/openstack/identity/v3/roles/requests.go
+++ b/openstack/identity/v3/roles/requests.go
@@ -9,6 +9,14 @@ import (
 	"github.com/gophercloud/gophercloud/v2/pagination"
 )
 
+// Option is a specific option defined at the API to enable features
+// on a role.
+type Option string
+
+const (
+	Immutable Option = "immutable"
+)
+
 // ListOptsBuilder allows extensions to add additional parameters to
 // the List request
 type ListOptsBuilder interface {
@@ -88,6 +96,9 @@ type CreateOpts struct {
 
 	// Extra is free-form extra key/value pairs to describe the role.
 	Extra map[string]any `json:"-"`
+
+	// Options are defined options in the API to enable certain features.
+	Options map[Option]any `json:"options,omitempty"`
 }
 
 // ToRoleCreateMap formats a CreateOpts into a create request.
@@ -135,6 +146,9 @@ type UpdateOpts struct {
 
 	// Extra is free-form extra key/value pairs to describe the role.
 	Extra map[string]any `json:"-"`
+
+	// Options are defined options in the API to enable certain features.
+	Options map[Option]any `json:"options,omitempty"`
 }
 
 // ToRoleUpdateMap formats a UpdateOpts into an update request.
diff --git a/openstack/identity/v3/roles/results.go b/openstack/identity/v3/roles/results.go
index 43abaf647..b2e348df0 100644
--- a/openstack/identity/v3/roles/results.go
+++ b/openstack/identity/v3/roles/results.go
@@ -23,6 +23,9 @@ type Role struct {
 
 	// Extra is a collection of miscellaneous key/values.
 	Extra map[string]any `json:"-"`
+
+	// Options are a set of defined options that allow certain features for a role
+	Options map[Option]any `json:"options"`
 }
 
 func (r *Role) UnmarshalJSON(b []byte) error {



Test patch:
diff --git a/internal/acceptance/openstack/identity/v3/roles_test.go b/internal/acceptance/openstack/identity/v3/roles_test.go
index b17ecd904..96337dc4c 100644
--- a/internal/acceptance/openstack/identity/v3/roles_test.go
+++ b/internal/acceptance/openstack/identity/v3/roles_test.go
@@ -63,6 +63,9 @@ func TestRolesCRUD(t *testing.T) {
 		Extra: map[string]any{
 			"description": "test role description",
 		},
+		Options: map[roles.Option]any{
+			"immutable": false,
+		},
 	}
 
 	// Create Role in the default domain
@@ -98,6 +101,9 @@ func TestRolesCRUD(t *testing.T) {
 		Extra: map[string]any{
 			"description": "updated test role description",
 		},
+		Options: map[roles.Option]any{
+			"immutable": nil,
+		},
 	}
 
 	newRole, err := roles.Update(context.TODO(), client, role.ID, updateOpts).Extract()
diff --git a/openstack/identity/v3/roles/testing/fixtures_test.go b/openstack/identity/v3/roles/testing/fixtures_test.go
index 1e8403eb6..3fa6171c1 100644
--- a/openstack/identity/v3/roles/testing/fixtures_test.go
+++ b/openstack/identity/v3/roles/testing/fixtures_test.go
@@ -52,9 +52,25 @@ const GetOutput = `
             "self": "https://example.com/identity/v3/roles/9fe1d3"
         },
         "name": "support",
-        "extra": {
-            "description": "read-only support role"
-        }
+        "description": "read-only support role"
+    }
+}
+`
+
+// GetOutputWithOptions provides a Get result of a role with options.
+const GetOutputWithOptions = `
+{
+    "role": {
+        "domain_id": "1789d1",
+        "id": "9fe1d3",
+        "links": {
+            "self": "https://example.com/identity/v3/roles/9fe1d3"
+        },
+        "name": "support",
+        "description": "read-only support role",
+		"options": {
+			"immutable": true
+		}
     }
 }
 `
@@ -65,8 +81,22 @@ const CreateRequest = `
     "role": {
         "domain_id": "1789d1",
         "name": "support",
-        "description": "read-only support role"
-    }
+		"description": "read-only support role"
+	}
+}
+`
+
+// CreateRequestWithOptions provides the input to a Create request with Options.
+const CreateRequestWithOptions = `
+{
+    "role": {
+        "domain_id": "1789d1",
+        "name": "support",
+		"description": "read-only support role",
+		"options": {
+			"immutable": true
+		}
+	}
 }
 `
 
@@ -74,7 +104,10 @@ const CreateRequest = `
 const UpdateRequest = `
 {
     "role": {
-        "description": "admin read-only support role"
+        "description": "admin read-only support role",
+		"options": {
+			"immutable": false
+		}
     }
 }
 `
@@ -91,7 +124,10 @@ const UpdateOutput = `
         "name": "support",
         "extra": {
             "description": "admin read-only support role"
-        }
+        },
+		"options": {
+			"immutable": false
+		}
     }
 }
 `
@@ -324,6 +360,22 @@ var SecondRole = roles.Role{
 	},
 }
 
+// SecondRoleWithOptions is the second role in the List request
+var SecondRoleWithOptions = roles.Role{
+	DomainID: "1789d1",
+	ID:       "9fe1d3",
+	Links: map[string]any{
+		"self": "https://example.com/identity/v3/roles/9fe1d3",
+	},
+	Name: "support",
+	Extra: map[string]any{
+		"description": "read-only support role",
+	},
+	Options: map[roles.Option]any{
+		roles.Immutable: true,
+	},
+}
+
 // SecondRoleUpdated is how SecondRole should look after an Update.
 var SecondRoleUpdated = roles.Role{
 	DomainID: "1789d1",
@@ -335,6 +387,9 @@ var SecondRoleUpdated = roles.Role{
 	Extra: map[string]any{
 		"description": "admin read-only support role",
 	},
+	Options: map[roles.Option]any{
+		roles.Immutable: false,
+	},
 }
 
 // ExpectedRolesSlice is the slice of roles expected to be returned from ListOutput.
@@ -381,6 +436,19 @@ func HandleCreateRoleSuccessfully(t *testing.T, fakeServer th.FakeServer) {
 	})
 }
 
+// HandleCreateRoleWithOptionsSuccessfully creates an HTTP handler at `/roles` on the
+// test handler mux that tests role creation.
+func HandleCreateWithOptionsRoleSuccessfully(t *testing.T, fakeServer th.FakeServer) {
+	fakeServer.Mux.HandleFunc("/roles", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, CreateRequestWithOptions)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprint(w, GetOutputWithOptions)
+	})
+}
+
 // HandleUpdateRoleSuccessfully creates an HTTP handler at `/roles` on the
 // test handler mux that tests role update.
 func HandleUpdateRoleSuccessfully(t *testing.T, fakeServer th.FakeServer) {
diff --git a/openstack/identity/v3/roles/testing/requests_test.go b/openstack/identity/v3/roles/testing/requests_test.go
index fb3021064..88da0a4b4 100644
--- a/openstack/identity/v3/roles/testing/requests_test.go
+++ b/openstack/identity/v3/roles/testing/requests_test.go
@@ -104,6 +104,27 @@ func TestCreateRole(t *testing.T) {
 	th.CheckDeepEquals(t, SecondRole, *actual)
 }
 
+func TestCreateWithOptionsRole(t *testing.T) {
+	fakeServer := th.SetupHTTP()
+	defer fakeServer.Teardown()
+	HandleCreateWithOptionsRoleSuccessfully(t, fakeServer)
+
+	createOpts := roles.CreateOpts{
+		Name:     "support",
+		DomainID: "1789d1",
+		Options: map[roles.Option]any{
+			roles.Immutable: true,
+		},
+		Extra: map[string]any{
+			"description": "read-only support role",
+		},
+	}
+
+	actual, err := roles.Create(context.TODO(), client.ServiceClient(fakeServer), createOpts).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, SecondRoleWithOptions, *actual)
+}
+
 func TestUpdateRole(t *testing.T) {
 	fakeServer := th.SetupHTTP()
 	defer fakeServer.Teardown()
@@ -113,6 +134,9 @@ func TestUpdateRole(t *testing.T) {
 		Extra: map[string]any{
 			"description": "admin read-only support role",
 		},
+		Options: map[roles.Option]any{
+			roles.Immutable: false,
+		},
 	}
 
 	actual, err := roles.Update(context.TODO(), client.ServiceClient(fakeServer), "9fe1d3", updateOpts).Extract()