diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..371ab05 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 0.1.0 +This is the first release. It contains full support for Dead Man's Snitch API. See the [godocs](https://godoc.org/github.com/prometheus/client_golang) for details. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ee07ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Premiere Global Services, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c3aa73 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# go-deadmanssnitch [![GoDoc](https://godoc.org/github.com/PremiereGlobal/go-deadmanssnitch?status.png)](http://godoc.org/github.com/PremiereGlobal/go-deadmanssnitch) [![Go Report Card](https://goreportcard.com/badge/github.com/PremiereGlobal/go-deadmanssnitch)](https://goreportcard.com/report/github.com/PremiereGlobal/go-deadmanssnitch) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/PremiereGlobal/go-deadmanssnitch/blob/master/LICENSE) +go-deadmanssnitch is a [Go](http://golang.org/) client library for the [Dead Man's Snitch](https://deadmanssnitch.com/docs/api/v1) API. + +## Installation +Make sure you have a working Go environment. To install, simply run: + +``` +go get github.com/PremiereGlobal/go-deadmanssnitch +``` + +## Usage + +``` +package main + +import ( + "github.com/PremiereGlobal/go-deadmansnitch" +) + +var apiKey = "" // Set your api key here + +func main() { + + client := deadmanssnitch.NewClient(apiKey) + ... +} +``` + +For more information, read the [godoc package documentation](http://godoc.org/github.com/PremiereGlobal/go-deadmanssnitch). + +## Examples + +### Check-In + +``` + var snitchToken = "" // Set your snitch token here + _, err := client.CheckIn(snitchToken) + if err != nil { + panic(err) + } +``` + +### List All Snitches + +``` +snitches, err := client.ListSnitches([]string{}) +if err != nil { + panic(err) +} + +``` + +### Create Snitch + +``` +snitch := deadmanssnitch.Snitch { + Name: "testSnitch", + Interval: "hourly", + AlertType: "basic", + Tags: []string{"test"}, + Notes: "This is an example snitch", +} + +snitches, err := client.CreateSnitch(snitch) +if err != nil { + panic(err) +} + +``` + +## Testing the Client Library +Tests will validate API calls by creating a test snitch, checking in and updating the snitch using all of the methods. It will then delete the snitch after waiting `wait` seconds (to allow for manual verification). + +``` +go test -v --args -apikey= -wait 30 +``` diff --git a/client.go b/client.go new file mode 100644 index 0000000..bad6a29 --- /dev/null +++ b/client.go @@ -0,0 +1,29 @@ +package deadmanssnitch + +import ( + "fmt" + "net/http" +) + +const ( + apiEndpoint = "https://api.deadmanssnitch.com" + apiVersion = "v1" +) + +// Client is the Dead Man's Snitch API client +type Client struct { + httpClient *http.Client + apiBaseURL string + apiKey string +} + +// NewClient creates a new API client +func NewClient(apiKey string) (*Client, error) { + client := &Client{ + httpClient: &http.Client{}, + apiBaseURL: fmt.Sprintf("%s/%s", apiEndpoint, apiVersion), + apiKey: apiKey, + } + + return client, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0345b85 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/PremiereGlobal/go-deadmanssnitch + +go 1.12 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/request.go b/request.go new file mode 100644 index 0000000..52eba77 --- /dev/null +++ b/request.go @@ -0,0 +1,72 @@ +package deadmanssnitch + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +// ErrorResponse represents the structure of a API error +type ErrorResponse struct { + ErrorType string `json:"type"` + ErrorString string `json:"error"` + Validations []Validation `json:"validations"` +} + +// Validation contains the detials of a API field validation error +type Validation struct { + Attribute string `json:"attribute"` + Message string `json:"message"` +} + +func (c *Client) do(method string, path string, body []byte) ([]byte, error) { + request, err := http.NewRequest(method, fmt.Sprintf("%s/%s", c.apiBaseURL, path), bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth(c.apiKey, "") + + resp, err := c.httpClient.Do(request) + return c.checkResponse(resp, err) +} + +func (c *Client) checkResponse(response *http.Response, err error) ([]byte, error) { + if err != nil { + return nil, fmt.Errorf("Error calling the API endpoint: %v", err) + } + + defer response.Body.Close() + + bodyBytes, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("Error reading the API response: %v", err) + } + + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent { + errorResponse := ErrorResponse{} + err = json.Unmarshal(bodyBytes, &errorResponse) + if err != nil { + return nil, err + } + + errorString := "" + + if response.StatusCode == http.StatusUnprocessableEntity { + // Fields invalid + for _, v := range errorResponse.Validations { + errorString = errorString + fmt.Sprintf("%s: %s, ", v.Attribute, v.Message) + } + } else { + // Generic error + errorString = fmt.Sprintf("%s: %s", errorResponse.ErrorType, errorResponse.ErrorString) + } + + return nil, fmt.Errorf("Error requesting %s %s, HTTP %d. %s", response.Request.Method, response.Request.URL, response.StatusCode, errorString) + } + + return bodyBytes, nil +} diff --git a/snitch.go b/snitch.go new file mode 100644 index 0000000..b617c3d --- /dev/null +++ b/snitch.go @@ -0,0 +1,199 @@ +package deadmanssnitch + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" +) + +// Snitch represents the details of a snitch +type Snitch struct { + + // The snitch's identifying token. + Token string `json:"token,omitempty"` + + // API URL to retrieve data about this specific Snitch. + Href string `json:"href,omitempty"` + + // The name of the snitch. + Name string `json:"name,omitempty"` + + // The list of keyword tags for this snitch. + Tags []string `json:"tags,omitempty"` + + // The status of the snitch. It could be: + // "pending" The snitch is new and your job has not yet checked in. + // "healthy" Your job has checked in since the beginning of the last period. + // "failed" Your job has not checked in since the beginning of the last period. (At least one alert has been sent.) + // "errored" Your job has reported that is has errored. (At least one alert has been sent.) Error Notices are only available on some plans. + // "paused" The snitch has been paused and will not worry about your failing job until your job checks-in again after you fix it. + Status string `json:"status,omitempty"` + + // Any user-supplied notes about this snitch. + Notes string `json:"notes,omitempty"` + + // The last time your job checked in healthy, as an ISO 8601 datetime with millisecond precision. The timezone is always UTC. If your job has not checked in healthy yet, this will be null. + CheckedInAt string `json:"checked_in_at,omitempty"` + + // The url your job should hit to check-in. + CheckInURL string `json:"check_in_url,omitempty"` + + // The size of the period window. If your job does not check-in during an entire period, you will be notified and the snitch status will show up as "failed". The interval can be "15_minute", "30_minute", "hourly", "daily", "weekly", or "monthly". + Interval string `json:"interval,omitempty"` + + // The type of alerts the snitch will use. basic will have a static deadline that it will expect to hear from it by, while smart will learn when your snitch checks in, moving the deadline closer so you can be alerted sooner. + AlertType string `json:"alert_type,omitempty"` + + // When the snitch was created, as an ISO 8601 datetime with millisecond precision. The timezone is always UTC. + CreatedAt string `json:"created_at,omitempty"` +} + +// ListSnitches returns a list of snitches with the provided `filters`. An empty filter will result in all snitches. +func (c *Client) ListSnitches(filters []string) (*[]Snitch, error) { + snitchList := []Snitch{} + + tagList := strings.Join(filters, ",") + + body, err := c.do("GET", fmt.Sprintf("snitches?tags=%s", tagList), nil) + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &snitchList) + if err != nil { + return nil, err + } + + return &snitchList, nil +} + +// CheckIn calls the check-in url for the snitch +func (c *Client) CheckIn(token string) error { + CheckInURL := fmt.Sprintf("https://nosnch.in/%s", token) + + resp, err := http.Get(CheckInURL) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusAccepted { + return nil + } + + return fmt.Errorf("Error checking in GET %s, HTTP %d. %s", CheckInURL, resp.StatusCode, body) +} + +// GetSnitch returns a single snitch +func (c *Client) GetSnitch(token string) (*Snitch, error) { + + snitch := Snitch{} + + body, err := c.do("GET", fmt.Sprintf("snitches/%s", token), nil) + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &snitch) + if err != nil { + return nil, err + } + + return &snitch, nil +} + +// CreateSnitch creates a new snitch +func (c *Client) CreateSnitch(snitch *Snitch) (*Snitch, error) { + snitchData, err := json.Marshal(snitch) + if err != nil { + return nil, err + } + + body, err := c.do("POST", "snitches", snitchData) + if err != nil { + return nil, err + } + + newSnitch := Snitch{} + err = json.Unmarshal(body, &newSnitch) + if err != nil { + return nil, err + } + + return &newSnitch, nil +} + +// UpdateSnitch updates the snitch identified by `token` +// The `updatedSnitch` parameter accepts a Snitch object in which you may +// provide only the attributes you wish to change. Empty fields +// in the object will not be touched. +func (c *Client) UpdateSnitch(token string, updatedSnitch *Snitch) (*Snitch, error) { + snitchData, err := json.Marshal(updatedSnitch) + if err != nil { + return nil, err + } + + body, err := c.do("PATCH", fmt.Sprintf("snitches/%s", token), snitchData) + if err != nil { + return nil, err + } + + newSnitch := Snitch{} + err = json.Unmarshal(body, &newSnitch) + if err != nil { + return nil, err + } + + return &newSnitch, nil +} + +// AddTags adds the given tags to the snitch, leaving existing tags unchanged +func (c *Client) AddTags(token string, newTags []string) error { + newTagData, err := json.Marshal(newTags) + if err != nil { + return err + } + + _, err = c.do("POST", fmt.Sprintf("snitches/%s/tags", token), newTagData) + if err != nil { + return err + } + + return nil +} + +// RemoveTags removes the given tags from the snitch +func (c *Client) RemoveTags(token string, rmTags []string) error { + for _, tag := range rmTags { + _, err := c.do("DELETE", fmt.Sprintf("snitches/%s/tags/%s", token, tag), nil) + if err != nil { + return err + } + } + + return nil +} + +// PauseSnitch pauses a snitch +func (c *Client) PauseSnitch(token string) error { + + _, err := c.do("POST", fmt.Sprintf("snitches/%s/pause", token), nil) + if err != nil { + return err + } + + return nil +} + +// DeleteSnitch deletes a snitch +func (c *Client) DeleteSnitch(token string) error { + _, err := c.do("DELETE", fmt.Sprintf("snitches/%s", token), nil) + if err != nil { + return err + } + + return nil +} diff --git a/snitch_test.go b/snitch_test.go new file mode 100644 index 0000000..67b458b --- /dev/null +++ b/snitch_test.go @@ -0,0 +1,228 @@ +package deadmanssnitch_test + +import ( + "flag" + "math/rand" + "testing" + "time" + + "github.com/PremiereGlobal/go-deadmanssnitch" +) + +var apiKey string +var wait int +var randomTag string +var dmsClient *deadmanssnitch.Client +var snitch deadmanssnitch.Snitch +var newSnitch *deadmanssnitch.Snitch +var updatedSnitch deadmanssnitch.Snitch +var updatedTags []string + +func init() { + var err error + + flag.StringVar(&apiKey, "apikey", "", "Dead Man's Snitch API key") + flag.IntVar(&wait, "wait", 0, "Number of seconds to sleep before deleting snitch (so it can be manually verified)") + flag.Parse() + + dmsClient, err = deadmanssnitch.NewClient(apiKey) + if err != nil { + panic(err) + } + + rand.Seed(time.Now().UTC().UnixNano()) + randomTag = RandomString(10) + + snitch = deadmanssnitch.Snitch{ + Name: "testSnitch", + Interval: "hourly", + AlertType: "basic", + Tags: []string{"test", randomTag}, + Notes: "This is a snitch created by github.com/PremiereGlobal/go-deadmanssnitch as a test", + } + + updatedSnitch = deadmanssnitch.Snitch{ + Name: "testSnitchUpdated", + Interval: "daily", + AlertType: "basic", + Tags: []string{"testUpdated", randomTag}, + Notes: "This is a snitch created by github.com/PremiereGlobal/go-deadmanssnitch as a test, and now it's updated", + } + + updatedTags = []string{"newtag1", "newtag2", "newtag3"} +} + +// Create a new snitch +func TestCreateSnitch(t *testing.T) { + var err error + newSnitch, err = dmsClient.CreateSnitch(&snitch) + if err != nil { + t.Error(err) + } +} + +// Check in on the snitch +func TestCheckIn(t *testing.T) { + err := dmsClient.CheckIn(newSnitch.Token) + if err != nil { + t.Error(err) + } +} + +// List all of the snitches +func TestListSnitchesAll(t *testing.T) { + snitches, err := dmsClient.ListSnitches([]string{}) + if err != nil { + t.Error(err) + } + + // Ensure there is at least 1 snitch (ours) and that ours is in the list + if len(*snitches) <= 0 { + t.Error("List all snitches - failed to find any snitches") + } + for _, v := range *snitches { + if v.Token == newSnitch.Token { + return + } + } + + t.Error("List all snitches - couldn't find our snitch") +} + +// List just the snitches with our tag +func TestListSnitchesFiltered(t *testing.T) { + snitches, err := dmsClient.ListSnitches([]string{"test", randomTag}) + if err != nil { + t.Error(err) + } + + // Ensure there is at least 1 snitch (ours) and that ours is in the list + if len(*snitches) != 1 { + t.Error("List filtered snitches - got more than 1 back") + } + for _, v := range *snitches { + if v.Token == newSnitch.Token { + return + } + } + + t.Error("List filtered snitches - couldn't find our snitch") +} + +// Get the snitch we created and verify the fields +func TestGetSnitch(t *testing.T) { + gottenSnitch, err := dmsClient.GetSnitch(newSnitch.Token) + if err != nil { + t.Error(err) + } + + // Verify all the fields match what we initially created + if gottenSnitch.Name != snitch.Name || + gottenSnitch.Interval != snitch.Interval || + gottenSnitch.AlertType != snitch.AlertType || + gottenSnitch.Notes != snitch.Notes || + !slicesEqual(gottenSnitch.Tags, snitch.Tags) { + t.Error("Get Snitch did not match created Snitch") + } +} + +// Update the snitch we created +func TestUpdateSnitch(t *testing.T) { + _, err := dmsClient.UpdateSnitch(newSnitch.Token, &updatedSnitch) + if err != nil { + t.Error(err) + } +} + +// Add tags to the snitch we created +func TestAddTags(t *testing.T) { + err := dmsClient.AddTags(newSnitch.Token, updatedTags) + if err != nil { + t.Error(err) + } +} + +// Remove tags on the snitch we created +func TestRemoveTags(t *testing.T) { + // Remove last two tags that we added + err := dmsClient.RemoveTags(newSnitch.Token, updatedTags[len(updatedTags)-2:]) + if err != nil { + t.Error(err) + } +} + +// Pause the snitch we created +func TestPauseSnitch(t *testing.T) { + + // Hol up + // Seems you can't pause a snitch right away + time.Sleep(time.Second * 3) + + err := dmsClient.PauseSnitch(newSnitch.Token) + if err != nil { + t.Error(err) + } +} + +// One last check to ensure that our snitch has updated correctly and that it is paused +func TestVerifyUpdatedSnitch(t *testing.T) { + gottenSnitch, err := dmsClient.GetSnitch(newSnitch.Token) + if err != nil { + t.Error(err) + } + + // Verify all the fields match what we initially created + expectedTags := append(updatedSnitch.Tags, updatedTags[:len(updatedTags)-2]...) + if gottenSnitch.Name != updatedSnitch.Name || + gottenSnitch.Interval != updatedSnitch.Interval || + gottenSnitch.AlertType != updatedSnitch.AlertType || + gottenSnitch.Notes != updatedSnitch.Notes || + !slicesEqual(gottenSnitch.Tags, expectedTags) { + t.Error("Updated Snitch did not match expected values") + } + + // Ensure it is paused + if gottenSnitch.Status != "paused" { + t.Errorf("Updated Snitch is not in the paused state. Actual state: %s", gottenSnitch.Status) + } +} + +// Delete the snitch we created +func TestDeleteSnitch(t *testing.T) { + + // Wait, if set + time.Sleep(time.Second * time.Duration(wait)) + + err := dmsClient.DeleteSnitch(newSnitch.Token) + if err != nil { + t.Error(err) + } + +} + +func slicesEqual(a, b []string) bool { + + // If one is nil, the other must also be nil. + if (a == nil) != (b == nil) { + return false + } + + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func RandomString(len int) string { + bytes := make([]byte, len) + for i := 0; i < len; i++ { + bytes[i] = byte(65 + rand.Intn(25)) //A=65 and Z = 65+25 + } + return string(bytes) +}