diff --git a/articles.go b/articles.go new file mode 100644 index 00000000..3623180a --- /dev/null +++ b/articles.go @@ -0,0 +1,137 @@ +package goshopify + +import ( + "context" + "fmt" + "time" +) + +const articlesBasePath = "articles" + +// The ArticlesService allows you to create, publish, and edit articles on a shop's blog +// See: https://shopify.dev/docs/api/admin-rest/stable/resources/article +type ArticlesService interface { + List(context.Context, uint64, interface{}) ([]Article, error) + Create(context.Context, uint64, Article) (*Article, error) + Get(context.Context, uint64, uint64) (*Article, error) + Update(context.Context, uint64, uint64, Article) (*Article, error) + Delete(context.Context, uint64, uint64) error + Count(context.Context, uint64, interface{}) (int, error) + ListTags(context.Context, interface{}) ([]string, error) + ListBlogTags(context.Context, uint64, interface{}) ([]string, error) +} + +type ArticleResource struct { + Article *Article `json:"article"` +} + +type ArticlesResource struct { + Articles []Article `json:"articles"` +} + +// ArticlesServiceOp handles communication with the articles related methods of +// the Shopify API. +type ArticlesServiceOp struct { + client *Client +} + +type ArticleTagsResource struct { + Tags []string `json:"tags,omitempty"` +} + +type ArticleImage struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Alt string `json:"alt,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Src string `json:"src,omitempty"` +} + +type MetaFields struct { + Key string `json:"key,omitempty"` + Value string `json:"value,omitempty"` + Type string `json:"type,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type Article struct { + Author string `json:"author,omitempty"` + BlogId uint64 `json:"blog_id,omitempty"` + BodyHtml string `json:"body_html,omitempty"` + Id uint64 `json:"id,omitempty"` + Handle string `json:"handle,omitempty"` + Image *ArticleImage `json:"image,omitempty"` + Metafields *MetaFields `json:"metafields"` + Published bool `json:"published,omitempty"` + SummaryHtml string `json:"summary_html,omitempty"` + Tags string `json:"tags,omitempty"` + Title string `json:"title,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + UserId int `json:"user_id,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// List all the articles in a blog. +func (s *ArticlesServiceOp) List(ctx context.Context, blogId uint64, options interface{}) ([]Article, error) { + path := fmt.Sprintf("%s/%d/%s.json", blogsBasePath, blogId, articlesBasePath) + resource := new(ArticlesResource) + err := s.client.Get(ctx, path, resource, options) + return resource.Articles, err +} + +// Create a article in a blog. +func (s *ArticlesServiceOp) Create(ctx context.Context, blogId uint64, article Article) (*Article, error) { + path := fmt.Sprintf("%s/%d/%s.json", blogsBasePath, blogId, articlesBasePath) + body := ArticleResource{ + Article: &article, + } + resource := new(ArticleResource) + err := s.client.Post(ctx, path, body, resource) + return resource.Article, err +} + +// Get an article by blog id and article id. +func (s *ArticlesServiceOp) Get(ctx context.Context, blogId uint64, articleId uint64) (*Article, error) { + path := fmt.Sprintf("%s/%d/%s/%d.json", blogsBasePath, blogId, articlesBasePath, articleId) + resource := new(ArticleResource) + err := s.client.Get(ctx, path, resource, nil) + return resource.Article, err +} + +// Update an article in a blog. +func (s *ArticlesServiceOp) Update(ctx context.Context, blogId uint64, articleId uint64, article Article) (*Article, error) { + path := fmt.Sprintf("%s/%d/%s/%d.json", blogsBasePath, blogId, articlesBasePath, articleId) + wrappedData := ArticleResource{Article: &article} + resource := new(ArticleResource) + err := s.client.Put(ctx, path, wrappedData, resource) + return resource.Article, err +} + +// Delete an article in a blog. +func (s *ArticlesServiceOp) Delete(ctx context.Context, blogId uint64, articleId uint64) error { + path := fmt.Sprintf("%s/%d/%s/%d.json", blogsBasePath, blogId, articlesBasePath, articleId) + return s.client.Delete(ctx, path) +} + +// ListTags Get all tags from all articles. +func (s *ArticlesServiceOp) ListTags(ctx context.Context, options interface{}) ([]string, error) { + path := fmt.Sprintf("%s/tags.json", articlesBasePath) + articleTags := new(ArticleTagsResource) + err := s.client.Get(ctx, path, &articleTags, options) + return articleTags.Tags, err +} + +// Count Articles from a Blog. +func (s *ArticlesServiceOp) Count(ctx context.Context, blogId uint64, options interface{}) (int, error) { + path := fmt.Sprintf("%s/%d/%s/count.json", blogsBasePath, blogId, articlesBasePath) + return s.client.Count(ctx, path, options) +} + +// ListBlogTags Get all tags from all articles in a blog. +func (s *ArticlesServiceOp) ListBlogTags(ctx context.Context, blogId uint64, options interface{}) ([]string, error) { + path := fmt.Sprintf("%s/%d/%s/tags.json", blogsBasePath, blogId, articlesBasePath) + articleTags := new(ArticleTagsResource) + err := s.client.Get(ctx, path, &articleTags, options) + return articleTags.Tags, err +} diff --git a/articles_test.go b/articles_test.go new file mode 100644 index 00000000..e2d0a8e0 --- /dev/null +++ b/articles_test.go @@ -0,0 +1,206 @@ +package goshopify + +import ( + "context" + "fmt" + "reflect" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestArticleList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"articles": [{"id":1},{"id":2}]}`, + ), + ) + + articles, err := client.Article.List(context.Background(), 241253187, nil) + if err != nil { + t.Errorf("Article.List returned error: %v", err) + } + + expected := []Article{ + { + Id: 1, + }, + { + Id: 2, + }, + } + if !reflect.DeepEqual(articles, expected) { + t.Errorf("Articles.List returned %+v, expected %+v", articles, expected) + } +} + +func TestArticleCreate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles.json", client.pathPrefix), + httpmock.NewStringResponder( + 201, + `{"article": {"id": 1}}`, + ), + ) + + article := Article{Title: "Test Article"} + createdArticle, err := client.Article.Create(context.Background(), 241253187, article) + if err != nil { + t.Errorf("Article.Create returned error: %v", err) + } + + expected := &Article{Id: 1} + if !reflect.DeepEqual(createdArticle, expected) { + t.Errorf("Article.Create returned %+v, expected %+v", createdArticle, expected) + } +} + +func TestArticleGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles/1.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"article": {"id": 1, "title": "Test Article"}}`, + ), + ) + + article, err := client.Article.Get(context.Background(), 241253187, 1) + if err != nil { + t.Errorf("Article.Get returned error: %v", err) + } + + expected := &Article{Id: 1, Title: "Test Article"} + if !reflect.DeepEqual(article, expected) { + t.Errorf("Article.Get returned %+v, expected %+v", article, expected) + } +} + +func TestArticleUpdate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "PUT", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles/1.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"article": {"id": 1, "title": "Updated Article"}}`, + ), + ) + + article := Article{Title: "Updated Article"} + updatedArticle, err := client.Article.Update(context.Background(), 241253187, 1, article) + if err != nil { + t.Errorf("Article.Update returned error: %v", err) + } + + expected := &Article{Id: 1, Title: "Updated Article"} + if !reflect.DeepEqual(updatedArticle, expected) { + t.Errorf("Article.Update returned %+v, expected %+v", updatedArticle, expected) + } +} + +func TestArticleDelete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "DELETE", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles/1.json", client.pathPrefix), + httpmock.NewStringResponder( + 204, // No content response + ``, + ), + ) + + err := client.Article.Delete(context.Background(), 241253187, 1) + if err != nil { + t.Errorf("Article.Delete returned error: %v", err) + } +} + +func TestArticleListTags(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/articles/tags.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"tags": ["tag1", "tag2"]}`, + ), + ) + + tags, err := client.Article.ListTags(context.Background(), nil) + if err != nil { + t.Errorf("Article.ListTags returned error: %v", err) + } + + expected := []string{"tag1", "tag2"} + if !reflect.DeepEqual(tags, expected) { + t.Errorf("Article.ListTags returned %+v, expected %+v", tags, expected) + } +} + +func TestArticleCount(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles/count.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"count": 2}`, + ), + ) + + count, err := client.Article.Count(context.Background(), 241253187, nil) + if err != nil { + t.Errorf("Article.Count returned error: %v", err) + } + + expected := 2 + if count != expected { + t.Errorf("Article.Count returned %d, expected %d", count, expected) + } +} + +func TestArticleListBlogTags(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/blogs/241253187/articles/tags.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"tags": ["blogTag1", "blogTag2"]}`, + ), + ) + + tags, err := client.Article.ListBlogTags(context.Background(), 241253187, nil) + if err != nil { + t.Errorf("Article.ListBlogTags returned error: %v", err) + } + + expected := []string{"blogTag1", "blogTag2"} + if !reflect.DeepEqual(tags, expected) { + t.Errorf("Article.ListBlogTags returned %+v, expected %+v", tags, expected) + } +} diff --git a/goshopify.go b/goshopify.go index 43453487..de9f9981 100644 --- a/goshopify.go +++ b/goshopify.go @@ -131,6 +131,7 @@ type Client struct { PaymentsTransactions PaymentsTransactionsService OrderRisk OrderRiskService ApiPermissions ApiPermissionsService + Article ArticlesService } // A general response error that follows a similar layout to Shopify's response @@ -336,6 +337,7 @@ func NewClient(app App, shopName, token string, opts ...Option) (*Client, error) c.PaymentsTransactions = &PaymentsTransactionsServiceOp{client: c} c.OrderRisk = &OrderRiskServiceOp{client: c} c.ApiPermissions = &ApiPermissionsServiceOp{client: c} + c.Article = &ArticlesServiceOp{client: c} // apply any options for _, opt := range opts {