~samwhited/paddle

7add74336ad0bc5cc7541c1b7f113697429bd08a — Sam Whited 2 years ago 3a8c9a2
paddle: initial handful of API endpoints
11 files changed, 640 insertions(+), 0 deletions(-)

A client.go
A coupons.go
A discount.go
A doc.go
A error.go
A go.mod
A license.go
A products.go
A roundtripper.go
A roundtripper_test.go
A time.go
A client.go => client.go +46 -0
@@ 0,0 1,46 @@
package paddle

import (
	"encoding/json"
	"net/http"
	"net/url"
	stdpath "path"
)

// NewClient returns a Paddle API client that wraps an existing HTTP client and
// adds auth support for all POST requests to the API endpoint.
func NewClient(c *http.Client, vendorID int, vendorAuthCode string) *Client {
	c.Transport = NewRoundTripper(c.Transport, vendorID, vendorAuthCode)
	return &Client{
		c: c,
	}
}

// Client is a paddle HTTP API client.
type Client struct {
	c *http.Client
}

// Do sends an HTTP POST request to the server and decodes common response
// fields.
func (c *Client) Do(path string, v url.Values) (json.RawMessage, error) {
	type commonResponse struct {
		Success  bool            `json:"success"`
		Err      Error           `json:"error,omitempty"`
		Response json.RawMessage `json:"response,omitempty"`
	}

	resp, err := c.c.PostForm(stdpath.Join(baseURL, path), v)
	if err != nil {
		return nil, err
	}
	r := &commonResponse{}
	err = json.NewDecoder(resp.Body).Decode(r)
	if err != nil {
		return nil, err
	}
	if r.Success == false {
		return nil, r.Err
	}
	return r.Response, err
}

A coupons.go => coupons.go +211 -0
@@ 0,0 1,211 @@
package paddle

import (
	"bytes"
	"encoding/json"
	"net/url"
	"strconv"
	"strings"
	"time"
)

const (
	createCouponsURL = "/product/create_coupon"
	listCouponsURL   = "/product/list_coupons"
	updateCouponsURL = "/product/update_coupon"
	deleteCouponsURL = "/product/delete_coupon"
)

// ListProducts requests a list of coupons available for the given product and
// returns an iterator that decodes them from the response body.
// If an error is returned, it is decoded and returned immediately.
func (c *Client) ListCoupons(productID int64) (*Coupons, error) {
	v := url.Values{}
	v.Add("product_id", strconv.FormatInt(productID, 10))

	msg, err := c.Do(listCouponsURL, v)
	if err != nil {
		return nil, err
	}
	return &Coupons{
		d: json.NewDecoder(bytes.NewReader(msg)),
	}, nil
}

// CreateCoupons creates a number of identical coupons.
// If num is greater than 1, coupon.Code is ignored and the codes are
// randomized.
// The coupon.Product field is ignored.
func (c *Client) CreateCoupons(num int, prefix, group string, couponType CouponType, recurring bool, coupon *Coupon) ([]string, error) {
	v := url.Values{}
	if num <= 1 {
		if coupon.Code != "" {
			v.Add("coupon_code", prefix+coupon.Code)
		}
	} else {
		v.Add("num_coupons", strconv.Itoa(num))
	}
	if prefix != "" && coupon.Code == "" {
		v.Add("coupon_prefix", prefix)
	}
	v.Add("description", coupon.Description)
	v.Add("coupon_type", string(couponType))
	var sb strings.Builder
	for _, product := range coupon.Product {
		if sb.Len() > 0 {
			sb.WriteByte(',')
		}
		/* #nosec */
		sb.WriteString(strconv.FormatInt(product, 10))
	}
	v.Add("product_ids", sb.String())
	v.Add("discount_type", string(coupon.DiscountType))
	v.Add("discount_amount", strconv.FormatFloat(coupon.DiscountAmount, 'f', -1, 64))
	if coupon.Currency != "" {
		v.Add("currency", coupon.Currency)
	}
	v.Add("allowed_uses", strconv.Itoa(coupon.AllowedUses))
	v.Add("expires", time.Time(coupon.Expires).Format(dateFmt))
	// TODO: paddle claims that when creating this you should use "0" or "1"… WTF?
	// See: https://paddle.com/docs/api-generate-coupon/
	v.Add("recurring", strconv.FormatBool(coupon.Recurring))
	v.Add("minimum_threshold", strconv.FormatFloat(coupon.Minimum, 'f', -1, 64))
	if group != "" {
		v.Add("group", group)
	}

	msg, err := c.Do(createCouponsURL, v)
	if err != nil {
		return nil, err
	}
	codes := []string{}
	if num > 0 {
		err = json.Unmarshal(msg, codes)
	}
	return codes, err
}

// UpdateCoupon updates properties of a single coupon.
//
// Empty values will be ignored.
// If products is nil, it will be ignored, if it is an empty slice, all products
// will be removed.
func (c *Client) UpdateCoupon(code string, coupon *Coupon) error {
	return c.updateCoupons("coupon_code", code, coupon.Code, coupon)
}

// UpdateCouponGroup updates properties of an entire group of coupons.
//
// See UpdateCoupon for more information.
func (c *Client) UpdateCouponGroup(group, newGroup string, coupon *Coupon) error {
	return c.updateCoupons("code", group, newGroup, coupon)
}

func (c *Client) updateCoupons(typ, code, newCode string, coupon *Coupon) error {
	v := url.Values{}
	v.Add(typ, code)
	if newCode != "" {
		v.Add("new_"+typ, newCode)
	}
	if coupon.Product != nil {
		var sb strings.Builder
		for _, product := range coupon.Product {
			if sb.Len() > 0 {
				sb.WriteByte(',')
			}
			/* #nosec */
			sb.WriteString(strconv.FormatInt(product, 10))
		}
		v.Add("product_ids", sb.String())
	}
	if !time.Time(coupon.Expires).IsZero() {
		v.Add("expires", time.Time(coupon.Expires).Format(dateFmt))
	}
	if coupon.AllowedUses > 0 {
		v.Add("allowed_uses", strconv.Itoa(coupon.AllowedUses))
	}
	if coupon.DiscountType != "" {
		if coupon.DiscountType == DiscountFlat {
			v.Add("currency", coupon.Currency)
		}
		v.Add("discount_amount", strconv.FormatFloat(coupon.DiscountAmount, 'f', -1, 64))
	}

	_, err := c.Do(updateCouponsURL, v)
	return err
}

// DeleteCoupon deletes the given coupon from the given product.
func (c *Client) DeleteCoupon(product int64, code string) error {
	v := url.Values{}
	v.Add("product_id", strconv.FormatInt(product, 64))
	v.Add("coupon_code", code)

	_, err := c.Do(deleteCouponsURL, v)
	return err
}

// Coupons provides a mechanism for lazily decoding and iterating over a list
// of coupons.
type Coupons struct {
	d *json.Decoder
}

// Next returns true if there are more coupons to decode.
func (c Coupons) Next() bool {
	return c.d.More()
}

// Decode returns the next coupon.
func (c Coupons) Decode() (*Coupon, error) {
	coupon := &Coupon{}
	err := c.d.Decode(coupon)
	return coupon, err
}

// Coupon represents a single coupon for a specific product.
type Coupon struct {
	ID             int64        `json:"id"`
	Code           string       `json:"coupon"`
	Description    string       `json:"description"`
	DiscountType   DiscountType `json:"discount_type"`
	DiscountAmount float64      `json:"discount_amount"`
	Currency       string       `json:"discount_currency"`
	AllowedUses    int          `json:"allowed_uses"`
	TimesUsed      int          `json:"times_used"`
	Recurring      bool         `json:"is_recurring"`
	Expires        Time         `json:"expires"`
	Product        []int64      `json:"-"`
	Minimum        float64      `json:"minimum_threshold"`
}

var _ json.Unmarshaler = (*Coupon)(nil)

// UnmarshalJSON satisfies the json.Unmarshaler interface for Coupon.
//
// It is necessary because when creating coupons a slice of product IDs is
// required, but when getting coupons an individual product ID is provided.
// When unmarshaling, convert the indivdiual product ID into a slice of length
// 1.
func (c *Coupon) UnmarshalJSON(b []byte) error {
	newC := struct {
		Coupon
		P int64 `json:"product_id"`
	}{}
	err := json.Unmarshal(b, &newC)
	if err != nil {
		return err
	}

	c.Product = []int64{newC.P}
	return nil
}

// CouponType is a specific type of coupon.
type CouponType string

// A list of valid coupon types.
const (
	CouponProduct  CouponType = "product"
	CouponCheckout CouponType = "checkout"
)

A discount.go => discount.go +10 -0
@@ 0,0 1,10 @@
package paddle

// DiscountType is a type of discount that can be applied with a coupon.
type DiscountType string

// A list of valid coupon types.
const (
	DiscountFlat       DiscountType = "flat"
	DiscountPercentage DiscountType = "percentage"
)

A doc.go => doc.go +2 -0
@@ 0,0 1,2 @@
// Package paddle is an HTTP API client for the Paddle payment processing service.
package paddle

A error.go => error.go +12 -0
@@ 0,0 1,12 @@
package paddle

// Error can unmarshal the "error" field of an API response.
type Error struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// Error implements the error interface for the Error type.
func (err Error) Error() string {
	return err.Message
}

A go.mod => go.mod +3 -0
@@ 0,0 1,3 @@
module soquee.net/paddle

go 1.11

A license.go => license.go +34 -0
@@ 0,0 1,34 @@
package paddle

import (
	"encoding/json"
	"net/url"
	"strconv"
	"time"
)

const createLicenseURL = "/product/generate_license"

// CreateLicense generates a new license code for a given SDK product.
func (c *Client) CreateLicense(productID int64, allowedUses int, expiresAt time.Time) (*License, error) {
	v := url.Values{}
	v.Add("product_id", strconv.FormatInt(productID, 10))
	v.Add("allowed_uses", strconv.Itoa(allowedUses))
	v.Add("expires_at", expiresAt.Format(dateFmt))
	msg, err := c.Do(createLicenseURL, v)
	if err != nil {
		return nil, err
	}
	l := &License{}
	err = json.Unmarshal(msg, l)
	if err != nil {
		return nil, err
	}
	return l, err
}

// License represents a license for an SDK product.
type License struct {
	Code      string `json:"license_code"`
	ExpiresAt Time   `json:"expires_at"`
}

A products.go => products.go +65 -0
@@ 0,0 1,65 @@
package paddle

import (
	"bytes"
	"encoding/json"
)

const listProductsURL = "/product/get_products"

// ListProducts requests a list of proucts available on your account and returns
// an iterator that decodes them from the response body.
// If an error is returned, it is decoded and returned immediately.
func (c *Client) ListProducts() (*Products, error) {
	msg, err := c.Do(listProductsURL, nil)
	if err != nil {
		return nil, err
	}
	pd := &productsDecode{}
	err = json.Unmarshal(msg, pd)
	if err != nil {
		return nil, err
	}
	return &Products{
		Total: pd.Total,
		Count: pd.Count,
		d:     json.NewDecoder(bytes.NewReader(pd.Products)),
	}, nil
}

type productsDecode struct {
	Total    int             `json:"total"`
	Count    int             `json:"count"`
	Products json.RawMessage `json:"products"`
}

// Products provides a mechanism for lazily decoding and iterating over a list
// of products.
type Products struct {
	Total int
	Count int
	d     *json.Decoder
}

// Next returns true if there are more products to decode.
func (p Products) Next() bool {
	return p.d.More()
}

// Decode returns the next product.
func (p Products) Decode() (*Product, error) {
	prod := &Product{}
	err := p.d.Decode(prod)
	return prod, err
}

// Product contains data from a single product.
type Product struct {
	ID          uint64   `json:"id"`
	Name        string   `json:"name"`
	Description string   `json:"description"`
	BasePrice   float64  `json:"base_price"`
	SalePrice   *int     `json:"sale_price"`
	Screenshots []string `json:"screenshots"`
	Icon        string   `json:"icon"`
}

A roundtripper.go => roundtripper.go +110 -0
@@ 0,0 1,110 @@
package paddle

import (
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
)

// Version is the Paddle API version that this package supports.
// It is exported for convenience.
const Version = "2.0"

const (
	apiHost = "vendors.paddle.com"
	baseURL = "https://" + apiHost + "/api/" + Version
)

// NewRoundTripper wraps an existing http.RoundTripper so that any POST request
// using content-type application/x-www-form-urlencoded sent through it to
// "https://vendors.paddle.com/api/version" will include authentication
// information.
// If a transport is nil, http.DefaultTransport is used instead.
//
// This is a lower level API exposed for advanced users; most of the time, users
// will want to create a Client instead.
func NewRoundTripper(transport http.RoundTripper, vendorID int, vendorAuthCode string) http.RoundTripper {
	if transport == nil {
		transport = http.DefaultTransport
	}
	if rt, ok := transport.(authRoundTripper); ok {
		return rt
	}

	v := url.Values{}
	v.Set("vendor_id", strconv.Itoa(vendorID))
	v.Set("vendor_auth_code", vendorAuthCode)
	encoded := v.Encode()
	return authRoundTripper{
		transport: transport,
		authData:  encoded,
	}
}

type authRoundTripper struct {
	transport http.RoundTripper
	authData  string
}

// RoundTrip fullfills the http.RoundTripper interface for authRoundTripper.
func (rt authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	if req == nil {
		return rt.transport.RoundTrip(req)
	}

	if req.Method == http.MethodPost &&
		req.URL.Scheme == "https" && req.URL.Host == apiHost &&
		strings.HasPrefix(req.URL.Path, "/api/"+Version) &&
		req.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
		cr := &checkRead{inner: req.Body}
		va := &valuesAppender{cr: cr, authData: rt.authData}
		req.Body = struct {
			io.Closer
			io.Reader
		}{
			Closer: req.Body,
			Reader: io.MultiReader(cr, va),
		}
	}
	return rt.transport.RoundTrip(req)
}

// checkRead is an io.ReadCloser that sets readSome if any data is ever read
// through it.
// Close calls are proxied.
type checkRead struct {
	inner    io.Reader
	readSome bool
}

func (r *checkRead) Read(p []byte) (int, error) {
	n, err := r.inner.Read(p)
	if n > 0 {
		r.readSome = true
	}

	return n, err
}

// valuesAppender checks if the cr has been read on its first read, and then
// sets up an inner reader that reads authData (prefixed with an "&" if
// required).
type valuesAppender struct {
	cr       *checkRead
	inner    io.Reader
	authData string
}

func (r *valuesAppender) Read(p []byte) (int, error) {
	if r.inner == nil {
		if r.cr.readSome {
			r.inner = strings.NewReader("&" + r.authData)
		} else {
			r.inner = strings.NewReader(r.authData)
		}
	}

	return r.inner.Read(p)
}

A roundtripper_test.go => roundtripper_test.go +116 -0
@@ 0,0 1,116 @@
package paddle_test

import (
	"io/ioutil"
	"net/http"
	"strconv"
	"strings"
	"testing"

	"soquee.net/paddle"
)

type roundTripperFunc func(req *http.Request) (*http.Response, error)

func (rt roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
	return rt(req)
}

func mustReq(req *http.Request, err error) *http.Request {
	if err != nil {
		panic(err)
	}
	return req
}

func setURLEncoded(req *http.Request, err error) *http.Request {
	req = mustReq(req, err)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return req
}

var rtTestCases = [...]struct {
	req *http.Request
	out string
}{
	// Don't set auth on non-POST methods
	0: {
		req: setURLEncoded(http.NewRequest("GET", "https://vendors.paddle.com/api/2.0", strings.NewReader(""))),
		out: "",
	},
	// Don't set auth on non-HTTPS
	1: {
		req: setURLEncoded(http.NewRequest("POST", "http://vendors.paddle.com/api/2.0", strings.NewReader("a=1&b=2"))),
		out: "a=1&b=2",
	},
	// Don't set auth if host not vendors.paddle.com
	2: {
		req: setURLEncoded(http.NewRequest("POST", "https://paddle.com/api/2.0", strings.NewReader("a=1&b=2"))),
		out: "a=1&b=2",
	},
	// Don't set auth if path not /api/version
	3: {
		req: setURLEncoded(http.NewRequest("POST", "https://paddle.com/api/1.0", strings.NewReader("a=1&b=2"))),
		out: "a=1&b=2",
	},
	// Don't set auth if not Content-Type: application/x-www-form-urlencoded
	4: {
		req: mustReq(http.NewRequest("POST", "https://vendors.paddle.com/api/2.0", strings.NewReader("a=1&b=2"))),
		out: "a=1&b=2",
	},
	// Must not append "&" if no existing body
	5: {
		req: setURLEncoded(http.NewRequest("POST", "https://vendors.paddle.com/api/2.0/test", strings.NewReader(""))),
		out: "vendor_auth_code=authcode&vendor_id=123",
	},
	// Must add "&" and append if existing body
	6: {
		req: setURLEncoded(http.NewRequest("POST", "https://vendors.paddle.com/api/2.0/test", strings.NewReader("a=1&b=2"))),
		out: "a=1&b=2&vendor_auth_code=authcode&vendor_id=123",
	},
}

func TestRoundTrip(t *testing.T) {
	for i, tc := range rtTestCases {
		t.Run(strconv.Itoa(i), func(t *testing.T) {
			var out string
			rt := paddle.NewRoundTripper(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
				buf, err := ioutil.ReadAll(req.Body)
				if err != nil {
					return nil, err
				}
				out = string(buf)
				return nil, nil
			}), 123, "authcode")

			_, err := rt.RoundTrip(tc.req)
			if err != nil {
				t.Errorf("Error while round tripping: %q", err)
			}
			if out != tc.out {
				t.Errorf("Unexpected output: want=%q, got=%q", tc.out, out)
			}
		})
	}
}

func TestDoubleWrap(t *testing.T) {
	rt := paddle.NewRoundTripper(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
		return &http.Response{Body: req.Body}, nil
	}), 123, "authcode")
	rt2 := paddle.NewRoundTripper(rt, 456, "newauthcode")
	req := setURLEncoded(http.NewRequest("POST", "https://vendors.paddle.com/api/2.0", strings.NewReader("a=1&b=2")))
	resp, err := rt2.RoundTrip(req)
	if err != nil {
		t.Fatalf("Unexpected error: %q", err)
	}
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("Unexpected error reading form test RoundTripper: %q", err)
	}

	const expected = "a=1&b=2&vendor_auth_code=authcode&vendor_id=123"
	if s := string(b); s != expected {
		t.Errorf("Unexpected output: want=%q, got=%q", expected, s)
	}
}

A time.go => time.go +31 -0
@@ 0,0 1,31 @@
package paddle

import (
	"encoding/json"
	"time"
)

const (
	dateFmt     = "2006-01-02"
	dateTimeFmt = "2006-01-02 15:04:05"
)

// Time is a custom time type that supports marshaling and unmarshaling Paddle's
// custom time format that ignores the standard JSON format.
type Time time.Time

var _ json.Unmarshaler = (*Time)(nil)

// UnmarshalJSON satisfies the json.Unmarshaler interface for Time.
func (t *Time) UnmarshalJSON(b []byte) error {
	newTime, err := time.Parse(dateFmt, string(b))
	if err != nil {
		newTime, err = time.Parse(dateTimeFmt, string(b))
		if err != nil {
			return err
		}
	}

	*t = Time(newTime)
	return nil
}