Repository: gophercloud/gophercloud

Issue:
identity.v3.Services is missing fields from Keystone API
### What happened?

Currently, identity.v3.Service has missing fields from the API such as `name` and `description` which were encapsulated within `Extra` fields.

We should:
* Add support for those fields
* Deprecate Extra field in future releases

[API Reference](https://docs.openstack.org/api-ref/identity/v3/index.html)

### Version

v2 (current)

Discussion:

Solution patch:
diff --git a/openstack/identity/v3/services/requests.go b/openstack/identity/v3/services/requests.go
index 8e8011da0..0a25c1817 100644
--- a/openstack/identity/v3/services/requests.go
+++ b/openstack/identity/v3/services/requests.go
@@ -15,6 +15,12 @@ type CreateOptsBuilder interface {
 
 // CreateOpts provides options used to create a service.
 type CreateOpts struct {
+	// Name is the name of the service.
+	Name string `json:"name,omitempty"`
+
+	// Description is the description of the service.
+	Description string `json:"description,omitempty"`
+
 	// Type is the type of the service.
 	Type string `json:"type"`
 
@@ -108,6 +114,12 @@ type UpdateOptsBuilder interface {
 
 // UpdateOpts provides options for updating a service.
 type UpdateOpts struct {
+	// Name is an updated name for the service.
+	Name *string `json:"name,omitempty"`
+
+	// Description is an update description for the service.
+	Description *string `json:"description,omitempty"`
+
 	// Type is the type of the service.
 	Type string `json:"type,omitempty"`
 
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
index 830906500..d5d6da2ad 100644
--- a/openstack/identity/v3/services/results.go
+++ b/openstack/identity/v3/services/results.go
@@ -50,6 +50,12 @@ type Service struct {
 	// ID is the unique ID of the service.
 	ID string `json:"id"`
 
+	// Name is the name of the service.
+	Name string `json:"name"`
+
+	// Description is the description of the service.
+	Description string `json:"description"`
+
 	// Type is the type of the service.
 	Type string `json:"type"`
 
@@ -75,8 +81,6 @@ func (r *Service) UnmarshalJSON(b []byte) error {
 	}
 	*r = Service(s.tmp)
 
-	// Collect other fields and bundle them into Extra
-	// but only if a field titled "extra" wasn't sent.
 	if s.Extra != nil {
 		r.Extra = s.Extra
 	} else {
@@ -85,8 +89,19 @@ func (r *Service) UnmarshalJSON(b []byte) error {
 		if err != nil {
 			return err
 		}
+
 		if resultMap, ok := result.(map[string]any); ok {
 			r.Extra = gophercloud.RemainingKeys(Service{}, resultMap)
+
+			// the following code is required for backward compatibility with the
+			// old behavior, when both description and name were in extra
+			if description, ok := resultMap["description"]; ok {
+				r.Extra["description"] = description
+			}
+
+			if name, ok := resultMap["name"]; ok {
+				r.Extra["name"] = name
+			}
 		}
 	}
 



Test patch:
diff --git a/openstack/identity/v3/services/testing/fixtures_test.go b/openstack/identity/v3/services/testing/fixtures_test.go
index 9bd0076c5..42811a09d 100644
--- a/openstack/identity/v3/services/testing/fixtures_test.go
+++ b/openstack/identity/v3/services/testing/fixtures_test.go
@@ -23,11 +23,11 @@ const ListOutput = `
             "links": {
                 "self": "https://example.com/identity/v3/services/1234"
             },
+            "name": "service-one",
             "type": "identity",
             "enabled": false,
             "extra": {
-                "name": "service-one",
-                "description": "Service One"
+                "email": "service-one@example.com"
             }
         },
         {
@@ -35,11 +35,11 @@ const ListOutput = `
             "links": {
                 "self": "https://example.com/identity/v3/services/9876"
             },
+            "name": "service-two",
+            "description": "Service Two",
             "type": "compute",
             "enabled": false,
             "extra": {
-                "name": "service-two",
-                "description": "Service Two",
                 "email": "service@example.com"
             }
         }
@@ -55,11 +55,11 @@ const GetOutput = `
         "links": {
             "self": "https://example.com/identity/v3/services/9876"
         },
+        "name": "service-two",
+        "description": "Service Two",
         "type": "compute",
         "enabled": false,
         "extra": {
-            "name": "service-two",
-            "description": "Service Two",
             "email": "service@example.com"
         }
     }
@@ -96,11 +96,11 @@ const UpdateOutput = `
         "links": {
             "self": "https://example.com/identity/v3/services/9876"
         },
+        "name": "service-two",
+        "description": "Service Two Updated",
         "type": "compute2",
         "enabled": false,
         "extra": {
-            "name": "service-two",
-            "description": "Service Two Updated",
             "email": "service@example.com"
         }
     }
@@ -113,11 +113,11 @@ var FirstService = services.Service{
 	Links: map[string]any{
 		"self": "https://example.com/identity/v3/services/1234",
 	},
+	Name:    "service-one",
 	Type:    "identity",
 	Enabled: false,
 	Extra: map[string]any{
-		"name":        "service-one",
-		"description": "Service One",
+		"email": "service-one@example.com",
 	},
 }
 
@@ -127,12 +127,12 @@ var SecondService = services.Service{
 	Links: map[string]any{
 		"self": "https://example.com/identity/v3/services/9876",
 	},
-	Type:    "compute",
-	Enabled: false,
+	Name:        "service-two",
+	Description: "Service Two",
+	Type:        "compute",
+	Enabled:     false,
 	Extra: map[string]any{
-		"name":        "service-two",
-		"description": "Service Two",
-		"email":       "service@example.com",
+		"email": "service@example.com",
 	},
 }
 
@@ -142,12 +142,12 @@ var SecondServiceUpdated = services.Service{
 	Links: map[string]any{
 		"self": "https://example.com/identity/v3/services/9876",
 	},
-	Type:    "compute2",
-	Enabled: false,
+	Name:        "service-two",
+	Description: "Service Two Updated",
+	Type:        "compute2",
+	Enabled:     false,
 	Extra: map[string]any{
-		"name":        "service-two",
-		"description": "Service Two Updated",
-		"email":       "service@example.com",
+		"email": "service@example.com",
 	},
 }
 
diff --git a/openstack/identity/v3/services/testing/requests_test.go b/openstack/identity/v3/services/testing/requests_test.go
index 12c241ea9..c88cefb32 100644
--- a/openstack/identity/v3/services/testing/requests_test.go
+++ b/openstack/identity/v3/services/testing/requests_test.go
@@ -17,11 +17,11 @@ func TestCreateSuccessful(t *testing.T) {
 	HandleCreateServiceSuccessfully(t, fakeServer)
 
 	createOpts := services.CreateOpts{
-		Type: "compute",
+		Name:        "service-two",
+		Description: "Service Two",
+		Type:        "compute",
 		Extra: map[string]any{
-			"name":        "service-two",
-			"description": "Service Two",
-			"email":       "service@example.com",
+			"email": "service@example.com",
 		},
 	}
 
@@ -60,7 +60,7 @@ func TestListServicesAllPages(t *testing.T) {
 	actual, err := services.ExtractServices(allPages)
 	th.AssertNoErr(t, err)
 	th.CheckDeepEquals(t, ExpectedServicesSlice, actual)
-	th.AssertEquals(t, ExpectedServicesSlice[0].Extra["name"], "service-one")
+	th.AssertEquals(t, ExpectedServicesSlice[0].Extra["email"], "service-one@example.com")
 	th.AssertEquals(t, ExpectedServicesSlice[1].Extra["email"], "service@example.com")
 }
 
@@ -81,16 +81,15 @@ func TestUpdateSuccessful(t *testing.T) {
 	defer fakeServer.Teardown()
 	HandleUpdateServiceSuccessfully(t, fakeServer)
 
+	updatedDescription := "Service Two Updated"
 	updateOpts := services.UpdateOpts{
-		Type: "compute2",
-		Extra: map[string]any{
-			"description": "Service Two Updated",
-		},
+		Description: &updatedDescription,
+		Type:        "compute2",
 	}
 	actual, err := services.Update(context.TODO(), client.ServiceClient(fakeServer), "9876", updateOpts).Extract()
 	th.AssertNoErr(t, err)
 	th.CheckDeepEquals(t, SecondServiceUpdated, *actual)
-	th.AssertEquals(t, SecondServiceUpdated.Extra["description"], "Service Two Updated")
+	th.AssertEquals(t, SecondServiceUpdated.Description, "Service Two Updated")
 }
 
 func TestDeleteSuccessful(t *testing.T) {