Skip to content
This repository has been archived by the owner on Feb 3, 2023. It is now read-only.

Commit

Permalink
Utilize v2 targetprocess API for GETs (#24)
Browse files Browse the repository at this point in the history
* Utilize v2 targetprocess API for GETs

Changes:
- BREAKING: removed Include() filter as it is not available in the v2
targetprocess API
- BREAKING: removed AssignedTeams from UserStory default struct (custom
structs can still include any fields a user would like)
- BREAKING: changed method signature of client.GetUserStories() to
include a boolean to determine if it should automatically page through
results
    - This was important for using the First() filter in this method. If
the method automatically paged with the First() filter function it would
return ALL results 1 UserStory at a time which is a lot of API calls and
woulud take a really long time.
- added ResponsibleTeam to the UserStory default struct
- added Select() and Result() filters
- added tests for each filter

Co-authored-by: Andrew Suderman <[email protected]>
  • Loading branch information
Luke Reed and Andrew Suderman authored Dec 4, 2020
1 parent c22c3c7 commit 218d0b7
Show file tree
Hide file tree
Showing 9 changed files with 573 additions and 104 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ all: lint test
lint:
golangci-lint run
test:
printf "\n\nTests:\n\n"
@printf "\nTests:\n"
$(GOCMD) test -v --bench --benchmem -coverprofile coverage.txt -covermode=atomic ./...
GO111MODULE=on $(GOCMD) vet ./... 2> govet-report.out
GO111MODULE=on $(GOCMD) tool cover -html=coverage.txt -o cover-report.html
Expand Down
76 changes: 49 additions & 27 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,22 @@ const (
userAgent = "go-targetprocess"
)

var (
defaultClient *http.Client
)

func init() {
defaultClient = http.DefaultClient
}

// Client is the API client for Targetprocess. Create this using NewClient.
// This can also be constructed manually but it isn't recommended.
type Client struct {
// BaseURL is the base URL for API requests.
BaseURL *url.URL
// baseURL is the base URL for v1 API requests.
baseURL *url.URL

// baseURLReadOnly is the base URL for v2 API requests.
baseURLReadOnly *url.URL

// Client is the HTTP client to use for communication.
Client *http.Client
Expand Down Expand Up @@ -70,29 +81,33 @@ type logger interface {
//
// token is your user access token taken from your account settings
// see here: https://dev.targetprocess.com/docs/authentication#token-authentication
func NewClient(account, token string) *Client {
c := http.DefaultClient
func NewClient(account, token string) (*Client, error) {
c := defaultClient
c.Timeout = 15 * time.Second
baseURLString := fmt.Sprintf("https://%s.tpondemand.com/api/v1/", account)
baseURLReadOnlyString := fmt.Sprintf("https://%s.tpondemand.com/api/v2/", account)
baseURL, err := url.Parse(baseURLString)
if err != nil {
panic(err)
return nil, err
}
return &Client{
BaseURL: baseURL,
Client: c,
Token: token,
UserAgent: userAgent,
ctx: context.Background(),
baseURLReadOnly, err := url.Parse(baseURLReadOnlyString)
if err != nil {
return nil, err
}
return &Client{
baseURL: baseURL,
baseURLReadOnly: baseURLReadOnly,
Client: c,
Token: token,
UserAgent: userAgent,
ctx: context.Background(),
}, nil
}

// WithContext takes a context.Context, sets it as context on the client and returns
// a Client pointer.
func (c *Client) WithContext(ctx context.Context) *Client {
newC := *c
newC.ctx = ctx
return &newC
func (c *Client) WithContext(ctx context.Context) {
c.ctx = ctx
}

// Get is a generic HTTP GET call to the targetprocess api passing in the type of entity and any query filters
Expand All @@ -101,7 +116,7 @@ func (c *Client) Get(out interface{}, entityType string, values url.Values, filt
if err != nil {
return errors.Wrapf(err, "Error parsing entity type: %s", entityType)
}
u := c.BaseURL.ResolveReference(rel)
u := c.baseURLReadOnly.ResolveReference(rel)

if values == nil {
values = url.Values{}
Expand All @@ -115,11 +130,11 @@ func (c *Client) Get(out interface{}, entityType string, values url.Values, filt
}
values = c.defaultParams(values)

c.debugLog("[targetprocess] GET %s%s?%s", c.BaseURL, entityType, values.Encode())
c.debugLog("[targetprocess] GET %s%s?%s", c.baseURLReadOnly, entityType, values.Encode())
fullURL := fmt.Sprintf("%s?%s", u.String(), values.Encode())
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return errors.Wrapf(err, "Invalid GET request: %s/%s", c.BaseURL, entityType)
return errors.Wrapf(err, "Invalid GET request: %s/%s", c.baseURLReadOnly, entityType)
}
return c.do(out, req, entityType)
}
Expand All @@ -130,13 +145,19 @@ func (c *Client) GetNext(out interface{}, nextURL string) error {
if err != nil {
return errors.Wrapf(err, "Invalid Next URL: %s", nextURL)
}

// The v1 and v2 API return differently formatted Next urls, so we need to be sure the Path ends with "/"
if !strings.HasSuffix(prevFull.Path, "/") {
prevFull.Path += "/"
}

splitPath := strings.Split(prevFull.EscapedPath(), "/")
entityType := splitPath[len(splitPath)-2]

entityURLType, err := url.Parse(entityType + "/")
if err != nil {
return errors.Wrapf(err, "Invalid Next URL Entity Type: %s", entityURLType)
}

return c.Get(out, entityType, prevFull.Query())
}

Expand All @@ -146,40 +167,41 @@ func (c *Client) Post(out interface{}, entityType string, values url.Values, bod
if err != nil {
return errors.Wrapf(err, "Error parsing entity type: %s", entityType)
}
u := c.BaseURL.ResolveReference(rel)
u := c.baseURL.ResolveReference(rel)

if values == nil {
values = url.Values{}
}
values = c.defaultParams(values)

c.debugLog("[targetprocess] POST %s/%s?%s", c.BaseURL, entityType, values.Encode())
c.debugLog("[targetprocess] POST %s/%s?%s", c.baseURL, entityType, values.Encode())
fullURL := fmt.Sprintf("%s?%s", u.String(), values.Encode())

req, err := http.NewRequest("POST", fullURL, bytes.NewBuffer(body))
if err != nil {
return errors.Wrapf(err, "Invalid POST request: %s/%s", c.BaseURL, entityType)
return errors.Wrapf(err, "Invalid POST request: %s/%s", c.baseURL, entityType)
}
return c.do(out, req, entityType)
}

func (c *Client) do(out interface{}, req *http.Request, urlPath string) error {
noParameterURL := fmt.Sprintf("%s://%s%s", req.URL.Scheme, req.URL.Host, req.URL.Path)

// Set the headers that will be required for every request
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
if c.UserAgent != "" {
req.Header.Add("User-Agent", c.UserAgent)
}

resp, err := c.Client.Do(req)
if err != nil {
return errors.Wrapf(err, "HTTP request failure on %s/%s", c.BaseURL, urlPath)
return errors.Wrapf(err, "HTTP request failure on %s", noParameterURL)
}

// Empty the body and close it to reuse the Transport
defer func() {
io.Copy(ioutil.Discard, resp.Body) // nolint:golint,errcheck
resp.Body.Close()
_, _ = io.Copy(ioutil.Discard, resp.Body)
_ = resp.Body.Close()
}()

if resp.StatusCode < 200 || resp.StatusCode > 299 {
Expand All @@ -200,7 +222,7 @@ func (c *Client) do(out interface{}, req *http.Request, urlPath string) error {

func (c *Client) defaultParams(v url.Values) url.Values {
if c.Token != "" {
v.Add("access_token", c.Token)
v.Add("accessToken", c.Token)
}
v.Set("format", "json")
v.Set("resultFormat", "json")
Expand Down
173 changes: 171 additions & 2 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,61 @@
package targetprocess

import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/stretchr/testify/assert"
)

const (
okResponse = `{
"items": [],
"next": "",
"prev": ""
}`
)

type genericResponse struct {
Items []string `json:"items"`
Next string `json:"next"`
Prev string `json:"prev"`
}

func newMockClient(handler http.Handler, account, token string) (*Client, func()) {
s := httptest.NewTLSServer(handler)

defaultClient = &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
return net.Dial(network, s.Listener.Addr().String())
},
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}

client, _ := NewClient(account, token)

return client, s.Close
}

// Example of client.Get() for godoc
func ExampleClient_Get() {
tpClient := NewClient("exampleaccount", "superSecretToken")
tpClient, err := NewClient("exampleaccount", "superSecretToken")
if err != nil {
fmt.Println("Failed to create tp client:", err)
os.Exit(1)
}
var response = UserResponse{}
err := tpClient.Get(response,
err = tpClient.Get(response,
"User",
nil)
if err != nil {
Expand All @@ -34,3 +79,127 @@ func ExampleClient_Get() {
jsonBytes, _ := json.Marshal(response)
fmt.Print(string(jsonBytes))
}

func TestWithContext(t *testing.T) {

type contextKey string
var testContextKey contextKey = "test"

tests := []struct {
name string
account string
accessToken string
contextKey contextKey
contextValue string
}{
{
name: "Add context",
account: "example",
accessToken: "abcd1234",
contextKey: testContextKey,
contextValue: "added",
},
}

for _, tt := range tests {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queryParams := r.URL.Query()
assert.Equal(t, tt.accessToken, queryParams.Get("accessToken"))
_, _ = w.Write([]byte(okResponse))
})
mockClient, teardown := newMockClient(h, tt.account, tt.accessToken)
mockClient.WithContext(context.WithValue(context.Background(), tt.contextKey, tt.contextValue))
resp := new(genericResponse)
err := mockClient.Get(resp, "Users", nil)
teardown()
if err != nil {
t.Logf("error sending get: %s", err)
t.Fail()
}
assert.Equal(t, tt.contextValue, mockClient.ctx.Value(tt.contextKey))
}
}

func TestGet(t *testing.T) {
tests := []struct {
name string
account string
accessToken string
entity string
path string
}{
{
name: "Users GET",
account: "example",
accessToken: "1234abcd",
entity: "Users",
},
}

for _, tt := range tests {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queryParams := r.URL.Query()
assert.Equal(t, "GET", r.Method)
assert.Equal(t, fmt.Sprintf("%s.tpondemand.com", tt.account), r.Host)
assert.Equal(t, fmt.Sprintf("/api/v2/%s/", tt.entity), r.URL.Path)
assert.Equal(t, tt.accessToken, queryParams.Get("accessToken"))
assert.Equal(t, "json", queryParams.Get("format"))
assert.Equal(t, "json", queryParams.Get("resultFormat"))
assert.Equal(t, "go-targetprocess", r.Header.Get("User-Agent"))
assert.Equal(t, "application/json", r.Header.Get("Accept"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
_, _ = w.Write([]byte(okResponse))
})
mockClient, teardown := newMockClient(h, tt.account, tt.accessToken)

resp := new(genericResponse)
err := mockClient.Get(resp, tt.entity, nil)
teardown()
if err != nil {
t.Logf("error sending get: %s", err)
t.Fail()
}
}
}

func TestPost(t *testing.T) {
tests := []struct {
name string
account string
accessToken string
entity string
path string
}{
{
name: "Users POST",
account: "example",
accessToken: "1234abcd",
entity: "UserStory",
},
}

for _, tt := range tests {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queryParams := r.URL.Query()
assert.Equal(t, "POST", r.Method)
assert.Equal(t, fmt.Sprintf("%s.tpondemand.com", tt.account), r.Host)
assert.Equal(t, fmt.Sprintf("/api/v1/%s/", tt.entity), r.URL.Path)
assert.Equal(t, tt.accessToken, queryParams.Get("accessToken"))
assert.Equal(t, "json", queryParams.Get("format"))
assert.Equal(t, "json", queryParams.Get("resultFormat"))
assert.Equal(t, "go-targetprocess", r.Header.Get("User-Agent"))
assert.Equal(t, "application/json", r.Header.Get("Accept"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
_, _ = w.Write([]byte(okResponse))
})
mockClient, teardown := newMockClient(h, tt.account, tt.accessToken)

resp := new(genericResponse)
err := mockClient.Post(resp, tt.entity, nil, nil)
teardown()
if err != nil {
t.Logf("error sending get: %s", err)
t.Fail()
}
}
}
Loading

0 comments on commit 218d0b7

Please sign in to comment.