diff --git a/README.md b/README.md index 4d719df..efd6d14 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # go-tonicpow -**go-tonicpow** is the official golang implementation for interacting with the TonicPow API +**go-tonicpow** is the official golang implementation for interacting with the [TonicPow API](https://docs.tonicpow.com) [![Build Status](https://travis-ci.com/tonicpow/go-tonicpow.svg?branch=master)](https://travis-ci.com/tonicpow/go-tonicpow) [![Report](https://goreportcard.com/badge/github.com/tonicpow/go-tonicpow?style=flat)](https://goreportcard.com/report/github.com/tonicpow/go-tonicpow) @@ -29,14 +29,21 @@ $ go get -u github.com/tonicpow/go-tonicpow You can view the generated [documentation here](https://godoc.org/github.com/tonicpow/go-tonicpow). ### Features -- Complete coverage for the [TonicPow.com](https://tonicpow.com/) API -- Client is completely configurable -- Customize API Key and User Agent per request +- [Client](client.go) is completely configurable - Using [heimdall http client](https://github.com/gojek/heimdall) with exponential backoff & more +- Coverage for the [TonicPow.com API](https://docs.tonicpow.com/) + - [x] Authentication + - [x] Users + - [x] Advertiser Profiles + - [x] Campaigns + - [x] Goals + - [x] Links ## Examples & Tests All unit tests and [examples](tonicpow_test.go) run via [Travis CI](https://travis-ci.org/tonicpow/go-tonicpow) and uses [Go version 1.13.x](https://golang.org/doc/go1.13). View the [deployment configuration file](.travis.yml). +View a [full example application](examples/examples.go). + Run all tests (including integration tests) ```bash $ cd ../go-tonicpow @@ -67,12 +74,13 @@ Basic implementation: package main import ( + "os" "github.com/tonicpow/go-tonicpow" ) func main() { - client, _ := NewClient(privateGUID) - resp, _ = client.ConvertGoal("signup-goal", "f773c231ee9.....", 0, "") + api, _ := tonicpow.NewClient(os.Getenv("TONICPOW_API_KEY"), tonicpow.LiveEnvironment, nil) + _ = api.ConvertGoal("new-lead-goal", "s358wef983283...", "", "") } ``` diff --git a/advertiser_profiles.go b/advertiser_profiles.go new file mode 100644 index 0000000..5661048 --- /dev/null +++ b/advertiser_profiles.go @@ -0,0 +1,103 @@ +package tonicpow + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// permitFields will remove fields that cannot be used +func (a *AdvertiserProfile) permitFields() { + a.UserID = 0 +} + +// CreateAdvertiserProfile will make a new advertiser profile +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#153c0b65-2d4c-4972-9aab-f791db05b37b +func (c *Client) CreateAdvertiserProfile(profile *AdvertiserProfile, userSessionToken string) (createdProfile *AdvertiserProfile, err error) { + + // Basic requirements + if profile.UserID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldUserID) + return + } + + // Fire the request + var response string + if response, err = c.request(modelAdvertiser, http.MethodPost, profile, userSessionToken); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + createdProfile = new(AdvertiserProfile) + err = json.Unmarshal([]byte(response), createdProfile) + return +} + +// GetAdvertiserProfile will get an existing advertiser profile +// This will return an error if the profile is not found (404) +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#b3a62d35-7778-4314-9321-01f5266c3b51 +func (c *Client) GetAdvertiserProfile(profileID uint64, userSessionToken string) (profile *AdvertiserProfile, err error) { + + // Must have an id + if profileID == 0 { + err = fmt.Errorf("missing field: %s", fieldID) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/details/%d", modelAdvertiser, profileID), http.MethodGet, nil, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + profile = new(AdvertiserProfile) + err = json.Unmarshal([]byte(response), profile) + return +} + +// UpdateAdvertiserProfile will update an existing profile +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#0cebd1ff-b1ce-4111-aff6-9d586f632a84 +func (c *Client) UpdateAdvertiserProfile(profile *AdvertiserProfile, userSessionToken string) (updatedProfile *AdvertiserProfile, err error) { + + // Basic requirements + if profile.ID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldID) + return + } + + // Permit fields + profile.permitFields() + + // Fire the request + var response string + if response, err = c.request(modelAdvertiser, http.MethodPut, profile, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + updatedProfile = new(AdvertiserProfile) + err = json.Unmarshal([]byte(response), updatedProfile) + return +} diff --git a/authentication.go b/authentication.go new file mode 100644 index 0000000..81429af --- /dev/null +++ b/authentication.go @@ -0,0 +1,59 @@ +package tonicpow + +import ( + "net/http" +) + +// createSession will establish a new session with the api +// This is run in the NewClient() method +// +// For more information: https://docs.tonicpow.com/#632ed94a-3afd-4323-af91-bdf307a399d2 +func (c *Client) createSession() (err error) { + + // Start the post data with api key + data := map[string]string{fieldApiKey: c.Parameters.apiKey} + + // Fire the request + var response string + if response, err = c.request("auth/session", http.MethodPost, data, ""); err != nil { + return + } + + // Only a 201 is treated as a success + err = c.error(http.StatusCreated, response) + return +} + +// ProlongSession will a session alive based on the forUser (user vs api session) +// Use customSessionToken for any token, user token, if empty it will use current api session token +// +// For more information: https://docs.tonicpow.com/#632ed94a-3afd-4323-af91-bdf307a399d2 +func (c *Client) ProlongSession(customSessionToken string) (err error) { + + // Fire the request + var response string + if response, err = c.request("auth/session", http.MethodGet, nil, customSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +} + +// EndSession will end a session based on the forUser (user vs api session) +// Use customSessionToken for any token, user token, if empty it will use current api session token +// +// For more information: https://docs.tonicpow.com/#632ed94a-3afd-4323-af91-bdf307a399d2 +func (c *Client) EndSession(customSessionToken string) (err error) { + + // Fire the request + var response string + if response, err = c.request("auth/session", http.MethodDelete, nil, customSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +} diff --git a/campaigns.go b/campaigns.go new file mode 100644 index 0000000..28788fd --- /dev/null +++ b/campaigns.go @@ -0,0 +1,129 @@ +package tonicpow + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// permitFields will remove fields that cannot be used +func (c *Campaign) permitFields() { + c.AdvertiserProfileID = 0 + c.Balance = 0 + c.BalanceSatoshis = 0 + c.FundingAddress = "" + c.PublicGUID = "" +} + +// CreateCampaign will make a new campaign +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#b67e92bf-a481-44f6-a31d-26e6e0c521b1 +func (c *Client) CreateCampaign(campaign *Campaign, userSessionToken string) (createdCampaign *Campaign, err error) { + + // Basic requirements + if campaign.AdvertiserProfileID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldAdvertiserProfileID) + return + } + + // Fire the request + var response string + if response, err = c.request(modelCampaign, http.MethodPost, campaign, userSessionToken); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + createdCampaign = new(Campaign) + err = json.Unmarshal([]byte(response), createdCampaign) + return +} + +// GetCampaign will get an existing campaign +// This will return an error if the campaign is not found (404) +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#b827446b-be34-4678-b347-33c4f63dbf9e +func (c *Client) GetCampaign(campaignID uint64, userSessionToken string) (campaign *Campaign, err error) { + + // Must have an id + if campaignID == 0 { + err = fmt.Errorf("missing field: %s", fieldID) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/details/%d", modelCampaign, campaignID), http.MethodGet, nil, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + campaign = new(Campaign) + err = json.Unmarshal([]byte(response), campaign) + return +} + +// GetCampaignBalance will update the models's balance from the chain +// +// For more information: https://docs.tonicpow.com/#b6c60c63-8ac5-4c74-a4a2-cf3e858e5a8d +func (c *Client) GetCampaignBalance(campaignID uint64) (campaign *Campaign, err error) { + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/balance/%d", modelCampaign, campaignID), http.MethodGet, nil, ""); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + campaign = new(Campaign) + err = json.Unmarshal([]byte(response), campaign) + return +} + +// UpdateCampaign will update an existing campaign +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#665eefd6-da42-4ca9-853c-fd8ca1bf66b2 +func (c *Client) UpdateCampaign(campaign *Campaign, userSessionToken string) (updatedCampaign *Campaign, err error) { + + // Basic requirements + if campaign.ID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldID) + return + } + + // Permit fields + campaign.permitFields() + + // Fire the request + var response string + if response, err = c.request(modelCampaign, http.MethodPut, campaign, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + updatedCampaign = new(Campaign) + err = json.Unmarshal([]byte(response), updatedCampaign) + return +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..33635fb --- /dev/null +++ b/client.go @@ -0,0 +1,248 @@ +package tonicpow + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "time" + + "github.com/gojek/heimdall" + "github.com/gojek/heimdall/httpclient" +) + +// Client is the parent struct that wraps the heimdall client +type Client struct { + httpClient heimdall.Client // carries out the POST operations + LastRequest *LastRequest // is the raw information from the last request + Parameters *Parameters // contains application specific values +} + +// Options holds all the configuration for connection, dialer and transport +type Options struct { + BackOffExponentFactor float64 `json:"back_off_exponent_factor"` + BackOffInitialTimeout time.Duration `json:"back_off_initial_timeout"` + BackOffMaximumJitterInterval time.Duration `json:"back_off_maximum_jitter_interval"` + BackOffMaxTimeout time.Duration `json:"back_off_max_timeout"` + DialerKeepAlive time.Duration `json:"dialer_keep_alive"` + DialerTimeout time.Duration `json:"dialer_timeout"` + RequestRetryCount int `json:"request_retry_count"` + RequestTimeout time.Duration `json:"request_timeout"` + TransportExpectContinueTimeout time.Duration `json:"transport_expect_continue_timeout"` + TransportIdleTimeout time.Duration `json:"transport_idle_timeout"` + TransportMaxIdleConnections int `json:"transport_max_idle_connections"` + TransportTLSHandshakeTimeout time.Duration `json:"transport_tls_handshake_timeout"` + UserAgent string `json:"user_agent"` +} + +// LastRequest is used to track what was submitted via the Request() +type LastRequest struct { + Error *Error `json:"error"` // error is the last error response from the api + Method string `json:"method"` // method is the HTTP method used + PostData string `json:"post_data"` // postData is the post data submitted if POST/PUT request + StatusCode int `json:"status_code"` // statusCode is the last code from the request + URL string `json:"url"` // url is the url used for the request +} + +// Parameters are application specific values for requests +type Parameters struct { + apiKey string // is the given api key for the user + apiSessionCookie *http.Cookie // is the current session cookie for the api key + environment APIEnvironment // is the current api environment to use + UserAgent string // (optional for changing user agents) + UserSessionCookie *http.Cookie // is the current session cookie for a user (on behalf) +} + +// ClientDefaultOptions will return an Options struct with the default settings +// Useful for starting with the default and then modifying as needed +func ClientDefaultOptions() (clientOptions *Options) { + return &Options{ + BackOffExponentFactor: 2.0, + BackOffInitialTimeout: 2 * time.Millisecond, + BackOffMaximumJitterInterval: 2 * time.Millisecond, + BackOffMaxTimeout: 10 * time.Millisecond, + DialerKeepAlive: 20 * time.Second, + DialerTimeout: 5 * time.Second, + RequestRetryCount: 2, + RequestTimeout: 10 * time.Second, + TransportExpectContinueTimeout: 3 * time.Second, + TransportIdleTimeout: 20 * time.Second, + TransportMaxIdleConnections: 10, + TransportTLSHandshakeTimeout: 5 * time.Second, + UserAgent: defaultUserAgent, + } +} + +// createClient will make a new http client based on the options provided +func createClient(options *Options) (c *Client) { + + // Create a client + c = new(Client) + + // Set options (either default or user modified) + if options == nil { + options = ClientDefaultOptions() + } + + // dial is the net dialer for clientDefaultTransport + dial := &net.Dialer{KeepAlive: options.DialerKeepAlive, Timeout: options.DialerTimeout} + + // clientDefaultTransport is the default transport struct for the HTTP client + clientDefaultTransport := &http.Transport{ + DialContext: dial.DialContext, + ExpectContinueTimeout: options.TransportExpectContinueTimeout, + IdleConnTimeout: options.TransportIdleTimeout, + MaxIdleConns: options.TransportMaxIdleConnections, + Proxy: http.ProxyFromEnvironment, + TLSHandshakeTimeout: options.TransportTLSHandshakeTimeout, + } + + // Determine the strategy for the http client (no retry enabled) + if options.RequestRetryCount <= 0 { + c.httpClient = httpclient.NewClient( + httpclient.WithHTTPTimeout(options.RequestTimeout), + httpclient.WithHTTPClient(&http.Client{ + Transport: clientDefaultTransport, + Timeout: options.RequestTimeout, + }), + ) + } else { // Retry enabled + // Create exponential back-off + backOff := heimdall.NewExponentialBackoff( + options.BackOffInitialTimeout, + options.BackOffMaxTimeout, + options.BackOffExponentFactor, + options.BackOffMaximumJitterInterval, + ) + + c.httpClient = httpclient.NewClient( + httpclient.WithHTTPTimeout(options.RequestTimeout), + httpclient.WithRetrier(heimdall.NewRetrier(backOff)), + httpclient.WithRetryCount(options.RequestRetryCount), + httpclient.WithHTTPClient(&http.Client{ + Transport: clientDefaultTransport, + Timeout: options.RequestTimeout, + }), + ) + } + + // Create a last request and parameters struct + c.LastRequest = new(LastRequest) + c.LastRequest.Error = new(Error) + c.Parameters = &Parameters{ + UserAgent: options.UserAgent, + } + return +} + +// request is a generic wrapper for all api requests +func (c *Client) request(endpoint string, method string, payload interface{}, customSessionToken string) (response string, err error) { + + // Set post value + var jsonValue []byte + + // Add the network value + endpoint = fmt.Sprintf("%s%s", c.Parameters.environment, endpoint) + + // Switch on methods + switch method { + case http.MethodPost, http.MethodPut: + { + if jsonValue, err = json.Marshal(payload); err != nil { + return + } + } + case http.MethodGet: + { + if payload != nil { + params := payload.(url.Values) + endpoint += "?" + params.Encode() + } + } + } + + // Store for debugging purposes + c.LastRequest.Method = method + c.LastRequest.PostData = string(jsonValue) + c.LastRequest.URL = endpoint + + // Start the request + var request *http.Request + if request, err = http.NewRequest(method, endpoint, bytes.NewBuffer(jsonValue)); err != nil { + return + } + + // Change the user agent + request.Header.Set("User-Agent", c.Parameters.UserAgent) + + // Set the content type + if method == http.MethodPost || method == http.MethodPut { + request.Header.Set("Content-Type", "application/json") + } + + // Custom token, used for user related requests + if len(customSessionToken) > 0 { + request.AddCookie(&http.Cookie{ + Name: sessionCookie, + Value: customSessionToken, + MaxAge: 60 * 60 * 24, + HttpOnly: true, + }) + } else if c.Parameters.apiSessionCookie != nil { + request.AddCookie(c.Parameters.apiSessionCookie) + } + + // Fire the http request + var resp *http.Response + if resp, err = c.httpClient.Do(request); err != nil { + return + } + + // Close the response body + defer func() { + _ = resp.Body.Close() + }() + + // Save the status + c.LastRequest.StatusCode = resp.StatusCode + + // Read the body + var body []byte + if body, err = ioutil.ReadAll(resp.Body); err != nil { + return + } + + // Got a session token? Set the session token for the api user or user via on behalf + for _, cookie := range resp.Cookies() { + if cookie.Name == sessionCookie { + if cookie.MaxAge <= 0 { + cookie = nil + } + if len(customSessionToken) > 0 { + c.Parameters.UserSessionCookie = cookie + } else { + c.Parameters.apiSessionCookie = cookie + } + break + } + } + + // Parse the response + response = string(body) + return +} + +// error will handle all basic error cases +func (c *Client) error(expectedStatusCode int, response string) (err error) { + if c.LastRequest.StatusCode != expectedStatusCode { + c.LastRequest.Error = new(Error) + if err = json.Unmarshal([]byte(response), c.LastRequest.Error); err != nil { + return + } + err = fmt.Errorf("%s", c.LastRequest.Error.Message) + } + return +} diff --git a/config.go b/config.go deleted file mode 100644 index e11fb06..0000000 --- a/config.go +++ /dev/null @@ -1,80 +0,0 @@ -package tonicpow - -import ( - "net" - "net/http" - "time" -) - -// APIEnvironment is used internally to represent the possible values -type APIEnvironment string - -// Package global constants and configuration -const ( - // LiveEnvironment is where we POST queries to (live) - LiveEnvironment APIEnvironment = "https://api.tonicpow.com/" - - // TestEnvironment is where we POST queries to (testing) - //TestEnvironment APIEnvironment = "https://test.tonicpow.com/" - - // LocalEnvironment is where we POST queries to (local) - LocalEnvironment APIEnvironment = "http://localhost:3000/" - - // ConnectionExponentFactor backoff exponent factor - ConnectionExponentFactor float64 = 2.0 - - // ConnectionInitialTimeout initial timeout - ConnectionInitialTimeout = 2 * time.Millisecond - - // ConnectionMaximumJitterInterval jitter interval - ConnectionMaximumJitterInterval = 2 * time.Millisecond - - // ConnectionMaxTimeout max timeout - ConnectionMaxTimeout = 10 * time.Millisecond - - // ConnectionRetryCount retry count - ConnectionRetryCount int = 2 - - // ConnectionWithHTTPTimeout with http timeout - ConnectionWithHTTPTimeout = 10 * time.Second - - // ConnectionTLSHandshakeTimeout tls handshake timeout - ConnectionTLSHandshakeTimeout = 5 * time.Second - - // ConnectionMaxIdleConnections max idle http connections - ConnectionMaxIdleConnections int = 10 - - // ConnectionIdleTimeout idle connection timeout - ConnectionIdleTimeout = 20 * time.Second - - // ConnectionExpectContinueTimeout expect continue timeout - ConnectionExpectContinueTimeout = 3 * time.Second - - // ConnectionDialerTimeout dialer timeout - ConnectionDialerTimeout = 5 * time.Second - - // ConnectionDialerKeepAlive keep alive - ConnectionDialerKeepAlive = 20 * time.Second - - // DefaultUserAgent is the default user agent for all requests - DefaultUserAgent string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.80 Safari/537.36" -) - -// HTTP and Dialer connection variables -var ( - // _Dialer net dialer for ClientDefaultTransport - _Dialer = &net.Dialer{ - KeepAlive: ConnectionDialerKeepAlive, - Timeout: ConnectionDialerTimeout, - } - - // ClientDefaultTransport is the default transport struct for the HTTP client - ClientDefaultTransport = &http.Transport{ - DialContext: _Dialer.DialContext, - ExpectContinueTimeout: ConnectionExpectContinueTimeout, - IdleConnTimeout: ConnectionIdleTimeout, - MaxIdleConns: ConnectionMaxIdleConnections, - Proxy: http.ProxyFromEnvironment, - TLSHandshakeTimeout: ConnectionTLSHandshakeTimeout, - } -) diff --git a/definitions.go b/definitions.go index a99b69a..a6b8257 100644 --- a/definitions.go +++ b/definitions.go @@ -1,18 +1,59 @@ package tonicpow -// ConversionResponse is the structure response from a conversion -type ConversionResponse struct { - Error - AdditionalData string `json:"additional_data"` - ConversionGoalID uint64 `json:"conversion_goal_id"` - ConversionGoalName string `json:"conversion_goal_name"` - ID uint64 `json:"id"` - UserID string `json:"user_id"` - ConversionTxID string `json:"conversion_tx_id"` - PayoutTxID string `json:"payout_tx_id"` -} +// APIEnvironment is used to differentiate the environment when making requests +type APIEnvironment string + +const ( + + // Field key names for various model requests + fieldAdditionalData = "additional_data" + fieldAdvertiserProfileID = "advertiser_profile_id" + fieldApiKey = "api_key" + fieldCampaignID = "campaign_id" + fieldEmail = "email" + fieldID = "id" + fieldName = "name" + fieldPassword = "password" + fieldPasswordConfirm = "password_confirm" + fieldPhone = "phone" + fieldPhoneCode = "phone_code" + fieldShortCode = "short_code" + fieldToken = "token" + fieldUserID = "user_id" + fieldVisitorSessionID = "visitor_session_id" + + // Model names (used for request endpoints) + modelAdvertiser = "advertisers" + modelCampaign = "campaigns" + modelGoal = "goals" + modelLink = "links" + modelUser = "users" + + // apiVersion current version for all endpoints + apiVersion = "v1" + + // defaultUserAgent is the default user agent for all requests + defaultUserAgent string = "go-tonicpow: " + apiVersion -// Error is the response from the request + // LiveEnvironment is the live production environment + LiveEnvironment APIEnvironment = "https://api.tonicpow.com/" + apiVersion + "/" + + // LocalEnvironment is for testing locally using your own api instance + LocalEnvironment APIEnvironment = "http://localhost:3000/" + apiVersion + "/" + + // StagingEnvironment is used for production-like testing + StagingEnvironment APIEnvironment = "https://apistaging.tonicpow.com/" + apiVersion + "/" + + // sessionCookie is the cookie name for session tokens + sessionCookie = "session_token" + + // TestEnvironment is a test-only environment + //TestEnvironment APIEnvironment = "https://test.tonicpow.com/"+apiVersion+"/" +) + +// Error is the universal error response from the API +// +// For more information: https://docs.tonicpow.com/#d7fe13a3-2b6d-4399-8d0f-1d6b8ad6ebd9 type Error struct { Code int `json:"code"` Data string `json:"data"` @@ -22,3 +63,90 @@ type Error struct { RequestGUID string `json:"request_guid"` URL string `json:"url"` } + +// User is the user model +// +// For more information: https://docs.tonicpow.com/#50b3c130-7254-4a05-b312-b14647736e38 +type User struct { + Balance uint64 `json:"balance,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"first_name,omitempty"` + ID uint64 `json:"id,omitempty"` + InternalAddress string `json:"internal_address,omitempty"` + LastName string `json:"last_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + NewPassword string `json:"new_password,omitempty"` + NewPasswordConfirm string `json:"new_password_confirm,omitempty"` + Password string `json:"password,omitempty"` + PayoutAddress string `json:"payout_address,omitempty"` + Phone string `json:"phone,omitempty"` + Status string `json:"status,omitempty"` +} + +// AdvertiserProfile is the advertiser_profile model (child of User) +// +// For more information: https://docs.tonicpow.com/#2f9ec542-0f88-4671-b47c-d0ee390af5ea +type AdvertiserProfile struct { + HomepageURL string `json:"homepage_url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + ID uint64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + UserID uint64 `json:"user_id,omitempty"` +} + +// Campaign is the campaign model (child of AdvertiserProfile) +// +// For more information: https://docs.tonicpow.com/#5aca2fc7-b3c8-445b-aa88-f62a681f8e0c +type Campaign struct { + AdvertiserProfileID uint64 `json:"advertiser_profile_id,omitempty"` + Balance float64 `json:"balance,omitempty"` + BalanceSatoshis int64 `json:"balance_satoshis,omitempty"` + Currency string `json:"currency,omitempty"` + Description string `json:"description,omitempty"` + FundingAddress string `json:"funding_address,omitempty"` + Goals []*Goal `json:"goals,omitempty"` + ID uint64 `json:"id,omitempty"` + ImageURL string `json:"image_url,omitempty"` + PayPerClickRate float64 `json:"pay_per_click_rate,omitempty"` + PublicGUID string `json:"public_guid,omitempty"` + TargetURL string `json:"target_url,omitempty"` + Title string `json:"title,omitempty"` +} + +// Goal is the goal model (child of Campaign) +// +// For more information: https://docs.tonicpow.com/#316b77ab-4900-4f3d-96a7-e67c00af10ca +type Goal struct { + CampaignID uint64 `json:"campaign_id,omitempty"` + Description string `json:"description,omitempty"` + ID uint64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + PayoutRate float64 `json:"payout_rate,omitempty"` + Payouts int `json:"payouts,omitempty"` + PayoutType string `json:"payout_type,omitempty"` + Title string `json:"title,omitempty"` +} + +// Conversion is the result of goal.Convert() +// +// For more information: https://docs.tonicpow.com/#caeffdd5-eaad-4fc8-ac01-8288b50e8e27 +type Conversion struct { + AdditionalData string `json:"additional_data,omitempty"` + ConversionTxID string `json:"conversion_tx_id,omitempty"` + GoalID uint64 `json:"goal_id,omitempty"` + GoalName string `json:"goal_name,omitempty"` + UserID string `json:"user_id,omitempty"` +} + +// Link is the link model (child of User) (relates Campaign to User) +// Use the CustomShortCode on create for using your own short code +// +// For more information: https://docs.tonicpow.com/#ee74c3ce-b4df-4d57-abf2-ccf3a80e4e1e +type Link struct { + CampaignID uint64 `json:"campaign_id,omitempty"` + CustomShortCode string `json:"custom_short_code,omitempty"` + ID uint64 `json:"id,omitempty"` + ShortCode string `json:"short_code,omitempty"` + ShortCodeURL string `json:"short_code_url,omitempty"` + UserID uint64 `json:"user_id,omitempty"` +} diff --git a/examples/examples.go b/examples/examples.go new file mode 100644 index 0000000..50d5c37 --- /dev/null +++ b/examples/examples.go @@ -0,0 +1,283 @@ +package main + +import ( + "fmt" + "log" + "math/rand" + "os" + + "github.com/tonicpow/go-tonicpow" +) + +var ( + // TonicPowAPI is the client we will load on start-up + TonicPowAPI *tonicpow.Client +) + +// Load the TonicPow API Client once when the application loads +func init() { + + // + // Get the API key (from env or your own config) + // + apiKey := os.Getenv("TONICPOW_API_KEY") + if len(apiKey) == 0 { + log.Fatalf("api key is required: %s", "TONICPOW_API_KEY") + } + + // + // Load the api client (creates a new session) + // You can also set the environment or client options + // + var err error + TonicPowAPI, err = tonicpow.NewClient(apiKey, tonicpow.LocalEnvironment, nil) + if err != nil { + log.Fatalf("error in NewClient: %s", err.Error()) + } +} + +func main() { + // + // Example for ending the api session for the application + // This is not needed, sessions will automatically expire + // + defer func() { + _ = TonicPowAPI.EndSession("") + }() + + // Example vars + var err error + var userSessionToken string + testPassword := "ExamplePassForNow0!" + + // + // Example: Prolong a session + // + if err = TonicPowAPI.ProlongSession(""); err != nil { + log.Fatalf("ProlongSession: %s", err.Error()) + } else { + log.Println("session created and prolonged...") + } + + // + // Example: Create a user + // + user := &tonicpow.User{ + Email: fmt.Sprintf("Testing%d@TonicPow.com", rand.Intn(100000)), + Password: testPassword, + } + if user, err = TonicPowAPI.CreateUser(user); err != nil { + log.Fatalf("create user failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("user %d created", user.ID) + } + + // + // Example: Get a user (id) + // + if user, err = TonicPowAPI.GetUser(user.ID, ""); err != nil { + log.Fatalf("get user failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got user by id %d", user.ID) + } + + // + // Example: Get a user (email) + // + if user, err = TonicPowAPI.GetUser(0, user.Email); err != nil { + log.Fatalf("get user failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got user by email %s", user.Email) + } + + // + // Example: Update a user + // + user.FirstName = "Austin" + if user, err = TonicPowAPI.UpdateUser(user, ""); err != nil { + log.Fatalf("update user failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("user %d updated - first_name: %s", user.ID, user.FirstName) + } + + // + // Example: Get new updated balance for user + // + if user, err = TonicPowAPI.GetUserBalance(user.ID); err != nil { + log.Fatalf("get user failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("user balance: %d", user.Balance) + } + + // + // Example: Forgot password + // + if err = TonicPowAPI.ForgotPassword(user.Email); err != nil { + log.Fatalf("forgot password failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("sent forgot password: %s", user.Email) + } + + // + // Example: Create an advertiser + // + advertiser := &tonicpow.AdvertiserProfile{ + UserID: user.ID, + Name: "Acme Advertising", + HomepageURL: "https://tonicpow.com", + IconURL: "https://tonicpow.com/images/logos/apple-touch-icon.png", + } + if advertiser, err = TonicPowAPI.CreateAdvertiserProfile(advertiser, ""); err != nil { + log.Fatalf("create advertiser failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("advertiser profile %s id %d created", advertiser.Name, advertiser.ID) + } + + // + // Example: Get an advertiser profile + // + if advertiser, err = TonicPowAPI.GetAdvertiserProfile(advertiser.ID, ""); err != nil { + log.Fatalf("get advertiser profile failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got advertiser profile by id %d", advertiser.ID) + } + + // + // Example: Login for a user + // + user.Password = testPassword + userSessionToken, err = TonicPowAPI.LoginUser(user) + if err != nil { + log.Fatalf("user login failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("user login: %s token: %s", user.Email, userSessionToken) + } + + // + // Example: Logout (just for our example) + // + defer func(token string) { + _ = TonicPowAPI.LogoutUser(token) + log.Println("user logout complete") + }(userSessionToken) + + // + // Example: Current user details + // + user, err = TonicPowAPI.CurrentUser() + if err != nil { + log.Fatalf("current user failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("current user: %s", user.Email) + } + + // + // Example: Create an advertiser as a user + // + advertiser = &tonicpow.AdvertiserProfile{ + UserID: user.ID, + Name: "Acme User Advertising", + HomepageURL: "https://tonicpow.com", + IconURL: "https://tonicpow.com/images/logos/apple-touch-icon.png", + } + if advertiser, err = TonicPowAPI.CreateAdvertiserProfile(advertiser, userSessionToken); err != nil { + log.Fatalf("create advertiser failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("advertiser profile %s id %d created", advertiser.Name, advertiser.ID) + } + + // + // Example: Get Advertiser Profile + // + if advertiser, err = TonicPowAPI.GetAdvertiserProfile(advertiser.ID, userSessionToken); err != nil { + log.Fatalf("get advertiser profile failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got advertiser profile by id %d", advertiser.ID) + } + + // + // Example: Update advertising profile + // + advertiser.Name = "Acme New User Advertising" + if advertiser, err = TonicPowAPI.UpdateAdvertiserProfile(advertiser, userSessionToken); err != nil { + log.Fatalf("update advertiser failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("advertiser profile %s id %d updated", advertiser.Name, advertiser.ID) + } + + // + // Example: Create a campaign + // + campaign := &tonicpow.Campaign{ + AdvertiserProfileID: advertiser.ID, + Currency: "USD", + Description: "Earn BSV for sharing things you like.", + ImageURL: "https://i.imgur.com/TbRFiaR.png", + PayPerClickRate: 0.01, + TargetURL: "https://offers.tonicpow.com", + Title: "TonicPow Offers", + } + if campaign, err = TonicPowAPI.CreateCampaign(campaign, userSessionToken); err != nil { + log.Fatalf("create campaign failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("campaign %s id %d created", campaign.Title, campaign.ID) + } + + // + // Example: Get Campaign + // + if campaign, err = TonicPowAPI.GetCampaign(campaign.ID, userSessionToken); err != nil { + log.Fatalf("get campaign failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got campaign by id %d", campaign.ID) + } + + // + // Example: Create a Goal + // + goal := &tonicpow.Goal{ + CampaignID: campaign.ID, + Description: "Bring leads and get paid!", + Name: "new-lead-landing-page", + PayoutRate: 0.50, + PayoutType: "flat", + Title: "Landing Page Leads", + } + if goal, err = TonicPowAPI.CreateGoal(goal, userSessionToken); err != nil { + log.Fatalf("create goal failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("goal %s id %d created", goal.Title, goal.ID) + } + + // + // Example: Get Goal + // + if goal, err = TonicPowAPI.GetGoal(goal.ID, userSessionToken); err != nil { + log.Fatalf("get goal failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got goal by id %d", goal.ID) + } + + // + // Example: Create a Link (making a custom short code using the user's name) + // + link := &tonicpow.Link{ + CampaignID: campaign.ID, + UserID: user.ID, + CustomShortCode: fmt.Sprintf("%s%d", user.FirstName, rand.Intn(100000)), + } + if link, err = TonicPowAPI.CreateLink(link, userSessionToken); err != nil { + log.Fatalf("create link failed - api error: %s data: %s", TonicPowAPI.LastRequest.Error.Message, TonicPowAPI.LastRequest.Error.Data) + } else { + log.Printf("link %s id %d created", link.ShortCode, link.ID) + } + + // + // Example: Get Link + // + if link, err = TonicPowAPI.GetLink(link.ID, userSessionToken); err != nil { + log.Fatalf("get link failed - api error: %s", TonicPowAPI.LastRequest.Error.Message) + } else { + log.Printf("got link by id %d", goal.ID) + } +} diff --git a/goals.go b/goals.go new file mode 100644 index 0000000..3676999 --- /dev/null +++ b/goals.go @@ -0,0 +1,141 @@ +package tonicpow + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// permitFields will remove fields that cannot be used +func (g *Goal) permitFields() { + g.CampaignID = 0 + g.Payouts = 0 +} + +// CreateGoal will make a new goal +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#29a93e9b-9726-474c-b25e-92586200a803 +func (c *Client) CreateGoal(goal *Goal, userSessionToken string) (createdGoal *Goal, err error) { + + // Basic requirements + if goal.CampaignID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldCampaignID) + return + } + + // Fire the request + var response string + if response, err = c.request(modelGoal, http.MethodPost, goal, userSessionToken); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + createdGoal = new(Goal) + err = json.Unmarshal([]byte(response), createdGoal) + return +} + +// GetGoal will get an existing goal +// This will return an error if the goal is not found (404) +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#48d7bbc8-5d7b-4078-87b7-25f545c3deaf +func (c *Client) GetGoal(goalID uint64, userSessionToken string) (goal *Goal, err error) { + + // Must have an id + if goalID == 0 { + err = fmt.Errorf("missing field: %s", fieldID) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/details/%d", modelGoal, goalID), http.MethodGet, nil, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + goal = new(Goal) + err = json.Unmarshal([]byte(response), goal) + return +} + +// UpdateGoal will update an existing goal +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#395f5b7d-6a5d-49c8-b1ae-abf7f90b42a2 +func (c *Client) UpdateGoal(goal *Goal, userSessionToken string) (updatedGoal *Goal, err error) { + + // Basic requirements + if goal.ID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldID) + return + } + + // Permit fields + goal.permitFields() + + // Fire the request + var response string + if response, err = c.request(modelGoal, http.MethodPut, goal, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + updatedGoal = new(Goal) + err = json.Unmarshal([]byte(response), updatedGoal) + return +} + +// ConvertGoal will fire a conversion for a given goal, if successful it will make a new Conversion +// +// For more information: https://docs.tonicpow.com/#caeffdd5-eaad-4fc8-ac01-8288b50e8e27 +func (c *Client) ConvertGoal(goalName, visitorSessionID, additionalData, customUserID string) (conversion *Conversion, err error) { + + // Must have a name + if len(goalName) == 0 { + err = fmt.Errorf("missing field: %s", fieldName) + return + } + + // Must have a session id + if len(visitorSessionID) == 0 { + err = fmt.Errorf("missing field: %s", fieldVisitorSessionID) + return + } + + // Start the post data + data := map[string]string{fieldName: goalName, fieldVisitorSessionID: visitorSessionID, fieldAdditionalData: additionalData, fieldUserID: customUserID} + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/convert", modelGoal), http.MethodPost, data, ""); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + conversion = new(Conversion) + err = json.Unmarshal([]byte(response), conversion) + return +} diff --git a/links.go b/links.go new file mode 100644 index 0000000..3874244 --- /dev/null +++ b/links.go @@ -0,0 +1,100 @@ +package tonicpow + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// CreateLink will make a new link +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#154bf9e1-6047-452f-a289-d21f507b0f1d +func (c *Client) CreateLink(link *Link, userSessionToken string) (createdLink *Link, err error) { + + // Basic requirements + if link.CampaignID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldCampaignID) + return + } + + if link.UserID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldUserID) + return + } + + // Fire the request + var response string + if response, err = c.request(modelLink, http.MethodPost, link, userSessionToken); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + createdLink = new(Link) + err = json.Unmarshal([]byte(response), createdLink) + return +} + +// GetLink will get an existing link +// This will return an error if the link is not found (404) +// Use the userSessionToken if making request on behalf of another user +// +// For more information: https://docs.tonicpow.com/#c53add03-303e-4f72-8847-2adfdb992eb3 +func (c *Client) GetLink(linkID uint64, userSessionToken string) (link *Link, err error) { + + // Must have an id + if linkID == 0 { + err = fmt.Errorf("missing field: %s", fieldID) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/details/%d", modelLink, linkID), http.MethodGet, nil, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + link = new(Link) + err = json.Unmarshal([]byte(response), link) + return +} + +// CheckLink will check for an existing link with a short_code +// This will return an error if the link is not found (404) +// +// For more information: https://docs.tonicpow.com/#cc9780b7-0d84-4a60-a28f-664b2ecb209b +func (c *Client) CheckLink(shortCode string) (link *Link, err error) { + + // Must have a short code + if len(shortCode) == 0 { + err = fmt.Errorf("missing field: %s", fieldShortCode) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/check/%s", modelLink, shortCode), http.MethodGet, nil, ""); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + link = new(Link) + err = json.Unmarshal([]byte(response), link) + return +} diff --git a/tonicpow.go b/tonicpow.go index ac0042d..e61f034 100644 --- a/tonicpow.go +++ b/tonicpow.go @@ -4,228 +4,30 @@ Package tonicpow is the official golang implementation for the TonicPow API package tonicpow import ( - "encoding/json" "fmt" - "io" - "io/ioutil" - "log" - "net/http" - "net/url" - "strings" - - "github.com/gojek/heimdall" - "github.com/gojek/heimdall/httpclient" ) -// Client holds client configuration settings -type Client struct { - - // HTTPClient carries out the POST operations - HTTPClient heimdall.Client - - // Parameters contains the search parameters that are submitted with your query, - // which may affect the data returned - Parameters *RequestParameters - - // LastRequest is the raw information from the last request - LastRequest *LastRequest -} - -// RequestParameters holds options that can affect data returned by a request. -type RequestParameters struct { - - // AdvertiserSecretKey - AdvertiserSecretKey string - - // Environment - Environment APIEnvironment - - // UserAgent (optional for changing user agents) - UserAgent string -} - -// LastRequest is used to track what was submitted via the Request() -type LastRequest struct { - - // Method is either POST or GET - Method string - - // PostData is the post data submitted if POST request - PostData string - - // StatusCode is the last code from the request - StatusCode int - - // URL is the url used for the request - URL string -} - -// NewClient creates a new client to submit queries with. -// Parameters values are set to the defaults defined by TonicPow. +// NewClient creates a new client to submit requests pre-loaded with the API key +// This will establish a new session given the API key // // For more information: https://docs.tonicpow.com -func NewClient(advertiserSecretKey string) (c *Client, err error) { +func NewClient(apiKey string, environment APIEnvironment, clientOptions *Options) (c *Client, err error) { - // Create a client - c = new(Client) - - // Create exponential backoff - backOff := heimdall.NewExponentialBackoff( - ConnectionInitialTimeout, - ConnectionMaxTimeout, - ConnectionExponentFactor, - ConnectionMaximumJitterInterval, - ) - - // Create the http client - //c.HTTPClient = new(http.Client) (@mrz this was the original HTTP client) - c.HTTPClient = httpclient.NewClient( - httpclient.WithHTTPTimeout(ConnectionWithHTTPTimeout), - httpclient.WithRetrier(heimdall.NewRetrier(backOff)), - httpclient.WithRetryCount(ConnectionRetryCount), - httpclient.WithHTTPClient(&http.Client{ - Transport: ClientDefaultTransport, - Timeout: ConnectionWithHTTPTimeout, - }), - ) - - // Create default parameters - c.Parameters = new(RequestParameters) - c.Parameters.UserAgent = DefaultUserAgent - c.Parameters.AdvertiserSecretKey = advertiserSecretKey - c.Parameters.Environment = LiveEnvironment - if len(advertiserSecretKey) == 0 { - err = fmt.Errorf("parameter %s cannot be empty", "advertiserSecretKey") + // apiKey is required + if len(apiKey) == 0 { + err = fmt.Errorf("parameter %s cannot be empty", fieldApiKey) return } - // Create a last request struct - c.LastRequest = new(LastRequest) - - // Return the client - return -} - -// Request is a generic TonicPow request wrapper that can be used without constraints -func (c *Client) Request(endpoint string, method string, params *url.Values) (response string, err error) { - - // Set reader - var bodyReader io.Reader - - // Add the network value - endpoint = fmt.Sprintf("%s%s", c.Parameters.Environment, endpoint) - - // Switch on POST vs GET - switch method { - case "POST": - { - encodedParams := params.Encode() - bodyReader = strings.NewReader(encodedParams) - c.LastRequest.PostData = encodedParams - } - case "GET": - { - if params != nil { - endpoint += "?" + params.Encode() - } - } - } - - // Store for debugging purposes - c.LastRequest.Method = method - c.LastRequest.URL = endpoint + // Create a client using the given options + c = createClient(clientOptions) - // Start the request - var request *http.Request - if request, err = http.NewRequest(method, endpoint, bodyReader); err != nil { - return - } + // Set the default parameters + c.Parameters.apiKey = apiKey + c.Parameters.environment = environment - // Change the header (user agent is in case they block default Go user agents) - request.Header.Set("User-Agent", c.Parameters.UserAgent) - - // Set the content type on POST - if method == "POST" { - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } + // Start a new api session + err = c.createSession() - // Fire the http request - var resp *http.Response - if resp, err = c.HTTPClient.Do(request); err != nil { - return - } - - // Close the response body - defer func() { - if err := resp.Body.Close(); err != nil { - log.Printf("error closing response body: %s", err.Error()) - } - }() - - // Read the body - var body []byte - if body, err = ioutil.ReadAll(resp.Body); err != nil { - return - } - - // Save the status - c.LastRequest.StatusCode = resp.StatusCode - - // Parse the response - response = string(body) - - // Done - return -} - -// ConvertGoal fires a conversion on a given goal name -func (c *Client) ConvertGoal(goalName string, sessionTxID string, userID string, additionalData string) (response *ConversionResponse, err error) { - - // Start the post data - postData := url.Values{} - - // Add the key - postData.Add("private_guid", c.Parameters.AdvertiserSecretKey) - - // Add the goal name - postData.Add("conversion_goal_name", goalName) - if len(goalName) == 0 { - err = fmt.Errorf("parameter %s cannot be empty", "goalName") - return - } - - // Add the session/click - postData.Add("click_tx_id", sessionTxID) - if len(sessionTxID) == 0 { - err = fmt.Errorf("parameter %s cannot be empty", "sessionTxID") - return - } - - // Add the user id if not found - if len(userID) > 0 { - postData.Add("user_id", userID) - } - - // Add the additional data if found - if len(additionalData) > 0 { - postData.Add("additional_data", additionalData) - } - - // Fire the request - var resp string - if resp, err = c.Request("conversions", "POST", &postData); err != nil { - return - } - - // Convert the response - response = new(ConversionResponse) - if err = json.Unmarshal([]byte(resp), response); err != nil { - return - } - - // Internal error from API - if len(response.Message) > 0 && response.Code > 201 { - err = fmt.Errorf("error from TonicPow API - code: %d message: %s", response.Code, response.Message) - } return } diff --git a/tonicpow_test.go b/tonicpow_test.go index c0b17ae..2b5875d 100644 --- a/tonicpow_test.go +++ b/tonicpow_test.go @@ -2,63 +2,298 @@ package tonicpow import ( "fmt" + "math/rand" + "net/http" + "os" "testing" + "time" +) + +var ( + // testAPIKey is the test api key for most tests (set in your env) + testAPIKey = os.Getenv("TONICPOW_API_KEY") + + // testAPIKeyHardCoded is for examples + testAPIKeyHardCoded = "3ez9d6z7a6549c3f5gf9g2cc8911achz" ) // TestNewClient test new client func TestNewClient(t *testing.T) { - client, err := NewClient("test123") + // Skip this test in short mode (not needed) + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + client, err := NewClient(testAPIKey, LocalEnvironment, nil) if err != nil { t.Fatal(err) } - if client.Parameters.AdvertiserSecretKey != "test123" { - t.Fatal("expected value to be test123") + if client.Parameters.apiKey != testAPIKey { + t.Fatalf("expected value to be %s", testAPIKey) + } + + if client.Parameters.apiSessionCookie == nil { + t.Fatalf("expected value to be set, was empty/nil") + } + + if client.Parameters.environment != LocalEnvironment { + t.Fatalf("expected value to be %s, got %s", LocalEnvironment, client.Parameters.environment) + } + + if client.Parameters.UserAgent != defaultUserAgent { + t.Fatalf("expected value to be %s, got %s", defaultUserAgent, client.Parameters.UserAgent) + } + + if client.LastRequest.StatusCode != http.StatusCreated { + t.Fatalf("expected value to be %d, got %d", http.StatusCreated, client.LastRequest.StatusCode) + } + + if client.LastRequest.Method != http.MethodPost { + t.Fatalf("expected value to be %s, got %s", http.MethodPost, client.LastRequest.Method) + } + + if len(client.LastRequest.URL) == 0 { + t.Fatalf("expected value to be set, was empty/nil") + } + + if client.LastRequest.PostData != fmt.Sprintf(`{"%s":"%s"}`, fieldApiKey, testAPIKey) { + t.Fatalf("expected value wrong,got %s", client.LastRequest.PostData) } } // ExampleNewClient example using NewClient() func ExampleNewClient() { - client, _ := NewClient("test123") - fmt.Println(client.Parameters.AdvertiserSecretKey) - // Output:test123 + client, _ := NewClient(testAPIKeyHardCoded, LocalEnvironment, nil) + fmt.Println(client.Parameters.apiKey) + // Output:3ez9d6z7a6549c3f5gf9g2cc8911achz } // BenchmarkNewClient benchmarks the NewClient method func BenchmarkNewClient(b *testing.B) { for i := 0; i < b.N; i++ { - _, _ = NewClient("test123") + _, _ = NewClient(testAPIKey, LocalEnvironment, nil) + } +} + +// TestDefaultOptions tests setting ClientDefaultOptions() +func TestDefaultOptions(t *testing.T) { + + options := ClientDefaultOptions() + + if options.UserAgent != defaultUserAgent { + t.Fatalf("expected value: %s got: %s", defaultUserAgent, options.UserAgent) + } + + if options.BackOffExponentFactor != 2.0 { + t.Fatalf("expected value: %f got: %f", 2.0, options.BackOffExponentFactor) + } + + if options.BackOffInitialTimeout != 2*time.Millisecond { + t.Fatalf("expected value: %v got: %v", 2*time.Millisecond, options.BackOffInitialTimeout) + } + + if options.BackOffMaximumJitterInterval != 2*time.Millisecond { + t.Fatalf("expected value: %v got: %v", 2*time.Millisecond, options.BackOffMaximumJitterInterval) + } + + if options.BackOffMaxTimeout != 10*time.Millisecond { + t.Fatalf("expected value: %v got: %v", 10*time.Millisecond, options.BackOffMaxTimeout) + } + + if options.DialerKeepAlive != 20*time.Second { + t.Fatalf("expected value: %v got: %v", 20*time.Second, options.DialerKeepAlive) + } + + if options.DialerTimeout != 5*time.Second { + t.Fatalf("expected value: %v got: %v", 5*time.Second, options.DialerTimeout) + } + + if options.RequestRetryCount != 2 { + t.Fatalf("expected value: %v got: %v", 2, options.RequestRetryCount) + } + + if options.RequestTimeout != 10*time.Second { + t.Fatalf("expected value: %v got: %v", 10*time.Second, options.RequestTimeout) + } + + if options.TransportExpectContinueTimeout != 3*time.Second { + t.Fatalf("expected value: %v got: %v", 3*time.Second, options.TransportExpectContinueTimeout) + } + + if options.TransportIdleTimeout != 20*time.Second { + t.Fatalf("expected value: %v got: %v", 20*time.Second, options.TransportIdleTimeout) + } + + if options.TransportMaxIdleConnections != 10 { + t.Fatalf("expected value: %v got: %v", 10, options.TransportMaxIdleConnections) + } + + if options.TransportTLSHandshakeTimeout != 5*time.Second { + t.Fatalf("expected value: %v got: %v", 5*time.Second, options.TransportTLSHandshakeTimeout) + } +} + +// TestClient_Request tests the method Request() +func TestClient_Request(t *testing.T) { + // Skip this test in short mode (not needed) + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + // Start a new client + client, err := NewClient(testAPIKey, LocalEnvironment, nil) + if err != nil { + t.Fatal(err) + } + + // Simple request - prolong session + err = client.ProlongSession("") + if err != nil { + t.Fatal(err) + } +} + +// TestClient_EndSession tests the methods ProlongSession() and EndSession() +func TestClient_EndSession(t *testing.T) { + // Skip this test in short mode (not needed) + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + // Start a new client + client, err := NewClient(testAPIKey, LocalEnvironment, nil) + if err != nil { + t.Fatal(err) + } + + // Prolong should success + err = client.ProlongSession("") + if err != nil { + t.Fatal(err) + } + + // Should be a 200 + if client.LastRequest.StatusCode != http.StatusOK { + t.Fatalf("expected to get %d but got %d", http.StatusOK, client.LastRequest.StatusCode) + } + + // End our current session + err = client.EndSession("") + if err != nil { + t.Fatal(err) + } + + // Prolong should fail + err = client.ProlongSession("") + if err == nil { + t.Fatalf("expected prolong to fail after ending session") + } + + // Should be a 401 + if client.LastRequest.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected to get %d but got %d", http.StatusUnauthorized, client.LastRequest.StatusCode) + } +} + +// TestClient_ProlongSession tests the ProlongSession() method +func TestClient_ProlongSession(t *testing.T) { + + // Skip this test in short mode (not needed) + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + // Start a new client + client, err := NewClient(testAPIKey, LocalEnvironment, nil) + if err != nil { + t.Fatal(err) + } + + // Using a custom token approach + customSessionToken := client.Parameters.apiSessionCookie.Value + + // Prolong should success + err = client.ProlongSession(customSessionToken) + if err != nil { + t.Fatal(err) + } +} + +// TestClient_CreateUser tests the CreateUser() method +func TestClient_CreateUser(t *testing.T) { + + // Skip this test in short mode (not needed) + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + // Start a new client + client, err := NewClient(testAPIKey, LocalEnvironment, nil) + if err != nil { + t.Fatal(err) + } + + user := &User{ + Email: fmt.Sprintf("Testing%d@TonicPow.com", rand.Intn(100000)), + FirstName: "Austin", + } + if user, err = client.CreateUser(user); err != nil { + t.Fatalf("%s", err.Error()) } } -// TestClient_ConvertGoal tests the method ConvertGoal() -func TestClient_ConvertGoal(t *testing.T) { - // Skip tis test in short mode (not needed) +// TestClient_UpdateUser tests the UpdateUser() method +func TestClient_UpdateUser(t *testing.T) { + + // Skip this test in short mode (not needed) if testing.Short() { t.Skip("skipping testing in short mode") } - privateGUID := "c2512ba9073341ae8c4bc8d153915cd8" - client, err := NewClient(privateGUID) + // Start a new client + client, err := NewClient(testAPIKey, LocalEnvironment, nil) if err != nil { t.Fatal(err) } - client.Parameters.Environment = LocalEnvironment - if client.Parameters.AdvertiserSecretKey != privateGUID { - t.Fatal("expected value to be " + privateGUID) + user := &User{ + Email: fmt.Sprintf("Testing%d@TonicPow.com", rand.Intn(100000)), + FirstName: "Austin", + } + if user, err = client.CreateUser(user); err != nil { + t.Fatalf("%s", err.Error()) + } + + user.MiddleName = "Danger" + if user, err = client.UpdateUser(user, ""); err != nil { + t.Fatalf("%s", err.Error()) } +} + +// TestClient_GetUser tests the GetUser() method +func TestClient_GetUser(t *testing.T) { - goalName := "signupgoal" - sessionTxID := "f773c231ee9383125fe7932d6dbdb5447577c39cae8cc28210d19f6471294485" - userID := "123" - additionalData := "test data" + // Skip this test in short mode (not needed) + if testing.Short() { + t.Skip("skipping testing in short mode") + } - var resp *ConversionResponse - resp, err = client.ConvertGoal(goalName, sessionTxID, userID, additionalData) + // Start a new client + client, err := NewClient(testAPIKey, LocalEnvironment, nil) if err != nil { - t.Fatal("error from ConvertGoal: " + err.Error()) + t.Fatal(err) } - t.Log("error:", resp.Message, "last_code", client.LastRequest.StatusCode, "payout: ", resp.PayoutTxID) + user := &User{ + Email: fmt.Sprintf("Testing%d@TonicPow.com", rand.Intn(100000)), + FirstName: "Austin", + } + if user, err = client.CreateUser(user); err != nil { + t.Fatalf("%s", err.Error()) + } + + if user, err = client.GetUser(user.ID, user.Email); err != nil { + t.Fatalf("%s", err.Error()) + } } diff --git a/users.go b/users.go new file mode 100644 index 0000000..b6f18b7 --- /dev/null +++ b/users.go @@ -0,0 +1,324 @@ +package tonicpow + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// permitFields will remove fields that cannot be used +func (u *User) permitFields() { + u.Balance = 0 + u.InternalAddress = "" + u.Status = "" +} + +// CreateUser will make a new user +// +// For more information: https://docs.tonicpow.com/#8de84fb5-ba77-42cc-abb0-f3044cc871b6 +func (c *Client) CreateUser(user *User) (createdUser *User, err error) { + + // Basic requirements + if len(user.Email) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldEmail) + return + } + + // Permit fields before creating + user.permitFields() + + // Fire the request + var response string + if response, err = c.request(modelUser, http.MethodPost, user, ""); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + createdUser = new(User) + err = json.Unmarshal([]byte(response), createdUser) + return +} + +// UpdateUser will update an existing user model +// Use the userSessionToken if the current user is editing their own user model +// +// For more information: https://docs.tonicpow.com/#7c3c3c3a-f636-469f-a884-449cf6fb35fe +func (c *Client) UpdateUser(user *User, userSessionToken string) (updatedUser *User, err error) { + + // Basic requirements + if user.ID == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldID) + return + } + + // Permit fields before updating + user.permitFields() + + // Fire the request + var response string + if response, err = c.request(modelUser, http.MethodPut, user, userSessionToken); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + updatedUser = new(User) + err = json.Unmarshal([]byte(response), updatedUser) + return +} + +// GetUser will get an existing user +// This will return an error if the user is not found (404) +// Use either an ID or email to get an existing user +// +// For more information: https://docs.tonicpow.com/#e6f764a2-5a91-4680-aa5e-03409dd878d8 +func (c *Client) GetUser(byID uint64, byEmail string) (user *User, err error) { + + // Must have either an ID or email + if byID == 0 && len(byEmail) == 0 { + err = fmt.Errorf("missing either %s or %s", fieldID, fieldEmail) + return + } + + // Set the values + params := url.Values{} + if byID > 0 { + params.Add(fieldID, fmt.Sprintf("%d", byID)) + } else { + params.Add(fieldEmail, byEmail) + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/details", modelUser), http.MethodGet, params, ""); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + user = new(User) + err = json.Unmarshal([]byte(response), user) + return +} + +// GetUserBalance will update a user's balance from the chain +// +// For more information: https://docs.tonicpow.com/#8478765b-95b8-47ad-8b86-2db5bce54924 +func (c *Client) GetUserBalance(userID uint64) (user *User, err error) { + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/balance/%d", modelUser, userID), http.MethodGet, nil, ""); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + user = new(User) + err = json.Unmarshal([]byte(response), user) + return +} + +// CurrentUser will the current user based on token +// Required: LoginUser() +// +// For more information: https://docs.tonicpow.com/#7f6e9b5d-8c7f-4afc-8e07-7aafdd891521 +func (c *Client) CurrentUser() (user *User, err error) { + + // No current user + if c.Parameters.UserSessionCookie == nil { + err = fmt.Errorf("missing user session, use LoginUser() first") + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/account", modelUser), http.MethodGet, nil, c.Parameters.UserSessionCookie.Value); err != nil { + return + } + + // Only a 200 is treated as a success + if err = c.error(http.StatusOK, response); err != nil { + return + } + + // Convert model response + user = new(User) + err = json.Unmarshal([]byte(response), user) + return +} + +// LoginUser will login for a given user +// +// For more information: https://docs.tonicpow.com/#5cad3e9a-5931-44bf-b110-4c4b74c7a070 +func (c *Client) LoginUser(user *User) (userSessionToken string, err error) { + + // Basic requirements + if len(user.Email) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldEmail) + return + } else if len(user.Password) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldPassword) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/login", modelUser), http.MethodPost, user, c.Parameters.apiSessionCookie.Value); err != nil { + return + } + + // Only a 201 is treated as a success + if err = c.error(http.StatusCreated, response); err != nil { + return + } + + // Convert model response + userSessionToken = c.Parameters.UserSessionCookie.Value + return +} + +// LogoutUser will logout a given session token +// +// For more information: https://docs.tonicpow.com/#39d65294-376a-4366-8f71-a02b08f9abdf +func (c *Client) LogoutUser(userSessionToken string) (err error) { + + // Basic requirements + if len(userSessionToken) == 0 { + err = fmt.Errorf("missing required attribute: %s", sessionCookie) + return + } + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/logout", modelUser), http.MethodDelete, nil, userSessionToken); err != nil { + return + } + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +} + +// ForgotPassword will fire a forgot password request +// +// For more information: https://docs.tonicpow.com/#2c33dae4-d6b1-4949-9e84-fb02157ab7cd +func (c *Client) ForgotPassword(emailAddress string) (err error) { + + // Basic requirements + if len(emailAddress) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldEmail) + return + } + + // Start the post data + data := map[string]string{fieldEmail: emailAddress} + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/password/forgot", modelUser), http.MethodPost, data, ""); err != nil { + return + } + + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +} + +// ResetPassword will reset a password from a ForgotPassword() request +// +// For more information: https://docs.tonicpow.com/#370fbeec-adb2-4ed3-82dc-2dffa840e490 +func (c *Client) ResetPassword(token, password, passwordConfirm string) (err error) { + + // Basic requirements + if len(token) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldToken) + return + } else if len(password) == 0 || len(passwordConfirm) == 0 { + err = fmt.Errorf("missing required attribute: %s or %s", fieldPassword, fieldPasswordConfirm) + return + } + + // Start the post data + data := map[string]string{fieldToken: token, fieldPassword: password, fieldPasswordConfirm: passwordConfirm} + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/password/reset", modelUser), http.MethodPut, data, ""); err != nil { + return + } + + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +} + +// CompleteEmailVerification will complete an email verification with a given token +// +// For more information: https://docs.tonicpow.com/#f5081800-a224-4f36-8014-94981f0bd55d +func (c *Client) CompleteEmailVerification(token string) (err error) { + + // Basic requirements + if len(token) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldToken) + return + } + + // Start the post data + data := map[string]string{fieldToken: token} + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/verify/%s", modelUser, fieldEmail), http.MethodPut, data, ""); err != nil { + return + } + + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +} + +// CompletePhoneVerification will complete a phone verification with a given code and number +// +// For more information: https://docs.tonicpow.com/#573403c4-b872-475d-ac04-de32a88ecd19 +func (c *Client) CompletePhoneVerification(phone, code string) (err error) { + + // Basic requirements + if len(phone) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldPhone) + return + } else if len(code) == 0 { + err = fmt.Errorf("missing required attribute: %s", fieldPhoneCode) + return + } + + // Start the post data + data := map[string]string{fieldPhone: phone, fieldPhoneCode: code} + + // Fire the request + var response string + if response, err = c.request(fmt.Sprintf("%s/verify/%s", modelUser, fieldPhone), http.MethodPut, data, ""); err != nil { + return + } + + // Only a 200 is treated as a success + err = c.error(http.StatusOK, response) + return +}