From da7e4f4d3416cf274501197c768693cb5ea67ed9 Mon Sep 17 00:00:00 2001 From: Adriano Caloiaro Date: Mon, 24 Mar 2025 15:57:35 -0600 Subject: [PATCH] initial commit Initial commit --- .envrc | 2 + .gitignore | 5 + LICENSE | 21 ++++ README.md | 13 +++ campaigns.go | 92 +++++++++++++++ client.go | 52 +++++++++ examples/list_campaigns/main.go | 27 +++++ examples/list_orders/main.go | 28 +++++ examples/list_products/main.go | 30 +++++ examples/place_order/main.go | 58 ++++++++++ go.mod | 3 + orders.go | 198 ++++++++++++++++++++++++++++++++ products.go | 160 ++++++++++++++++++++++++++ 13 files changed, 689 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 campaigns.go create mode 100644 client.go create mode 100644 examples/list_campaigns/main.go create mode 100644 examples/list_orders/main.go create mode 100644 examples/list_products/main.go create mode 100644 examples/place_order/main.go create mode 100644 go.mod create mode 100644 orders.go create mode 100644 products.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..7a54737 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +source .env + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34c3cd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dbcfbfb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Adriano Caloiaro + +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..25658a1 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# go-tremendous + +go-tremendous is a Go client for [Tremendous](https://tremendous.com) + +This client supports a subset of the [tremendous api](https://developers.tremendous.com/docs/introduction). It does not support some endpoints and parameters, and is not affiliated with Tremendous. + +If you would like to contribute additional functionality to this client, please feel free to open a pull request. + +If you would like to report a bug or request additional features, please do not open an issue. This client is provided as-is. + +## Usage + +See [examples](examples/) for examples. diff --git a/campaigns.go b/campaigns.go new file mode 100644 index 0000000..a01ddcb --- /dev/null +++ b/campaigns.go @@ -0,0 +1,92 @@ +package tremendous + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// CampaignsService handles the "campaigns" resource. +type CampaignsService struct { + client *Client +} + +// Campaign represents a single campaign record from Tremendous. +type Campaign struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` +} + +// listCampaignsResponse models the JSON response for GET /campaigns. +type listCampaignsResponse struct { + Campaigns []Campaign `json:"campaigns"` +} + +// List fetches a filtered list of campaigns from the API. +// +// Example usage: +// +// client.Campaigns.List(&tremendous.ListCampaignsOptions{Name: "Summer", Status: "active"}) +func (cs *CampaignsService) List() ([]Campaign, error) { + // Construct the base endpoint + baseURL := fmt.Sprintf("%s/campaigns", cs.client.BaseURL) + + req, err := http.NewRequest(http.MethodGet, baseURL, nil) + if err != nil { + return nil, err + } + req.Header = cs.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var answer listCampaignsResponse + if err := json.NewDecoder(resp.Body).Decode(&answer); err != nil { + return nil, fmt.Errorf("failed to decode campaigns list: %w", err) + } + return answer.Campaigns, nil +} + +// Retrieve fetches a single campaign by its ID if needed. +// (Optional: Implement similarly to the templates/products retrieve.) +func (cs *CampaignsService) Retrieve(campaignID string) (*Campaign, error) { + campaignID = strings.TrimSpace(campaignID) + urlStr := fmt.Sprintf("%s/campaigns/%s", cs.client.BaseURL, campaignID) + + req, err := http.NewRequest(http.MethodGet, urlStr, nil) + if err != nil { + return nil, err + } + req.Header = cs.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var single struct { + Campaign Campaign `json:"campaign"` + } + if err := json.NewDecoder(resp.Body).Decode(&single); err != nil { + return nil, fmt.Errorf("failed to decode single campaign: %w", err) + } + return &single.Campaign, nil +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..d5c690d --- /dev/null +++ b/client.go @@ -0,0 +1,52 @@ +package tremendous + +import ( + "fmt" + "net/http" +) + +// TestflightURL is the testflight API endpoint. +const TestflightURL = "https://testflight.tremendous.com/api/v2" + +// ProductionURL is the production API endpoint +const ProductionURL = "https://api.tremendous.com/api/v2" + +// Client is the primary entry point for using the Tremendous API +type Client struct { + APIKey string + BaseURL string + Campaigns *CampaignsService + Orders *OrdersService + Products *ProductsService +} + +type ClientArgs struct { + ApiKey string // the api key used to authenticate to the API + Production bool // whether to use the production or testflight API +} + +// NewClient constructs a new Tremendous client using the given API key. +func NewClient(args ClientArgs) *Client { + baseURL := TestflightURL + if args.Production { + baseURL = ProductionURL + } + client := &Client{ + APIKey: args.ApiKey, + BaseURL: baseURL, + } + client.Campaigns = &CampaignsService{client: client} + client.Orders = &OrdersService{client: client} + client.Products = &ProductsService{client: client} + return client +} + +// headers returns the default headers for requests, including authorization. +func (c *Client) headers() http.Header { + h := http.Header{} + h.Set("Authorization", fmt.Sprintf("Bearer %s", c.APIKey)) + h.Set("Content-Type", "application/json") + h.Set("Accept", "application/json") + h.Set("User-Agent", "go-tremendous/1.0.0") + return h +} diff --git a/examples/list_campaigns/main.go b/examples/list_campaigns/main.go new file mode 100644 index 0000000..4cfef21 --- /dev/null +++ b/examples/list_campaigns/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "cmp" + "fmt" + "os" + + "github.com/acaloiaro/go-tremendous" +) + +func main() { + // Replace with your actual API key. + args := tremendous.ClientArgs{ + ApiKey: cmp.Or(os.Getenv("TREMENDOUS_API_KEY"), "YOUR_API_KEY"), + Production: false, + } + client := tremendous.NewClient(args) + campaigns, err := client.Campaigns.List() + if err != nil { + fmt.Printf("unable to list campaigns: %s\n", err) + return + } + + for _, c := range campaigns { + fmt.Println(c) + } +} diff --git a/examples/list_orders/main.go b/examples/list_orders/main.go new file mode 100644 index 0000000..0b60cd6 --- /dev/null +++ b/examples/list_orders/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "cmp" + "fmt" + "log" + "os" + + "github.com/acaloiaro/go-tremendous" +) + +func main() { + // Replace with your actual API key. + args := tremendous.ClientArgs{ + ApiKey: cmp.Or(os.Getenv("TREMENDOUS_API_KEY"), "YOUR_API_KEY"), + Production: false, + } + client := tremendous.NewClient(args) + + orders, err := client.Orders.List() + if err != nil { + log.Fatalf("List error: %v", err) + } + + for i, order := range orders { + fmt.Printf("Order %d: %v\n", i, order) + } +} diff --git a/examples/list_products/main.go b/examples/list_products/main.go new file mode 100644 index 0000000..619f7f5 --- /dev/null +++ b/examples/list_products/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "cmp" + "fmt" + "log" + "os" + + "github.com/acaloiaro/go-tremendous" +) + +func main() { + // Replace with your actual API key. + args := tremendous.ClientArgs{ + ApiKey: cmp.Or(os.Getenv("TREMENDOUS_API_KEY"), "YOUR_API_KEY"), + Production: false, + } + client := tremendous.NewClient(args) + + products, err := client.Products.List(&tremendous.ListProductsOptions{ + Country: "US", // list only US products + }) + if err != nil { + log.Fatalf("List error: %v", err) + } + + for i, product := range products { + fmt.Printf("Product %d: %v\n", i, product) + } +} diff --git a/examples/place_order/main.go b/examples/place_order/main.go new file mode 100644 index 0000000..9ebfe95 --- /dev/null +++ b/examples/place_order/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "cmp" + "fmt" + "log" + "os" + + "github.com/acaloiaro/go-tremendous" +) + +func main() { + // Replace with your actual API key. + args := tremendous.ClientArgs{ + ApiKey: cmp.Or(os.Getenv("TREMENDOUS_API_KEY"), "YOUR_API_KEY"), + Production: false, + } + client := tremendous.NewClient(args) + + campaigns, err := client.Campaigns.List() + if err != nil { + fmt.Println("unable to list campaigns", err) + return + } + + newOrderArgs := tremendous.OrderArgs{ + Reward: tremendous.RewardArg{ + CampaignID: &campaigns[0].ID, + Recipient: tremendous.OrderRecipient{ + Name: "Testy McTesterson", + Email: "testy@example.com", + }, + Value: tremendous.OrderDenomination{ + Denomination: 1.00, + CurrencyCode: "USD", + }, + Delivery: tremendous.OrderDelivery{ + Method: "LINK", + }, + }, + Payment: tremendous.OrderPaymentArg{ + FundingSourceID: "BALANCE", + }, + } + // Example: Create an order (replace with valid fields). + newOrder, err := client.Orders.Create(newOrderArgs) + if err != nil { + log.Fatalf("Create error: %v", err) + } + fmt.Println("Created order:", newOrder, "\n\n") + + // Example: Retrieve that order by ID. + retOrder, err := client.Orders.Retrieve(newOrder.ID) + if err != nil { + log.Fatalf("Retrieve error: %v", err) + } + fmt.Println("Retrieved order:", retOrder) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..44936a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/acaloiaro/go-tremendous + +go 1.23.0 diff --git a/orders.go b/orders.go new file mode 100644 index 0000000..8258e55 --- /dev/null +++ b/orders.go @@ -0,0 +1,198 @@ +package tremendous + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// OrdersService handles the "orders" resource. +type OrdersService struct { + client *Client +} + +// Order represents the JSON structure of an order in the Tremendous API. +type Order struct { + ID string `json:"id,omitempty"` + ExternalID string `json:"external_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + Message string `json:"message,omitempty"` + Status string `json:"status,omitempty"` + Payment Payment `json:"payment,omitempty"` + Rewards []Reward `json:"rewards"` +} + +// Payment captures details of the payment object used by the order. +type Payment struct { + FundingSourceID string `json:"funding_source_id,omitempty"` + Amount float64 `json:"amount,omitempty"` + CurrencyCode string `json:"currency_code,omitempty"` + Object string `json:"object,omitempty"` +} + +// Reward captures details of a reward that has been paid out +type Reward struct { + ID string `json:"id"` + OrderID string `json:"order_id"` + CreatedAt time.Time `json:"created_at"` + Value OrderDenomination `json:"value"` + Delivery OrderDelivery `json:"delivery"` + Recipient OrderRecipient `json:"recipient"` +} + +// OrderArgs is the request struct for creating an order. It wraps +// the "order" object exactly as Tremendous expects in the JSON payload. +type OrderArgs struct { + CampaignID *string `json:"campaign_id"` + ExternalID *string `json:"external_id"` + Payment OrderPaymentArg `json:"payment"` + Reward RewardArg `json:"reward"` +} + +// OrderPaymentArg describes an order's payment details +type OrderPaymentArg struct { + FundingSourceID string `json:"funding_source_id,omitempty"` +} + +// OrderDenomination denominates the reward's monetary value +type OrderDenomination struct { + Denomination float64 `json:"denomination"` + CurrencyCode string `json:"currency_code"` +} + +// OrderDelivery designates how an order's reward is delivered +type OrderDelivery struct { + Method string `json:"method"` // email, link, or phone + Status string `json:"status"` + Link string `json:"link"` +} + +// RewardArg is subset of field for on order reward +type RewardArg struct { + CampaignID *string `json:"campaign_id"` + Delivery OrderDelivery `json:"delivery"` + Products []string `json:"products"` + Recipient OrderRecipient `json:"recipient"` + Value OrderDenomination `json:"value"` +} + +// OrderRecipient contains details about reward recipients +type OrderRecipient struct { + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` +} + +// singleOrderResponse models the JSON for a single order return. +type singleOrderResponse struct { + Order Order `json:"order"` +} + +// listOrdersResponse models the JSON for a multi-order listing. +type listOrdersResponse struct { + Orders []Order `json:"orders"` +} + +// List fetches all orders. (Query params, pagination, etc., may be added later.) +func (s *OrdersService) List() ([]Order, error) { + url := fmt.Sprintf("%s/orders", s.client.BaseURL) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header = s.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var ordersResp listOrdersResponse + if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil { + return nil, fmt.Errorf("failed to decode orders list: %w", err) + } + return ordersResp.Orders, nil +} + +// Retrieve fetches a single order by its ID. +func (s *OrdersService) Retrieve(orderID string) (*Order, error) { + orderID = strings.TrimSpace(orderID) + url := fmt.Sprintf("%s/orders/%s", s.client.BaseURL, orderID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header = s.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var singleResp singleOrderResponse + if err := json.NewDecoder(resp.Body).Decode(&singleResp); err != nil { + return nil, fmt.Errorf("failed to decode single order: %w", err) + } + return &singleResp.Order, nil +} + +// Create sends a new order to the Tremendous API for creation. +// It requires the Payment field to be non-nil in CreateOrderArgs. +func (s *OrdersService) Create(args OrderArgs) (*Order, error) { + url := fmt.Sprintf("%s/orders", s.client.BaseURL) + + // Basic validation: ensure payment is present + if args.Payment.FundingSourceID == "" { + return nil, errors.New("the 'payment.funding_source_id' field is required but was empty") + } + + // Marshal the typed struct into JSON + bodyBytes, err := json.Marshal(&args) + if err != nil { + return nil, fmt.Errorf("failed to marshal CreateOrderArgs: %w", err) + } + + log.Println("Request", string(bodyBytes)) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, err + } + req.Header = s.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Expect 201 Created (some APIs return 200 instead). + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var singleResp singleOrderResponse + if err := json.NewDecoder(resp.Body).Decode(&singleResp); err != nil { + return nil, fmt.Errorf("failed to decode create order response: %w", err) + } + return &singleResp.Order, nil +} diff --git a/products.go b/products.go new file mode 100644 index 0000000..d860e36 --- /dev/null +++ b/products.go @@ -0,0 +1,160 @@ +package tremendous + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// ProductsService handles the “products” resource. +type ProductsService struct { + client *Client +} + +// Product is a fully parsed product object (fields may vary by API). +type Product struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Brand string `json:"brand,omitempty"` + Description string `json:"description,omitempty"` + Price float64 `json:"price,omitempty"` + Currency string `json:"currency,omitempty"` + Object string `json:"object,omitempty"` +} + +// CreateProductArgs is the typed request for creating a new product. +type CreateProductArgs struct { + Product CreateProduct `json:"product"` +} + +// CreateProduct is the structure inside the top-level "product" key for creation. +type CreateProduct struct { + Name string `json:"name,omitempty"` + Brand string `json:"brand,omitempty"` + Description string `json:"description,omitempty"` + Price float64 `json:"price,omitempty"` + Currency string `json:"currency,omitempty"` +} + +// singleProductResponse models the JSON for a single product spit back by the API. +type singleProductResponse struct { + Product Product `json:"product"` +} + +// listProductsResponse models the JSON structure for multiple products. +type listProductsResponse struct { + Products []Product `json:"products"` +} + +// ListProductsOptions specifies optional query filters, such as country. +type ListProductsOptions struct { + Country string // e.g. "US" or "CA" +} + +// List retrieves all products, optionally filtering by country (and other options). +func (ps *ProductsService) List(opts *ListProductsOptions) ([]Product, error) { + base := fmt.Sprintf("%s/products", ps.client.BaseURL) + + // Build query parameters only if opts is provided + if opts != nil { + queryVals := url.Values{} + if opts.Country != "" { + queryVals.Set("country", opts.Country) + } + // Add additional query parameters as needed + encoded := queryVals.Encode() + if encoded != "" { + base += "?" + encoded + } + } + + req, err := http.NewRequest(http.MethodGet, base, nil) + if err != nil { + return nil, err + } + req.Header = ps.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var listResp listProductsResponse + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return nil, fmt.Errorf("failed to decode products list: %w", err) + } + return listResp.Products, nil +} + +// Create adds a new product to the Tremendous API. +func (ps *ProductsService) Create(args CreateProductArgs) (*Product, error) { + url := fmt.Sprintf("%s/products", ps.client.BaseURL) + + jsonBytes, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("failed to marshal CreateProductArgs: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBytes)) + if err != nil { + return nil, err + } + req.Header = ps.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Expect 201 Created or 200 is possible in some APIs + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var singleResp singleProductResponse + if err := json.NewDecoder(resp.Body).Decode(&singleResp); err != nil { + return nil, fmt.Errorf("failed to decode create product response: %w", err) + } + return &singleResp.Product, nil +} + +// Retrieve fetches a single product by its ID. +func (ps *ProductsService) Retrieve(productID string) (*Product, error) { + productID = strings.TrimSpace(productID) + url := fmt.Sprintf("%s/products/%s", ps.client.BaseURL, productID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header = ps.client.headers() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var singleResp singleProductResponse + if err := json.NewDecoder(resp.Body).Decode(&singleResp); err != nil { + return nil, fmt.Errorf("failed to decode single product: %w", err) + } + return &singleResp.Product, nil +}