From b4bec8f5578f47155e5585a3075806c476d8e92a Mon Sep 17 00:00:00 2001 From: bryanl Date: Wed, 3 Sep 2014 10:03:30 -0400 Subject: [PATCH] initial commit --- LICENSE.txt | 55 ++++++ Makefile | 10 ++ README.md | 68 ++++++++ action.go | 76 +++++++++ action_request.go | 12 ++ action_request_test.go | 16 ++ action_test.go | 67 ++++++++ doapi.go | 351 ++++++++++++++++++++++++++++++++++++++ doapi_test.go | 369 ++++++++++++++++++++++++++++++++++++++++ doc.go | 2 + domains.go | 149 ++++++++++++++++ domains_test.go | 196 +++++++++++++++++++++ droplet_actions.go | 132 ++++++++++++++ droplet_actions_test.go | 268 +++++++++++++++++++++++++++++ droplets.go | 176 +++++++++++++++++++ droplets_test.go | 191 +++++++++++++++++++++ image_actions.go | 45 +++++ image_actions_test.go | 59 +++++++ images.go | 47 +++++ images_test.go | 45 +++++ keys.go | 121 +++++++++++++ keys_test.go | 144 ++++++++++++++++ regions.go | 45 +++++ regions_test.go | 43 +++++ sizes.go | 44 +++++ sizes_test.go | 46 +++++ strings.go | 84 +++++++++ timestamp.go | 35 ++++ timestamp_test.go | 176 +++++++++++++++++++ util/droplet.go | 47 +++++ 30 files changed, 3119 insertions(+) create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 action.go create mode 100644 action_request.go create mode 100644 action_request_test.go create mode 100644 action_test.go create mode 100644 doapi.go create mode 100644 doapi_test.go create mode 100644 doc.go create mode 100644 domains.go create mode 100644 domains_test.go create mode 100644 droplet_actions.go create mode 100644 droplet_actions_test.go create mode 100644 droplets.go create mode 100644 droplets_test.go create mode 100644 image_actions.go create mode 100644 image_actions_test.go create mode 100644 images.go create mode 100644 images_test.go create mode 100644 keys.go create mode 100644 keys_test.go create mode 100644 regions.go create mode 100644 regions_test.go create mode 100644 sizes.go create mode 100644 sizes_test.go create mode 100644 strings.go create mode 100644 timestamp.go create mode 100644 timestamp_test.go create mode 100644 util/droplet.go diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..d9cd60c4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,55 @@ +Copyright (c) 2014 The godo AUTHORS. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +====================== +Portions of the client are based on code at: +https://github.com/google/go-github/ + +Copyright (c) 2013 The go-github AUTHORS. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..86a0a0b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +OPEN = $(shell which xdg-open || which gnome-open || which open) + +cov: + @@gocov test | gocov-html > /tmp/coverage.html + @@${OPEN} /tmp/coverage.html + +ci: + go get -d -v -t ./... + go build ./... + go test -v ./... diff --git a/README.md b/README.md new file mode 100644 index 00000000..5af6f8b6 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# GODO + +Godo is a Go client library for accessing the DigitalOcean V2 API. + +## Usage + +```go +import "github.com/digitaloceancloud/godo" +``` + +Create a new DigitalOcean client, then use the exposed services to +access different parts of the DigitalOcean API. + +### Authentication + +Currently, Personal Access Token (PAT) is the only method of +authenticating with the API. You can manage your tokens +at the Digital Ocean Control Panel [Applications Page](https://cloud.digitalocean.com/settings/applications). + +You can then use your token to creat a new client: + +```go +import "code.google.com/p/goauth2/oauth" + +pat := "mytoken" +t := &oauth.Transport{ + Token: &oauth.Token{AccessToken: pat}, +} + +client := godo.NewClient(t.Client()) +``` + +## Examples + +[Digital Ocean API Documentation](https://developers.digitalocean.com/v2/) + + +To list all Droplets your account has access to: + +```go +droplets, _, err := client.Droplet.List() +if err != nil { + fmt.Printf("error: %v\n\n", err) + return err +} else { + fmt.Printf("%v\n\n", godo.Stringify(droplets)) +} +``` + +To create a new Droplet: + +```go +dropletName := "super-cool-droplet" + +createRequest := &godo.DropletCreateRequest{ + Name: godo.String(dropletName), + Region: godo.String("nyc2"), + Size: godo.String("512mb"), + Image: godo.Int(3240036), // ubuntu 14.04 64bit +} + +newDroplet, _, err := client.Droplet.Create(createRequest) + +if err != nil { + fmt.Printf("Something bad happened: %s\n\n", err) + return err +} +``` diff --git a/action.go b/action.go new file mode 100644 index 00000000..dd596da2 --- /dev/null +++ b/action.go @@ -0,0 +1,76 @@ +package godo + +import "fmt" + +const ( + actionsBasePath = "v2/actions" + + // ActionInProgress is an in progress action status + ActionInProgress = "in-progress" + + //ActionCompleted is a completed action status + ActionCompleted = "completed" +) + +// ImageActionsService handles communition with the image action related methods of the +// DigitalOcean API. +type ActionsService struct { + client *Client +} + +type actionsRoot struct { + Actions []Action `json:"actions"` +} + +type actionRoot struct { + Event Action `json:"action"` +} + +// Action represents a DigitalOcean Action +type Action struct { + ID int `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + StartedAt *Timestamp `json:"started_at"` + CompletedAt *Timestamp `json:"completed_at"` + ResourceID int `json:"resource_id"` + ResourceType string `json:"resource_type"` +} + +// List all actions +func (s *ActionsService) List() ([]Action, *Response, error) { + path := actionsBasePath + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(actionsRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return root.Actions, resp, err +} + +func (s *ActionsService) Get(id int) (*Action, *Response, error) { + path := fmt.Sprintf("%s/%d", actionsBasePath, id) + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.Event, resp, err +} + +func (a Action) String() string { + return Stringify(a) +} diff --git a/action_request.go b/action_request.go new file mode 100644 index 00000000..d4411766 --- /dev/null +++ b/action_request.go @@ -0,0 +1,12 @@ +package godo + +// ActionRequest reprents DigitalOcean Action Request +type ActionRequest struct { + Type string `json:"type"` + Params map[string]interface{} `json:"params,omitempty"` +} + +// Converts an ActionRequest to a string. +func (d ActionRequest) String() string { + return Stringify(d) +} diff --git a/action_request_test.go b/action_request_test.go new file mode 100644 index 00000000..5b752102 --- /dev/null +++ b/action_request_test.go @@ -0,0 +1,16 @@ +package godo + +import "testing" + +func TestActionRequest_String(t *testing.T) { + action := &ActionRequest{ + Type: "transfer", + Params: map[string]interface{}{"key-1": "value-1"}, + } + + stringified := action.String() + expected := `godo.ActionRequest{Type:"transfer", Params:map[key-1:value-1]}` + if expected != stringified { + t.Errorf("Action.Stringify returned %+v, expected %+v", stringified, expected) + } +} diff --git a/action_test.go b/action_test.go new file mode 100644 index 00000000..0c116a71 --- /dev/null +++ b/action_test.go @@ -0,0 +1,67 @@ +package godo + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAction_List(t *testing.T) { + setup() + defer teardown() + + assert := assert.New(t) + + mux.HandleFunc("/v2/actions", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"actions": [{"id":1},{"id":2}]}`) + testMethod(t, r, "GET") + }) + + actions, _, err := client.Actions.List() + assert.NoError(err) + expected := []Action{{ID: 1}, {ID: 2}} + assert.Equal(expected, actions) +} + +func TestAction_Get(t *testing.T) { + setup() + defer teardown() + + assert := assert.New(t) + + mux.HandleFunc("/v2/actions/12345", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"action": {"id":12345}}`) + testMethod(t, r, "GET") + }) + + action, _, err := client.Actions.Get(12345) + assert.NoError(err) + assert.Equal(12345, action.ID) +} + +func TestAction_String(t *testing.T) { + assert := assert.New(t) + pt, err := time.Parse(time.RFC3339, "2014-05-08T20:36:47Z") + assert.NoError(err) + + startedAt := &Timestamp{ + Time: pt, + } + action := &Action{ + ID: 1, + Status: "in-progress", + Type: "transfer", + StartedAt: startedAt, + } + + stringified := action.String() + expected := `godo.Action{ID:1, Status:"in-progress", Type:"transfer", ` + + `StartedAt:godo.Timestamp{2014-05-08 20:36:47 +0000 UTC}, ` + + `ResourceID:0, ResourceType:""}` + if expected != stringified { + t.Errorf("Action.Stringify returned %+v, expected %+v", stringified, expected) + } +} diff --git a/doapi.go b/doapi.go new file mode 100644 index 00000000..901026da --- /dev/null +++ b/doapi.go @@ -0,0 +1,351 @@ +package godo + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "strconv" + "time" + + "github.com/google/go-querystring/query" + headerLink "github.com/tent/http-link-go" +) + +const ( + libraryVersion = "0.1.0" + defaultBaseURL = "https://api.digitalocean.com/" + userAgent = "godo/" + libraryVersion + mediaType = "application/json" + + headerRateLimit = "X-RateLimit-Limit" + headerRateRemaining = "X-RateLimit-Remaining" + headerRateReset = "X-RateLimit-Reset" +) + +// Client manages communication with DigitalOcean V2 API. +type Client struct { + // HTTP client used to communicate with the DO API. + client *http.Client + + // Base URL for API requests. + BaseURL *url.URL + + // User agent for client + UserAgent string + + // Rate contains the current rate limit for the client as determined by the most recent + // API call. + Rate Rate + + // Services used for communicating with the API + Actions *ActionsService + Domains *DomainsService + Droplet *DropletsService + DropletActions *DropletActionsService + Images *ImagesService + ImageActions *ImageActionsService + Keys *KeysService + Regions *RegionsService + Sizes *SizesService +} + +// ListOptions specifies the optional parameters to various List methods that +// support pagination. +type ListOptions struct { + // For paginated result sets, page of results to retrieve. + Page int `url:"page,omitempty"` + + // For paginated result sets, the number of results to include per page. + PerPage int `url:"per_page,omitempty"` +} + +// Response is a Digital Ocean response. This wraps the standard http.Response returned from DigitalOcean. +type Response struct { + *http.Response + + // These fields provide the page values for paginating through a set of + // results. Any or all of these may be set to the zero value for + // responses that are not part of a paginated set, or for which there + // are no additional pages. + + NextPage string + PrevPage string + FirstPage string + LastPage string + + // Monitoring URI + Monitor string + + Rate +} + +// An ErrorResponse reports the error caused by an API request +type ErrorResponse struct { + // HTTP response that caused this error + Response *http.Response + + // Error message + Message string +} + +// Rate contains the rate limit for the current client. +type Rate struct { + // The number of request per hour the client is currently limited to. + Limit int `json:"limit"` + + // The number of remaining requests the client can make this hour. + Remaining int `json:"remaining"` + + // The time at w\hic the current rate limit will reset. + Reset Timestamp `json:"reset"` +} + +func addOptions(s string, opt interface{}) (string, error) { + v := reflect.ValueOf(opt) + + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + qv, err := query.Values(opt) + if err != nil { + return s, err + } + + u.RawQuery = qv.Encode() + return u.String(), nil +} + +// NewClient returns a new Digital Ocean API client. +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + + baseURL, _ := url.Parse(defaultBaseURL) + + c := &Client{client: httpClient, BaseURL: baseURL, UserAgent: userAgent} + c.Actions = &ActionsService{client: c} + c.Domains = &DomainsService{client: c} + c.Droplet = &DropletsService{client: c} + c.DropletActions = &DropletActionsService{client: c} + c.Images = &ImagesService{client: c} + c.ImageActions = &ImageActionsService{client: c} + c.Keys = &KeysService{client: c} + c.Regions = &RegionsService{client: c} + c.Sizes = &SizesService{client: c} + + return c +} + +// NewRequest creates an API request. A relative URL can be provided in urlStr, which will be resolved to the +// BaseURL of the Client. Relative URLS should always be specified without a preceding slash. If specified, the +// value pointed to by body is JSON encoded and included in as the request body. +func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { + rel, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + + u := c.BaseURL.ResolveReference(rel) + + buf := new(bytes.Buffer) + if body != nil { + err := json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", mediaType) + req.Header.Add("Accept", mediaType) + req.Header.Add("User-Agent", userAgent) + return req, nil +} + +// newResponse creates a new Response for the provided http.Response +func newResponse(r *http.Response) *Response { + response := Response{Response: r} + response.populatePageValues() + response.populateRate() + response.populateMonitor() + + return &response +} + +// populatePageValues parses the HTTP Link response headers and populates the +// various pagination link values in the Response. +func (r *Response) populatePageValues() { + links, err := r.links() + + if err == nil { + var l headerLink.Link + var ok bool + + l, ok = links["next"] + if ok { + r.NextPage = l.URI + } + l, ok = links["prev"] + if ok { + r.PrevPage = l.URI + } + + l, ok = links["first"] + if ok { + r.FirstPage = l.URI + } + + l, ok = links["last"] + if ok { + r.LastPage = l.URI + } + } +} + +func (r *Response) populateMonitor() { + links, err := r.links() + + if err == nil { + link, ok := links["monitor"] + if ok { + r.Monitor = link.URI + } + } +} + +func (r *Response) links() (map[string]headerLink.Link, error) { + if linkText, ok := r.Response.Header["Link"]; ok { + links, err := headerLink.Parse(linkText[0]) + + if err != nil { + return nil, err + } + + linkMap := map[string]headerLink.Link{} + for _, link := range links { + linkMap[link.Rel] = link + } + + return linkMap, nil + } + + return map[string]headerLink.Link{}, nil +} + +// populateRate parses the rate related headers and populates the response Rate. +func (r *Response) populateRate() { + if limit := r.Header.Get(headerRateLimit); limit != "" { + r.Rate.Limit, _ = strconv.Atoi(limit) + } + if remaining := r.Header.Get(headerRateRemaining); remaining != "" { + r.Rate.Remaining, _ = strconv.Atoi(remaining) + } + if reset := r.Header.Get(headerRateReset); reset != "" { + if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 { + r.Rate.Reset = Timestamp{time.Unix(v, 0)} + } + } +} + +// Do sends an API request and returns the API response. The API response is JSON decoded and stored in the value +// pointed to by v, or returned as an error if an API error has occurred. If v implements the io.Writer interface, +// the raw response will be written to v, without attempting to decode it. +func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) { + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + response := newResponse(resp) + c.Rate = response.Rate + + err = CheckResponse(resp) + if err != nil { + return response, err + } + + if v != nil { + if w, ok := v.(io.Writer); ok { + io.Copy(w, resp.Body) + } else { + json.NewDecoder(resp.Body).Decode(v) + } + } + + return response, err +} +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("%v %v: %d %v", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, r.Message) +} + +// CheckResponse checks the API response for errors, and returns them if present. A response is considered an +// error if it has a status code outside the 200 range. API error responses are expected to have either no response +// body, or a JSON response body that maps to ErrorResponse. Any other response body will be silently ignored. +func CheckResponse(r *http.Response) error { + if c := r.StatusCode; c >= 200 && c <= 299 { + return nil + } + + errorResponse := &ErrorResponse{Response: r} + data, err := ioutil.ReadAll(r.Body) + if err == nil && len(data) > 0 { + json.Unmarshal(data, errorResponse) + } + + return errorResponse +} + +func (r Rate) String() string { + return Stringify(r) +} + +// String is a helper routine that allocates a new string value +// to store v and returns a pointer to it. +func String(v string) *string { + p := new(string) + *p = v + return p +} + +// Int is a helper routine that allocates a new int32 value +// to store v and returns a pointer to it, but unlike Int32 +// its argument value is an int. +func Int(v int) *int { + p := new(int) + *p = v + return p +} + +// Bool is a helper routine that allocates a new bool value +// to store v and returns a pointer to it. +func Bool(v bool) *bool { + p := new(bool) + *p = v + return p +} + +// StreamToString converts a reader to a string +func StreamToString(stream io.Reader) string { + buf := new(bytes.Buffer) + buf.ReadFrom(stream) + return buf.String() +} diff --git a/doapi_test.go b/doapi_test.go new file mode 100644 index 00000000..309dea49 --- /dev/null +++ b/doapi_test.go @@ -0,0 +1,369 @@ +package godo + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" +) + +var ( + mux *http.ServeMux + + client *Client + + server *httptest.Server +) + +func setup() { + mux = http.NewServeMux() + server = httptest.NewServer(mux) + + client = NewClient(nil) + url, _ := url.Parse(server.URL) + client.BaseURL = url +} + +func teardown() { + server.Close() +} + +func testMethod(t *testing.T, r *http.Request, expected string) { + if expected != r.Method { + t.Errorf("Request method = %v, expected %v", r.Method, expected) + } +} + +type values map[string]string + +func testFormValues(t *testing.T, r *http.Request, values values) { + expected := url.Values{} + for k, v := range values { + expected.Add(k, v) + } + + r.ParseForm() + if !reflect.DeepEqual(expected, r.Form) { + t.Errorf("Request parameters = %v, expected %v", r.Form, expected) + } +} + +func testURLParseError(t *testing.T, err error) { + if err == nil { + t.Errorf("Expected error to be returned") + } + if err, ok := err.(*url.Error); !ok || err.Op != "parse" { + t.Errorf("Expected URL parse error, got %+v", err) + } +} + +func TestNewClient(t *testing.T) { + c := NewClient(nil) + if c.BaseURL.String() != defaultBaseURL { + t.Errorf("NewClient BaseURL = %v, expected %v", c.BaseURL.String(), defaultBaseURL) + } + + if c.UserAgent != userAgent { + t.Errorf("NewClick UserAgent = %v, expected %v", c.UserAgent, userAgent) + } +} + +func TestNewRequest(t *testing.T) { + c := NewClient(nil) + + inURL, outURL := "/foo", defaultBaseURL+"foo" + inBody, outBody := &DropletCreateRequest{Name: "l"}, `{"name":"l","region":"","size":"","image":"","ssh_keys":null}`+"\n" + req, _ := c.NewRequest("GET", inURL, inBody) + + // test relative URL was expanded + if req.URL.String() != outURL { + t.Errorf("NewRequest(%v) URL = %v, expected %v", inURL, req.URL, outURL) + } + + // test body was JSON encoded + body, _ := ioutil.ReadAll(req.Body) + if string(body) != outBody { + t.Errorf("NewRequest(%v)Body = %v, expected %v", inBody, string(body), outBody) + } + + // test default user-agent is attached to the request + userAgent := req.Header.Get("User-Agent") + if c.UserAgent != userAgent { + t.Errorf("NewRequest() User-Agent = %v, expected %v", userAgent, c.UserAgent) + } +} + +func TestNewRequest_invalidJSON(t *testing.T) { + c := NewClient(nil) + + type T struct { + A map[int]interface{} + } + _, err := c.NewRequest("GET", "/", &T{}) + + if err == nil { + t.Error("Expected error to be returned.") + } + if err, ok := err.(*json.UnsupportedTypeError); !ok { + t.Errorf("Expected a JSON error; got %#v.", err) + } +} + +func TestNewRequest_badURL(t *testing.T) { + c := NewClient(nil) + _, err := c.NewRequest("GET", ":", nil) + testURLParseError(t, err) +} + +func TestDo(t *testing.T) { + setup() + defer teardown() + + type foo struct { + A string + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if m := "GET"; m != r.Method { + t.Errorf("Request method = %v, expected %v", r.Method, m) + } + fmt.Fprint(w, `{"A":"a"}`) + }) + + req, _ := client.NewRequest("GET", "/", nil) + body := new(foo) + client.Do(req, body) + + expected := &foo{"a"} + if !reflect.DeepEqual(body, expected) { + t.Errorf("Response body = %v, expected %v", body, expected) + } +} + +func TestDo_httpError(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Bad Request", 400) + }) + + req, _ := client.NewRequest("GET", "/", nil) + _, err := client.Do(req, nil) + + if err == nil { + t.Error("Expected HTTP 400 error.") + } +} + +// Test handling of an error caused by the internal http client's Do() +// function. +func TestDo_redirectLoop(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusFound) + }) + + req, _ := client.NewRequest("GET", "/", nil) + _, err := client.Do(req, nil) + + if err == nil { + t.Error("Expected error to be returned.") + } + if err, ok := err.(*url.Error); !ok { + t.Errorf("Expected a URL error; got %#v.", err) + } +} + +func TestCheckResponse(t *testing.T) { + res := &http.Response{ + Request: &http.Request{}, + StatusCode: http.StatusBadRequest, + Body: ioutil.NopCloser(strings.NewReader(`{"message":"m", + "errors": [{"resource": "r", "field": "f", "code": "c"}]}`)), + } + err := CheckResponse(res).(*ErrorResponse) + + if err == nil { + t.Fatalf("Expected error response.") + } + + expected := &ErrorResponse{ + Response: res, + Message: "m", + } + if !reflect.DeepEqual(err, expected) { + t.Errorf("Error = %#v, expected %#v", err, expected) + } +} + +// ensure that we properly handle API errors that do not contain a response +// body +func TestCheckResponse_noBody(t *testing.T) { + res := &http.Response{ + Request: &http.Request{}, + StatusCode: http.StatusBadRequest, + Body: ioutil.NopCloser(strings.NewReader("")), + } + err := CheckResponse(res).(*ErrorResponse) + + if err == nil { + t.Errorf("Expected error response.") + } + + expected := &ErrorResponse{ + Response: res, + } + if !reflect.DeepEqual(err, expected) { + t.Errorf("Error = %#v, expected %#v", err, expected) + } +} + +func TestErrorResponse_Error(t *testing.T) { + res := &http.Response{Request: &http.Request{}} + err := ErrorResponse{Message: "m", Response: res} + if err.Error() == "" { + t.Errorf("Expected non-empty ErrorResponse.Error()") + } +} + +func TestDo_rateLimit(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add(headerRateLimit, "60") + w.Header().Add(headerRateRemaining, "59") + w.Header().Add(headerRateReset, "1372700873") + }) + + var expected int + + if expected = 0; client.Rate.Limit != expected { + t.Errorf("Client rate limit = %v, expected %v", client.Rate.Limit, expected) + } + if expected = 0; client.Rate.Remaining != expected { + t.Errorf("Client rate remaining = %v, got %v", client.Rate.Remaining, expected) + } + if !client.Rate.Reset.IsZero() { + t.Errorf("Client rate reset not initialized to zero value") + } + + req, _ := client.NewRequest("GET", "/", nil) + client.Do(req, nil) + + if expected = 60; client.Rate.Limit != expected { + t.Errorf("Client rate limit = %v, expected %v", client.Rate.Limit, expected) + } + if expected = 59; client.Rate.Remaining != expected { + t.Errorf("Client rate remaining = %v, expected %v", client.Rate.Remaining, expected) + } + reset := time.Date(2013, 7, 1, 17, 47, 53, 0, time.UTC) + if client.Rate.Reset.UTC() != reset { + t.Errorf("Client rate reset = %v, expected %v", client.Rate.Reset, reset) + } +} + +func TestDo_rateLimit_errorResponse(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add(headerRateLimit, "60") + w.Header().Add(headerRateRemaining, "59") + w.Header().Add(headerRateReset, "1372700873") + http.Error(w, "Bad Request", 400) + }) + + var expected int + + req, _ := client.NewRequest("GET", "/", nil) + client.Do(req, nil) + + if expected = 60; client.Rate.Limit != expected { + t.Errorf("Client rate limit = %v, expected %v", client.Rate.Limit, expected) + } + if expected = 59; client.Rate.Remaining != expected { + t.Errorf("Client rate remaining = %v, expected %v", client.Rate.Remaining, expected) + } + reset := time.Date(2013, 7, 1, 17, 47, 53, 0, time.UTC) + if client.Rate.Reset.UTC() != reset { + t.Errorf("Client rate reset = %v, expected %v", client.Rate.Reset, reset) + } +} + +func TestResponse_populatePageValues(t *testing.T) { + r := http.Response{ + Header: http.Header{ + "Link": {`; rel="first",` + + ` ; rel="prev",` + + ` ; rel="next",` + + ` ; rel="last"`, + }, + }, + } + + response := newResponse(&r) + + links := map[string]string{ + "first": "https://api.digitalocean.com/?page=1", + "prev": "https://api.digitalocean.com/?page=2", + "next": "https://api.digitalocean.com/?page=4", + "last": "https://api.digitalocean.com/?page=5", + } + + if expected, got := links["first"], response.FirstPage; expected != got { + t.Errorf("response.FirstPage: %v, expected %v", got, expected) + } + if expected, got := links["prev"], response.PrevPage; expected != got { + t.Errorf("response.PrevPage: %v, expected %v", got, expected) + } + if expected, got := links["next"], response.NextPage; expected != got { + t.Errorf("response.NextPage: %v, expected %v", got, expected) + } + if expected, got := links["last"], response.LastPage; expected != got { + t.Errorf("response.LastPage: %v, expected %v", got, expected) + } +} + +func TestResponse_populatePageValues_invalid(t *testing.T) { + r := http.Response{ + Header: http.Header{ + "Link": {`,` + + `; rel="first",` + + `https://api.digitalocean.com/?page=2; rel="prev",` + + `; rel="next",` + + `; rel="last"`, + }, + }, + } + + response := newResponse(&r) + if expected, got := "", response.FirstPage; expected != got { + t.Errorf("response.FirstPage: %v, expected %v", expected, got) + } + if expected, got := "", response.PrevPage; expected != got { + t.Errorf("response.PrevPage: %v, expected %v", expected, got) + } + if expected, got := "", response.NextPage; expected != got { + t.Errorf("response.NextPage: %v, expected %v", expected, got) + } + if expected, got := "", response.LastPage; expected != got { + t.Errorf("response.LastPage: %v, expected %v", expected, got) + } + + // more invalid URLs + r = http.Response{ + Header: http.Header{ + "Link": {`; rel="first"`}, + }, + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 00000000..e660f794 --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// Package godo is the DigtalOcean API v2 client for Go +package godo diff --git a/domains.go b/domains.go new file mode 100644 index 00000000..d34c2989 --- /dev/null +++ b/domains.go @@ -0,0 +1,149 @@ +package godo + +import "fmt" + +const domainsBasePath = "v2/domains" + +// DomainsService handles communication wit the domain related methods of the +// DigitalOcean API. +type DomainsService struct { + client *Client +} + +type DomainRecordRoot struct { + DomainRecord *DomainRecord `json:"domain_record"` +} + +type DomainRecordsRoot struct { + DomainRecords []DomainRecord `json:"domain_records"` +} + +// DomainRecord represents a DigitalOcean DomainRecord +type DomainRecord struct { + ID int `json:"id,float64,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` + Priority int `json:"priority,omitempty"` + Port int `json:"port,omitempty"` + Weight int `json:"weight,omitempty"` +} + +type DomainRecordsOptions struct { + ListOptions +} + +// Converts a DomainRecord to a string. +func (d DomainRecord) String() string { + return Stringify(d) +} + +// DomainRecordEditRequest represents a request to update a domain record. +type DomainRecordEditRequest struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` + Priority int `json:"priority,omitempty"` + Port int `json:"port,omitempty"` + Weight int `json:"weight,omitempty"` +} + +// Converts a DomainRecordEditRequest to a string. +func (d DomainRecordEditRequest) String() string { + return Stringify(d) +} + +// Records returns a slice of DomainRecords for a domain +func (s *DomainsService) Records(domain string, opt *DomainRecordsOptions) ([]DomainRecord, *Response, error) { + path := fmt.Sprintf("%s/%s/records", domainsBasePath, domain) + path, err := addOptions(path, opt) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + records := new(DomainRecordsRoot) + resp, err := s.client.Do(req, records) + if err != nil { + return nil, resp, err + } + + return records.DomainRecords, resp, err +} + +// Record returns the record id from a domain +func (s *DomainsService) Record(domain string, id int) (*DomainRecord, *Response, error) { + path := fmt.Sprintf("%s/%s/records/%d", domainsBasePath, domain, id) + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + record := new(DomainRecordRoot) + resp, err := s.client.Do(req, record) + if err != nil { + return nil, resp, err + } + + return record.DomainRecord, resp, err +} + +// DeleteRecord deletes a record from a domain identified by id +func (s *DomainsService) DeleteRecord(domain string, id int) (*Response, error) { + path := fmt.Sprintf("%s/%s/records/%d", domainsBasePath, domain, id) + + req, err := s.client.NewRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + + return resp, err +} + +// EditRecord edits a record using a DomainRecordEditRequest +func (s *DomainsService) EditRecord( + domain string, + id int, + editRequest *DomainRecordEditRequest) (*DomainRecord, *Response, error) { + path := fmt.Sprintf("%s/%s/records/%d", domainsBasePath, domain, id) + + req, err := s.client.NewRequest("PUT", path, editRequest) + if err != nil { + return nil, nil, err + } + + d := new(DomainRecord) + resp, err := s.client.Do(req, d) + if err != nil { + return nil, resp, err + } + + return d, resp, err +} + +// CreateRecord creates a record using a DomainRecordEditRequest +func (s *DomainsService) CreateRecord( + domain string, + createRequest *DomainRecordEditRequest) (*DomainRecord, *Response, error) { + path := fmt.Sprintf("%s/%s/records", domainsBasePath, domain) + req, err := s.client.NewRequest("POST", path, createRequest) + + if err != nil { + return nil, nil, err + } + + d := new(DomainRecordRoot) + resp, err := s.client.Do(req, d) + if err != nil { + return nil, resp, err + } + + return d.DomainRecord, resp, err +} diff --git a/domains_test.go b/domains_test.go new file mode 100644 index 00000000..f7d83485 --- /dev/null +++ b/domains_test.go @@ -0,0 +1,196 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestDomains_AllRecordsForDomainName(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"domain_records":[{"id":1},{"id":2}]}`) + }) + + records, _, err := client.Domains.Records("example.com", nil) + if err != nil { + t.Errorf("Domains.List returned error: %v", err) + } + + expected := []DomainRecord{{ID: 1}, {ID: 2}} + if !reflect.DeepEqual(records, expected) { + t.Errorf("Domains.List returned %+v, expected %+v", records, expected) + } +} + +func TestDomains_AllRecordsForDomainName_PerPage(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) { + perPage := r.URL.Query().Get("per_page") + if perPage != "2" { + t.Fatalf("expected '2', got '%s'", perPage) + } + + fmt.Fprint(w, `{"domain_records":[{"id":1},{"id":2}]}`) + }) + + dro := &DomainRecordsOptions{ListOptions{PerPage: 2}} + records, _, err := client.Domains.Records("example.com", dro) + if err != nil { + t.Errorf("Domains.List returned error: %v", err) + } + + expected := []DomainRecord{{ID: 1}, {ID: 2}} + if !reflect.DeepEqual(records, expected) { + t.Errorf("Domains.List returned %+v, expected %+v", records, expected) + } +} + +func TestDomains_GetRecordforDomainName(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/domains/example.com/records/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"domain_record":{"id":1}}`) + }) + + record, _, err := client.Domains.Record("example.com", 1) + if err != nil { + t.Errorf("Domains.GetRecord returned error: %v", err) + } + + expected := &DomainRecord{ID: 1} + if !reflect.DeepEqual(record, expected) { + t.Errorf("Domains.GetRecord returned %+v, expected %+v", record, expected) + } +} + +func TestDomains_DeleteRecordForDomainName(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/domains/example.com/records/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + }) + + _, err := client.Domains.DeleteRecord("example.com", 1) + if err != nil { + t.Errorf("Domains.RecordDelete returned error: %v", err) + } +} + +func TestDomains_CreateRecordForDomainName(t *testing.T) { + setup() + defer teardown() + + createRequest := &DomainRecordEditRequest{ + Type: "CNAME", + Name: "example", + Data: "@", + Priority: 10, + Port: 10, + Weight: 10, + } + + mux.HandleFunc("/v2/domains/example.com/records", + func(w http.ResponseWriter, r *http.Request) { + v := new(DomainRecordEditRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, createRequest) { + t.Errorf("Request body = %+v, expected %+v", v, createRequest) + } + + fmt.Fprintf(w, `{"domain_record": {"id":1}}`) + }) + + record, _, err := client.Domains.CreateRecord("example.com", createRequest) + if err != nil { + t.Errorf("Domains.CreateRecord returned error: %v", err) + } + + expected := &DomainRecord{ID: 1} + if !reflect.DeepEqual(record, expected) { + t.Errorf("Domains.CreateRecord returned %+v, expected %+v", record, expected) + } +} + +func TestDomains_EditRecordForDomainName(t *testing.T) { + setup() + defer teardown() + + editRequest := &DomainRecordEditRequest{ + Type: "CNAME", + Name: "example", + Data: "@", + Priority: 10, + Port: 10, + Weight: 10, + } + + mux.HandleFunc("/v2/domains/example.com/records/1", func(w http.ResponseWriter, r *http.Request) { + v := new(DomainRecordEditRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "PUT") + if !reflect.DeepEqual(v, editRequest) { + t.Errorf("Request body = %+v, expected %+v", v, editRequest) + } + + fmt.Fprintf(w, `{"id":1}`) + }) + + record, _, err := client.Domains.EditRecord("example.com", 1, editRequest) + if err != nil { + t.Errorf("Domains.EditRecord returned error: %v", err) + } + + expected := &DomainRecord{ID: 1} + if !reflect.DeepEqual(record, expected) { + t.Errorf("Domains.EditRecord returned %+v, expected %+v", record, expected) + } +} + +func TestDomainRecord_String(t *testing.T) { + record := &DomainRecord{ + ID: 1, + Type: "CNAME", + Name: "example", + Data: "@", + Priority: 10, + Port: 10, + Weight: 10, + } + + stringified := record.String() + expected := `godo.DomainRecord{ID:1, Type:"CNAME", Name:"example", Data:"@", Priority:10, Port:10, Weight:10}` + if expected != stringified { + t.Errorf("DomainRecord.String returned %+v, expected %+v", stringified, expected) + } +} + +func TestDomainRecordEditRequest_String(t *testing.T) { + record := &DomainRecordEditRequest{ + Type: "CNAME", + Name: "example", + Data: "@", + Priority: 10, + Port: 10, + Weight: 10, + } + + stringified := record.String() + expected := `godo.DomainRecordEditRequest{Type:"CNAME", Name:"example", Data:"@", Priority:10, Port:10, Weight:10}` + if expected != stringified { + t.Errorf("DomainRecordEditRequest.String returned %+v, expected %+v", stringified, expected) + } +} diff --git a/droplet_actions.go b/droplet_actions.go new file mode 100644 index 00000000..ab70577f --- /dev/null +++ b/droplet_actions.go @@ -0,0 +1,132 @@ +package godo + +import ( + "fmt" + "net/url" +) + +// DropletActionsService handles communication with the droplet action related +// methods of the DigitalOcean API. +type DropletActionsService struct { + client *Client +} + +// Shutdown a Droplet +func (s *DropletActionsService) Shutdown(id int) (*Action, *Response, error) { + request := &ActionRequest{Type: "shutdown"} + return s.doAction(id, request) +} + +// PowerOff a Droplet +func (s *DropletActionsService) PowerOff(id int) (*Action, *Response, error) { + request := &ActionRequest{Type: "power_off"} + return s.doAction(id, request) +} + +// PowerCycle a Droplet +func (s *DropletActionsService) PowerCycle(id int) (*Action, *Response, error) { + request := &ActionRequest{Type: "power_cycle"} + return s.doAction(id, request) +} + +// Reboot a Droplet +func (s *DropletActionsService) Reboot(id int) (*Action, *Response, error) { + request := &ActionRequest{Type: "reboot"} + return s.doAction(id, request) +} + +// Restore an image to a Droplet +func (s *DropletActionsService) Restore(id, imageID int) (*Action, *Response, error) { + options := map[string]interface{}{ + "image": float64(imageID), + } + + requestType := "restore" + request := &ActionRequest{ + Type: requestType, + Params: options, + } + return s.doAction(id, request) +} + +// Resize a Droplet +func (s *DropletActionsService) Resize(id int, sizeSlug string) (*Action, *Response, error) { + options := map[string]interface{}{ + "size": sizeSlug, + } + + requestType := "resize" + request := &ActionRequest{ + Type: requestType, + Params: options, + } + return s.doAction(id, request) +} + +// Rename a Droplet +func (s *DropletActionsService) Rename(id int, name string) (*Action, *Response, error) { + options := map[string]interface{}{ + "name": name, + } + + requestType := "rename" + request := &ActionRequest{ + Type: requestType, + Params: options, + } + return s.doAction(id, request) +} + +func (s *DropletActionsService) doAction(id int, request *ActionRequest) (*Action, *Response, error) { + path := dropletActionPath(id) + + req, err := s.client.NewRequest("POST", path, request) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.Event, resp, err +} + +// Get an action for a particular droplet by id. +func (s *DropletActionsService) Get(dropletID, actionID int) (*Action, *Response, error) { + path := fmt.Sprintf("%s/%d", dropletActionPath(dropletID), actionID) + return s.get(path) +} + +// GetByURI gets an action for a particular droplet by id. +func (s *DropletActionsService) GetByURI(rawurl string) (*Action, *Response, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, nil, err + } + + return s.get(u.Path) + +} + +func (s *DropletActionsService) get(path string) (*Action, *Response, error) { + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.Event, resp, err + +} + +func dropletActionPath(dropletID int) string { + return fmt.Sprintf("v2/droplets/%d/actions", dropletID) +} diff --git a/droplet_actions_test.go b/droplet_actions_test.go new file mode 100644 index 00000000..0a7d2f4c --- /dev/null +++ b/droplet_actions_test.go @@ -0,0 +1,268 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestDropletActions_Shutdown(t *testing.T) { + setup() + defer teardown() + + request := &ActionRequest{ + Type: "shutdown", + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.DropletActions.Shutdown(1) + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_PowerOff(t *testing.T) { + setup() + defer teardown() + + request := &ActionRequest{ + Type: "power_off", + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.DropletActions.PowerOff(1) + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_Reboot(t *testing.T) { + setup() + defer teardown() + + request := &ActionRequest{ + Type: "reboot", + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + + }) + + action, _, err := client.DropletActions.Reboot(1) + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_Restore(t *testing.T) { + setup() + defer teardown() + + options := map[string]interface{}{ + "image": float64(1), + } + + request := &ActionRequest{ + Type: "restore", + Params: options, + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + + }) + + action, _, err := client.DropletActions.Restore(1, 1) + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_Resize(t *testing.T) { + setup() + defer teardown() + + options := map[string]interface{}{ + "size": "1024mb", + } + + request := &ActionRequest{ + Type: "resize", + Params: options, + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + + }) + + action, _, err := client.DropletActions.Resize(1, "1024mb") + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_Rename(t *testing.T) { + setup() + defer teardown() + + options := map[string]interface{}{ + "name": "Droplet-Name", + } + + request := &ActionRequest{ + Type: "rename", + Params: options, + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.DropletActions.Rename(1, "Droplet-Name") + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_PowerCycle(t *testing.T) { + setup() + defer teardown() + + request := &ActionRequest{ + Type: "power_cycle", + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + + }) + + action, _, err := client.DropletActions.PowerCycle(1) + if err != nil { + t.Errorf("DropletActions.Shutdown returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Shutdown returned %+v, expected %+v", action, expected) + } +} + +func TestDropletActions_Get(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/droplets/123/actions/456", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.DropletActions.Get(123, 456) + if err != nil { + t.Errorf("DropletActions.Get returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.Get returned %+v, expected %+v", action, expected) + } +} diff --git a/droplets.go b/droplets.go new file mode 100644 index 00000000..5a68e0ad --- /dev/null +++ b/droplets.go @@ -0,0 +1,176 @@ +package godo + +import "fmt" + +const dropletBasePath = "v2/droplets" + +// DropletsService handles communication with the droplet related methods of the +// DigitalOcean API. +type DropletsService struct { + client *Client +} + +// Droplet represents a DigitalOcean Droplet +type Droplet struct { + ID int `json:"id,float64,omitempty"` + Name string `json:"name,omitempty"` + Memory int `json:"memory,omitempty"` + Vcpus int `json:"vcpus,omitempty"` + Disk int `json:"disk,omitempty"` + Region *Region `json:"region,omitempty"` + Image *Image `json:"image,omitempty"` + Size *Size `json:"size,omitempty"` + BackupIDs []int `json:"backup_ids,omitempty"` + SnapshotIDs []int `json:"snapshot_ids,omitempty"` + Locked bool `json:"locked,bool,omitempty"` + Status string `json:"status,omitempty"` + Networks *Networks `json:"networks,omitempty"` + ActionIDs []int `json:"action_ids,omitempty"` +} + +// Convert Droplet to a string +func (d Droplet) String() string { + return Stringify(d) +} + +// DropletRoot represents a Droplet root +type DropletRoot struct { + Droplet *Droplet `json:"droplet"` + Links *Links `json:"links,omitempty"` +} + +type dropletsRoot struct { + Droplets []Droplet `json:"droplets"` +} + +// DropletCreateRequest represents a request to create a droplet. +type DropletCreateRequest struct { + Name string `json:"name"` + Region string `json:"region"` + Size string `json:"size"` + Image string `json:"image"` + SSHKeys []interface{} `json:"ssh_keys"` +} + +func (d DropletCreateRequest) String() string { + return Stringify(d) +} + +// Networks represents the droplet's networks +type Networks struct { + V4 []Network `json:"v4,omitempty"` + V6 []Network `json:"v6,omitempty"` +} + +// Network represents a DigitalOcean Network +type Network struct { + IPAddress string `json:"ip_address,omitempty"` + Netmask string `json:"netmask,omitempty"` + Gateway string `json:"gateway,omitempty"` + Type string `json:"type,omitempty"` +} + +func (n Network) String() string { + return Stringify(n) +} + +// Links are extra links for a droplet +type Links struct { + Actions []Link `json:"actions,omitempty"` +} + +// Action extracts Link +func (l *Links) Action(action string) *Link { + for _, a := range l.Actions { + if a.Rel == action { + return &a + } + } + + return nil +} + +// Link represents a link +type Link struct { + ID int `json:"id,omitempty"` + Rel string `json:"rel,omitempty"` + HREF string `json:"href,omitempty"` +} + +// List all droplets +func (s *DropletsService) List() ([]Droplet, *Response, error) { + path := dropletBasePath + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + droplets := new(dropletsRoot) + resp, err := s.client.Do(req, droplets) + if err != nil { + return nil, resp, err + } + + return droplets.Droplets, resp, err +} + +// Get individual droplet +func (s *DropletsService) Get(dropletID int) (*DropletRoot, *Response, error) { + path := fmt.Sprintf("%s/%d", dropletBasePath, dropletID) + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(DropletRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, err +} + +// Create droplet +func (s *DropletsService) Create(createRequest *DropletCreateRequest) (*DropletRoot, *Response, error) { + path := dropletBasePath + + req, err := s.client.NewRequest("POST", path, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(DropletRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, err +} + +// Delete droplet +func (s *DropletsService) Delete(dropletID int) (*Response, error) { + path := fmt.Sprintf("%s/%d", dropletBasePath, dropletID) + + req, err := s.client.NewRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + + return resp, err +} + +func (s *DropletsService) dropletActionStatus(uri string) (string, error) { + action, _, err := s.client.DropletActions.GetByURI(uri) + + if err != nil { + return "", err + } + + return action.Status, nil +} diff --git a/droplets_test.go b/droplets_test.go new file mode 100644 index 00000000..96e3d2d7 --- /dev/null +++ b/droplets_test.go @@ -0,0 +1,191 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestDroplets_ListDroplets(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/droplets", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"droplets": [{"id":1},{"id":2}]}`) + }) + + droplets, _, err := client.Droplet.List() + if err != nil { + t.Errorf("Droplets.List returned error: %v", err) + } + + expected := []Droplet{{ID: 1}, {ID: 2}} + if !reflect.DeepEqual(droplets, expected) { + t.Errorf("Droplets.List returned %+v, expected %+v", droplets, expected) + } +} + +func TestDroplets_GetDroplet(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/droplets/12345", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"droplet":{"id":12345}}`) + }) + + droplets, _, err := client.Droplet.Get(12345) + if err != nil { + t.Errorf("Droplet.Get returned error: %v", err) + } + + expected := &DropletRoot{Droplet: &Droplet{ID: 12345}} + if !reflect.DeepEqual(droplets, expected) { + t.Errorf("Droplets.Get returned %+v, expected %+v", droplets, expected) + } +} + +func TestDroplets_Create(t *testing.T) { + setup() + defer teardown() + + createRequest := &DropletCreateRequest{ + Name: "name", + Region: "region", + Size: "size", + Image: "1", + } + + mux.HandleFunc("/v2/droplets", func(w http.ResponseWriter, r *http.Request) { + v := new(DropletCreateRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, createRequest) { + t.Errorf("Request body = %+v, expected %+v", v, createRequest) + } + + fmt.Fprintf(w, `{"droplet":{"id":1}}`) + }) + + droplet, _, err := client.Droplet.Create(createRequest) + if err != nil { + t.Errorf("Droplets.Create returned error: %v", err) + } + + expected := &DropletRoot{Droplet: &Droplet{ID: 1}} + if !reflect.DeepEqual(droplet, expected) { + t.Errorf("Droplets.Create returned %+v, expected %+v", droplet, expected) + } +} + +func TestDroplets_Destroy(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/droplets/12345", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + }) + + _, err := client.Droplet.Delete(12345) + if err != nil { + t.Errorf("Droplet.Delete returned error: %v", err) + } +} + +func TestLinks_Actions(t *testing.T) { + setup() + defer teardown() + + aLink := Link{ID: 1, Rel: "a", HREF: "http://example.com/a"} + + links := Links{ + Actions: []Link{ + aLink, + Link{ID: 2, Rel: "b", HREF: "http://example.com/b"}, + Link{ID: 2, Rel: "c", HREF: "http://example.com/c"}, + }, + } + + link := links.Action("a") + + if *link != aLink { + t.Errorf("expected %+v, got %+v", aLink, link) + } + +} + +func TestNetwork_String(t *testing.T) { + network := &Network{ + IPAddress: "192.168.1.2", + Netmask: "255.255.255.0", + Gateway: "192.168.1.1", + } + + stringified := network.String() + expected := `godo.Network{IPAddress:"192.168.1.2", Netmask:"255.255.255.0", Gateway:"192.168.1.1", Type:""}` + if expected != stringified { + t.Errorf("Distribution.String returned %+v, expected %+v", stringified, expected) + } + +} + +func TestDroplet_String(t *testing.T) { + + region := &Region{ + Slug: "region", + Name: "Region", + Sizes: []string{"1", "2"}, + Available: true, + } + + image := &Image{ + ID: 1, + Name: "Image", + Distribution: "Ubuntu", + Slug: "image", + Public: true, + Regions: []string{"one", "two"}, + } + + size := &Size{ + Slug: "size", + PriceMonthly: 123, + PriceHourly: 456, + Regions: []string{"1", "2"}, + } + network := &Network{ + IPAddress: "192.168.1.2", + Netmask: "255.255.255.0", + Gateway: "192.168.1.1", + } + networks := &Networks{ + V4: []Network{*network}, + } + + droplet := &Droplet{ + ID: 1, + Name: "droplet", + Memory: 123, + Vcpus: 456, + Disk: 789, + Region: region, + Image: image, + Size: size, + BackupIDs: []int{1}, + SnapshotIDs: []int{1}, + ActionIDs: []int{1}, + Locked: false, + Status: "active", + Networks: networks, + } + + stringified := droplet.String() + expected := `godo.Droplet{ID:1, Name:"droplet", Memory:123, Vcpus:456, Disk:789, Region:godo.Region{Slug:"region", Name:"Region", Sizes:["1" "2"], Available:true}, Image:godo.Image{ID:1, Name:"Image", Distribution:"Ubuntu", Slug:"image", Public:true, Regions:["one" "two"]}, Size:godo.Size{Slug:"size", Memory:0, Vcpus:0, Disk:0, PriceMonthly:123, PriceHourly:456, Regions:["1" "2"]}, BackupIDs:[1], SnapshotIDs:[1], Locked:false, Status:"active", Networks:godo.Networks{V4:[godo.Network{IPAddress:"192.168.1.2", Netmask:"255.255.255.0", Gateway:"192.168.1.1", Type:""}]}, ActionIDs:[1]}` + if expected != stringified { + t.Errorf("Droplet.String returned %+v, expected %+v", stringified, expected) + } +} diff --git a/image_actions.go b/image_actions.go new file mode 100644 index 00000000..5138b77a --- /dev/null +++ b/image_actions.go @@ -0,0 +1,45 @@ +package godo + +import "fmt" + +// ImageActionsService handles communition with the image action related methods of the +// DigitalOcean API. +type ImageActionsService struct { + client *Client +} + +// Transfer an image +func (i *ImageActionsService) Transfer(imageID int, transferRequest *ActionRequest) (*Action, *Response, error) { + path := fmt.Sprintf("v2/images/%d/actions", imageID) + + req, err := i.client.NewRequest("POST", path, transferRequest) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := i.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.Event, resp, err +} + +// Get an action for a particular image by id. +func (i *ImageActionsService) Get(imageID, actionID int) (*Action, *Response, error) { + path := fmt.Sprintf("v2/images/%d/actions/%d", imageID, actionID) + + req, err := i.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := i.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.Event, resp, err +} diff --git a/image_actions_test.go b/image_actions_test.go new file mode 100644 index 00000000..fbac9982 --- /dev/null +++ b/image_actions_test.go @@ -0,0 +1,59 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestImageActions_Transfer(t *testing.T) { + setup() + defer teardown() + + transferRequest := &ActionRequest{} + + mux.HandleFunc("/v2/images/12345/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, transferRequest) { + t.Errorf("Request body = %+v, expected %+v", v, transferRequest) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + + }) + + transfer, _, err := client.ImageActions.Transfer(12345, transferRequest) + if err != nil { + t.Errorf("ImageActions.Transfer returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(transfer, expected) { + t.Errorf("ImageActions.Transfer returned %+v, expected %+v", transfer, expected) + } +} + +func TestImageActions_Get(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/images/123/actions/456", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.ImageActions.Get(123, 456) + if err != nil { + t.Errorf("ImageActions.Get returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("ImageActions.Get returned %+v, expected %+v", action, expected) + } +} diff --git a/images.go b/images.go new file mode 100644 index 00000000..85515854 --- /dev/null +++ b/images.go @@ -0,0 +1,47 @@ +package godo + +// ImagesService handles communication with the image related methods of the +// DigitalOcean API. +type ImagesService struct { + client *Client +} + +// Image represents a DigitalOcean Image +type Image struct { + ID int `json:"id,float64,omitempty"` + Name string `json:"name,omitempty"` + Distribution string `json:"distribution,omitempty"` + Slug string `json:"slug,omitempty"` + Public bool `json:"public,omitempty"` + Regions []string `json:"regions,omitempty"` +} + +type imageRoot struct { + Image Image +} + +type imagesRoot struct { + Images []Image +} + +func (i Image) String() string { + return Stringify(i) +} + +// List all sizes +func (s *ImagesService) List() ([]Image, *Response, error) { + path := "v2/images" + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + images := new(imagesRoot) + resp, err := s.client.Do(req, images) + if err != nil { + return nil, resp, err + } + + return images.Images, resp, err +} diff --git a/images_test.go b/images_test.go new file mode 100644 index 00000000..03e9af7b --- /dev/null +++ b/images_test.go @@ -0,0 +1,45 @@ +package godo + +import ( + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestImages_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/images", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"images":[{"id":1},{"id":2}]}`) + }) + + images, _, err := client.Images.List() + if err != nil { + t.Errorf("Images.List returned error: %v", err) + } + + expected := []Image{{ID: 1}, {ID: 2}} + if !reflect.DeepEqual(images, expected) { + t.Errorf("Images.List returned %+v, expected %+v", images, expected) + } +} + +func TestImage_String(t *testing.T) { + image := &Image{ + ID: 1, + Name: "Image", + Distribution: "Ubuntu", + Slug: "image", + Public: true, + Regions: []string{"one", "two"}, + } + + stringified := image.String() + expected := `godo.Image{ID:1, Name:"Image", Distribution:"Ubuntu", Slug:"image", Public:true, Regions:["one" "two"]}` + if expected != stringified { + t.Errorf("Image.String returned %+v, expected %+v", stringified, expected) + } +} diff --git a/keys.go b/keys.go new file mode 100644 index 00000000..532ea0bf --- /dev/null +++ b/keys.go @@ -0,0 +1,121 @@ +package godo + +import "fmt" + +const keysBasePath = "v2/account/keys" + +// KeysService handles communication with key related method of the +// DigitalOcean API. +type KeysService struct { + client *Client +} + +// Key represents a DigitalOcean Key. +type Key struct { + ID int `json:"id,float64,omitempty"` + Name string `json:"name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + PublicKey string `json:"public_key,omitempty"` +} + +type keysRoot struct { + SSHKeys []Key `json:"ssh_keys"` +} + +type keyRoot struct { + SSHKey Key `json:"ssh_key"` +} + +func (s Key) String() string { + return Stringify(s) +} + +// KeyCreateRequest represents a request to create a new key. +type KeyCreateRequest struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` +} + +// List all keys +func (s *KeysService) List() ([]Key, *Response, error) { + req, err := s.client.NewRequest("GET", keysBasePath, nil) + if err != nil { + return nil, nil, err + } + + keys := new(keysRoot) + resp, err := s.client.Do(req, keys) + if err != nil { + return nil, resp, err + } + + return keys.SSHKeys, resp, err +} + +// Performs a get given a path +func (s *KeysService) get(path string) (*Key, *Response, error) { + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(keyRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.SSHKey, resp, err +} + +// GetByID gets a Key by id +func (s *KeysService) GetByID(keyID int) (*Key, *Response, error) { + path := fmt.Sprintf("%s/%d", keysBasePath, keyID) + return s.get(path) +} + +// GetByFingerprint gets a Key by by fingerprint +func (s *KeysService) GetByFingerprint(fingerprint string) (*Key, *Response, error) { + path := fmt.Sprintf("%s/%s", keysBasePath, fingerprint) + return s.get(path) +} + +// Create a key using a KeyCreateRequest +func (s *KeysService) Create(createRequest *KeyCreateRequest) (*Key, *Response, error) { + req, err := s.client.NewRequest("POST", keysBasePath, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(keyRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.SSHKey, resp, err +} + +// Delete key using a path +func (s *KeysService) delete(path string) (*Response, error) { + req, err := s.client.NewRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + + return resp, err +} + +// DeleteByID deletes a key by its id +func (s *KeysService) DeleteByID(keyID int) (*Response, error) { + path := fmt.Sprintf("%s/%d", keysBasePath, keyID) + return s.delete(path) +} + +// DeleteByFingerprint deletes a key by its fingerprint +func (s *KeysService) DeleteByFingerprint(fingerprint string) (*Response, error) { + path := fmt.Sprintf("%s/%s", keysBasePath, fingerprint) + return s.delete(path) +} diff --git a/keys_test.go b/keys_test.go new file mode 100644 index 00000000..4c162871 --- /dev/null +++ b/keys_test.go @@ -0,0 +1,144 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestKeys_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/account/keys", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"ssh_keys":[{"id":1},{"id":2}]} `) + }) + + keys, _, err := client.Keys.List() + if err != nil { + t.Errorf("Keys.List returned error: %v", err) + } + + expected := []Key{{ID: 1}, {ID: 2}} + if !reflect.DeepEqual(keys, expected) { + t.Errorf("Keys.List returned %+v, expected %+v", keys, expected) + } +} + +func TestKeys_GetByID(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/account/keys/12345", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"ssh_key": {"id":12345}}`) + }) + + keys, _, err := client.Keys.GetByID(12345) + if err != nil { + t.Errorf("Keys.GetByID returned error: %v", err) + } + + expected := &Key{ID: 12345} + if !reflect.DeepEqual(keys, expected) { + t.Errorf("Keys.GetByID returned %+v, expected %+v", keys, expected) + } +} + +func TestKeys_GetByFingerprint(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/account/keys/aa:bb:cc", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"ssh_key": {"fingerprint":"aa:bb:cc"}}`) + }) + + keys, _, err := client.Keys.GetByFingerprint("aa:bb:cc") + if err != nil { + t.Errorf("Keys.GetByFingerprint returned error: %v", err) + } + + expected := &Key{Fingerprint: "aa:bb:cc"} + if !reflect.DeepEqual(keys, expected) { + t.Errorf("Keys.GetByFingerprint returned %+v, expected %+v", keys, expected) + } +} + +func TestKeys_Create(t *testing.T) { + setup() + defer teardown() + + createRequest := &KeyCreateRequest{ + Name: "name", + PublicKey: "ssh-rsa longtextandstuff", + } + + mux.HandleFunc("/v2/account/keys", func(w http.ResponseWriter, r *http.Request) { + v := new(KeyCreateRequest) + json.NewDecoder(r.Body).Decode(v) + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, createRequest) { + t.Errorf("Request body = %+v, expected %+v", v, createRequest) + } + + fmt.Fprintf(w, `{"ssh_key":{"id":1}}`) + }) + + key, _, err := client.Keys.Create(createRequest) + if err != nil { + t.Errorf("Keys.Create returned error: %v", err) + } + + expected := &Key{ID: 1} + if !reflect.DeepEqual(key, expected) { + t.Errorf("Keys.Create returned %+v, expected %+v", key, expected) + } +} + +func TestKeys_DestroyByID(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/account/keys/12345", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + }) + + _, err := client.Keys.DeleteByID(12345) + if err != nil { + t.Errorf("Keys.Delete returned error: %v", err) + } +} + +func TestKeys_DestroyByFingerprint(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/account/keys/aa:bb:cc", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + }) + + _, err := client.Keys.DeleteByFingerprint("aa:bb:cc") + if err != nil { + t.Errorf("Keys.Delete returned error: %v", err) + } +} + +func TestKey_String(t *testing.T) { + key := &Key{ + ID: 123, + Name: "Key", + Fingerprint: "fingerprint", + PublicKey: "public key", + } + + stringified := key.String() + expected := `godo.Key{ID:123, Name:"Key", Fingerprint:"fingerprint", PublicKey:"public key"}` + if expected != stringified { + t.Errorf("Key.String returned %+v, expected %+v", stringified, expected) + } +} diff --git a/regions.go b/regions.go new file mode 100644 index 00000000..d8fba0ee --- /dev/null +++ b/regions.go @@ -0,0 +1,45 @@ +package godo + +// RegionsService handles communication with the region related methods of the +// DigitalOcean API. +type RegionsService struct { + client *Client +} + +// Region represents a DigitalOcean Region +type Region struct { + Slug string `json:"slug,omitempty"` + Name string `json:"name,omitempty"` + Sizes []string `json:"sizes,omitempty"` + Available bool `json:"available,omitempty` +} + +type regionsRoot struct { + Regions []Region +} + +type regionRoot struct { + Region Region +} + +func (r Region) String() string { + return Stringify(r) +} + +// List all regions +func (s *RegionsService) List() ([]Region, *Response, error) { + path := "v2/regions" + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + regions := new(regionsRoot) + resp, err := s.client.Do(req, regions) + if err != nil { + return nil, resp, err + } + + return regions.Regions, resp, err +} diff --git a/regions_test.go b/regions_test.go new file mode 100644 index 00000000..66613242 --- /dev/null +++ b/regions_test.go @@ -0,0 +1,43 @@ +package godo + +import ( + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestRegions_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/regions", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"regions":[{"slug":"1"},{"slug":"2"}]}`) + }) + + regions, _, err := client.Regions.List() + if err != nil { + t.Errorf("Regions.List returned error: %v", err) + } + + expected := []Region{{Slug: "1"}, {Slug: "2"}} + if !reflect.DeepEqual(regions, expected) { + t.Errorf("Regions.List returned %+v, expected %+v", regions, expected) + } +} + +func TestRegion_String(t *testing.T) { + region := &Region{ + Slug: "region", + Name: "Region", + Sizes: []string{"1", "2"}, + Available: true, + } + + stringified := region.String() + expected := `godo.Region{Slug:"region", Name:"Region", Sizes:["1" "2"], Available:true}` + if expected != stringified { + t.Errorf("Region.String returned %+v, expected %+v", stringified, expected) + } +} diff --git a/sizes.go b/sizes.go new file mode 100644 index 00000000..3fb4bdb4 --- /dev/null +++ b/sizes.go @@ -0,0 +1,44 @@ +package godo + +// SizesService handles communication with the size related methods of the +// DigitalOcean API. +type SizesService struct { + client *Client +} + +// Size represents a DigitalOcean Size +type Size struct { + Slug string `json:"slug,omitempty"` + Memory int `json:"memory,omitempty"` + Vcpus int `json:"vcpus,omitempty"` + Disk int `json:"disk,omitempty"` + PriceMonthly float64 `json:"price_monthly,omitempty"` + PriceHourly float64 `json:"price_hourly,omitempty"` + Regions []string `json:"regions,omitempty"` +} + +func (s Size) String() string { + return Stringify(s) +} + +type sizesRoot struct { + Sizes []Size +} + +// List all images +func (s *SizesService) List() ([]Size, *Response, error) { + path := "v2/sizes" + + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + sizes := new(sizesRoot) + resp, err := s.client.Do(req, sizes) + if err != nil { + return nil, resp, err + } + + return sizes.Sizes, resp, err +} diff --git a/sizes_test.go b/sizes_test.go new file mode 100644 index 00000000..ca952229 --- /dev/null +++ b/sizes_test.go @@ -0,0 +1,46 @@ +package godo + +import ( + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestSizes_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/sizes", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"sizes":[{"slug":"1"},{"slug":"2"}]}`) + }) + + sizes, _, err := client.Sizes.List() + if err != nil { + t.Errorf("Sizes.List returned error: %v", err) + } + + expected := []Size{{Slug: "1"}, {Slug: "2"}} + if !reflect.DeepEqual(sizes, expected) { + t.Errorf("Sizes.List returned %+v, expected %+v", sizes, expected) + } +} + +func TestSize_String(t *testing.T) { + size := &Size{ + Slug: "slize", + Memory: 123, + Vcpus: 456, + Disk: 789, + PriceMonthly: 123, + PriceHourly: 456, + Regions: []string{"1", "2"}, + } + + stringified := size.String() + expected := `godo.Size{Slug:"slize", Memory:123, Vcpus:456, Disk:789, PriceMonthly:123, PriceHourly:456, Regions:["1" "2"]}` + if expected != stringified { + t.Errorf("Size.String returned %+v, expected %+v", stringified, expected) + } +} diff --git a/strings.go b/strings.go new file mode 100644 index 00000000..082343a6 --- /dev/null +++ b/strings.go @@ -0,0 +1,84 @@ +package godo + +import ( + "bytes" + "reflect" + "io" + "fmt" +) + +var timestampType = reflect.TypeOf(Timestamp{}) + +// Stringify attempts to create a string representation of Digital Ocean types +func Stringify(message interface{}) string { + var buf bytes.Buffer + v := reflect.ValueOf(message) + stringifyValue(&buf, v) + return buf.String() +} + +// stringifyValue was graciously cargoculted from the goprotubuf library +func stringifyValue(w io.Writer, val reflect.Value) { + if val.Kind() == reflect.Ptr && val.IsNil() { + w.Write([]byte("")) + return + } + + v := reflect.Indirect(val) + + switch v.Kind() { + case reflect.String: + fmt.Fprintf(w, `"%s"`, v) + case reflect.Slice: + w.Write([]byte{'['}) + for i := 0; i < v.Len(); i++ { + if i > 0 { + w.Write([]byte{' '}) + } + + stringifyValue(w, v.Index(i)) + } + + w.Write([]byte{']'}) + return + case reflect.Struct: + if v.Type().Name() != "" { + w.Write([]byte(v.Type().String())) + } + + // special handling of Timestamp values + if v.Type() == timestampType { + fmt.Fprintf(w, "{%s}", v.Interface()) + return + } + + w.Write([]byte{'{'}) + + var sep bool + for i := 0; i < v.NumField(); i++ { + fv := v.Field(i) + if fv.Kind() == reflect.Ptr && fv.IsNil() { + continue + } + if fv.Kind() == reflect.Slice && fv.IsNil() { + continue + } + + if sep { + w.Write([]byte(", ")) + } else { + sep = true + } + + w.Write([]byte(v.Type().Field(i).Name)) + w.Write([]byte{':'}) + stringifyValue(w, fv) + } + + w.Write([]byte{'}'}) + default: + if v.CanInterface() { + fmt.Fprint(w, v.Interface()) + } + } +} diff --git a/timestamp.go b/timestamp.go new file mode 100644 index 00000000..4df53639 --- /dev/null +++ b/timestamp.go @@ -0,0 +1,35 @@ +package godo + +import ( + "strconv" + "time" +) + +// Timestamp represents a time that can be unmarshalled from a JSON string +// formatted as either an RFC3339 or Unix timestamp. All +// exported methods of time.Time can be called on Timestamp. +type Timestamp struct { + time.Time +} + +func (t Timestamp) String() string { + return t.Time.String() +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// Time is expected in RFC3339 or Unix format. +func (t *Timestamp) UnmarshalJSON(data []byte) (err error) { + str := string(data) + i, err := strconv.ParseInt(str, 10, 64) + if err == nil { + t.Time = time.Unix(i, 0) + } else { + t.Time, err = time.Parse(`"`+time.RFC3339+`"`, str) + } + return +} + +// Equal reports whether t and u are equal based on time.Equal +func (t Timestamp) Equal(u Timestamp) bool { + return t.Time.Equal(u.Time) +} diff --git a/timestamp_test.go b/timestamp_test.go new file mode 100644 index 00000000..087e8aac --- /dev/null +++ b/timestamp_test.go @@ -0,0 +1,176 @@ +package godo + +import ( + "encoding/json" + "fmt" + "testing" + "time" +) + +const ( + emptyTimeStr = `"0001-01-01T00:00:00Z"` + referenceTimeStr = `"2006-01-02T15:04:05Z"` + referenceUnixTimeStr = `1136214245` +) + +var ( + referenceTime = time.Date(2006, 01, 02, 15, 04, 05, 0, time.UTC) + unixOrigin = time.Unix(0, 0).In(time.UTC) +) + +func TestTimestamp_Marshal(t *testing.T) { + testCases := []struct { + desc string + data Timestamp + want string + wantErr bool + equal bool + }{ + {"Reference", Timestamp{referenceTime}, referenceTimeStr, false, true}, + {"Empty", Timestamp{}, emptyTimeStr, false, true}, + {"Mismatch", Timestamp{}, referenceTimeStr, false, false}, + } + for _, tc := range testCases { + out, err := json.Marshal(tc.data) + if gotErr := (err != nil); gotErr != tc.wantErr { + t.Errorf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + } + got := string(out) + equal := got == tc.want + if (got == tc.want) != tc.equal { + t.Errorf("%s: got=%s, want=%s, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestTimestamp_Unmarshal(t *testing.T) { + testCases := []struct { + desc string + data string + want Timestamp + wantErr bool + equal bool + }{ + {"Reference", referenceTimeStr, Timestamp{referenceTime}, false, true}, + {"ReferenceUnix", `1136214245`, Timestamp{referenceTime}, false, true}, + {"Empty", emptyTimeStr, Timestamp{}, false, true}, + {"UnixStart", `0`, Timestamp{unixOrigin}, false, true}, + {"Mismatch", referenceTimeStr, Timestamp{}, false, false}, + {"MismatchUnix", `0`, Timestamp{}, false, false}, + {"Invalid", `"asdf"`, Timestamp{referenceTime}, true, false}, + } + for _, tc := range testCases { + var got Timestamp + err := json.Unmarshal([]byte(tc.data), &got) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Errorf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + continue + } + equal := got.Equal(tc.want) + if equal != tc.equal { + t.Errorf("%s: got=%#v, want=%#v, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestTimstamp_MarshalReflexivity(t *testing.T) { + testCases := []struct { + desc string + data Timestamp + }{ + {"Reference", Timestamp{referenceTime}}, + {"Empty", Timestamp{}}, + } + for _, tc := range testCases { + data, err := json.Marshal(tc.data) + if err != nil { + t.Errorf("%s: Marshal err=%v", tc.desc, err) + } + var got Timestamp + err = json.Unmarshal(data, &got) + if !got.Equal(tc.data) { + t.Errorf("%s: %+v != %+v", tc.desc, got, data) + } + } +} + +type WrappedTimestamp struct { + A int + Time Timestamp +} + +func TestWrappedTimstamp_Marshal(t *testing.T) { + testCases := []struct { + desc string + data WrappedTimestamp + want string + wantErr bool + equal bool + }{ + {"Reference", WrappedTimestamp{0, Timestamp{referenceTime}}, fmt.Sprintf(`{"A":0,"Time":%s}`, referenceTimeStr), false, true}, + {"Empty", WrappedTimestamp{}, fmt.Sprintf(`{"A":0,"Time":%s}`, emptyTimeStr), false, true}, + {"Mismatch", WrappedTimestamp{}, fmt.Sprintf(`{"A":0,"Time":%s}`, referenceTimeStr), false, false}, + } + for _, tc := range testCases { + out, err := json.Marshal(tc.data) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Errorf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + } + got := string(out) + equal := got == tc.want + if equal != tc.equal { + t.Errorf("%s: got=%s, want=%s, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestWrappedTimestamp_Unmarshal(t *testing.T) { + testCases := []struct { + desc string + data string + want WrappedTimestamp + wantErr bool + equal bool + }{ + {"Reference", referenceTimeStr, WrappedTimestamp{0, Timestamp{referenceTime}}, false, true}, + {"ReferenceUnix", referenceUnixTimeStr, WrappedTimestamp{0, Timestamp{referenceTime}}, false, true}, + {"Empty", emptyTimeStr, WrappedTimestamp{0, Timestamp{}}, false, true}, + {"UnixStart", `0`, WrappedTimestamp{0, Timestamp{unixOrigin}}, false, true}, + {"Mismatch", referenceTimeStr, WrappedTimestamp{0, Timestamp{}}, false, false}, + {"MismatchUnix", `0`, WrappedTimestamp{0, Timestamp{}}, false, false}, + {"Invalid", `"asdf"`, WrappedTimestamp{0, Timestamp{referenceTime}}, true, false}, + } + for _, tc := range testCases { + var got Timestamp + err := json.Unmarshal([]byte(tc.data), &got) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Errorf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + continue + } + equal := got.Time.Equal(tc.want.Time.Time) + if equal != tc.equal { + t.Errorf("%s: got=%#v, want=%#v, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestWrappedTimestamp_MarshalReflexivity(t *testing.T) { + testCases := []struct { + desc string + data WrappedTimestamp + }{ + {"Reference", WrappedTimestamp{0, Timestamp{referenceTime}}}, + {"Empty", WrappedTimestamp{0, Timestamp{}}}, + } + for _, tc := range testCases { + bytes, err := json.Marshal(tc.data) + if err != nil { + t.Errorf("%s: Marshal err=%v", tc.desc, err) + } + var got WrappedTimestamp + err = json.Unmarshal(bytes, &got) + if !got.Time.Equal(tc.data.Time) { + t.Errorf("%s: %+v != %+v", tc.desc, got, tc.data) + } + } +} diff --git a/util/droplet.go b/util/droplet.go new file mode 100644 index 00000000..da6cef21 --- /dev/null +++ b/util/droplet.go @@ -0,0 +1,47 @@ +package util + +import ( + "fmt" + "time" + + "github.com/digitaloceancloud/godo" +) + +const ( + // activeFailure is the amount of times we can fail before deciding + // the check for active is a total failure. This can help account + // for servers randomly not answering. + activeFailure = 3 +) + +// WaitForActive waits for a droplet to become active +func WaitForActive(client *godo.Client, monitorURI string) error { + if len(monitorURI) == 0 { + return fmt.Errorf("create had no monitor uri") + } + + completed := false + failCount := 0 + for !completed { + action, _, err := client.DropletActions.GetByURI(monitorURI) + + if err != nil { + if failCount <= activeFailure { + failCount++ + continue + } + return err + } + + switch action.Status { + case godo.ActionInProgress: + time.Sleep(5 * time.Second) + case godo.ActionCompleted: + completed = true + default: + return fmt.Errorf("unknown status: [%s]", action.Status) + } + } + + return nil +}