Update to current API spec

* Allow requesting pages in lists by number
* Add tooling to allow examination of page links if they exist
* Add domain management CRUD
This commit is contained in:
bryanl 2014-09-06 17:56:23 -04:00
parent 6a5163c77e
commit 96accbf5e9
20 changed files with 989 additions and 246 deletions

View File

@ -1,11 +1,16 @@
# GODO
# Godo
Godo is a Go client library for accessing the DigitalOcean V2 API.
You can view the client API docs here: [http://godoc.org/github.com/digitalocean/godo](http://godoc.org/github.com/digitalocean/godo)
You can view Digital Ocean API docs here: [https://developers.digitalocean.com/v2/](https://developers.digitalocean.com/v2/)
## Usage
```go
import "github.com/digitaloceancloud/godo"
import "github.com/digitalocean/godo"
```
Create a new DigitalOcean client, then use the exposed services to
@ -32,20 +37,6 @@ 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:
@ -53,16 +44,57 @@ To create a new Droplet:
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
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
fmt.Printf("Something bad happened: %s\n\n", err)
return err
}
```
### Pagination
If a list of items is paginated by the API, you must request pages individually. For example, to fetch all Droplets:
```go
func DropletList(client *godo.Client) ([]godo.Droplet, error) {
// create a list to hold our droplets
list := []godo.Droplet{}
// create options. initially, these will be blank
opt := &godo.ListOptions{}
for {
droplets, resp, err := client.Droplets.List(opt)
if err != nil {
return err
}
// append the current page's droplets to our list
for _, d := range droplets {
list = append(list, d)
}
// if we are at the last page, break out the for loop
if resp.Links.IsLastPage() {
break
}
page, err := resp.Links.CurrentPage()
if err != nil {
return err
}
// set the page we want for the next request
opt.Page = page + 1
}
return nil
}
```

View File

@ -15,7 +15,7 @@ const (
// ActionsService handles communction with action related methods of the
// DigitalOcean API: https://developers.digitalocean.com/#actions
type ActionsService interface {
List() ([]Action, *Response, error)
List(*ListOptions) ([]Action, *Response, error)
Get(int) (*Action, *Response, error)
}
@ -27,6 +27,7 @@ type ActionsServiceOp struct {
type actionsRoot struct {
Actions []Action `json:"actions"`
Links *Links `json:"links"`
}
type actionRoot struct {
@ -45,8 +46,12 @@ type Action struct {
}
// List all actions
func (s *ActionsServiceOp) List() ([]Action, *Response, error) {
func (s *ActionsServiceOp) List(opt *ListOptions) ([]Action, *Response, error) {
path := actionsBasePath
path, err := addOptions(path, opt)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest("GET", path, nil)
if err != nil {
@ -58,6 +63,9 @@ func (s *ActionsServiceOp) List() ([]Action, *Response, error) {
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return root.Actions, resp, err
}

View File

@ -22,7 +22,7 @@ func TestAction_List(t *testing.T) {
testMethod(t, r, "GET")
})
actions, _, err := client.Actions.List()
actions, _, err := client.Actions.List(nil)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
@ -33,6 +33,54 @@ func TestAction_List(t *testing.T) {
}
}
func TestAction_ListActionMultiplePages(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/v2/actions", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"actions": [{"id":1},{"id":2}], "links":{"pages":{"next":"http://example.com/v2/droplets/?page=2"}}}`)
testMethod(t, r, "GET")
})
_, resp, err := client.Actions.List(nil)
if err != nil {
t.Fatal(nil)
}
checkCurrentPage(t, resp, 1)
}
func TestAction_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"actions": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/actions/?page=3",
"prev":"http://example.com/v2/actions/?page=1",
"last":"http://example.com/v2/actions/?page=3",
"first":"http://example.com/v2/actions/?page=1"
}
}
}`
mux.HandleFunc("/v2/actions", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Actions.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestAction_Get(t *testing.T) {
setup()
defer teardown()

View File

@ -8,7 +8,12 @@ const domainsBasePath = "v2/domains"
// See: https://developers.digitalocean.com/#domains and
// https://developers.digitalocean.com/#domain-records
type DomainsService interface {
Records(string, *DomainRecordsOptions) ([]DomainRecord, *Response, error)
List(*ListOptions) ([]Domain, *Response, error)
Get(string) (*DomainRoot, *Response, error)
Create(*DomainCreateRequest) (*DomainRoot, *Response, error)
Delete(string) (*Response, error)
Records(string, *ListOptions) ([]DomainRecord, *Response, error)
Record(string, int) (*DomainRecord, *Response, error)
DeleteRecord(string, int) (*Response, error)
EditRecord(string, int, *DomainRecordEditRequest) (*DomainRecord, *Response, error)
@ -21,6 +26,29 @@ type DomainsServiceOp struct {
client *Client
}
// Domain represents a Digital Ocean domain
type Domain struct {
Name string `json:"name"`
TTL int `json:"ttl"`
ZoneFile string `json:"zone_file"`
}
// DomainRoot represents a response from the Digital Ocean API
type DomainRoot struct {
Domain *Domain `json:"domain"`
}
type domainsRoot struct {
Domains []Domain `json:"domains"`
Links *Links `json:"links"`
}
// DomainCreateRequest respresents a request to create a domain.
type DomainCreateRequest struct {
Name string `json:"name"`
IPAddress string `json:"ip_address"`
}
// DomainRecordRoot is the root of an individual Domain Record response
type DomainRecordRoot struct {
DomainRecord *DomainRecord `json:"domain_record"`
@ -29,6 +57,7 @@ type DomainRecordRoot struct {
// DomainRecordsRoot is the root of a group of Domain Record responses
type DomainRecordsRoot struct {
DomainRecords []DomainRecord `json:"domain_records"`
Links *Links `json:"links"`
}
// DomainRecord represents a DigitalOcean DomainRecord
@ -42,16 +71,6 @@ type DomainRecord struct {
Weight int `json:"weight,omitempty"`
}
// DomainRecordsOptions are options for DomainRecords
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"`
@ -62,13 +81,97 @@ type DomainRecordEditRequest struct {
Weight int `json:"weight,omitempty"`
}
func (d Domain) String() string {
return Stringify(d)
}
// List all domains
func (s DomainsServiceOp) List(opt *ListOptions) ([]Domain, *Response, error) {
path := domainsBasePath
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
}
root := new(domainsRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return root.Domains, resp, err
}
// Get individual domain
func (s *DomainsServiceOp) Get(name string) (*DomainRoot, *Response, error) {
path := fmt.Sprintf("%s/%s", domainsBasePath, name)
req, err := s.client.NewRequest("GET", path, nil)
if err != nil {
return nil, nil, err
}
root := new(DomainRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
return root, resp, err
}
// Create a new domain
func (s *DomainsServiceOp) Create(createRequest *DomainCreateRequest) (*DomainRoot, *Response, error) {
path := domainsBasePath
req, err := s.client.NewRequest("POST", path, createRequest)
if err != nil {
return nil, nil, err
}
root := new(DomainRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
return root, resp, err
}
// Delete droplet
func (s *DomainsServiceOp) Delete(name string) (*Response, error) {
path := fmt.Sprintf("%s/%s", domainsBasePath, name)
req, err := s.client.NewRequest("DELETE", path, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req, nil)
return resp, err
}
// Converts a DomainRecord to a string.
func (d DomainRecord) String() string {
return Stringify(d)
}
// 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 *DomainsServiceOp) Records(domain string, opt *DomainRecordsOptions) ([]DomainRecord, *Response, error) {
func (s *DomainsServiceOp) Records(domain string, opt *ListOptions) ([]DomainRecord, *Response, error) {
path := fmt.Sprintf("%s/%s/records", domainsBasePath, domain)
path, err := addOptions(path, opt)
if err != nil {
@ -80,13 +183,16 @@ func (s *DomainsServiceOp) Records(domain string, opt *DomainRecordsOptions) ([]
return nil, nil, err
}
records := new(DomainRecordsRoot)
resp, err := s.client.Do(req, records)
root := new(DomainRecordsRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return records.DomainRecords, resp, err
return root.DomainRecords, resp, err
}
// Record returns the record id from a domain

View File

@ -14,6 +14,149 @@ func TestAction_DomainsServiceOpImplementsDomainsService(t *testing.T) {
}
}
func TestDomains_ListDomains(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/v2/domains", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, `{"domains": [{"name":"foo.com"},{"name":"bar.com"}]}`)
})
domains, _, err := client.Domains.List(nil)
if err != nil {
t.Errorf("Domains.List returned error: %v", err)
}
expected := []Domain{{Name: "foo.com"}, {Name: "bar.com"}}
if !reflect.DeepEqual(domains, expected) {
t.Errorf("Domains.List returned %+v, expected %+v", domains, expected)
}
}
func TestDomains_ListDomainsMultiplePages(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/v2/domains", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, `{"domains": [{"id":1},{"id":2}], "links":{"pages":{"next":"http://example.com/v2/domains/?page=2"}}}`)
})
_, resp, err := client.Domains.List(nil)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 1)
}
func TestDomains_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"domains": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/domains/?page=3",
"prev":"http://example.com/v2/domains/?page=1",
"last":"http://example.com/v2/domains/?page=3",
"first":"http://example.com/v2/domains/?page=1"
}
}
}`
mux.HandleFunc("/v2/domains", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Domains.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestDomains_GetDomain(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/v2/domains/example.com", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, `{"domain":{"name":"example.com"}}`)
})
domains, _, err := client.Domains.Get("example.com")
if err != nil {
t.Errorf("domain.Get returned error: %v", err)
}
expected := &DomainRoot{Domain: &Domain{Name: "example.com"}}
if !reflect.DeepEqual(domains, expected) {
t.Errorf("domains.Get returned %+v, expected %+v", domains, expected)
}
}
func TestDomains_Create(t *testing.T) {
setup()
defer teardown()
createRequest := &DomainCreateRequest{
Name: "example.com",
IPAddress: "127.0.0.1",
}
mux.HandleFunc("/v2/domains", func(w http.ResponseWriter, r *http.Request) {
v := new(DomainCreateRequest)
err := json.NewDecoder(r.Body).Decode(v)
if err != nil {
t.Fatal(err)
}
testMethod(t, r, "POST")
if !reflect.DeepEqual(v, createRequest) {
t.Errorf("Request body = %+v, expected %+v", v, createRequest)
}
dr := DomainRoot{&Domain{Name: v.Name}}
b, err := json.Marshal(dr)
if err != nil {
t.Fatal(err)
}
fmt.Fprint(w, string(b))
})
domain, _, err := client.Domains.Create(createRequest)
if err != nil {
t.Errorf("Domains.Create returned error: %v", err)
}
expected := &DomainRoot{Domain: &Domain{Name: "example.com"}}
if !reflect.DeepEqual(domain, expected) {
t.Errorf("Domains.Create returned %+v, expected %+v", domain, expected)
}
}
func TestDomains_Destroy(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/v2/domains/example.com", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "DELETE")
})
_, err := client.Domains.Delete("example.com")
if err != nil {
t.Errorf("Domains.Delete returned error: %v", err)
}
}
func TestDomains_AllRecordsForDomainName(t *testing.T) {
setup()
defer teardown()
@ -47,7 +190,7 @@ func TestDomains_AllRecordsForDomainName_PerPage(t *testing.T) {
fmt.Fprint(w, `{"domain_records":[{"id":1},{"id":2}]}`)
})
dro := &DomainRecordsOptions{ListOptions{PerPage: 2}}
dro := &ListOptions{PerPage: 2}
records, _, err := client.Domains.Records("example.com", dro)
if err != nil {
t.Errorf("Domains.List returned error: %v", err)

View File

@ -8,7 +8,7 @@ const dropletBasePath = "v2/droplets"
// endpoints of the Digital Ocean API
// See: https://developers.digitalocean.com/#droplets
type DropletsService interface {
List() ([]Droplet, *Response, error)
List(*ListOptions) ([]Droplet, *Response, error)
Get(int) (*DropletRoot, *Response, error)
Create(*DropletCreateRequest) (*DropletRoot, *Response, error)
Delete(int) (*Response, error)
@ -52,6 +52,7 @@ type DropletRoot struct {
type dropletsRoot struct {
Droplets []Droplet `json:"droplets"`
Links *Links `json:"links"`
}
// DropletCreateRequest represents a request to create a droplet.
@ -85,45 +86,29 @@ 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 *DropletsServiceOp) List() ([]Droplet, *Response, error) {
func (s *DropletsServiceOp) List(opt *ListOptions) ([]Droplet, *Response, error) {
path := dropletBasePath
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
}
droplets := new(dropletsRoot)
resp, err := s.client.Do(req, droplets)
root := new(dropletsRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return droplets.Droplets, resp, err
return root.Droplets, resp, err
}
// Get individual droplet

View File

@ -23,7 +23,7 @@ func TestDroplets_ListDroplets(t *testing.T) {
fmt.Fprint(w, `{"droplets": [{"id":1},{"id":2}]}`)
})
droplets, _, err := client.Droplet.List()
droplets, _, err := client.Droplets.List(nil)
if err != nil {
t.Errorf("Droplets.List returned error: %v", err)
}
@ -34,6 +34,70 @@ func TestDroplets_ListDroplets(t *testing.T) {
}
}
func TestDroplets_ListDropletsMultiplePages(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/v2/droplets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
dr := dropletsRoot{
Droplets: []Droplet{
Droplet{ID: 1},
Droplet{ID: 2},
},
Links: &Links{
Pages: &Pages{Next: "http://example.com/v2/droplets/?page=2"},
},
}
b, err := json.Marshal(dr)
if err != nil {
t.Fatal(err)
}
fmt.Fprint(w, string(b))
})
_, resp, err := client.Droplets.List(nil)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 1)
}
func TestDroplets_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"droplets": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/droplets/?page=3",
"prev":"http://example.com/v2/droplets/?page=1",
"last":"http://example.com/v2/droplets/?page=3",
"first":"http://example.com/v2/droplets/?page=1"
}
}
}`
mux.HandleFunc("/v2/droplets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Droplets.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestDroplets_GetDroplet(t *testing.T) {
setup()
defer teardown()
@ -43,7 +107,7 @@ func TestDroplets_GetDroplet(t *testing.T) {
fmt.Fprint(w, `{"droplet":{"id":12345}}`)
})
droplets, _, err := client.Droplet.Get(12345)
droplets, _, err := client.Droplets.Get(12345)
if err != nil {
t.Errorf("Droplet.Get returned error: %v", err)
}
@ -77,7 +141,7 @@ func TestDroplets_Create(t *testing.T) {
fmt.Fprintf(w, `{"droplet":{"id":1}}`)
})
droplet, _, err := client.Droplet.Create(createRequest)
droplet, _, err := client.Droplets.Create(createRequest)
if err != nil {
t.Errorf("Droplets.Create returned error: %v", err)
}
@ -96,34 +160,12 @@ func TestDroplets_Destroy(t *testing.T) {
testMethod(t, r, "DELETE")
})
_, err := client.Droplet.Delete(12345)
_, err := client.Droplets.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",

59
godo.go
View File

@ -45,7 +45,7 @@ type Client struct {
// Services used for communicating with the API
Actions ActionsService
Domains DomainsService
Droplet DropletsService
Droplets DropletsService
DropletActions DropletActionsService
Images ImagesService
ImageActions ImageActionsService
@ -68,15 +68,9 @@ type ListOptions struct {
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
// Links that were returned with the response. These are parsed from
// request body and not the header.
Links *Links
// Monitoring URI
Monitor string
@ -137,7 +131,7 @@ func NewClient(httpClient *http.Client) *Client {
c := &Client{client: httpClient, BaseURL: baseURL, UserAgent: userAgent}
c.Actions = &ActionsServiceOp{client: c}
c.Domains = &DomainsServiceOp{client: c}
c.Droplet = &DropletsServiceOp{client: c}
c.Droplets = &DropletsServiceOp{client: c}
c.DropletActions = &DropletActionsServiceOp{client: c}
c.Images = &ImagesServiceOp{client: c}
c.ImageActions = &ImageActionsServiceOp{client: c}
@ -181,54 +175,11 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ
// 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])

View File

@ -300,74 +300,6 @@ func TestDo_rateLimit_errorResponse(t *testing.T) {
}
}
func TestResponse_populatePageValues(t *testing.T) {
r := http.Response{
Header: http.Header{
"Link": {`<https://api.digitalocean.com/?page=1>; rel="first",` +
` <https://api.digitalocean.com/?page=2>; rel="prev",` +
` <https://api.digitalocean.com/?page=4>; rel="next",` +
` <https://api.digitalocean.com/?page=5>; 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": {`<https://api.digitalocean.com/?page=1>,` +
`<https://api.digitalocean.com/?page=abc>; rel="first",` +
`https://api.digitalocean.com/?page=2; rel="prev",` +
`<https://api.digitalocean.com/>; rel="next",` +
`<https://api.digitalocean.com/?page=>; 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": {`<https://api.digitalocean.com/%?page=2>; rel="first"`},
},
}
}
func Implements(interfaceObject interface{}, object interface{}, msgAndArgs ...interface{}) bool {
interfaceType := reflect.TypeOf(interfaceObject).Elem()
if !reflect.TypeOf(object).Implements(interfaceType) {
@ -376,3 +308,15 @@ func Implements(interfaceObject interface{}, object interface{}, msgAndArgs ...i
return true
}
func checkCurrentPage(t *testing.T, resp *Response, expectedPage int) {
links := resp.Links
p, err := links.CurrentPage()
if err != nil {
t.Fatal(err)
}
if p != expectedPage {
t.Fatalf("expected current page to be '%d', was '%d'", expectedPage, p)
}
}

View File

@ -4,7 +4,7 @@ package godo
// endpoints of the Digital Ocean API
// See: https://developers.digitalocean.com/#images
type ImagesService interface {
List() ([]Image, *Response, error)
List(*ListOptions) ([]Image, *Response, error)
}
// ImagesServiceOp handles communication with the image related methods of the
@ -29,6 +29,7 @@ type imageRoot struct {
type imagesRoot struct {
Images []Image
Links *Links `json:"links"`
}
func (i Image) String() string {
@ -36,19 +37,26 @@ func (i Image) String() string {
}
// List all sizes
func (s *ImagesServiceOp) List() ([]Image, *Response, error) {
func (s *ImagesServiceOp) List(opt *ListOptions) ([]Image, *Response, error) {
path := "v2/images"
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
}
images := new(imagesRoot)
resp, err := s.client.Do(req, images)
root := new(imagesRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return images.Images, resp, err
return root.Images, resp, err
}

View File

@ -22,7 +22,7 @@ func TestImages_List(t *testing.T) {
fmt.Fprint(w, `{"images":[{"id":1},{"id":2}]}`)
})
images, _, err := client.Images.List()
images, _, err := client.Images.List(nil)
if err != nil {
t.Errorf("Images.List returned error: %v", err)
}
@ -33,6 +33,53 @@ func TestImages_List(t *testing.T) {
}
}
func TestImages_ListImagesMultiplePages(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}], "links":{"pages":{"next":"http://example.com/v2/images/?page=2"}}}`)
})
_, resp, err := client.Images.List(&ListOptions{Page: 2})
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 1)
}
func TestImages_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"images": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/images/?page=3",
"prev":"http://example.com/v2/images/?page=1",
"last":"http://example.com/v2/images/?page=3",
"first":"http://example.com/v2/images/?page=1"
}
}
}`
mux.HandleFunc("/v2/images", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Images.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestImage_String(t *testing.T) {
image := &Image{
ID: 1,

24
keys.go
View File

@ -8,7 +8,7 @@ const keysBasePath = "v2/account/keys"
// endpoints of the Digital Ocean API
// See: https://developers.digitalocean.com/#keys
type KeysService interface {
List() ([]Key, *Response, error)
List(*ListOptions) ([]Key, *Response, error)
GetByID(int) (*Key, *Response, error)
GetByFingerprint(string) (*Key, *Response, error)
Create(*KeyCreateRequest) (*Key, *Response, error)
@ -31,7 +31,8 @@ type Key struct {
}
type keysRoot struct {
SSHKeys []Key `json:"ssh_keys"`
SSHKeys []Key `json:"ssh_keys"`
Links *Links `json:"links"`
}
type keyRoot struct {
@ -49,19 +50,28 @@ type KeyCreateRequest struct {
}
// List all keys
func (s *KeysServiceOp) List() ([]Key, *Response, error) {
req, err := s.client.NewRequest("GET", keysBasePath, nil)
func (s *KeysServiceOp) List(opt *ListOptions) ([]Key, *Response, error) {
path := keysBasePath
path, err := addOptions(path, opt)
if err != nil {
return nil, nil, err
}
keys := new(keysRoot)
resp, err := s.client.Do(req, keys)
req, err := s.client.NewRequest("GET", path, nil)
if err != nil {
return nil, nil, err
}
root := new(keysRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return keys.SSHKeys, resp, err
return root.SSHKeys, resp, err
}
// Performs a get given a path

View File

@ -20,10 +20,10 @@ func TestKeys_List(t *testing.T) {
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}]} `)
fmt.Fprint(w, `{"ssh_keys":[{"id":1},{"id":2}]}`)
})
keys, _, err := client.Keys.List()
keys, _, err := client.Keys.List(nil)
if err != nil {
t.Errorf("Keys.List returned error: %v", err)
}
@ -34,6 +34,52 @@ func TestKeys_List(t *testing.T) {
}
}
func TestKeys_ListKeysMultiplePages(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, `{"droplets": [{"id":1},{"id":2}], "links":{"pages":{"next":"http://example.com/v2/account/keys/?page=2"}}}`)
})
_, resp, err := client.Keys.List(nil)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 1)
}
func TestKeys_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"keys": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/account/keys/?page=3",
"prev":"http://example.com/v2/account/keys/?page=1",
"last":"http://example.com/v2/account/keys/?page=3",
"first":"http://example.com/v2/account/keys/?page=1"
}
}
}`
mux.HandleFunc("/v2/account/keys", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Keys.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestKeys_GetByID(t *testing.T) {
setup()
defer teardown()

85
links.go Normal file
View File

@ -0,0 +1,85 @@
package godo
import (
"net/url"
"strconv"
)
// Links manages links that are returned along with a List
type Links struct {
Pages *Pages `json:"pages,omitempty"`
Actions []LinkAction `json:"actions,omitempty"`
}
// Pages are pages specified in Links
type Pages struct {
First string `json:"first,omitempty"`
Prev string `json:"prev,omitempty"`
Last string `json:"last,omitempty"`
Next string `json:"next,omitempty"`
}
// LinkAction is a pointer to an action
type LinkAction struct {
ID int `json:"id,omitempty"`
Rel string `json:"rel,omitempty"`
HREF string `json:"href,omitempty"`
}
// CurrentPage is current page of the list
func (l *Links) CurrentPage() (int, error) {
return l.Pages.current()
}
func (p *Pages) current() (int, error) {
switch {
case p == nil:
return 1, nil
case p.Prev == "" && p.Next != "":
return 1, nil
case p.Prev != "":
prevPage, err := pageForURL(p.Prev)
if err != nil {
return 0, err
}
return prevPage + 1, nil
}
return 0, nil
}
// IsLastPage returns true if the current page is the last
func (l *Links) IsLastPage() bool {
if l == nil {
return true
}
return l.Pages.isLast()
}
func (p *Pages) isLast() bool {
if p.Last == "" {
return true
}
return false
}
func pageForURL(urlText string) (int, error) {
u, err := url.ParseRequestURI(urlText)
if err != nil {
return 0, err
}
pageStr := u.Query().Get("page")
page, err := strconv.Atoi(pageStr)
if err != nil {
return 0, err
}
return page, nil
}
func (la *LinkAction) Get(client *Client) (*Action, *Response, error) {
return client.Actions.Get(la.ID)
}

176
links_test.go Normal file
View File

@ -0,0 +1,176 @@
package godo
import (
"encoding/json"
"testing"
)
var (
firstPageLinksJSONBlob = []byte(`{
"links": {
"pages": {
"last": "https://api.digitalocean.com/v2/droplets/?page=3",
"next": "https://api.digitalocean.com/v2/droplets/?page=2"
}
}
}`)
otherPageLinksJSONBlob = []byte(`{
"links": {
"pages": {
"first": "https://api.digitalocean.com/v2/droplets/?page=1",
"prev": "https://api.digitalocean.com/v2/droplets/?page=1",
"last": "https://api.digitalocean.com/v2/droplets/?page=3",
"next": "https://api.digitalocean.com/v2/droplets/?page=3"
}
}
}`)
lastPageLinksJSONBlob = []byte(`{
"links": {
"pages": {
"first": "https://api.digitalocean.com/v2/droplets/?page=1",
"prev": "https://api.digitalocean.com/v2/droplets/?page=2"
}
}
}`)
missingLinksJSONBlob = []byte(`{ }`)
)
type godoList struct {
Links Links `json:"links"`
}
func loadLinksJSON(t *testing.T, j []byte) Links {
var list godoList
err := json.Unmarshal(j, &list)
if err != nil {
t.Fatal(err)
}
return list.Links
}
func TestLinks_ParseFirst(t *testing.T) {
links := loadLinksJSON(t, firstPageLinksJSONBlob)
_, err := links.CurrentPage()
if err != nil {
t.Fatal(err)
}
r := &Response{Links: &links}
checkCurrentPage(t, r, 1)
if links.IsLastPage() {
t.Fatalf("shouldn't be last page")
}
}
func TestLinks_ParseMiddle(t *testing.T) {
links := loadLinksJSON(t, otherPageLinksJSONBlob)
_, err := links.CurrentPage()
if err != nil {
t.Fatal(err)
}
r := &Response{Links: &links}
checkCurrentPage(t, r, 2)
if links.IsLastPage() {
t.Fatalf("shouldn't be last page")
}
}
func TestLinks_ParseLast(t *testing.T) {
links := loadLinksJSON(t, lastPageLinksJSONBlob)
_, err := links.CurrentPage()
if err != nil {
t.Fatal(err)
}
r := &Response{Links: &links}
checkCurrentPage(t, r, 3)
if !links.IsLastPage() {
t.Fatalf("expected last page")
}
}
func TestLinks_ParseMissing(t *testing.T) {
links := loadLinksJSON(t, missingLinksJSONBlob)
_, err := links.CurrentPage()
if err != nil {
t.Fatal(err)
}
r := &Response{Links: &links}
checkCurrentPage(t, r, 1)
}
func TestLinks_ParseURL(t *testing.T) {
type linkTest struct {
name, url string
expected int
}
linkTests := []linkTest{
{
name: "prev",
url: "https://api.digitalocean.com/v2/droplets/?page=1",
expected: 1,
},
{
name: "last",
url: "https://api.digitalocean.com/v2/droplets/?page=5",
expected: 5,
},
{
name: "nexta",
url: "https://api.digitalocean.com/v2/droplets/?page=2",
expected: 2,
},
}
for _, lT := range linkTests {
p, err := pageForURL(lT.url)
if err != nil {
t.Fatal(err)
}
if p != lT.expected {
t.Error("expected page for '%s' to be '%d', was '%d'",
lT.url, lT.expected, p)
}
}
}
func TestLinks_ParseEmptyString(t *testing.T) {
type linkTest struct {
name, url string
expected int
}
linkTests := []linkTest{
{
name: "none",
url: "http://example.com",
expected: 0,
},
{
name: "bad",
url: "no url",
expected: 0,
},
{
name: "empty",
url: "",
expected: 0,
},
}
for _, lT := range linkTests {
_, err := pageForURL(lT.url)
if err == nil {
t.Fatalf("expected error for test '%s', but received none", lT.name)
}
}
}

View File

@ -4,7 +4,7 @@ package godo
// endpoints of the Digital Ocean API
// See: https://developers.digitalocean.com/#regions
type RegionsService interface {
List() ([]Region, *Response, error)
List(*ListOptions) ([]Region, *Response, error)
}
// RegionsServiceOp handles communication with the region related methods of the
@ -23,6 +23,7 @@ type Region struct {
type regionsRoot struct {
Regions []Region
Links *Links `json:"links"`
}
type regionRoot struct {
@ -34,19 +35,26 @@ func (r Region) String() string {
}
// List all regions
func (s *RegionsServiceOp) List() ([]Region, *Response, error) {
func (s *RegionsServiceOp) List(opt *ListOptions) ([]Region, *Response, error) {
path := "v2/regions"
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
}
regions := new(regionsRoot)
resp, err := s.client.Do(req, regions)
root := new(regionsRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return regions.Regions, resp, err
return root.Regions, resp, err
}

View File

@ -22,7 +22,7 @@ func TestRegions_List(t *testing.T) {
fmt.Fprint(w, `{"regions":[{"slug":"1"},{"slug":"2"}]}`)
})
regions, _, err := client.Regions.List()
regions, _, err := client.Regions.List(nil)
if err != nil {
t.Errorf("Regions.List returned error: %v", err)
}
@ -33,6 +33,54 @@ func TestRegions_List(t *testing.T) {
}
}
func TestRegions_ListRegionsMultiplePages(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": [{"id":1},{"id":2}], "links":{"pages":{"next":"http://example.com/v2/regions/?page=2"}}}`)
})
_, resp, err := client.Regions.List(nil)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 1)
}
func TestRegions_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"regions": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/regions/?page=3",
"prev":"http://example.com/v2/regions/?page=1",
"last":"http://example.com/v2/regions/?page=3",
"first":"http://example.com/v2/regions/?page=1"
}
}
}`
mux.HandleFunc("/v2/regions", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Regions.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestRegion_String(t *testing.T) {
region := &Region{
Slug: "region",

View File

@ -4,7 +4,7 @@ package godo
// endpoints of the Digital Ocean API
// See: https://developers.digitalocean.com/#sizes
type SizesService interface {
List() ([]Size, *Response, error)
List(*ListOptions) ([]Size, *Response, error)
}
// SizesServiceOp handles communication with the size related methods of the
@ -30,22 +30,30 @@ func (s Size) String() string {
type sizesRoot struct {
Sizes []Size
Links *Links `json:"links"`
}
// List all images
func (s *SizesServiceOp) List() ([]Size, *Response, error) {
func (s *SizesServiceOp) List(opt *ListOptions) ([]Size, *Response, error) {
path := "v2/sizes"
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
}
sizes := new(sizesRoot)
resp, err := s.client.Do(req, sizes)
root := new(sizesRoot)
resp, err := s.client.Do(req, root)
if err != nil {
return nil, resp, err
}
if l := root.Links; l != nil {
resp.Links = l
}
return sizes.Sizes, resp, err
return root.Sizes, resp, err
}

View File

@ -22,7 +22,7 @@ func TestSizes_List(t *testing.T) {
fmt.Fprint(w, `{"sizes":[{"slug":"1"},{"slug":"2"}]}`)
})
sizes, _, err := client.Sizes.List()
sizes, _, err := client.Sizes.List(nil)
if err != nil {
t.Errorf("Sizes.List returned error: %v", err)
}
@ -33,6 +33,54 @@ func TestSizes_List(t *testing.T) {
}
}
func TestSizes_ListSizesMultiplePages(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": [{"id":1},{"id":2}], "links":{"pages":{"next":"http://example.com/v2/sizes/?page=2"}}}`)
})
_, resp, err := client.Sizes.List(nil)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 1)
}
func TestSizes_RetrievePageByNumber(t *testing.T) {
setup()
defer teardown()
jBlob := `
{
"sizes": [{"id":1},{"id":2}],
"links":{
"pages":{
"next":"http://example.com/v2/sizes/?page=3",
"prev":"http://example.com/v2/sizes/?page=1",
"last":"http://example.com/v2/sizes/?page=3",
"first":"http://example.com/v2/sizes/?page=1"
}
}
}`
mux.HandleFunc("/v2/sizes", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
fmt.Fprint(w, jBlob)
})
opt := &ListOptions{Page: 2}
_, resp, err := client.Sizes.List(opt)
if err != nil {
t.Fatal(err)
}
checkCurrentPage(t, resp, 2)
}
func TestSize_String(t *testing.T) {
size := &Size{
Slug: "slize",

View File

@ -2,9 +2,9 @@ package godo
import (
"bytes"
"reflect"
"io"
"fmt"
"io"
"reflect"
)
var timestampType = reflect.TypeOf(Timestamp{})