diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb483e3..e01582af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [v1.6.0] - 2018-10-16 + +- #185 Projects support [beta] - @mchitten + ## [v1.5.0] - 2018-10-01 - #181 Adding tagging images support - @hugocorbucci diff --git a/domains.go b/domains.go index cbcd4605..de266512 100644 --- a/domains.go +++ b/domains.go @@ -97,6 +97,10 @@ func (d Domain) String() string { return Stringify(d) } +func (d Domain) URN() string { + return ToURN("Domain", d.Name) +} + // List all domains. func (s DomainsServiceOp) List(ctx context.Context, opt *ListOptions) ([]Domain, *Response, error) { path := domainsBasePath diff --git a/droplets.go b/droplets.go index a3650edf..ab508f1c 100644 --- a/droplets.go +++ b/droplets.go @@ -125,6 +125,10 @@ func (d Droplet) String() string { return Stringify(d) } +func (d Droplet) URN() string { + return ToURN("Droplet", d.ID) +} + // DropletRoot represents a Droplet root type dropletRoot struct { Droplet *Droplet `json:"droplet"` diff --git a/firewalls.go b/firewalls.go index f34774bb..c28cac03 100644 --- a/firewalls.go +++ b/firewalls.go @@ -49,6 +49,10 @@ func (fw Firewall) String() string { return Stringify(fw) } +func (fw Firewall) URN() string { + return ToURN("Firewall", fw.ID) +} + // FirewallRequest represents the configuration to be applied to an existing or a new Firewall. type FirewallRequest struct { Name string `json:"name"` diff --git a/floating_ips.go b/floating_ips.go index deea3a13..4545e903 100644 --- a/floating_ips.go +++ b/floating_ips.go @@ -37,6 +37,10 @@ func (f FloatingIP) String() string { return Stringify(f) } +func (f FloatingIP) URN() string { + return ToURN("FloatingIP", f.IP) +} + type floatingIPsRoot struct { FloatingIPs []FloatingIP `json:"floating_ips"` Links *Links `json:"links"` diff --git a/godo.go b/godo.go index 002e6c3c..e836eddf 100644 --- a/godo.go +++ b/godo.go @@ -18,7 +18,7 @@ import ( ) const ( - libraryVersion = "1.5.0" + libraryVersion = "1.6.0" defaultBaseURL = "https://api.digitalocean.com/" userAgent = "godo/" + libraryVersion mediaType = "application/json" @@ -64,6 +64,7 @@ type Client struct { LoadBalancers LoadBalancersService Certificates CertificatesService Firewalls FirewallsService + Projects ProjectsService // Optional function called after every successful request made to the DO APIs onRequestCompleted RequestCompletionCallback @@ -159,23 +160,24 @@ func NewClient(httpClient *http.Client) *Client { c.Account = &AccountServiceOp{client: c} c.Actions = &ActionsServiceOp{client: c} c.CDNs = &CDNServiceOp{client: c} + c.Certificates = &CertificatesServiceOp{client: c} c.Domains = &DomainsServiceOp{client: c} c.Droplets = &DropletsServiceOp{client: c} c.DropletActions = &DropletActionsServiceOp{client: c} + c.Firewalls = &FirewallsServiceOp{client: c} c.FloatingIPs = &FloatingIPsServiceOp{client: c} c.FloatingIPActions = &FloatingIPActionsServiceOp{client: c} c.Images = &ImagesServiceOp{client: c} c.ImageActions = &ImageActionsServiceOp{client: c} c.Keys = &KeysServiceOp{client: c} + c.LoadBalancers = &LoadBalancersServiceOp{client: c} + c.Projects = &ProjectsServiceOp{client: c} c.Regions = &RegionsServiceOp{client: c} - c.Snapshots = &SnapshotsServiceOp{client: c} c.Sizes = &SizesServiceOp{client: c} + c.Snapshots = &SnapshotsServiceOp{client: c} c.Storage = &StorageServiceOp{client: c} c.StorageActions = &StorageActionsServiceOp{client: c} c.Tags = &TagsServiceOp{client: c} - c.LoadBalancers = &LoadBalancersServiceOp{client: c} - c.Certificates = &CertificatesServiceOp{client: c} - c.Firewalls = &FirewallsServiceOp{client: c} return c } diff --git a/load_balancers.go b/load_balancers.go index de19fe7f..1472fff0 100644 --- a/load_balancers.go +++ b/load_balancers.go @@ -47,6 +47,10 @@ func (l LoadBalancer) String() string { return Stringify(l) } +func (l LoadBalancer) URN() string { + return ToURN("LoadBalancer", l.ID) +} + // AsRequest creates a LoadBalancerRequest that can be submitted to Update with the current values of the LoadBalancer. // Modifying the returned LoadBalancerRequest will not modify the original LoadBalancer. func (l LoadBalancer) AsRequest() *LoadBalancerRequest { diff --git a/projects.go b/projects.go new file mode 100644 index 00000000..52291a1e --- /dev/null +++ b/projects.go @@ -0,0 +1,302 @@ +package godo + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path" +) + +const ( + // DefaultProject is the ID you should use if you are working with your + // default project. + DefaultProject = "default" + + projectsBasePath = "/v2/projects" +) + +// ProjectsService is an interface for creating and managing Projects with the DigitalOcean API. +// See: https://developers.digitalocean.com/documentation/documentation/v2/#projects +type ProjectsService interface { + List(context.Context, *ListOptions) ([]Project, *Response, error) + GetDefault(context.Context) (*Project, *Response, error) + Get(context.Context, string) (*Project, *Response, error) + Create(context.Context, *CreateProjectRequest) (*Project, *Response, error) + Update(context.Context, string, *UpdateProjectRequest) (*Project, *Response, error) + Delete(context.Context, string) (*Response, error) + + ListResources(context.Context, string, *ListOptions) ([]ProjectResource, *Response, error) + AssignResources(context.Context, string, ...interface{}) ([]ProjectResource, *Response, error) +} + +// ProjectsServiceOp handles communication with Projects methods of the DigitalOcean API. +type ProjectsServiceOp struct { + client *Client +} + +// Project represents a DigitalOcean Project configuration. +type Project struct { + ID string `json:"id"` + OwnerUUID string `json:"owner_uuid"` + OwnerID uint64 `json:"owner_id"` + Name string `json:"name"` + Description string `json:"description"` + Purpose string `json:"purpose"` + Environment string `json:"environment"` + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// String creates a human-readable description of a Project. +func (p Project) String() string { + return Stringify(p) +} + +// CreateProjectRequest represents the request to create a new project. +type CreateProjectRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Purpose string `json:"purpose"` + Environment string `json:"environment"` +} + +// UpdateProjectRequest represents the request to update project information. +// This type expects certain attribute types, but is built this way to allow +// nil values as well. See `updateProjectRequest` for the "real" types. +type UpdateProjectRequest struct { + Name interface{} + Description interface{} + Purpose interface{} + Environment interface{} + IsDefault interface{} +} + +type updateProjectRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Purpose *string `json:"purpose"` + Environment *string `json:"environment"` + IsDefault *bool `json:"is_default"` +} + +// MarshalJSON takes an UpdateRequest and converts it to the "typed" request +// which is sent to the projects API. This is a PATCH request, which allows +// partial attributes, so `null` values are OK. +func (upr *UpdateProjectRequest) MarshalJSON() ([]byte, error) { + d := &updateProjectRequest{} + if str, ok := upr.Name.(string); ok { + d.Name = &str + } + if str, ok := upr.Description.(string); ok { + d.Description = &str + } + if str, ok := upr.Purpose.(string); ok { + d.Purpose = &str + } + if str, ok := upr.Environment.(string); ok { + d.Environment = &str + } + if val, ok := upr.IsDefault.(bool); ok { + d.IsDefault = &val + } + + return json.Marshal(d) +} + +type assignResourcesRequest struct { + Resources []string `json:"resources"` +} + +// ProjectResource is the projects API's representation of a resource. +type ProjectResource struct { + URN string `json:"urn"` + AssignedAt string `json:"assigned_at"` + Links *ProjectResourceLinks `json:"links"` + Status string `json:"status,omitempty"` +} + +// ProjetResourceLinks specify the link for more information about the resource. +type ProjectResourceLinks struct { + Self string `json:"self"` +} + +type projectsRoot struct { + Projects []Project `json:"projects"` + Links *Links `json:"links"` +} + +type projectRoot struct { + Project *Project `json:"project"` +} + +type projectResourcesRoot struct { + Resources []ProjectResource `json:"resources"` + Links *Links `json:"links,omitempty"` +} + +var _ ProjectsService = &ProjectsServiceOp{} + +// List Projects. +func (p *ProjectsServiceOp) List(ctx context.Context, opts *ListOptions) ([]Project, *Response, error) { + path, err := addOptions(projectsBasePath, opts) + if err != nil { + return nil, nil, err + } + + req, err := p.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(projectsRoot) + resp, err := p.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + + return root.Projects, resp, err +} + +// GetDefault project. +func (p *ProjectsServiceOp) GetDefault(ctx context.Context) (*Project, *Response, error) { + return p.getHelper(ctx, "default") +} + +// Get retrieves a single project by its ID. +func (p *ProjectsServiceOp) Get(ctx context.Context, projectID string) (*Project, *Response, error) { + return p.getHelper(ctx, projectID) +} + +// Create a new project. +func (p *ProjectsServiceOp) Create(ctx context.Context, cr *CreateProjectRequest) (*Project, *Response, error) { + req, err := p.client.NewRequest(ctx, http.MethodPost, projectsBasePath, cr) + if err != nil { + return nil, nil, err + } + + root := new(projectRoot) + resp, err := p.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Project, resp, err +} + +// Update an existing project. +func (p *ProjectsServiceOp) Update(ctx context.Context, projectID string, ur *UpdateProjectRequest) (*Project, *Response, error) { + path := path.Join(projectsBasePath, projectID) + req, err := p.client.NewRequest(ctx, http.MethodPatch, path, ur) + if err != nil { + return nil, nil, err + } + + root := new(projectRoot) + resp, err := p.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Project, resp, err +} + +// Delete an existing project. You cannot have any resources in a project +// before deleting it. See the API documentation for more details. +func (p *ProjectsServiceOp) Delete(ctx context.Context, projectID string) (*Response, error) { + path := path.Join(projectsBasePath, projectID) + req, err := p.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + + return p.client.Do(ctx, req, nil) +} + +// ListResources lists all resources in a project. +func (p *ProjectsServiceOp) ListResources(ctx context.Context, projectID string, opts *ListOptions) ([]ProjectResource, *Response, error) { + basePath := path.Join(projectsBasePath, projectID, "resources") + path, err := addOptions(basePath, opts) + if err != nil { + return nil, nil, err + } + + req, err := p.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(projectResourcesRoot) + resp, err := p.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + + return root.Resources, resp, err +} + +// AssignResources assigns one or more resources to a project. AssignResources +// accepts resources in two possible formats: + +// 1. The resource type, like `&Droplet{ID: 1}` or `&FloatingIP{IP: "1.2.3.4"}` +// 2. A valid DO URN as a string, like "do:droplet:1234" +// +// There is no unassign. To move a resource to another project, just assign +// it to that other project. +func (p *ProjectsServiceOp) AssignResources(ctx context.Context, projectID string, resources ...interface{}) ([]ProjectResource, *Response, error) { + path := path.Join(projectsBasePath, projectID, "resources") + + ar := &assignResourcesRequest{ + Resources: make([]string, len(resources)), + } + + for i, resource := range resources { + switch resource.(type) { + case ResourceWithURN: + ar.Resources[i] = resource.(ResourceWithURN).URN() + case string: + ar.Resources[i] = resource.(string) + default: + return nil, nil, fmt.Errorf("%T must either be a string or have a valid URN method", resource) + } + } + req, err := p.client.NewRequest(ctx, http.MethodPost, path, ar) + if err != nil { + return nil, nil, err + } + + root := new(projectResourcesRoot) + resp, err := p.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + + return root.Resources, resp, err +} + +func (p *ProjectsServiceOp) getHelper(ctx context.Context, projectID string) (*Project, *Response, error) { + path := path.Join(projectsBasePath, projectID) + + req, err := p.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(projectRoot) + resp, err := p.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Project, resp, err +} diff --git a/projects_test.go b/projects_test.go new file mode 100644 index 00000000..aeefd9c9 --- /dev/null +++ b/projects_test.go @@ -0,0 +1,609 @@ +package godo + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "strings" + "testing" +) + +func TestProjects_List(t *testing.T) { + setup() + defer teardown() + + projects := []Project{ + { + ID: "project-1", + Name: "project-1", + }, + { + ID: "project-2", + Name: "project-2", + }, + } + + mux.HandleFunc("/v2/projects", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + resp, _ := json.Marshal(projects) + fmt.Fprint(w, fmt.Sprintf(`{"projects":%s}`, string(resp))) + }) + + resp, _, err := client.Projects.List(ctx, nil) + if err != nil { + t.Errorf("Projects.List returned error: %v", err) + } + + if !reflect.DeepEqual(resp, projects) { + t.Errorf("Projects.List returned %+v, expected %+v", resp, projects) + } +} + +func TestProjects_ListWithMultiplePages(t *testing.T) { + setup() + defer teardown() + + mockResp := ` + { + "projects": [ + { + "uuid": "project-1", + "name": "project-1" + }, + { + "uuid": "project-2", + "name": "project-2" + } + ], + "links": { + "pages": { + "next": "http://example.com/v2/projects?page=2" + } + } + }` + + mux.HandleFunc("/v2/projects", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, mockResp) + }) + + _, resp, err := client.Projects.List(ctx, nil) + if err != nil { + t.Errorf("Projects.List returned error: %v", err) + } + + checkCurrentPage(t, resp, 1) +} + +func TestProjects_ListWithPageNumber(t *testing.T) { + setup() + defer teardown() + + mockResp := ` + { + "projects": [ + { + "uuid": "project-1", + "name": "project-1" + }, + { + "uuid": "project-2", + "name": "project-2" + } + ], + "links": { + "pages": { + "next": "http://example.com/v2/projects?page=3", + "prev": "http://example.com/v2/projects?page=1", + "last": "http://example.com/v2/projects?page=3", + "first": "http://example.com/v2/projects?page=1" + } + } + }` + + mux.HandleFunc("/v2/projects", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, mockResp) + }) + + _, resp, err := client.Projects.List(ctx, &ListOptions{Page: 2}) + if err != nil { + t.Errorf("Projects.List returned error: %v", err) + } + + checkCurrentPage(t, resp, 2) +} + +func TestProjects_GetDefault(t *testing.T) { + setup() + defer teardown() + + project := &Project{ + ID: "project-1", + Name: "project-1", + } + + mux.HandleFunc("/v2/projects/default", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + resp, _ := json.Marshal(project) + fmt.Fprint(w, fmt.Sprintf(`{"project":%s}`, string(resp))) + }) + + resp, _, err := client.Projects.GetDefault(ctx) + if err != nil { + t.Errorf("Projects.GetDefault returned error: %v", err) + } + + if !reflect.DeepEqual(resp, project) { + t.Errorf("Projects.GetDefault returned %+v, expected %+v", resp, project) + } +} + +func TestProjects_GetWithUUID(t *testing.T) { + setup() + defer teardown() + + project := &Project{ + ID: "project-1", + Name: "project-1", + } + + mux.HandleFunc("/v2/projects/project-1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + resp, _ := json.Marshal(project) + fmt.Fprint(w, fmt.Sprintf(`{"project":%s}`, string(resp))) + }) + + resp, _, err := client.Projects.Get(ctx, "project-1") + if err != nil { + t.Errorf("Projects.Get returned error: %v", err) + } + + if !reflect.DeepEqual(resp, project) { + t.Errorf("Projects.Get returned %+v, expected %+v", resp, project) + } +} + +func TestProjects_Create(t *testing.T) { + setup() + defer teardown() + + createRequest := &CreateProjectRequest{ + Name: "my project", + Description: "for my stuff", + Purpose: "Just trying out DigitalOcean", + Environment: "Production", + } + + createResp := &Project{ + ID: "project-id", + Name: createRequest.Name, + Description: createRequest.Description, + Purpose: createRequest.Purpose, + Environment: createRequest.Environment, + } + + mux.HandleFunc("/v2/projects", func(w http.ResponseWriter, r *http.Request) { + v := new(CreateProjectRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + testMethod(t, r, http.MethodPost) + if !reflect.DeepEqual(v, createRequest) { + t.Errorf("Request body = %+v, expected %+v", v, createRequest) + } + + resp, _ := json.Marshal(createResp) + fmt.Fprintf(w, fmt.Sprintf(`{"project":%s}`, string(resp))) + }) + + project, _, err := client.Projects.Create(ctx, createRequest) + if err != nil { + t.Errorf("Projects.Create returned error: %v", err) + } + + if !reflect.DeepEqual(project, createResp) { + t.Errorf("Projects.Create returned %+v, expected %+v", project, createResp) + } +} + +func TestProjects_UpdateWithOneAttribute(t *testing.T) { + setup() + defer teardown() + + updateRequest := &UpdateProjectRequest{ + Name: "my-great-project", + } + updateResp := &Project{ + ID: "project-id", + Name: updateRequest.Name.(string), + Description: "some-other-description", + Purpose: "some-other-purpose", + Environment: "some-other-env", + IsDefault: false, + } + + mux.HandleFunc("/v2/projects/project-1", func(w http.ResponseWriter, r *http.Request) { + reqBytes, respErr := ioutil.ReadAll(r.Body) + if respErr != nil { + t.Error("projects mock didn't work") + } + + req := strings.TrimSuffix(string(reqBytes), "\n") + expectedReq := `{"name":"my-great-project","description":null,"purpose":null,"environment":null,"is_default":null}` + if req != expectedReq { + t.Errorf("projects req didn't match up:\n expected %+v\n got %+v\n", expectedReq, req) + } + + resp, _ := json.Marshal(updateResp) + fmt.Fprintf(w, fmt.Sprintf(`{"project":%s}`, string(resp))) + }) + + project, _, err := client.Projects.Update(ctx, "project-1", updateRequest) + if err != nil { + t.Errorf("Projects.Update returned error: %v", err) + } + if !reflect.DeepEqual(project, updateResp) { + t.Errorf("Projects.Update returned %+v, expected %+v", project, updateResp) + } +} + +func TestProjects_UpdateWithAllAttributes(t *testing.T) { + setup() + defer teardown() + + updateRequest := &UpdateProjectRequest{ + Name: "my-great-project", + Description: "some-description", + Purpose: "some-purpose", + Environment: "some-env", + IsDefault: true, + } + updateResp := &Project{ + ID: "project-id", + Name: updateRequest.Name.(string), + Description: updateRequest.Description.(string), + Purpose: updateRequest.Purpose.(string), + Environment: updateRequest.Environment.(string), + IsDefault: updateRequest.IsDefault.(bool), + } + + mux.HandleFunc("/v2/projects/project-1", func(w http.ResponseWriter, r *http.Request) { + reqBytes, respErr := ioutil.ReadAll(r.Body) + if respErr != nil { + t.Error("projects mock didn't work") + } + + req := strings.TrimSuffix(string(reqBytes), "\n") + expectedReq := `{"name":"my-great-project","description":"some-description","purpose":"some-purpose","environment":"some-env","is_default":true}` + if req != expectedReq { + t.Errorf("projects req didn't match up:\n expected %+v\n got %+v\n", expectedReq, req) + } + + resp, _ := json.Marshal(updateResp) + fmt.Fprintf(w, fmt.Sprintf(`{"project":%s}`, string(resp))) + }) + + project, _, err := client.Projects.Update(ctx, "project-1", updateRequest) + if err != nil { + t.Errorf("Projects.Update returned error: %v", err) + } + if !reflect.DeepEqual(project, updateResp) { + t.Errorf("Projects.Update returned %+v, expected %+v", project, updateResp) + } +} + +func TestProjects_Destroy(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/projects/project-1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + }) + + _, err := client.Projects.Delete(ctx, "project-1") + if err != nil { + t.Errorf("Projects.Delete returned error: %v", err) + } +} + +func TestProjects_ListResources(t *testing.T) { + setup() + defer teardown() + + resources := []ProjectResource{ + { + URN: "do:droplet:1", + AssignedAt: "2018-09-27 00:00:00", + Links: &ProjectResourceLinks{ + Self: "http://example.com/v2/droplets/1", + }, + }, + { + URN: "do:floatingip:1.2.3.4", + AssignedAt: "2018-09-27 00:00:00", + Links: &ProjectResourceLinks{ + Self: "http://example.com/v2/floating_ips/1.2.3.4", + }, + }, + } + + mux.HandleFunc("/v2/projects/project-1/resources", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + resp, _ := json.Marshal(resources) + fmt.Fprint(w, fmt.Sprintf(`{"resources":%s}`, string(resp))) + }) + + resp, _, err := client.Projects.ListResources(ctx, "project-1", nil) + if err != nil { + t.Errorf("Projects.List returned error: %v", err) + } + + if !reflect.DeepEqual(resp, resources) { + t.Errorf("Projects.ListResources returned %+v, expected %+v", resp, resources) + } +} + +func TestProjects_ListResourcesWithMultiplePages(t *testing.T) { + setup() + defer teardown() + + mockResp := ` + { + "resources": [ + { + "urn": "do:droplet:1", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/droplets/1" + } + }, + { + "urn": "do:floatingip:1.2.3.4", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/floating_ips/1.2.3.4" + } + } + ], + "links": { + "pages": { + "next": "http://example.com/v2/projects/project-1/resources?page=2" + } + } + }` + + mux.HandleFunc("/v2/projects/project-1/resources", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, mockResp) + }) + + _, resp, err := client.Projects.ListResources(ctx, "project-1", nil) + if err != nil { + t.Errorf("Projects.ListResources returned error: %v", err) + } + + checkCurrentPage(t, resp, 1) +} + +func TestProjects_ListResourcesWithPageNumber(t *testing.T) { + setup() + defer teardown() + + mockResp := ` + { + "resources": [ + { + "urn": "do:droplet:1", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/droplets/1" + } + }, + { + "urn": "do:floatingip:1.2.3.4", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/floating_ips/1.2.3.4" + } + } + ], + "links": { + "pages": { + "next": "http://example.com/v2/projects/project-1/resources?page=3", + "prev": "http://example.com/v2/projects/project-1/resources?page=1", + "last": "http://example.com/v2/projects/project-1/resources?page=3", + "first": "http://example.com/v2/projects/project-1/resources?page=1" + } + } + }` + + mux.HandleFunc("/v2/projects/project-1/resources", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, mockResp) + }) + + _, resp, err := client.Projects.ListResources(ctx, "project-1", &ListOptions{Page: 2}) + if err != nil { + t.Errorf("Projects.ListResources returned error: %v", err) + } + + checkCurrentPage(t, resp, 2) +} + +func TestProjects_AssignFleetResourcesWithTypes(t *testing.T) { + setup() + defer teardown() + + assignableResources := []interface{}{ + &Droplet{ID: 1234}, + &FloatingIP{IP: "1.2.3.4"}, + } + + mockResp := ` + { + "resources": [ + { + "urn": "do:droplet:1234", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/droplets/1" + } + }, + { + "urn": "do:floatingip:1.2.3.4", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/floating_ips/1.2.3.4" + } + } + ] + }` + + mux.HandleFunc("/v2/projects/project-1/resources", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + reqBytes, respErr := ioutil.ReadAll(r.Body) + if respErr != nil { + t.Error("projects mock didn't work") + } + + req := strings.TrimSuffix(string(reqBytes), "\n") + expectedReq := `{"resources":["do:droplet:1234","do:floatingip:1.2.3.4"]}` + if req != expectedReq { + t.Errorf("projects assign req didn't match up:\n expected %+v\n got %+v\n", expectedReq, req) + } + + fmt.Fprint(w, mockResp) + }) + + _, _, err := client.Projects.AssignResources(ctx, "project-1", assignableResources...) + if err != nil { + t.Errorf("Projects.AssignResources returned error: %v", err) + } +} + +func TestProjects_AssignFleetResourcesWithStrings(t *testing.T) { + setup() + defer teardown() + + assignableResources := []interface{}{ + "do:droplet:1234", + "do:floatingip:1.2.3.4", + } + + mockResp := ` + { + "resources": [ + { + "urn": "do:droplet:1234", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/droplets/1" + } + }, + { + "urn": "do:floatingip:1.2.3.4", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/floating_ips/1.2.3.4" + } + } + ] + }` + + mux.HandleFunc("/v2/projects/project-1/resources", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + reqBytes, respErr := ioutil.ReadAll(r.Body) + if respErr != nil { + t.Error("projects mock didn't work") + } + + req := strings.TrimSuffix(string(reqBytes), "\n") + expectedReq := `{"resources":["do:droplet:1234","do:floatingip:1.2.3.4"]}` + if req != expectedReq { + t.Errorf("projects assign req didn't match up:\n expected %+v\n got %+v\n", expectedReq, req) + } + + fmt.Fprint(w, mockResp) + }) + + _, _, err := client.Projects.AssignResources(ctx, "project-1", assignableResources...) + if err != nil { + t.Errorf("Projects.AssignResources returned error: %v", err) + } +} + +func TestProjects_AssignFleetResourcesWithStringsAndTypes(t *testing.T) { + setup() + defer teardown() + + assignableResources := []interface{}{ + "do:droplet:1234", + &FloatingIP{IP: "1.2.3.4"}, + } + + mockResp := ` + { + "resources": [ + { + "urn": "do:droplet:1234", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/droplets/1" + } + }, + { + "urn": "do:floatingip:1.2.3.4", + "assigned_at": "2018-09-27 00:00:00", + "links": { + "self": "http://example.com/v2/floating_ips/1.2.3.4" + } + } + ] + }` + + mux.HandleFunc("/v2/projects/project-1/resources", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + reqBytes, respErr := ioutil.ReadAll(r.Body) + if respErr != nil { + t.Error("projects mock didn't work") + } + + req := strings.TrimSuffix(string(reqBytes), "\n") + expectedReq := `{"resources":["do:droplet:1234","do:floatingip:1.2.3.4"]}` + if req != expectedReq { + t.Errorf("projects assign req didn't match up:\n expected %+v\n got %+v\n", expectedReq, req) + } + + fmt.Fprint(w, mockResp) + }) + + _, _, err := client.Projects.AssignResources(ctx, "project-1", assignableResources...) + if err != nil { + t.Errorf("Projects.AssignResources returned error: %v", err) + } +} + +func TestProjects_AssignFleetResourcesWithTypeWithoutURNReturnsError(t *testing.T) { + setup() + defer teardown() + + type fakeType struct{} + + assignableResources := []interface{}{ + fakeType{}, + } + + _, _, err := client.Projects.AssignResources(ctx, "project-1", assignableResources...) + if err == nil { + t.Errorf("expected Projects.AssignResources to error, but it did not") + } + + if err.Error() != "godo.fakeType must either be a string or have a valid URN method" { + t.Errorf("Projects.AssignResources returned the wrong error: %v", err) + } +} diff --git a/storage.go b/storage.go index f9c266fa..a79332a7 100644 --- a/storage.go +++ b/storage.go @@ -59,6 +59,10 @@ func (f Volume) String() string { return Stringify(f) } +func (f Volume) URN() string { + return ToURN("Volume", f.ID) +} + type storageVolumesRoot struct { Volumes []Volume `json:"volumes"` Links *Links `json:"links"` diff --git a/strings.go b/strings.go index 4a8bfb63..4d5c0ad2 100644 --- a/strings.go +++ b/strings.go @@ -5,10 +5,20 @@ import ( "fmt" "io" "reflect" + "strings" ) var timestampType = reflect.TypeOf(Timestamp{}) +type ResourceWithURN interface { + URN() string +} + +// ToURN converts the resource type and ID to a valid DO API URN. +func ToURN(resourceType string, id interface{}) string { + return fmt.Sprintf("%s:%s:%v", "do", strings.ToLower(resourceType), id) +} + // Stringify attempts to create a string representation of DigitalOcean types func Stringify(message interface{}) string { var buf bytes.Buffer