diff --git a/auth.go b/auth.go index 26b5eb9..8fbedee 100644 --- a/auth.go +++ b/auth.go @@ -1,75 +1,76 @@ package writeas import ( + "context" "fmt" "net/http" ) // LogIn authenticates a user with Write.as. // See https://developers.write.as/docs/api/#authenticate-a-user -func (c *Client) LogIn(username, pass string) (*AuthUser, error) { +func (c *Client) LogIn(ctx context.Context, username, pass string) (*AuthUser, error) { u := &AuthUser{} up := struct { Alias string `json:"alias"` Pass string `json:"pass"` }{ Alias: username, Pass: pass, } - env, err := c.post("/auth/login", up, u) + env, err := c.post(ctx, "/auth/login", up, u) if err != nil { return nil, err } var ok bool if u, ok = env.Data.(*AuthUser); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusOK { if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } else if status == http.StatusUnauthorized { return nil, fmt.Errorf("Incorrect password.") } else if status == http.StatusNotFound { return nil, fmt.Errorf("User does not exist.") } else if status == http.StatusTooManyRequests { return nil, fmt.Errorf("Too many log in attempts in a short period of time.") } return nil, fmt.Errorf("Problem authenticating: %d. %v\n", status, err) } c.SetToken(u.AccessToken) return u, nil } // LogOut logs the current user out, making the Client's current access token // invalid. -func (c *Client) LogOut() error { - env, err := c.delete("/auth/me", nil) +func (c *Client) LogOut(ctx context.Context) error { + env, err := c.delete(ctx, "/auth/me", nil) if err != nil { return err } status := env.Code if status != http.StatusNoContent { if status == http.StatusNotFound { return fmt.Errorf("Access token is invalid or doesn't exist") } return fmt.Errorf("Unable to log out: %v", env.ErrorMessage) } // Logout successful, so update the Client c.token = "" return nil } func (c *Client) isNotLoggedIn(code int) bool { if c.token == "" { return false } return code == http.StatusUnauthorized } diff --git a/auth_test.go b/auth_test.go index a9ece6f..063e963 100644 --- a/auth_test.go +++ b/auth_test.go @@ -1,19 +1,23 @@ package writeas -import "testing" +import ( + "context" + "testing" +) func TestAuthentication(t *testing.T) { dwac := NewDevClient() + ctx := context.Background() // Log in - _, err := dwac.LogIn("demo", "demo") + _, err := dwac.LogIn(ctx, "demo", "demo") if err != nil { t.Fatalf("Unable to log in: %v", err) } // Log out - err = dwac.LogOut() + err = dwac.LogOut(ctx) if err != nil { t.Fatalf("Unable to log out: %v", err) } } diff --git a/author.go b/author.go index 06af35c..3b3fe10 100644 --- a/author.go +++ b/author.go @@ -1,54 +1,55 @@ package writeas import ( + "context" "fmt" "net/http" ) type ( // Author represents a Write.as author. Author struct { User *User Name string `json:"name"` Slug string `json:"slug"` } // AuthorParams are used to create or update a Write.as author. AuthorParams struct { // Name is the public display name of the Author. Name string `json:"name"` // Slug is the optional slug for the Author. Slug string `json:"slug"` // OrgAlias is the alias of the organization the Author belongs to. OrgAlias string `json:"-"` } ) // CreateContributor creates a new contributor on the given organization. -func (c *Client) CreateContributor(sp *AuthorParams) (*Author, error) { +func (c *Client) CreateContributor(ctx context.Context, sp *AuthorParams) (*Author, error) { if sp.OrgAlias == "" { return nil, fmt.Errorf("AuthorParams.OrgAlias is required.") } a := &Author{} - env, err := c.post("/organizations/"+sp.OrgAlias+"/contributors", sp, a) + env, err := c.post(ctx, "/organizations/"+sp.OrgAlias+"/contributors", sp, a) if err != nil { return nil, err } var ok bool if a, ok = env.Data.(*Author); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusCreated { if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } return nil, fmt.Errorf("Problem creating author: %d. %s\n", status, env.ErrorMessage) } return a, nil } diff --git a/author_test.go b/author_test.go index fa8b08c..cd6bd0d 100644 --- a/author_test.go +++ b/author_test.go @@ -1,37 +1,41 @@ package writeas -import "testing" +import ( + "context" + "testing" +) func TestClient_CreateContributor(t *testing.T) { c := NewClientWith(Config{URL: "http://localhost:7777/api"}) - _, err := c.LogIn("test", "test") + ctx := context.Background() + _, err := c.LogIn(ctx, "test", "test") if err != nil { t.Fatalf("login: %s", err) } tests := []struct { name string AName string ASlug string AOrg string }{ { name: "good", AName: "Bob Contrib", ASlug: "bob", AOrg: "write-as", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err = c.CreateContributor(&AuthorParams{ + _, err = c.CreateContributor(ctx, &AuthorParams{ Name: test.AName, Slug: test.ASlug, OrgAlias: test.AOrg, }) if err != nil { t.Fatalf("create %s: %s", test.name, err) } }) } } diff --git a/collection.go b/collection.go index da44638..f9547b8 100644 --- a/collection.go +++ b/collection.go @@ -1,186 +1,187 @@ package writeas import ( + "context" "fmt" "net/http" ) type ( // Collection represents a collection of posts. Blogs are a type of collection // on Write.as. Collection struct { Alias string `json:"alias"` Title string `json:"title"` Description string `json:"description"` StyleSheet string `json:"style_sheet"` Private bool `json:"private"` Views int64 `json:"views"` Domain string `json:"domain,omitempty"` Email string `json:"email,omitempty"` URL string `json:"url,omitempty"` TotalPosts int `json:"total_posts"` Posts *[]Post `json:"posts,omitempty"` } // CollectionParams holds values for creating a collection. CollectionParams struct { Alias string `json:"alias"` Title string `json:"title"` Description string `json:"description,omitempty"` } ) // CreateCollection creates a new collection, returning a user-friendly error // if one comes up. Requires a Write.as subscription. See // https://developers.write.as/docs/api/#create-a-collection -func (c *Client) CreateCollection(sp *CollectionParams) (*Collection, error) { +func (c *Client) CreateCollection(ctx context.Context, sp *CollectionParams) (*Collection, error) { p := &Collection{} - env, err := c.post("/collections", sp, p) + env, err := c.post(ctx, "/collections", sp, p) if err != nil { return nil, err } var ok bool if p, ok = env.Data.(*Collection); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusCreated { if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } else if status == http.StatusForbidden { return nil, fmt.Errorf("Casual or Pro user required.") } else if status == http.StatusConflict { return nil, fmt.Errorf("Collection name is already taken.") } else if status == http.StatusPreconditionFailed { return nil, fmt.Errorf("Reached max collection quota.") } return nil, fmt.Errorf("Problem getting post: %d. %v\n", status, err) } return p, nil } // GetCollection retrieves a collection, returning the Collection and any error // (in user-friendly form) that occurs. See // https://developers.write.as/docs/api/#retrieve-a-collection -func (c *Client) GetCollection(alias string) (*Collection, error) { +func (c *Client) GetCollection(ctx context.Context, alias string) (*Collection, error) { coll := &Collection{} - env, err := c.get(fmt.Sprintf("/collections/%s", alias), coll) + env, err := c.get(ctx, fmt.Sprintf("/collections/%s", alias), coll) if err != nil { return nil, err } var ok bool if coll, ok = env.Data.(*Collection); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status == http.StatusOK { return coll, nil } else if status == http.StatusNotFound { return nil, fmt.Errorf("Collection not found.") } else { return nil, fmt.Errorf("Problem getting collection: %d. %v\n", status, err) } } // GetCollectionPosts retrieves a collection's posts, returning the Posts // and any error (in user-friendly form) that occurs. See // https://developers.write.as/docs/api/#retrieve-collection-posts -func (c *Client) GetCollectionPosts(alias string) (*[]Post, error) { +func (c *Client) GetCollectionPosts(ctx context.Context, alias string) (*[]Post, error) { coll := &Collection{} - env, err := c.get(fmt.Sprintf("/collections/%s/posts", alias), coll) + env, err := c.get(ctx, fmt.Sprintf("/collections/%s/posts", alias), coll) if err != nil { return nil, err } var ok bool if coll, ok = env.Data.(*Collection); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status == http.StatusOK { return coll.Posts, nil } else if status == http.StatusNotFound { return nil, fmt.Errorf("Collection not found.") } else { return nil, fmt.Errorf("Problem getting collection: %d. %v\n", status, err) } } // 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) { +func (c *Client) GetCollectionPost(ctx context.Context, alias, slug string) (*Post, error) { post := Post{} - env, err := c.get(fmt.Sprintf("/collections/%s/posts/%s", alias, slug), &post) + env, err := c.get(ctx, 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) { +func (c *Client) GetUserCollections(ctx context.Context) (*[]Collection, error) { colls := &[]Collection{} - env, err := c.get("/me/collections", colls) + env, err := c.get(ctx, "/me/collections", colls) if err != nil { return nil, err } var ok bool if colls, ok = env.Data.(*[]Collection); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusOK { if c.isNotLoggedIn(status) { return nil, fmt.Errorf("Not authenticated.") } return nil, fmt.Errorf("Problem getting collections: %d. %v\n", status, err) } 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 { +func (c *Client) DeleteCollection(ctx context.Context, alias string) error { endpoint := "/collections/" + alias - env, err := c.delete(endpoint, nil /* data */) + env, err := c.delete(ctx, 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 d6bb49b..ae4e462 100644 --- a/collection_test.go +++ b/collection_test.go @@ -1,107 +1,111 @@ package writeas import ( + "context" "fmt" "strings" "testing" "time" ) func TestGetCollection(t *testing.T) { dwac := NewDevClient() - res, err := dwac.GetCollection("tester") + res, err := dwac.GetCollection(context.Background(), "tester") if err != nil { t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) } if res == nil { t.Error("Expected collection to not be nil") } } func TestGetCollectionPosts(t *testing.T) { dwac := NewDevClient() posts := []Post{} + ctx := context.Background() t.Run("Get all posts in collection", func(t *testing.T) { - res, err := dwac.GetCollectionPosts("tester") + res, err := dwac.GetCollectionPosts(ctx, "tester") if err != nil { t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) } if len(*res) == 0 { 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) + res, err := dwac.GetCollectionPost(ctx, "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) { wac := NewDevClient() - _, err := wac.LogIn("demo", "demo") + ctx := context.Background() + _, err := wac.LogIn(ctx, "demo", "demo") if err != nil { t.Fatalf("Unable to log in: %v", err) } - defer wac.LogOut() + defer wac.LogOut(ctx) - res, err := wac.GetUserCollections() + res, err := wac.GetUserCollections(ctx) if err != nil { t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) } else { t.Logf("User collections: %+v", res) if len(*res) == 0 { t.Errorf("No collections returned!") } } } func TestCreateAndDeleteCollection(t *testing.T) { wac := NewDevClient() - _, err := wac.LogIn("demo", "demo") + ctx := context.Background() + _, err := wac.LogIn(ctx, "demo", "demo") if err != nil { t.Fatalf("Unable to log in: %v", err) } - defer wac.LogOut() + defer wac.LogOut(ctx) now := time.Now().Unix() alias := fmt.Sprintf("test-collection-%v", now) - c, err := wac.CreateCollection(&CollectionParams{ + c, err := wac.CreateCollection(ctx, &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 { + if err := wac.DeleteCollection(ctx, 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) + err := wac.DeleteCollection(context.Background(), 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) } } diff --git a/formatting.go b/formatting.go index 5a85aee..f721044 100644 --- a/formatting.go +++ b/formatting.go @@ -1,39 +1,40 @@ package writeas import ( + "context" "fmt" "net/http" ) type BodyResponse struct { Body string `json:"body"` } // Markdown takes raw Markdown and renders it into usable HTML. See // https://developers.write.as/docs/api/#render-markdown. -func (c *Client) Markdown(body, collectionURL string) (string, error) { +func (c *Client) Markdown(ctx context.Context, body, collectionURL string) (string, error) { p := &BodyResponse{} data := struct { RawBody string `json:"raw_body"` CollectionURL string `json:"collection_url,omitempty"` }{ RawBody: body, CollectionURL: collectionURL, } - env, err := c.post("/markdown", data, p) + env, err := c.post(ctx, "/markdown", data, p) if err != nil { return "", err } var ok bool if p, ok = env.Data.(*BodyResponse); !ok { return "", fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusOK { return "", fmt.Errorf("Problem getting markdown: %d. %s\n", status, env.ErrorMessage) } return p.Body, nil } diff --git a/formatting_test.go b/formatting_test.go index 0a83844..668e784 100644 --- a/formatting_test.go +++ b/formatting_test.go @@ -1,23 +1,24 @@ package writeas import ( + "context" "testing" ) func TestMarkdown(t *testing.T) { dwac := NewDevClient() in := "This is *formatted* in __Markdown__." out := `
This is formatted in Markdown.
` - res, err := dwac.Markdown(in, "") + res, err := dwac.Markdown(context.Background(), in, "") if err != nil { t.Errorf("Unexpected fetch results: %+v, err: %v\n", res, err) } if res != out { t.Errorf(`Got: '%s' Expected: '%s'`, res, out) } } diff --git a/post.go b/post.go index a98269e..22de9cd 100644 --- a/post.go +++ b/post.go @@ -1,344 +1,345 @@ package writeas import ( + "context" "fmt" "net/http" "time" ) type ( // Post represents a published Write.as post, whether anonymous, owned by a // user, or part of a collection. Post struct { ID string `json:"id"` Slug string `json:"slug"` Token string `json:"token"` Font string `json:"appearance"` Language *string `json:"language"` RTL *bool `json:"rtl"` Listed bool `json:"listed"` Type PostType `json:"type"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` Title string `json:"title"` Content string `json:"body"` Views int64 `json:"views"` Tags []string `json:"tags"` Images []string `json:"images"` OwnerName string `json:"owner,omitempty"` Collection *Collection `json:"collection,omitempty"` } // OwnedPostParams are, together, fields only the original post author knows. OwnedPostParams struct { ID string `json:"id"` Token string `json:"token,omitempty"` } // PostParams holds values for creating or updating a post. PostParams struct { // Parameters only for updating ID string `json:"-"` Token string `json:"token,omitempty"` // Parameters for creating or updating 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"` AuthorSlug *string `json:"author,omitempty"` Categories []Category `json:"categories,omitempty"` // Parameters only for creating Crosspost []map[string]string `json:"crosspost,omitempty"` // Parameters for collection posts Collection string `json:"-"` } // PinnedPostParams holds values for pinning a post PinnedPostParams struct { ID string `json:"id"` Position int `json:"position"` } // BatchPostResult contains the post-specific result as part of a larger // batch operation. BatchPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` } // ClaimPostResult contains the post-specific result for a request to // associate a post to an account. ClaimPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` Post *Post `json:"post,omitempty"` } ) type PostType string const ( TypePost PostType = "post" TypePrompt = "prompt" TypePromptArchive = "prompt-arch" TypeSubmission = "submission" TypeSubmissionDraft = "submission-draft" ) // GetPost retrieves a published post, returning the Post and any error (in // user-friendly form) that occurs. See // https://developers.write.as/docs/api/#retrieve-a-post. -func (c *Client) GetPost(id string) (*Post, error) { +func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) { p := &Post{} - env, err := c.get(fmt.Sprintf("/posts/%s", id), p) + env, err := c.get(ctx, fmt.Sprintf("/posts/%s", id), p) if err != nil { return nil, err } var ok bool if p, ok = env.Data.(*Post); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status == http.StatusOK { return p, nil } else if status == http.StatusNotFound { return nil, fmt.Errorf("Post not found.") } else if status == http.StatusGone { return nil, fmt.Errorf("Post unpublished.") } 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 // up. See https://developers.write.as/docs/api/#publish-a-post. -func (c *Client) CreatePost(sp *PostParams) (*Post, error) { +func (c *Client) CreatePost(ctx context.Context, sp *PostParams) (*Post, error) { p := &Post{} endPre := "" if sp.Collection != "" { endPre = "/collections/" + sp.Collection } - env, err := c.post(endPre+"/posts", sp, p) + env, err := c.post(ctx, endPre+"/posts", sp, p) if err != nil { return nil, err } var ok bool if p, ok = env.Data.(*Post); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code 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://developers.write.as/docs/api/#update-a-post. -func (c *Client) UpdatePost(id, token string, sp *PostParams) (*Post, error) { - return c.updatePost("", id, token, sp) +func (c *Client) UpdatePost(ctx context.Context, id, token string, sp *PostParams) (*Post, error) { + return c.updatePost(ctx, "", id, token, sp) } -func (c *Client) updatePost(collection, identifier, token string, sp *PostParams) (*Post, error) { +func (c *Client) updatePost(ctx context.Context, collection, identifier, token string, sp *PostParams) (*Post, error) { p := &Post{} endpoint := "/posts/" + identifier /* if collection != "" { endpoint = "/collections/" + collection + endpoint } else { sp.Token = token } */ sp.Token = token - env, err := c.put(endpoint, sp, p) + env, err := c.put(ctx, endpoint, sp, p) if err != nil { return nil, err } var ok bool if p, ok = env.Data.(*Post); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusOK { if c.isNotLoggedIn(status) { return nil, fmt.Errorf("Not authenticated.") } else if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } return nil, fmt.Errorf("Problem updating post: %d. %s\n", status, env.ErrorMessage) } return p, nil } // DeletePost permanently deletes a published post. See // https://developers.write.as/docs/api/#delete-a-post. -func (c *Client) DeletePost(id, token string) error { - return c.deletePost("", id, token) +func (c *Client) DeletePost(ctx context.Context, id, token string) error { + return c.deletePost(ctx, "", id, token) } -func (c *Client) deletePost(collection, identifier, token string) error { +func (c *Client) deletePost(ctx context.Context, 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) + env, err := c.delete(ctx, endpoint, p) if err != nil { return err } status := env.Code if status == http.StatusNoContent { return nil } else if c.isNotLoggedIn(status) { return fmt.Errorf("Not authenticated.") } else if status == http.StatusBadRequest { return fmt.Errorf("Bad request: %s", env.ErrorMessage) } return fmt.Errorf("Problem deleting post: %d. %s\n", status, env.ErrorMessage) } // ClaimPosts associates anonymous posts with a user / account. // https://developers.write.as/docs/api/#claim-posts. -func (c *Client) ClaimPosts(sp *[]OwnedPostParams) (*[]ClaimPostResult, error) { +func (c *Client) ClaimPosts(ctx context.Context, sp *[]OwnedPostParams) (*[]ClaimPostResult, error) { p := &[]ClaimPostResult{} - env, err := c.post("/posts/claim", sp, p) + env, err := c.post(ctx, "/posts/claim", sp, p) if err != nil { return nil, err } var ok bool if p, ok = env.Data.(*[]ClaimPostResult); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status == http.StatusOK { return p, nil } else if c.isNotLoggedIn(status) { return nil, fmt.Errorf("Not authenticated.") } else if status == http.StatusBadRequest { return nil, fmt.Errorf("Bad request: %s", env.ErrorMessage) } else { return nil, fmt.Errorf("Problem claiming post: %d. %s\n", status, env.ErrorMessage) } // TODO: does this also happen with moving posts? } // GetUserPosts retrieves the authenticated user's posts. // See https://developers.write.as/docs/api/#retrieve-user-39-s-posts -func (c *Client) GetUserPosts() (*[]Post, error) { +func (c *Client) GetUserPosts(ctx context.Context) (*[]Post, error) { p := &[]Post{} - env, err := c.get("/me/posts", p) + env, err := c.get(ctx, "/me/posts", p) if err != nil { return nil, err } var ok bool if p, ok = env.Data.(*[]Post); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } status := env.Code if status != http.StatusOK { if c.isNotLoggedIn(status) { return nil, fmt.Errorf("Not authenticated.") } return nil, fmt.Errorf("Problem getting user posts: %d. %s\n", status, env.ErrorMessage) } return p, nil } // PinPost pins a post in the given collection. // See https://developers.write.as/docs/api/#pin-a-post-to-a-collection -func (c *Client) PinPost(alias string, pp *PinnedPostParams) error { +func (c *Client) PinPost(ctx context.Context, alias string, pp *PinnedPostParams) error { res := &[]BatchPostResult{} - env, err := c.post(fmt.Sprintf("/collections/%s/pin", alias), []*PinnedPostParams{pp}, res) + env, err := c.post(ctx, fmt.Sprintf("/collections/%s/pin", alias), []*PinnedPostParams{pp}, res) if err != nil { return err } var ok bool if res, ok = env.Data.(*[]BatchPostResult); !ok { return fmt.Errorf("Wrong data returned from API.") } // Check for basic request errors on top level response status := env.Code if status != http.StatusOK { if c.isNotLoggedIn(status) { return fmt.Errorf("Not authenticated.") } return fmt.Errorf("Problem pinning post: %d. %s\n", status, env.ErrorMessage) } // Check the individual post result if len(*res) == 0 || len(*res) > 1 { return fmt.Errorf("Wrong data returned from API.") } 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: %s", res[0].ErrorMessage) } return nil } // UnpinPost unpins a post from the given collection. // See https://developers.write.as/docs/api/#unpin-a-post-from-a-collection -func (c *Client) UnpinPost(alias string, pp *PinnedPostParams) error { +func (c *Client) UnpinPost(ctx context.Context, alias string, pp *PinnedPostParams) error { res := &[]BatchPostResult{} - env, err := c.post(fmt.Sprintf("/collections/%s/unpin", alias), []*PinnedPostParams{pp}, res) + env, err := c.post(ctx, fmt.Sprintf("/collections/%s/unpin", alias), []*PinnedPostParams{pp}, res) if err != nil { return err } var ok bool if res, ok = env.Data.(*[]BatchPostResult); !ok { return fmt.Errorf("Wrong data returned from API.") } // Check for basic request errors on top level response status := env.Code if status != http.StatusOK { if c.isNotLoggedIn(status) { return fmt.Errorf("Not authenticated.") } return fmt.Errorf("Problem unpinning post: %d. %s\n", status, env.ErrorMessage) } // Check the individual post result if len(*res) == 0 || len(*res) > 1 { return fmt.Errorf("Wrong data returned from API.") } 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: %s", res[0].ErrorMessage) } return nil } diff --git a/post_test.go b/post_test.go index 9d2fb47..7a0c8cc 100644 --- a/post_test.go +++ b/post_test.go @@ -1,93 +1,96 @@ package writeas import ( + "context" "fmt" "testing" ) func TestPostRoundTrip(t *testing.T) { var id, token string dwac := NewClient() + ctx := context.Background() t.Run("Create post", func(t *testing.T) { - p, err := dwac.CreatePost(&PostParams{ + p, err := dwac.CreatePost(ctx, &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 }) t.Run("Get post", func(t *testing.T) { - res, err := dwac.GetPost(id) + res, err := dwac.GetPost(ctx, 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) } } }) t.Run("Update post", func(t *testing.T) { - p, err := dwac.UpdatePost(id, token, &PostParams{ + p, err := dwac.UpdatePost(ctx, id, token, &PostParams{ Content: "Now it's been updated!", }) if err != nil { t.Errorf("Post update failed: %v", err) return } t.Logf("Post updated: %+v", p) }) t.Run("Delete post", func(t *testing.T) { - err := dwac.DeletePost(id, token) + err := dwac.DeletePost(ctx, id, token) if err != nil { t.Errorf("Post delete failed: %v", err) return } t.Logf("Post deleted!") }) } func TestPinUnPin(t *testing.T) { dwac := NewDevClient() - _, err := dwac.LogIn("demo", "demo") + ctx := context.Background() + _, err := dwac.LogIn(ctx, "demo", "demo") if err != nil { t.Fatalf("Unable to log in: %v", err) } - defer dwac.LogOut() + defer dwac.LogOut(ctx) t.Run("Pin post", func(t *testing.T) { - err := dwac.PinPost("tester", &PinnedPostParams{ID: "olx6uk7064heqltf"}) + err := dwac.PinPost(ctx, "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"}) + err := dwac.UnpinPost(ctx, "tester", &PinnedPostParams{ID: "olx6uk7064heqltf"}) if err != nil { t.Fatalf("Unpin failed: %v", err) } }) } func ExampleClient_CreatePost() { dwac := NewDevClient() // Publish a post - p, err := dwac.CreatePost(&PostParams{ + p, err := dwac.CreatePost(context.Background(), &PostParams{ Title: "Title!", Content: "This is a post.", Font: "sans", }) if err != nil { fmt.Printf("Unable to create: %v", err) return } fmt.Printf("%s", p.Content) // Output: This is a post. } diff --git a/user.go b/user.go index 48143ef..9e5c24e 100644 --- a/user.go +++ b/user.go @@ -1,68 +1,69 @@ package writeas import ( + "context" "fmt" "net/http" "time" ) type ( // AuthUser represents a just-authenticated user. It contains information // that'll only be returned once (now) per user session. AuthUser struct { AccessToken string `json:"access_token,omitempty"` Password string `json:"password,omitempty"` User *User `json:"user"` } // User represents a registered Write.as user. User struct { Username string `json:"username"` Email string `json:"email"` Created time.Time `json:"created"` // Optional properties Subscription *UserSubscription `json:"subscription"` } // UserSubscription contains information about a user's Write.as // subscription. UserSubscription struct { Name string `json:"name"` Begin time.Time `json:"begin"` End time.Time `json:"end"` AutoRenew bool `json:"auto_renew"` Active bool `json:"is_active"` Delinquent bool `json:"is_delinquent"` } ) // GetMe retrieves the authenticated User's information. // See: https://developers.write.as/docs/api/#retrieve-authenticated-user -func (c *Client) GetMe(verbose bool) (*User, error) { +func (c *Client) GetMe(ctx context.Context, verbose bool) (*User, error) { if c.Token() == "" { return nil, fmt.Errorf("Unable to get user; no access token given.") } params := "" if verbose { params = "?verbose=true" } - env, err := c.get("/me"+params, nil) + env, err := c.get(ctx, "/me"+params, nil) if err != nil { return nil, err } status := env.Code if status == http.StatusUnauthorized { return nil, fmt.Errorf("invalid or expired token") } var u *User var ok bool if u, ok = env.Data.(*User); !ok { return nil, fmt.Errorf("Wrong data returned from API.") } return u, nil } diff --git a/writeas.go b/writeas.go index 021255e..bfae77a 100644 --- a/writeas.go +++ b/writeas.go @@ -1,219 +1,220 @@ // Package writeas provides the binding for the Write.as API package writeas import ( "bytes" + "context" "encoding/json" "fmt" "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://writeasw4b635r4o3vec6mu45s47ohfyro5vayzx2zjwod4pjswyovyd.onion/api" // Current go-writeas version Version = "2" ) // Client is used to interact with the Write.as API. It can be used to make // authenticated or unauthenticated calls. type Client struct { baseURL string // Access token for the user making requests. token string // Application-level API key. apiKey string // Client making requests to the API client *http.Client // UserAgent overrides the default User-Agent header UserAgent string } // defaultHTTPTimeout is the default http.Client timeout. const defaultHTTPTimeout = 10 * time.Second // NewClient creates a new API client. By default, all requests are made // unauthenticated. To optionally make authenticated requests, call `SetToken`. // -// c := writeas.NewClient() -// c.SetToken("00000000-0000-0000-0000-000000000000") +// c := writeas.NewClient() +// c.SetToken("00000000-0000-0000-0000-000000000000") func NewClient() *Client { 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 { 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: httpClient, baseURL: c.URL, token: c.Token, } } // SetToken sets the user token for all future Client requests. Setting this to // an empty string will change back to unauthenticated requests. func (c *Client) SetToken(token string) { c.token = token } // SetApplicationKey sets an application-level API key for all Client requests. func (c *Client) SetApplicationKey(key string) { c.apiKey = key } // SetClient sets a custom http.Client to use instead of the default. func (c *Client) SetClient(cl *http.Client) { c.client = cl } // Token returns the user token currently set to the Client. func (c *Client) Token() string { return c.token } // BaseURL returns the base API URL the Client will make calls against. func (c *Client) BaseURL() string { return c.baseURL } -func (c *Client) get(path string, r interface{}) (*impart.Envelope, error) { +func (c *Client) get(ctx context.Context, path string, r interface{}) (*impart.Envelope, error) { method := "GET" if method != "GET" && method != "HEAD" { return nil, fmt.Errorf("Method %s not currently supported by library (only HEAD and GET).\n", method) } - return c.request(method, path, nil, r) + return c.request(ctx, method, path, nil, r) } -func (c *Client) post(path string, data, r interface{}) (*impart.Envelope, error) { +func (c *Client) post(ctx context.Context, path string, data, r interface{}) (*impart.Envelope, error) { b := new(bytes.Buffer) json.NewEncoder(b).Encode(data) - return c.request("POST", path, b, r) + return c.request(ctx, "POST", path, b, r) } -func (c *Client) put(path string, data, r interface{}) (*impart.Envelope, error) { +func (c *Client) put(ctx context.Context, path string, data, r interface{}) (*impart.Envelope, error) { b := new(bytes.Buffer) json.NewEncoder(b).Encode(data) - return c.request("PUT", path, b, r) + return c.request(ctx, "PUT", path, b, r) } -func (c *Client) delete(path string, data map[string]string) (*impart.Envelope, error) { - r, err := c.buildRequest("DELETE", path, nil) +func (c *Client) delete(ctx context.Context, path string, data map[string]string) (*impart.Envelope, error) { + r, err := c.buildRequest(ctx, "DELETE", path, nil) if err != nil { return nil, err } q := r.URL.Query() for k, v := range data { q.Add(k, v) } r.URL.RawQuery = q.Encode() return c.doRequest(r, nil) } -func (c *Client) request(method, path string, data io.Reader, result interface{}) (*impart.Envelope, error) { - r, err := c.buildRequest(method, path, data) +func (c *Client) request(ctx context.Context, method, path string, data io.Reader, result interface{}) (*impart.Envelope, error) { + r, err := c.buildRequest(ctx, method, path, data) if err != nil { return nil, err } return c.doRequest(r, result) } -func (c *Client) buildRequest(method, path string, data io.Reader) (*http.Request, error) { +func (c *Client) buildRequest(ctx context.Context, method, path string, data io.Reader) (*http.Request, error) { url := fmt.Sprintf("%s%s", c.baseURL, path) - r, err := http.NewRequest(method, url, data) + r, err := http.NewRequestWithContext(ctx, method, url, data) if err != nil { return nil, fmt.Errorf("Create request: %v", err) } c.prepareRequest(r) return r, nil } func (c *Client) doRequest(r *http.Request, result interface{}) (*impart.Envelope, error) { resp, err := c.client.Do(r) if err != nil { return nil, fmt.Errorf("Request: %v", err) } defer resp.Body.Close() env := &impart.Envelope{ Code: resp.StatusCode, } if result != nil { env.Data = result err = json.NewDecoder(resp.Body).Decode(&env) if err != nil { return nil, err } } return env, nil } func (c *Client) prepareRequest(r *http.Request) { ua := c.UserAgent if ua == "" { ua = "go-writeas v" + Version } r.Header.Set("User-Agent", ua) r.Header.Add("Content-Type", "application/json") if c.token != "" { r.Header.Add("Authorization", "Token "+c.token) } if c.apiKey != "" { r.Header.Add("X-API-Key", c.apiKey) } }