initial commit

Initial commit
This commit is contained in:
Adriano Caloiaro 2025-03-24 15:57:35 -06:00
commit da7e4f4d34
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
13 changed files with 689 additions and 0 deletions

2
.envrc Normal file
View file

@ -0,0 +1,2 @@
source .env

5
.gitignore vendored Normal file
View file

@ -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/

21
LICENSE Normal file
View file

@ -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.

13
README.md Normal file
View file

@ -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.

92
campaigns.go Normal file
View file

@ -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
}

52
client.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/acaloiaro/go-tremendous
go 1.23.0

198
orders.go Normal file
View file

@ -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
}

160
products.go Normal file
View file

@ -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
}