From 17a7274192c165df5662d917de6a6aaf3394d93a Mon Sep 17 00:00:00 2001 From: Kurt McAlpine Date: Tue, 17 Nov 2020 21:55:46 +1300 Subject: [PATCH] Implement goal resource --- docs/resources/goal.md | 29 +++++ docs/resources/shared_link.md | 2 +- docs/resources/site.md | 5 +- internal/provider/provider.go | 1 + internal/provider/resource_goal.go | 118 +++++++++++++++++ internal/provider/resource_goal_test.go | 39 ++++++ internal/provider/resource_shared_link.go | 5 + internal/provider/resource_site.go | 5 + plausibleclient/client.go | 3 + plausibleclient/goal.go | 150 ++++++++++++++++++++++ plausibleclient/mutexkv.go | 51 ++++++++ plausibleclient/shared_link.go | 8 ++ plausibleclient/site.go | 23 ++++ 13 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 docs/resources/goal.md create mode 100644 internal/provider/resource_goal.go create mode 100644 internal/provider/resource_goal_test.go create mode 100644 plausibleclient/goal.go create mode 100644 plausibleclient/mutexkv.go diff --git a/docs/resources/goal.md b/docs/resources/goal.md new file mode 100644 index 0000000..b9fa31a --- /dev/null +++ b/docs/resources/goal.md @@ -0,0 +1,29 @@ +--- +page_title: "plausible_goal Resource - terraform-provider-plausible" +subcategory: "" +description: |- + +--- + +# Resource `plausible_goal` + + + + + +## Schema + +### Required + +- **site_id** (String, Required) The domain of the site to create the goal for. + +### Optional + +- **event_name** (String, Optional) Custom event E.g. `Signup` +- **page_path** (String, Optional) Page path event. E.g. `/success` + +### Read-only + +- **id** (String, Read-only) The goal ID + + diff --git a/docs/resources/shared_link.md b/docs/resources/shared_link.md index fdf2f42..cd1f7d3 100644 --- a/docs/resources/shared_link.md +++ b/docs/resources/shared_link.md @@ -19,11 +19,11 @@ description: |- ### Optional -- **id** (String, Optional) The ID of this resource. - **password** (String, Optional) Add a password or leave it blank so anyone with the link can see the stats. ### Read-only +- **id** (String, Read-only) The shared link ID - **link** (String, Read-only) Shared link diff --git a/docs/resources/site.md b/docs/resources/site.md index 38b6b5c..2e37702 100644 --- a/docs/resources/site.md +++ b/docs/resources/site.md @@ -18,12 +18,9 @@ description: |- - **domain** (String, Required) - **timezone** (String, Required) -### Optional - -- **id** (String, Optional) The ID of this resource. - ### Read-only +- **id** (String, Read-only) The site ID - **javascript_snippet** (String, Read-only) Include this snippet in the of your website. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index de3d838..3797e5d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -31,6 +31,7 @@ func New(version string) func() *schema.Provider { ResourcesMap: map[string]*schema.Resource{ "plausible_site": resourceSite(), "plausible_shared_link": resourceSharedLink(), + "plausible_goal": resourceGoal(), }, } diff --git a/internal/provider/resource_goal.go b/internal/provider/resource_goal.go new file mode 100644 index 0000000..1cfa8bf --- /dev/null +++ b/internal/provider/resource_goal.go @@ -0,0 +1,118 @@ +package provider + +import ( + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/mcalpinefree/terraform-provider-plausible/plausibleclient" +) + +func resourceGoal() *schema.Resource { + return &schema.Resource{ + Create: resourceGoalCreate, + Read: resourceGoalRead, + Delete: resourceGoalDelete, + + Schema: map[string]*schema.Schema{ + "id": { + Description: "The goal ID", + Type: schema.TypeString, + Computed: true, + }, + "site_id": { + Description: "The domain of the site to create the goal for.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "page_path": { + Description: "Page path event. E.g. `/success`", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{"event_name"}, + }, + "event_name": { + Description: "Custom event E.g. `Signup`", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{"page_path"}, + }, + }, + } +} + +func resourceGoalCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient) + domain := d.Get("site_id").(string) + + var goalType plausibleclient.GoalType + goal := "" + if v, ok := d.GetOk("page_path"); ok { + goalType = plausibleclient.PagePath + goal = v.(string) + } else if v, ok := d.GetOk("event_name"); ok { + goalType = plausibleclient.EventName + goal = v.(string) + } else { + return fmt.Errorf("page_path or event_name needs to be defined") + } + + resp, err := client.plausibleClient.CreateGoal(domain, goalType, goal) + if err != nil { + return err + } + d.SetId(fmt.Sprintf("%d", resp.ID)) + + return resourceGoalSetResourceData(resp, d) +} + +func resourceGoalSetResourceData(g *plausibleclient.Goal, d *schema.ResourceData) error { + d.Set("site_id", g.Domain) + if g.PagePath != nil { + d.Set("page_path", *g.PagePath) + } else if g.EventName != nil { + d.Set("event_name", *g.EventName) + } else { + return fmt.Errorf("either PagePath or EventName needs to not be nil") + } + return nil +} + +func resourceGoalRead(d *schema.ResourceData, meta interface{}) error { + id := d.Id() + + idInt, err := strconv.Atoi(id) + if err != nil { + return err + } + g := &plausibleclient.Goal{ + ID: idInt, + Domain: d.Get("site_id").(string), + } + + if v, ok := d.GetOk("page_path"); ok { + pagePath := v.(string) + g.PagePath = &pagePath + } else if v, ok := d.GetOk("event_name"); ok { + eventName := v.(string) + g.EventName = &eventName + } else { + return fmt.Errorf("page_path or event_name needs to be defined") + } + + return resourceGoalSetResourceData(g, d) +} + +func resourceGoalDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient) + id := d.Id() + domain := d.Get("site_id").(string) + idInt, err := strconv.Atoi(id) + if err != nil { + return err + } + return client.plausibleClient.DeleteGoal(domain, idInt) +} diff --git a/internal/provider/resource_goal_test.go b/internal/provider/resource_goal_test.go new file mode 100644 index 0000000..713ed1f --- /dev/null +++ b/internal/provider/resource_goal_test.go @@ -0,0 +1,39 @@ +package provider + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccResourceGoal(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceGoal(acctest.RandomWithPrefix("testacc-tf")), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("plausible_goal.testacc", "id", regexp.MustCompile(`^[0-9]+$`)), + ), + }, + }, + }) +} + +func testAccResourceGoal(domain string) string { + return fmt.Sprintf(` +resource "plausible_site" "testacc" { + domain = "%s" + timezone = "Pacific/Auckland" +} + +resource "plausible_goal" "testacc" { + site_id = plausible_site.testacc.id + page_path = "/success" +} + `, domain) +} diff --git a/internal/provider/resource_shared_link.go b/internal/provider/resource_shared_link.go index 2226acb..a80f28b 100644 --- a/internal/provider/resource_shared_link.go +++ b/internal/provider/resource_shared_link.go @@ -12,6 +12,11 @@ func resourceSharedLink() *schema.Resource { Delete: resourceSharedLinkDelete, Schema: map[string]*schema.Schema{ + "id": { + Description: "The shared link ID", + Type: schema.TypeString, + Computed: true, + }, "site_id": { Description: "The domain of the site to create the shared link for.", Type: schema.TypeString, diff --git a/internal/provider/resource_site.go b/internal/provider/resource_site.go index ffd9f5d..ecc85a0 100644 --- a/internal/provider/resource_site.go +++ b/internal/provider/resource_site.go @@ -18,6 +18,11 @@ func resourceSite() *schema.Resource { }, Schema: map[string]*schema.Schema{ + "id": { + Description: "The site ID", + Type: schema.TypeString, + Computed: true, + }, "domain": { Type: schema.TypeString, Required: true, diff --git a/plausibleclient/client.go b/plausibleclient/client.go index 5c652e9..15289ad 100644 --- a/plausibleclient/client.go +++ b/plausibleclient/client.go @@ -11,6 +11,7 @@ type Client struct { username string password string loggedIn bool + mutexkv *MutexKV } func (c *Client) login() error { @@ -39,5 +40,7 @@ func NewClient(username, password string) *Client { Jar: jar, } + c.mutexkv = NewMutexKV() + return &c } diff --git a/plausibleclient/goal.go b/plausibleclient/goal.go new file mode 100644 index 0000000..bbae9e7 --- /dev/null +++ b/plausibleclient/goal.go @@ -0,0 +1,150 @@ +package plausibleclient + +import ( + "fmt" + "net/url" + + "github.com/PuerkitoBio/goquery" +) + +type Goal struct { + ID int + Domain string + PagePath *string + EventName *string +} + +type GoalType int + +const ( + PagePath GoalType = iota + EventName +) + +func (c *Client) CreateGoal(domain string, goalType GoalType, goal string) (*Goal, error) { + if !c.loggedIn { + err := c.login() + if err != nil { + return nil, err + } + } + + c.mutexkv.Lock(domain) + defer c.mutexkv.Unlock(domain) + + resp, err := c.httpClient.Get("https://plausible.io/" + domain + "/goals/new") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Load the HTML document + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, err + } + + // Find the form CSRF token + csrfToken := "" + csrfTokenExists := false + doc.Find(`form > input[name="_csrf_token"]`).Each(func(i int, s *goquery.Selection) { + csrfToken, csrfTokenExists = s.Attr("value") + }) + if !csrfTokenExists { + return nil, fmt.Errorf("could not find csrf token in HTML form") + } + + values := url.Values{} + values.Add("_csrf_token", csrfToken) + result := Goal{Domain: domain} + switch goalType { + case PagePath: + result.PagePath = &goal + case EventName: + result.EventName = &goal + } + + if result.PagePath != nil { + values.Add("goal[page_path]", *result.PagePath) + } else { + values.Add("goal[page_path]", "") + } + if result.EventName != nil { + values.Add("goal[event_name]", *result.EventName) + } else { + values.Add("goal[event_name]", "") + } + + before, err := c.GetSiteSettings(domain) + if err != nil { + return nil, err + } + + _, err = c.httpClient.PostForm("https://plausible.io/"+domain+"/goals", values) + if err != nil { + return nil, err + } + + after, err := c.GetSiteSettings(domain) + if err != nil { + return nil, err + } + + if len(before.Goals) != (len(after.Goals) - 1) { + return nil, fmt.Errorf("expected there to be one more goal after requesting to create a new one, but the count went from %d to %d", len(before.SharedLinks), len(after.SharedLinks)) + } + +AFTER: + for _, v := range after.Goals { + for _, w := range before.Goals { + if v == w { + continue AFTER + } + } + result.ID = v + return &result, nil + } + + return nil, fmt.Errorf("could not find newly created goal") +} + +func (c *Client) DeleteGoal(domain string, id int) error { + if !c.loggedIn { + err := c.login() + if err != nil { + return err + } + } + + c.mutexkv.Lock(domain) + defer c.mutexkv.Unlock(domain) + + resp, err := c.httpClient.Get("https://plausible.io/" + domain + "/settings") + if err != nil { + return err + } + defer resp.Body.Close() + + // Load the HTML document + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return err + } + + // Find the form CSRF token + csrfToken := "" + csrfTokenExists := false + cssSelector := fmt.Sprintf(`button[data-to="/%s/goals/%d"]`, domain, id) + doc.Find(cssSelector).Each(func(i int, s *goquery.Selection) { + csrfToken, csrfTokenExists = s.Attr("data-csrf") + }) + if !csrfTokenExists { + return fmt.Errorf("could not find csrf token in HTML form") + } + + values := url.Values{} + values.Add("_csrf_token", csrfToken) + values.Add("_method", "delete") + _, err = c.httpClient.PostForm(fmt.Sprintf("https://plausible.io/%s/goals/%d", domain, id), values) + return err +} diff --git a/plausibleclient/mutexkv.go b/plausibleclient/mutexkv.go new file mode 100644 index 0000000..d012a1b --- /dev/null +++ b/plausibleclient/mutexkv.go @@ -0,0 +1,51 @@ +package plausibleclient + +import ( + "log" + "sync" +) + +// MutexKV is a simple key/value store for arbitrary mutexes. It can be used to +// serialize changes across arbitrary collaborators that share knowledge of the +// keys they must serialize on. +// +// The initial use case is to let aws_security_group_rule resources serialize +// their access to individual security groups based on SG ID. +type MutexKV struct { + lock sync.Mutex + store map[string]*sync.Mutex +} + +// Locks the mutex for the given key. Caller is responsible for calling Unlock +// for the same key +func (m *MutexKV) Lock(key string) { + log.Printf("[DEBUG] Locking %q", key) + m.get(key).Lock() + log.Printf("[DEBUG] Locked %q", key) +} + +// Unlock the mutex for the given key. Caller must have called Lock for the same key first +func (m *MutexKV) Unlock(key string) { + log.Printf("[DEBUG] Unlocking %q", key) + m.get(key).Unlock() + log.Printf("[DEBUG] Unlocked %q", key) +} + +// Returns a mutex for the given key, no guarantee of its lock status +func (m *MutexKV) get(key string) *sync.Mutex { + m.lock.Lock() + defer m.lock.Unlock() + mutex, ok := m.store[key] + if !ok { + mutex = &sync.Mutex{} + m.store[key] = mutex + } + return mutex +} + +// Returns a properly initalized MutexKV +func NewMutexKV() *MutexKV { + return &MutexKV{ + store: make(map[string]*sync.Mutex), + } +} diff --git a/plausibleclient/shared_link.go b/plausibleclient/shared_link.go index da6ac96..b609698 100644 --- a/plausibleclient/shared_link.go +++ b/plausibleclient/shared_link.go @@ -23,6 +23,10 @@ func (c *Client) CreateSharedLink(domain, password string) (*SharedLink, error) return nil, err } } + + c.mutexkv.Lock(domain) + defer c.mutexkv.Unlock(domain) + resp, err := c.httpClient.Get("https://plausible.io/sites/" + domain + "/shared-links/new") if err != nil { return nil, err @@ -97,6 +101,10 @@ func (c *Client) DeleteSharedLink(domain, id string) error { return err } } + + c.mutexkv.Lock(domain) + defer c.mutexkv.Unlock(domain) + resp, err := c.httpClient.Get("https://plausible.io/" + domain + "/settings") if err != nil { return err diff --git a/plausibleclient/site.go b/plausibleclient/site.go index 4b74cd6..3995e04 100644 --- a/plausibleclient/site.go +++ b/plausibleclient/site.go @@ -3,6 +3,8 @@ package plausibleclient import ( "fmt" "net/url" + "strconv" + "strings" "github.com/PuerkitoBio/goquery" ) @@ -129,6 +131,7 @@ type SiteSettings struct { Domain string Timezone string SharedLinks []string + Goals []int } func (c *Client) GetSiteSettings(domain string) (*SiteSettings, error) { @@ -174,9 +177,29 @@ func (c *Client) GetSiteSettings(domain string) (*SiteSettings, error) { } }) + var goals []int + var errs []error + doc.Find(`button[data-to*="/` + domain + `/goals/"]`).Each(func(i int, s *goquery.Selection) { + g, exists := s.Attr("data-to") + if exists { + parts := strings.Split(g, "/") + id, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + errs = append(errs, err) + return + } + goals = append(goals, id) + } + }) + + if len(errs) > 0 { + return nil, fmt.Errorf("Could not parse goal ids: %v", errs) + } + return &SiteSettings{ Domain: domain, Timezone: timezone, SharedLinks: sharedLinks, + Goals: goals, }, nil }