diff --git a/README.md b/README.md index d1ee8d3..5288001 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # go-writeas -[![godoc](https://godoc.org/go.code.as/writeas.v1?status.svg)](https://godoc.org/go.code.as/writeas.v1) +[![godoc](https://godoc.org/go.code.as/writeas.v2?status.svg)](https://godoc.org/go.code.as/writeas.v2) Official Write.as Go client library. ## Installation +**Warning**: the `v2` branch is under heavy development and its API will change without notice. + +For a stable API, use `go.code.as/writeas.v1` and upgrade to `v2` once everything is merged into `master`. + ```bash -go get go.code.as/writeas.v1 +go get go.code.as/writeas.v2 ``` ## Documentation @@ -17,7 +21,7 @@ See all functionality and usages in the [API documentation](https://developer.wr ### Example usage ```go -import "go.code.as/writeas.v1" +import "go.code.as/writeas.v2" func main() { // Create the client @@ -37,11 +41,7 @@ func main() { token := p.Token // Update a published post - p, err = c.UpdatePost(&writeas.PostParams{ - OwnedPostParams: writeas.OwnedPostParams{ - ID: p.ID, - Token: token, - }, + p, err = c.UpdatePost(p.ID, token, &writeas.PostParams{ Content: "Now it's been updated!", }) if err != nil { @@ -55,12 +55,7 @@ func main() { } // Delete a post - err = c.DeletePost(&writeas.PostParams{ - OwnedPostParams: writeas.OwnedPostParams{ - ID: p.ID, - Token: token, - }, - }) + err = c.DeletePost(p.ID, token) } ``` @@ -68,7 +63,7 @@ func main() { The library covers our usage, but might not be comprehensive of the API. So we always welcome contributions and improvements from the community. Before sending pull requests, make sure you've done the following: -* Run `go fmt` on all updated .go files. +* Run `goimports` on all updated .go files. * Document all exported structs and funcs. ## License diff --git a/auth_test.go b/auth_test.go index 3c78c7e..a9ece6f 100644 --- a/auth_test.go +++ b/auth_test.go @@ -1,8 +1,6 @@ package writeas -import ( - "testing" -) +import "testing" func TestAuthentication(t *testing.T) { dwac := NewDevClient() diff --git a/collection.go b/collection.go index d63e116..9b4a925 100644 --- a/collection.go +++ b/collection.go @@ -26,8 +26,9 @@ type ( // CollectionParams holds values for creating a collection. CollectionParams struct { - Alias string `json:"alias"` - Title string `json:"title"` + Alias string `json:"alias"` + Title string `json:"title"` + Description string `json:"description,omitempty"` } ) @@ -112,6 +113,30 @@ func (c *Client) GetCollectionPosts(alias string) (*[]Post, error) { } } +// GetCollectionPost retrieves a post from a collection +// and any error (in user-friendly form) that occurs). See +// https://developers.write.as/docs/api/#retrieve-a-collection-post +func (c *Client) GetCollectionPost(alias, slug string) (*Post, error) { + post := Post{} + + env, err := c.get(fmt.Sprintf("/collections/%s/posts/%s", alias, slug), &post) + if err != nil { + return nil, err + } + + if _, ok := env.Data.(*Post); !ok { + return nil, fmt.Errorf("Wrong data returned from API.") + } + + if env.Code == http.StatusOK { + return &post, nil + } else if env.Code == http.StatusNotFound { + return nil, fmt.Errorf("Post %s not found in collection %s", slug, alias) + } + + return nil, fmt.Errorf("Problem getting post %s from collection %s: %d. %v\n", slug, alias, env.Code, err) +} + // GetUserCollections retrieves the authenticated user's collections. // See https://developers.write.as/docs/api/#retrieve-user-39-s-collections func (c *Client) GetUserCollections() (*[]Collection, error) { @@ -135,3 +160,27 @@ func (c *Client) GetUserCollections() (*[]Collection, error) { } return colls, nil } + +// DeleteCollection permanently deletes a collection and makes any posts on it +// anonymous. +// +// See https://developers.write.as/docs/api/#delete-a-collection. +func (c *Client) DeleteCollection(alias string) error { + endpoint := "/collections/" + alias + env, err := c.delete(endpoint, nil /* data */) + if err != nil { + return err + } + + status := env.Code + switch status { + case http.StatusNoContent: + return nil + case http.StatusUnauthorized: + return fmt.Errorf("Not authenticated.") + case http.StatusBadRequest: + return fmt.Errorf("Bad request: %s", env.ErrorMessage) + default: + return fmt.Errorf("Problem deleting collection: %d. %s\n", status, env.ErrorMessage) + } +} diff --git a/collection_test.go b/collection_test.go index da9a7f3..d6bb49b 100644 --- a/collection_test.go +++ b/collection_test.go @@ -2,34 +2,51 @@ package writeas import ( "fmt" + "strings" "testing" + "time" ) func TestGetCollection(t *testing.T) { - wac := NewClient() + dwac := NewDevClient() - res, err := wac.GetCollection("blog") + res, err := dwac.GetCollection("tester") if err != nil { t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) - } else { - t.Logf("Collection: %+v", res) - if res.Title != "write.as" { - t.Errorf("Unexpected fetch results: %+v\n", res) - } + } + if res == nil { + t.Error("Expected collection to not be nil") } } func TestGetCollectionPosts(t *testing.T) { - wac := NewClient() + dwac := NewDevClient() + posts := []Post{} - res, err := wac.GetCollectionPosts("blog") - if err != nil { - t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) - } else { + t.Run("Get all posts in collection", func(t *testing.T) { + res, err := dwac.GetCollectionPosts("tester") + if err != nil { + t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) + } if len(*res) == 0 { - t.Errorf("No posts returned!") + t.Error("Expected at least on post in collection") } - } + posts = *res + }) + t.Run("Get one post from collection", func(t *testing.T) { + res, err := dwac.GetCollectionPost("tester", posts[0].Slug) + if err != nil { + t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) + } + + if res == nil { + t.Errorf("No post returned!") + } + + if len(res.Content) == 0 { + t.Errorf("Post content is empty!") + } + }) } func TestGetUserCollections(t *testing.T) { @@ -51,13 +68,40 @@ func TestGetUserCollections(t *testing.T) { } } -func ExampleClient_GetCollection() { - c := NewClient() - coll, err := c.GetCollection("blog") +func TestCreateAndDeleteCollection(t *testing.T) { + wac := NewDevClient() + _, err := wac.LogIn("demo", "demo") if err != nil { - fmt.Printf("%v", err) - return + t.Fatalf("Unable to log in: %v", err) + } + defer wac.LogOut() + + now := time.Now().Unix() + alias := fmt.Sprintf("test-collection-%v", now) + c, err := wac.CreateCollection(&CollectionParams{ + Alias: alias, + Title: fmt.Sprintf("Test Collection %v", now), + }) + if err != nil { + t.Fatalf("Unable to create collection %q: %v", alias, err) + } + + if err := wac.DeleteCollection(c.Alias); err != nil { + t.Fatalf("Unable to delete collection %q: %v", alias, err) + } +} + +func TestDeleteCollectionUnauthenticated(t *testing.T) { + wac := NewDevClient() + + now := time.Now().Unix() + alias := fmt.Sprintf("test-collection-does-not-exist-%v", now) + err := wac.DeleteCollection(alias) + if err == nil { + t.Fatalf("Should not be able to delete collection %q unauthenticated.", alias) + } + + if !strings.Contains(err.Error(), "Not authenticated") { + t.Fatalf("Error message should be more informative: %v", err) } - fmt.Printf("%s", coll.Title) - // Output: write.as } diff --git a/go.mod b/go.mod index 40ab49a..b88b28a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/writeas/go-writeas +module github.com/writeas/go-writeas/v2 go 1.9 diff --git a/post.go b/post.go index 977918e..1f8a55b 100644 --- a/post.go +++ b/post.go @@ -31,7 +31,7 @@ type ( // OwnedPostParams are, together, fields only the original post author knows. OwnedPostParams struct { - ID string `json:"-"` + ID string `json:"id"` Token string `json:"token,omitempty"` } @@ -42,11 +42,14 @@ type ( Token string `json:"token,omitempty"` // Parameters for creating or updating - Title string `json:"title,omitempty"` - Content string `json:"body,omitempty"` - Font string `json:"font,omitempty"` - IsRTL *bool `json:"rtl,omitempty"` - Language *string `json:"lang,omitempty"` + Slug string `json:"slug"` + Created *time.Time `json:"created,omitempty"` + Updated *time.Time `json:"updated,omitempty"` + Title string `json:"title,omitempty"` + Content string `json:"body,omitempty"` + Font string `json:"font,omitempty"` + IsRTL *bool `json:"rtl,omitempty"` + Language *string `json:"lang,omitempty"` // Parameters only for creating Crosspost []map[string]string `json:"crosspost,omitempty"` @@ -102,7 +105,7 @@ func (c *Client) GetPost(id string) (*Post, error) { } else if status == http.StatusGone { return nil, fmt.Errorf("Post unpublished.") } - return nil, fmt.Errorf("Problem getting post: %d. %v\n", status, err) + return nil, fmt.Errorf("Problem getting post: %d. %s\n", status, env.ErrorMessage) } // CreatePost publishes a new post, returning a user-friendly error if one comes @@ -124,20 +127,33 @@ func (c *Client) CreatePost(sp *PostParams) (*Post, error) { } status := env.Code - if status == http.StatusCreated { - return p, nil - } else if status == http.StatusBadRequest { - return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) - } else { - return nil, fmt.Errorf("Problem getting post: %d. %v\n", status, err) + if status != http.StatusCreated { + if status == http.StatusBadRequest { + return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) + } + return nil, fmt.Errorf("Problem creating post: %d. %s\n", status, env.ErrorMessage) } + return p, nil } // UpdatePost updates a published post with the given PostParams. See // https://developer.write.as/docs/api/#update-a-post. -func (c *Client) UpdatePost(sp *PostParams) (*Post, error) { +func (c *Client) UpdatePost(id, token string, sp *PostParams) (*Post, error) { + return c.updatePost("", id, token, sp) +} + +func (c *Client) updatePost(collection, identifier, token string, sp *PostParams) (*Post, error) { p := &Post{} - env, err := c.put(fmt.Sprintf("/posts/%s", sp.ID), sp, p) + endpoint := "/posts/" + identifier + /* + if collection != "" { + endpoint = "/collections/" + collection + endpoint + } else { + sp.Token = token + } + */ + sp.Token = token + env, err := c.put(endpoint, sp, p) if err != nil { return nil, err } @@ -154,17 +170,29 @@ func (c *Client) UpdatePost(sp *PostParams) (*Post, error) { } else if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } - return nil, fmt.Errorf("Problem getting post: %d. %v\n", status, err) + return nil, fmt.Errorf("Problem updating post: %d. %s\n", status, env.ErrorMessage) } return p, nil } // DeletePost permanently deletes a published post. See // https://developer.write.as/docs/api/#delete-a-post. -func (c *Client) DeletePost(sp *PostParams) error { - env, err := c.delete(fmt.Sprintf("/posts/%s", sp.ID), map[string]string{ - "token": sp.Token, - }) +func (c *Client) DeletePost(id, token string) error { + return c.deletePost("", id, token) +} + +func (c *Client) deletePost(collection, identifier, token string) error { + p := map[string]string{} + endpoint := "/posts/" + identifier + /* + if collection != "" { + endpoint = "/collections/" + collection + endpoint + } else { + p["token"] = token + } + */ + p["token"] = token + env, err := c.delete(endpoint, p) if err != nil { return err } @@ -177,14 +205,14 @@ func (c *Client) DeletePost(sp *PostParams) error { } else if status == http.StatusBadRequest { return fmt.Errorf("Bad request: %s", env.ErrorMessage) } - return fmt.Errorf("Problem getting post: %d. %v\n", status, err) + return fmt.Errorf("Problem deleting post: %d. %s\n", status, env.ErrorMessage) } // ClaimPosts associates anonymous posts with a user / account. // https://developer.write.as/docs/api/#claim-posts. func (c *Client) ClaimPosts(sp *[]OwnedPostParams) (*[]ClaimPostResult, error) { p := &[]ClaimPostResult{} - env, err := c.put("/posts/claim", sp, p) + env, err := c.post("/posts/claim", sp, p) if err != nil { return nil, err } @@ -202,7 +230,7 @@ func (c *Client) ClaimPosts(sp *[]OwnedPostParams) (*[]ClaimPostResult, error) { } else if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } else { - return nil, fmt.Errorf("Problem getting post: %d. %v\n", status, err) + return nil, fmt.Errorf("Problem claiming post: %d. %s\n", status, env.ErrorMessage) } // TODO: does this also happen with moving posts? } @@ -226,7 +254,7 @@ func (c *Client) GetUserPosts() (*[]Post, error) { if c.isNotLoggedIn(status) { return nil, fmt.Errorf("Not authenticated.") } - return nil, fmt.Errorf("Problem getting posts: %d. %v\n", status, err) + return nil, fmt.Errorf("Problem getting user posts: %d. %s\n", status, env.ErrorMessage) } return p, nil } @@ -251,7 +279,7 @@ func (c *Client) PinPost(alias string, pp *PinnedPostParams) error { if c.isNotLoggedIn(status) { return fmt.Errorf("Not authenticated.") } - return fmt.Errorf("Problem pinning post: %d. %v\n", status, err) + return fmt.Errorf("Problem pinning post: %d. %s\n", status, env.ErrorMessage) } // Check the individual post result @@ -261,7 +289,7 @@ func (c *Client) PinPost(alias string, pp *PinnedPostParams) error { if (*res)[0].Code != http.StatusOK { return fmt.Errorf("Problem pinning post: %d", (*res)[0].Code) // TODO: return ErrorMessage (right now it'll be empty) - // return fmt.Errorf("Problem pinning post: %v", res[0].ErrorMessage) + // return fmt.Errorf("Problem pinning post: %s", res[0].ErrorMessage) } return nil } @@ -286,7 +314,7 @@ func (c *Client) UnpinPost(alias string, pp *PinnedPostParams) error { if c.isNotLoggedIn(status) { return fmt.Errorf("Not authenticated.") } - return fmt.Errorf("Problem unpinning post: %d. %v\n", status, err) + return fmt.Errorf("Problem unpinning post: %d. %s\n", status, env.ErrorMessage) } // Check the individual post result @@ -296,7 +324,7 @@ func (c *Client) UnpinPost(alias string, pp *PinnedPostParams) error { if (*res)[0].Code != http.StatusOK { return fmt.Errorf("Problem unpinning post: %d", (*res)[0].Code) // TODO: return ErrorMessage (right now it'll be empty) - // return fmt.Errorf("Problem unpinning post: %v", res[0].ErrorMessage) + // return fmt.Errorf("Problem unpinning post: %s", res[0].ErrorMessage) } return nil } diff --git a/post_test.go b/post_test.go index abdfa3a..9d2fb47 100644 --- a/post_test.go +++ b/post_test.go @@ -1,89 +1,58 @@ package writeas import ( - "testing" - "fmt" - "strings" + "testing" ) -func TestCreatePost(t *testing.T) { - wac := NewClient() - p, err := wac.CreatePost(&PostParams{ - Title: "Title!", - Content: "This is a post.", - Font: "sans", - }) - if err != nil { - t.Errorf("Post create failed: %v", err) - return - } - t.Logf("Post created: %+v", p) - - token := p.Token - - // Update post - p, err = wac.UpdatePost(&PostParams{ - ID: p.ID, - Token: token, - Content: "Now it's been updated!", +func TestPostRoundTrip(t *testing.T) { + var id, token string + dwac := NewClient() + t.Run("Create post", func(t *testing.T) { + p, err := dwac.CreatePost(&PostParams{ + Title: "Title!", + Content: "This is a post.", + Font: "sans", + }) + if err != nil { + t.Errorf("Post create failed: %v", err) + return + } + t.Logf("Post created: %+v", p) + id, token = p.ID, p.Token }) - if err != nil { - t.Errorf("Post update failed: %v", err) - return - } - t.Logf("Post updated: %+v", p) - - // Delete post - err = wac.DeletePost(&PostParams{ - ID: p.ID, - Token: token, + t.Run("Get post", func(t *testing.T) { + res, err := dwac.GetPost(id) + if err != nil { + t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) + } else { + t.Logf("Post: %+v", res) + if res.Content != "This is a post." { + t.Errorf("Unexpected fetch results: %+v\n", res) + } + } }) - if err != nil { - t.Errorf("Post delete failed: %v", err) - return - } - t.Logf("Post deleted!") -} - -func TestGetPost(t *testing.T) { - dwac := NewDevClient() - res, err := dwac.GetPost("zekk5r9apum6p") - if err != nil { - t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) - } else { - t.Logf("Post: %+v", res) - if res.Content != "This is a post." { - t.Errorf("Unexpected fetch results: %+v\n", res) + t.Run("Update post", func(t *testing.T) { + p, err := dwac.UpdatePost(id, token, &PostParams{ + Content: "Now it's been updated!", + }) + if err != nil { + t.Errorf("Post update failed: %v", err) + return } - } - - wac := NewClient() - res, err = wac.GetPost("3psnxyhqxy3hq") - if err != nil { - t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) - } else { - if !strings.HasPrefix(res.Content, " Write.as Blog") { - t.Errorf("Unexpected fetch results: %+v\n", res) + t.Logf("Post updated: %+v", p) + }) + t.Run("Delete post", func(t *testing.T) { + err := dwac.DeletePost(id, token) + if err != nil { + t.Errorf("Post delete failed: %v", err) + return } - } -} - -func TestPinPost(t *testing.T) { - dwac := NewDevClient() - _, err := dwac.LogIn("demo", "demo") - if err != nil { - t.Fatalf("Unable to log in: %v", err) - } - defer dwac.LogOut() - - err = dwac.PinPost("tester", &PinnedPostParams{ID: "olx6uk7064heqltf"}) - if err != nil { - t.Fatalf("Pin failed: %v", err) - } + t.Logf("Post deleted!") + }) } -func TestUnpinPost(t *testing.T) { +func TestPinUnPin(t *testing.T) { dwac := NewDevClient() _, err := dwac.LogIn("demo", "demo") if err != nil { @@ -91,17 +60,25 @@ func TestUnpinPost(t *testing.T) { } defer dwac.LogOut() - err = dwac.UnpinPost("tester", &PinnedPostParams{ID: "olx6uk7064heqltf"}) - if err != nil { - t.Fatalf("Unpin failed: %v", err) - } + t.Run("Pin post", func(t *testing.T) { + err := dwac.PinPost("tester", &PinnedPostParams{ID: "olx6uk7064heqltf"}) + if err != nil { + t.Fatalf("Pin failed: %v", err) + } + }) + t.Run("Unpin post", func(t *testing.T) { + err := dwac.UnpinPost("tester", &PinnedPostParams{ID: "olx6uk7064heqltf"}) + if err != nil { + t.Fatalf("Unpin failed: %v", err) + } + }) } func ExampleClient_CreatePost() { - c := NewClient() + dwac := NewDevClient() // Publish a post - p, err := c.CreatePost(&PostParams{ + p, err := dwac.CreatePost(&PostParams{ Title: "Title!", Content: "This is a post.", Font: "sans", diff --git a/user.go b/user.go index e10f3c8..5973d9c 100644 --- a/user.go +++ b/user.go @@ -1,8 +1,6 @@ package writeas -import ( - "time" -) +import "time" type ( // AuthUser represents a just-authenticated user. It contains information diff --git a/writeas.go b/writeas.go index a31b636..fa87ae1 100644 --- a/writeas.go +++ b/writeas.go @@ -3,19 +3,23 @@ package writeas import ( "bytes" - "code.as/core/socks" "encoding/json" "fmt" - "github.com/writeas/impart" "io" "net/http" "time" + + "code.as/core/socks" + "github.com/writeas/impart" ) const ( apiURL = "https://write.as/api" devAPIURL = "https://development.write.as/api" torAPIURL = "http://writeas7pm7rcdqg.onion/api" + + // Current go-writeas version + Version = "2-dev" ) // Client is used to interact with the Write.as API. It can be used to make @@ -41,31 +45,55 @@ const defaultHTTPTimeout = 10 * time.Second // c := writeas.NewClient() // c.SetToken("00000000-0000-0000-0000-000000000000") func NewClient() *Client { - return &Client{ - client: &http.Client{Timeout: defaultHTTPTimeout}, - baseURL: apiURL, - } + return NewClientWith(Config{URL: apiURL}) } // NewTorClient creates a new API client for communicating with the Write.as // Tor hidden service, using the given port to connect to the local SOCKS // proxy. func NewTorClient(port int) *Client { - dialSocksProxy := socks.DialSocksProxy(socks.SOCKS5, fmt.Sprintf("127.0.0.1:%d", port)) - transport := &http.Transport{Dial: dialSocksProxy} - return &Client{ - client: &http.Client{Transport: transport}, - baseURL: torAPIURL, - } + return NewClientWith(Config{URL: torAPIURL, TorPort: port}) } // NewDevClient creates a new API client for development and testing. It'll // communicate with our development servers, and SHOULD NOT be used in // production. func NewDevClient() *Client { + return NewClientWith(Config{URL: devAPIURL}) +} + +// Config configures a Write.as client. +type Config struct { + // URL of the Write.as API service. Defaults to https://write.as/api. + URL string + + // If specified, the API client will communicate with the Write.as Tor + // hidden service using the provided port to connect to the local SOCKS + // proxy. + TorPort int + + // If specified, requests will be authenticated using this user token. + // This may be provided after making a few anonymous requests with + // SetToken. + Token string +} + +// NewClientWith builds a new API client with the provided configuration. +func NewClientWith(c Config) *Client { + if c.URL == "" { + c.URL = apiURL + } + + httpClient := &http.Client{Timeout: defaultHTTPTimeout} + if c.TorPort > 0 { + dialSocksProxy := socks.DialSocksProxy(socks.SOCKS5, fmt.Sprintf("127.0.0.1:%d", c.TorPort)) + httpClient.Transport = &http.Transport{Dial: dialSocksProxy} + } + return &Client{ - client: &http.Client{Timeout: defaultHTTPTimeout}, - baseURL: devAPIURL, + client: httpClient, + baseURL: c.URL, + token: c.Token, } } @@ -161,9 +189,9 @@ func (c *Client) doRequest(r *http.Request, result interface{}) (*impart.Envelop func (c *Client) prepareRequest(r *http.Request) { ua := c.UserAgent if ua == "" { - ua = "go-writeas v1" + ua = "go-writeas v" + Version } - r.Header.Add("User-Agent", ua) + r.Header.Set("User-Agent", ua) r.Header.Add("Content-Type", "application/json") if c.token != "" { r.Header.Add("Authorization", "Token "+c.token)