diff --git a/acceptance/openstack/identity/v3/project_test.go b/acceptance/openstack/identity/v3/project_test.go new file mode 100644 index 00000000..f944c689 --- /dev/null +++ b/acceptance/openstack/identity/v3/project_test.go @@ -0,0 +1,73 @@ +// +build acceptance + +package v3 + +import ( + "testing" + + "github.com/rackspace/gophercloud/openstack/identity/v3/projects" + "github.com/rackspace/gophercloud/pagination" + th "github.com/rackspace/gophercloud/testhelper" +) + +func TestProjectCRUDOperations(t *testing.T) { + serviceClient := createAuthenticatedClient(t) + if serviceClient == nil { + return + } + + enabled := true + + // Create project + opts := projects.CreateOpts{ + Enabled: &enabled, + Name: "Test project", + Description: "This is test project", + } + project, err := projects.Create(serviceClient, opts).Extract() + th.AssertNoErr(t, err) + defer projects.Delete(serviceClient, project.ID) + th.AssertEquals(t, project.Enabled, true) + th.AssertEquals(t, project.Name, "Test project") + th.AssertEquals(t, project.Description, "This is test project") + + // List projects + pager := projects.List(serviceClient, projects.ListOpts{}) + err = pager.EachPage(func(page pagination.Page) (bool, error) { + t.Logf("--- Page ---") + + projectList, err := projects.ExtractProjects(page) + th.AssertNoErr(t, err) + + for _, p := range projectList { + t.Logf("Project: ID [%s] Name [%s] Is enabled? [%s]", + p.ID, p.Name, p.Enabled) + } + + return true, nil + }) + th.CheckNoErr(t, err) + projectID := project.ID + + // Get a project + if projectID == "" { + t.Fatalf("In order to retrieve a project, the ProjectID must be set") + } + project, err = projects.Get(serviceClient, projectID).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, project.ID, projectID) + th.AssertEquals(t, project.DomainID, "") + th.AssertEquals(t, project.ParentID, "") + th.AssertEquals(t, project.Enabled, true) + th.AssertEquals(t, project.Name, "Test project") + th.AssertEquals(t, project.Description, "This is test project") + + // Update project + project, err = projects.Update(serviceClient, projectID, projects.UpdateOpts{Name: "New test project name"}).Extract() + th.AssertNoErr(t, err) + th.AssertEquals(t, project.Name, "New test project name") + + // Delete project + res := projects.Delete(serviceClient, projectID) + th.AssertNoErr(t, res.Err) +} diff --git a/openstack/identity/v3/projects/requests.go b/openstack/identity/v3/projects/requests.go new file mode 100644 index 00000000..358484f5 --- /dev/null +++ b/openstack/identity/v3/projects/requests.go @@ -0,0 +1,179 @@ +package projects + +import ( + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +type projectOpts struct { + DomainID string + ParentID string + Enabled *bool + Name string + Description string +} + +// CreateOptsBuilder is the interface options structs have to satisfy in order +// to be used in the main Create operation in this package. Since many +// extensions decorate or modify the common logic, it is useful for them to +// satisfy a basic interface in order for them to be used. +type CreateOptsBuilder interface { + ToProjectCreateMap() (map[string]interface{}, error) +} + +// CreateOpts is the common options struct used in this package's Create +// operation. +type CreateOpts projectOpts + +// ToProjectCreateMap casts a CreateOpts struct to a map. +func (opts CreateOpts) ToProjectCreateMap() (map[string]interface{}, error) { + p := make(map[string]interface{}) + + if opts.DomainID != "" { + p["domain_id"] = opts.DomainID + } + if opts.ParentID != "" { + p["parent_id"] = opts.ParentID + } + if opts.Enabled != nil { + p["enabled"] = &opts.Enabled + } + if opts.Name != "" { + p["name"] = opts.Name + } + if opts.Description != "" { + p["description"] = opts.Description + } + + return map[string]interface{}{"project": p}, nil +} + +// Create accepts a CreateOpts struct and creates a new project using the values +// provided. +// +// The tenant ID that is contained in the URI is the tenant that creates the +// network. An admin user, however, has the option of specifying another tenant +// ID in the CreateOpts struct. +func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult { + var res CreateResult + + reqBody, err := opts.ToProjectCreateMap() + if err != nil { + res.Err = err + return res + } + _, res.Err = client.Post(createURL(client), reqBody, &res.Body, nil) + return res +} + +// ListOptsBuilder allows extensions to add additional parameters to the +// List request. +type ListOptsBuilder interface { + ToProjectListQuery() (string, error) +} + +// ListOpts allows the filtering and of paginated collections through the API. +// Filtering is achieved by passing in struct field values that map to +// the project attributes you want to filter on. Page and PerPage are used for +// pagination. +type ListOpts struct { + DomainID string `q:"domain_id"` + ParentID string `q:"parent_id"` + Name string `q:"name"` + Enabled bool `q:"enabled"` + Page int `q:"page"` + PerPage int `q:"per_page"` +} + +// ToProjectListQuery formats a ListOpts into a query string. +func (opts ListOpts) ToProjectListQuery() (string, error) { + q, err := gophercloud.BuildQueryString(opts) + if err != nil { + return "", err + } + return q.String(), nil +} + +// List returns a Pager which allows you to iterate over a collection of +// projects. It accepts a ListOpts struct, which allows you to filter the +// returned collection. +func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager { + url := listURL(client) + if opts != nil { + query, err := opts.ToProjectListQuery() + if err != nil { + return pagination.Pager{Err: err} + } + url += query + } + + return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page { + return ProjectPage{pagination.LinkedPageBase{PageResult: r}} + }) +} + +// Get retrieves a specific project based on its unique ID. +func Get(client *gophercloud.ServiceClient, id string) GetResult { + var res GetResult + _, res.Err = client.Get(getURL(client, id), &res.Body, nil) + return res +} + +// UpdateOptsBuilder is the interface options structs have to satisfy in order +// to be used in the main Update operation in this package. Since many +// extensions decorate or modify the common logic, it is useful for them to +// satisfy a basic interface in order for them to be used. +type UpdateOptsBuilder interface { + ToProjectUpdateMap() (map[string]interface{}, error) +} + +// UpdateOpts is the common options struct used in this package's Update +// operation. +type UpdateOpts projectOpts + +// ToProjectUpdateMap casts a UpdateOpts struct to a map. +func (opts UpdateOpts) ToProjectUpdateMap() (map[string]interface{}, error) { + p := make(map[string]interface{}) + + if opts.DomainID != "" { + p["domain_id"] = opts.DomainID + } + if opts.ParentID != "" { + p["parent_id"] = opts.ParentID + } + if opts.Enabled != nil { + p["enabled"] = &opts.Enabled + } + if opts.Name != "" { + p["name"] = opts.Name + } + if opts.Description != "" { + p["description"] = opts.Description + } + + return map[string]interface{}{"project": p}, nil +} + +// Update accepts a UpdateOpts struct and updates an existing project using the +// values provided. For more information, see the Create function. +func Update(client *gophercloud.ServiceClient, projectID string, opts UpdateOptsBuilder) UpdateResult { + var res UpdateResult + + reqBody, err := opts.ToProjectUpdateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Patch(updateURL(client, projectID), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + return res +} + +// Delete accepts a unique ID and deletes the project associated with it. +func Delete(client *gophercloud.ServiceClient, projectID string) DeleteResult { + var result DeleteResult + _, result.Err = client.Delete(deleteURL(client, projectID), nil) + return result +} diff --git a/openstack/identity/v3/projects/requests_test.go b/openstack/identity/v3/projects/requests_test.go new file mode 100644 index 00000000..94a3e770 --- /dev/null +++ b/openstack/identity/v3/projects/requests_test.go @@ -0,0 +1,276 @@ +package projects + +import ( + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/rackspace/gophercloud/pagination" + "github.com/rackspace/gophercloud/testhelper" + "github.com/rackspace/gophercloud/testhelper/client" +) + +func TestCreateSuccessful(t *testing.T) { + testhelper.SetupHTTP() + defer testhelper.TeardownHTTP() + + testhelper.Mux.HandleFunc("/projects", func(w http.ResponseWriter, r *http.Request) { + testhelper.TestMethod(t, r, "POST") + testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID) + testhelper.TestJSONRequest(t, r, `{ + "project": { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "name": "Test Group", + "description": "My new project" + } + }`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "project": { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "id": "263fd9", + "name": "Test Group", + "description": "My new project" + } + }`) + }) + + enabled := true + opts := CreateOpts{ + DomainID: "1789d1", + ParentID: "123c56", + Enabled: &enabled, + Name: "Test Group", + Description: "My new project", + } + result, err := Create(client.ServiceClient(), opts).Extract() + if err != nil { + t.Fatalf("Unexpected error from Create: %v", err) + } + + if result.ID != "263fd9" { + t.Errorf("Project id was unexpected [%s]", result.ID) + } + if result.DomainID != "1789d1" { + t.Errorf("Project domain_id was unexpected [%s]", result.DomainID) + } + if result.ParentID != "123c56" { + t.Errorf("Project parent_id was unexpected [%s]", result.ParentID) + } + if !result.Enabled { + t.Errorf("Project enabled was unexpected [%v]", result.Enabled) + } + if result.Name != "Test Group" { + t.Errorf("Project name was unexpected [%s]", result.Name) + } + if result.Description != "My new project" { + t.Errorf("Project description was unexpected [%s]", result.Description) + } +} + +func TestListSinglePage(t *testing.T) { + testhelper.SetupHTTP() + defer testhelper.TeardownHTTP() + + testhelper.Mux.HandleFunc("/projects", func(w http.ResponseWriter, r *http.Request) { + testhelper.TestMethod(t, r, "GET") + testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprintf(w, ` + { + "links": { + "next": null, + "previous": null + }, + "projects": [ + { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "id": "263fd9", + "name": "Test Group" + }, + { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "id": "50ef01", + "name": "Build Group" + } + ] + } + `) + }) + + count := 0 + err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { + count++ + actual, err := ExtractProjects(page) + if err != nil { + return false, err + } + + expected := []Project{ + Project{ + DomainID: "1789d1", + ParentID: "123c56", + Enabled: true, + ID: "263fd9", + Name: "Test Group", + }, + Project{ + DomainID: "1789d1", + ParentID: "123c56", + Enabled: true, + ID: "50ef01", + Name: "Build Group", + }, + } + + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Expected %#v, got %#v", expected, actual) + } + + return true, nil + }) + if err != nil { + t.Errorf("Unexpected error while paging: %v", err) + } + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestGetSuccessful(t *testing.T) { + testhelper.SetupHTTP() + defer testhelper.TeardownHTTP() + + testhelper.Mux.HandleFunc("/projects/263fd9", func(w http.ResponseWriter, r *http.Request) { + testhelper.TestMethod(t, r, "GET") + testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprintf(w, ` + { + "project": { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "id": "263fd9", + "name": "Test Group", + "description": "My new project" + } + } + `) + }) + + result, err := Get(client.ServiceClient(), "263fd9").Extract() + if err != nil { + t.Fatalf("Error fetching service information: %v", err) + } + if result.ID != "263fd9" { + t.Errorf("Project id was unexpected [%s]", result.ID) + } + if result.DomainID != "1789d1" { + t.Errorf("Project domain_id was unexpected [%s]", result.DomainID) + } + if result.ParentID != "123c56" { + t.Errorf("Project parent_id was unexpected [%s]", result.ParentID) + } + if !result.Enabled { + t.Errorf("Project enabled was unexpected [%v]", result.Enabled) + } + if result.Name != "Test Group" { + t.Errorf("Project name was unexpected [%s]", result.Name) + } + if result.Description != "My new project" { + t.Errorf("Project description was unexpected [%s]", result.Description) + } +} + +func TestUpdateSuccessful(t *testing.T) { + testhelper.SetupHTTP() + defer testhelper.TeardownHTTP() + + testhelper.Mux.HandleFunc("/projects/263fd9", func(w http.ResponseWriter, r *http.Request) { + testhelper.TestMethod(t, r, "PATCH") + testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID) + testhelper.TestJSONRequest(t, r, `{ + "project": { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "name": "Test Group", + "description": "My new project" + } + }`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{ + "project": { + "domain_id": "1789d1", + "parent_id": "123c56", + "enabled": true, + "id": "263fd9", + "name": "Test Group", + "description": "My new project" + } + }`) + }) + + enabled := true + opts := UpdateOpts{ + DomainID: "1789d1", + ParentID: "123c56", + Enabled: &enabled, + Name: "Test Group", + Description: "My new project", + } + result, err := Update(client.ServiceClient(), "263fd9", opts).Extract() + if err != nil { + t.Fatalf("Unexpected error from Update: %v", err) + } + + if result.ID != "263fd9" { + t.Errorf("Project id was unexpected [%s]", result.ID) + } + if result.DomainID != "1789d1" { + t.Errorf("Project domain_id was unexpected [%s]", result.DomainID) + } + if result.ParentID != "123c56" { + t.Errorf("Project parent_id was unexpected [%s]", result.ParentID) + } + if !result.Enabled { + t.Errorf("Project enabled was unexpected [%v]", result.Enabled) + } + if result.Name != "Test Group" { + t.Errorf("Project name was unexpected [%s]", result.Name) + } + if result.Description != "My new project" { + t.Errorf("Project description was unexpected [%s]", result.Description) + } +} + +func TestDeleteSuccessful(t *testing.T) { + testhelper.SetupHTTP() + defer testhelper.TeardownHTTP() + + testhelper.Mux.HandleFunc("/projects/263fd9", func(w http.ResponseWriter, r *http.Request) { + testhelper.TestMethod(t, r, "DELETE") + testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID) + + w.WriteHeader(http.StatusNoContent) + }) + + res := Delete(client.ServiceClient(), "263fd9") + testhelper.AssertNoErr(t, res.Err) +} diff --git a/openstack/identity/v3/projects/results.go b/openstack/identity/v3/projects/results.go new file mode 100644 index 00000000..a1ae8486 --- /dev/null +++ b/openstack/identity/v3/projects/results.go @@ -0,0 +1,92 @@ +package projects + +import ( + "github.com/mitchellh/mapstructure" + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +// Project Project struct +type Project struct { + // The ID of the domain for the project. + DomainID string `mapstructure:"domain_id" json:"domain_id"` + + // The ID of the parent project. + ParentID string `mapstructure:"parent_id" json:"parent_id"` + + // Enables or disables a project. + // Set to true to enable the project or false to disable the project. Default is true. + Enabled bool `mapstructure:"enabled" json:"enabled"` + + // The ID for the project. + ID string `mapstructure:"id" json:"id"` + + // The project name. + Name string `mapstructure:"name" json:"name"` + + // The project description. + Description string `mapstructure:"description" json:"description"` +} + +type commonResult struct { + gophercloud.Result +} + +func (r commonResult) Extract() (*Project, error) { + if r.Err != nil { + return nil, r.Err + } + + var response struct { + Project Project `json:"project"` + } + + err := mapstructure.Decode(r.Body, &response) + + return &response.Project, err +} + +// CreateResult represents the result of a create operation. +type CreateResult struct { + commonResult +} + +// GetResult represents the result of a get operation. +type GetResult struct { + commonResult +} + +// UpdateResult represents the result of a update operation. +type UpdateResult struct { + commonResult +} + +// DeleteResult represents the result of a delete operation. +type DeleteResult struct { + gophercloud.ErrResult +} + +// ProjectPage Page containing projects +type ProjectPage struct { + pagination.LinkedPageBase +} + +// IsEmpty checks if projects page is empty +func (p ProjectPage) IsEmpty() (bool, error) { + projects, err := ExtractProjects(p) + if err != nil { + return true, err + } + return len(projects) == 0, nil +} + +// ExtractProjects extracts projects list from response +func ExtractProjects(page pagination.Page) ([]Project, error) { + var response struct { + Projects []Project `mapstructure:"projects"` + } + + err := mapstructure.Decode(page.(ProjectPage).Body, &response) + + return response.Projects, err +} diff --git a/openstack/identity/v3/projects/urls.go b/openstack/identity/v3/projects/urls.go new file mode 100644 index 00000000..34767d0a --- /dev/null +++ b/openstack/identity/v3/projects/urls.go @@ -0,0 +1,31 @@ +package projects + +import "github.com/rackspace/gophercloud" + +func resourceURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("projects", id) +} + +func rootURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("projects") +} + +func getURL(c *gophercloud.ServiceClient, id string) string { + return resourceURL(c, id) +} + +func listURL(c *gophercloud.ServiceClient) string { + return rootURL(c) +} + +func createURL(c *gophercloud.ServiceClient) string { + return rootURL(c) +} + +func updateURL(c *gophercloud.ServiceClient, id string) string { + return resourceURL(c, id) +} + +func deleteURL(c *gophercloud.ServiceClient, id string) string { + return resourceURL(c, id) +} diff --git a/openstack/identity/v3/projects/urls_test.go b/openstack/identity/v3/projects/urls_test.go new file mode 100644 index 00000000..8ddd7e5b --- /dev/null +++ b/openstack/identity/v3/projects/urls_test.go @@ -0,0 +1,47 @@ +package projects + +import ( + "testing" + + "github.com/rackspace/gophercloud" +) + +func TestGetURL(t *testing.T) { + client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"} + url := getURL(&client, "12345") + if url != "http://localhost:5000/v3/projects/12345" { + t.Errorf("Unexpected project URL generated: [%s]", url) + } +} + +func TestListURL(t *testing.T) { + client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"} + url := listURL(&client) + if url != "http://localhost:5000/v3/projects" { + t.Errorf("Unexpected project URL generated: [%s]", url) + } +} + +func TestCreateURL(t *testing.T) { + client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"} + url := createURL(&client) + if url != "http://localhost:5000/v3/projects" { + t.Errorf("Unexpected project URL generated: [%s]", url) + } +} + +func TestUpdateURL(t *testing.T) { + client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"} + url := updateURL(&client, "12345") + if url != "http://localhost:5000/v3/projects/12345" { + t.Errorf("Unexpected project URL generated: [%s]", url) + } +} + +func TestDeleteURL(t *testing.T) { + client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"} + url := deleteURL(&client, "12345") + if url != "http://localhost:5000/v3/projects/12345" { + t.Errorf("Unexpected project URL generated: [%s]", url) + } +} diff --git a/openstack/identity/v3/tokens/results.go b/openstack/identity/v3/tokens/results.go index d134f7d4..6b1499e5 100644 --- a/openstack/identity/v3/tokens/results.go +++ b/openstack/identity/v3/tokens/results.go @@ -41,6 +41,31 @@ type CatalogEntry struct { Endpoints []Endpoint `mapstructure:"endpoints"` } +// Project provides information about the project to which this token grants access. +type Project struct { + ID string `mapstructure:"id"` + Name string `mapstructure:"name"` +} + +// Domain provides information about the domain to which this token grants access. +type Domain struct { + ID string `mapstructure:"id"` + Name string `mapstructure:"name"` +} + +// User represents a user resource that exists on the API. +type User struct { + Domain Domain `mapstructure:"domain"` + ID string `mapstructure:"id"` + Name string `mapstructure:"name"` +} + +// Authorization need user info which can get from token authentication's response +type Role struct { + ID string `mapstructure:"id"` + Name string `mapstructure:"name"` +} + // ServiceCatalog provides a view into the service catalog from a previous, successful authentication. type ServiceCatalog struct { Entries []CatalogEntry @@ -65,7 +90,9 @@ func (r commonResult) ExtractToken() (*Token, error) { var response struct { Token struct { - ExpiresAt string `mapstructure:"expires_at"` + ExpiresAt string `mapstructure:"expires_at"` + Project Project `mapstructure:"project"` + Roles []Role `mapstructure:"roles"` } `mapstructure:"token"` } @@ -82,6 +109,9 @@ func (r commonResult) ExtractToken() (*Token, error) { // Attempt to parse the timestamp. token.ExpiresAt, err = time.Parse(gophercloud.RFC3339Milli, response.Token.ExpiresAt) + token.Project = response.Token.Project + token.Roles = response.Token.Roles + return &token, err } @@ -105,6 +135,26 @@ func (result CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) { return &ServiceCatalog{Entries: response.Token.Entries}, nil } +// ExtractUser returns the User that was generated along with the user's Token. +func (result CreateResult) ExtractUser() (*User, error) { + if result.Err != nil { + return nil, result.Err + } + + var response struct { + Token struct { + User User `mapstructure:"user"` + } `mapstructure:"token"` + } + + err := mapstructure.Decode(result.Body, &response) + if err != nil { + return nil, err + } + + return &response.Token.User, nil +} + // CreateResult defers the interpretation of a created token. // Use ExtractToken() to interpret it as a Token, or ExtractServiceCatalog() to interpret it as a service catalog. type CreateResult struct { @@ -120,7 +170,7 @@ func createErr(err error) CreateResult { // GetResult is the deferred response from a Get call. type GetResult struct { - commonResult + CreateResult } // RevokeResult is the deferred response from a Revoke call. @@ -136,4 +186,10 @@ type Token struct { // ExpiresAt is the timestamp at which this token will no longer be accepted. ExpiresAt time.Time + + // Project provides information about the project to which this token grants access. + Project Project + + // Authorization need user info which can get from token authentication's response + Roles []Role }