From 1f1601cd3c27c484f508e5d2c7fb6204437f68a6 Mon Sep 17 00:00:00 2001 From: tsuji_jumpei Date: Thu, 4 Dec 2014 21:08:46 +0900 Subject: [PATCH 1/2] Initial push --- .travis.yml | 13 ++++ Makefile | 15 +++++ README.md | 37 ++++++++++++ appstore/model.go | 62 +++++++++++++++++++ appstore/validator.go | 118 +++++++++++++++++++++++++++++++++++++ appstore/validator_test.go | 112 +++++++++++++++++++++++++++++++++++ 6 files changed, 357 insertions(+) create mode 100644 .travis.yml create mode 100644 Makefile create mode 100644 appstore/model.go create mode 100644 appstore/validator.go create mode 100644 appstore/validator_test.go diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5339cb9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: go +go: +- 1.3 +before_install: + sudo pip install codecov +install: +- go get code.google.com/p/go.tools/cmd/cover +- go get ./... +script: +- make cover +after_success: + codecov + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d9b45c --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ + +.PHONEY: all setup test cover + +all: setup cover + +setup: + go get code.google.com/p/go.tools/cmd/cover + go get ./... + +test: + go test -v ./... + +cover: + go test -v -coverprofile=coverage.txt -covermode=count ./appstore + diff --git a/README.md b/README.md index e3ff76f..372c92a 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,40 @@ go-iap ====== go-iap verifies the purchase receipt via AppStore or GooglePlayStore + + +# Installation +``` +go get github.com/dogenzaka/go-iap +``` + + +# Quick Start + +### In App Purchase (via App Store) + +``` +import( + "github.com/dogenzaka/go-iap/appstore" +) + +func main() { + client := appstore.New() + req := appstore.IAPRequest{ + ReceiptData: "your receipt data encoded by base64", + } + resp, err := client.Verify(&req) +} +``` + +# ToDo +- [x] App Store Client +- [ ] Google Play Store Client + + +# Support +iOS7 or above + + +# License +Gorv is licensed under the MIT. diff --git a/appstore/model.go b/appstore/model.go new file mode 100644 index 0000000..a958159 --- /dev/null +++ b/appstore/model.go @@ -0,0 +1,62 @@ +package appstore + +type ( + // The IAPRequest type has the request parameter + IAPRequest struct { + ReceiptData string `json:"receipt-data"` + } + + // The RequestDate type indicates the date and time that the request was sent + RequestDate struct { + RequestDate string `json:"request_date"` + RequestDateMS string `json:"request_date_ms"` + RequestDatePST string `json:"request_date_pst"` + } + + // The PurchaseDate type indicates the date and time that the item was purchased + PurchaseDate struct { + PurchaseDate string `json:"purchase_date"` + PurchaseDateMS string `json:"purchase_date_ms"` + PurchaseDatePST string `json:"purchase_date_pst"` + } + + // The OriginalPurchaseDate type indicates the beginning of the subscription period + OriginalPurchaseDate struct { + OriginalPurchaseDate string `json:"original_purchase_date"` + OriginalPurchaseDateMS string `json:"original_purchase_date_ms"` + OriginalPurchaseDatePST string `json:"original_purchase_date_pst"` + } + + // The InApp type has the receipt attributes + InApp struct { + Quantity string `json:"quantity"` + ProductID string `json:"product_id"` + TransactionID string `json:"transaction_id"` + OriginalTransactionID string `json:"original_transaction_id"` + IsTrialPeriod string `json:"is_trial_period"` + ExpiresDate string `json:"expires_date"` + CancellationDate string `json:"cancellation_date"` + AppItemID string `json:"app_item_id"` + VersionExternalIdentifier string `json:"version_external_identifier"` + WebOrderLineItemID string `json:"web_order_line_item_id"` + PurchaseDate + OriginalPurchaseDate + } + + // The Receipt type has whole data of receipt + Receipt struct { + ReceiptType string `json:"receipt_type"` + BundleID string `json:"bundle_id"` + ApplicationVersion string `json:"application_version"` + OriginalApplicationVersion string `json:"original_application_version"` + InApp []InApp `json:"in_app"` + RequestDate + } + + // The IAPResponse type has the response properties + IAPResponse struct { + Status int `json:"status"` + Environment string `json:"environment"` + Receipt Receipt `json:"receipt"` + } +) diff --git a/appstore/validator.go b/appstore/validator.go new file mode 100644 index 0000000..e9c26af --- /dev/null +++ b/appstore/validator.go @@ -0,0 +1,118 @@ +package appstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/parnurzeal/gorequest" +) + +const ( + sandboxURL string = "https://sandbox.itunes.apple.com/verifyReceipt" + productionURL string = "https://buy.itunes.apple.com/verifyReceipt" +) + +// Config is a configuration to initialize client +type Config struct { + IsProduction bool + TimeOut time.Duration +} + +// IAPClient is an interface to call validation API in App Store +type IAPClient interface { + Verify(IAPRequest) (IAPResponse, error) +} + +// Client implements IAPClient +type Client struct { + URL string + TimeOut time.Duration +} + +// HandleError returns error message by status code +func HandleError(status int) error { + var message string + + switch status { + case 0: + return nil + + case 21000: + message = "The App Store could not read the JSON object you provided." + + case 21002: + message = "The data in the receipt-data property was malformed or missing." + + case 21003: + message = "The receipt could not be authenticated." + + case 21005: + message = "The receipt server is not currently available." + + case 21007: + message = "This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead." + + case 21008: + message = "This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead." + + default: + message = "An unknown error ocurred" + } + + return errors.New(message) +} + +// New creates a client object +func New() *Client { + client := &Client{ + URL: sandboxURL, + TimeOut: time.Second * 5, + } + if os.Getenv("IAP_ENVIRONMENT") == "production" { + client.URL = productionURL + } + return client +} + +// NewWithConfig creates a client with configuration +func NewWithConfig(config Config) *Client { + if config.TimeOut == 0 { + config.TimeOut = time.Second * 5 + } + + client := &Client{ + URL: sandboxURL, + TimeOut: config.TimeOut, + } + if config.IsProduction { + client.URL = productionURL + } + + return client +} + +// Verify sends receipts and gets validation result +func (c *Client) Verify(req *IAPRequest) (IAPResponse, error) { + result := IAPResponse{} + res, body, errs := gorequest.New(). + Post(c.URL). + Send(req). + Timeout(c.TimeOut). + End() + + if errs != nil { + return result, fmt.Errorf("%v", errs) + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return result, errors.New("An error occurred in IAP - code:" + strconv.Itoa(res.StatusCode)) + } + + err := json.NewDecoder(strings.NewReader(body)).Decode(&result) + + return result, err +} diff --git a/appstore/validator_test.go b/appstore/validator_test.go new file mode 100644 index 0000000..db67415 --- /dev/null +++ b/appstore/validator_test.go @@ -0,0 +1,112 @@ +package appstore + +import ( + "errors" + "reflect" + "testing" + "time" +) + +func TestHandleError(t *testing.T) { + var expected, actual error + + // status 0 + expected = nil + actual = HandleError(0) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status 21000 + expected = errors.New("The App Store could not read the JSON object you provided.") + actual = HandleError(21000) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status 21002 + expected = errors.New("The data in the receipt-data property was malformed or missing.") + actual = HandleError(21002) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status 21003 + expected = errors.New("The receipt could not be authenticated.") + actual = HandleError(21003) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status 21005 + expected = errors.New("The receipt server is not currently available.") + actual = HandleError(21005) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status 21007 + expected = errors.New("This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.") + actual = HandleError(21007) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status 21008 + expected = errors.New("This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.") + actual = HandleError(21008) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } + + // status unkown + expected = errors.New("An unknown error ocurred") + actual = HandleError(100) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNew(t *testing.T) { + expected := &Client{ + URL: "https://sandbox.itunes.apple.com/verifyReceipt", + TimeOut: time.Second * 5, + } + + actual := New() + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNewWithConfig(t *testing.T) { + config := Config{ + IsProduction: true, + TimeOut: time.Second * 2, + } + + expected := &Client{ + URL: "https://buy.itunes.apple.com/verifyReceipt", + TimeOut: time.Second * 2, + } + + actual := NewWithConfig(config) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestVerify(t *testing.T) { + client := New() + + expected := IAPResponse{ + Status: 21002, + } + req := IAPRequest{ + ReceiptData: "dummy data", + } + actual, _ := client.Verify(&req) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} From 99e9a4e75919669b967c683d4d9315720aa93968 Mon Sep 17 00:00:00 2001 From: tsuji_jumpei Date: Thu, 4 Dec 2014 22:35:01 +0900 Subject: [PATCH 2/2] Added the subscription validator for in app billing --- .travis.yml | 3 +- Makefile | 3 + appstore/validator.go | 8 +-- appstore/validator_test.go | 4 +- playstore/validator.go | 127 ++++++++++++++++++++++++++++++++++++ playstore/validator_test.go | 107 ++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 playstore/validator.go create mode 100644 playstore/validator_test.go diff --git a/.travis.yml b/.travis.yml index 5339cb9..dc16968 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,7 @@ go: before_install: sudo pip install codecov install: -- go get code.google.com/p/go.tools/cmd/cover -- go get ./... +- make setup script: - make cover after_success: diff --git a/Makefile b/Makefile index 5d9b45c..673751c 100644 --- a/Makefile +++ b/Makefile @@ -12,4 +12,7 @@ test: cover: go test -v -coverprofile=coverage.txt -covermode=count ./appstore + go test -v -coverprofile=playstore.txt -covermode=count ./playstore + cat playstore.txt | grep -v "mode: count" >> coverage.txt + rm playstore.txt diff --git a/appstore/validator.go b/appstore/validator.go index e9c26af..dd1fb90 100644 --- a/appstore/validator.go +++ b/appstore/validator.go @@ -68,8 +68,8 @@ func HandleError(status int) error { } // New creates a client object -func New() *Client { - client := &Client{ +func New() Client { + client := Client{ URL: sandboxURL, TimeOut: time.Second * 5, } @@ -80,12 +80,12 @@ func New() *Client { } // NewWithConfig creates a client with configuration -func NewWithConfig(config Config) *Client { +func NewWithConfig(config Config) Client { if config.TimeOut == 0 { config.TimeOut = time.Second * 5 } - client := &Client{ + client := Client{ URL: sandboxURL, TimeOut: config.TimeOut, } diff --git a/appstore/validator_test.go b/appstore/validator_test.go index db67415..1af6d9a 100644 --- a/appstore/validator_test.go +++ b/appstore/validator_test.go @@ -68,7 +68,7 @@ func TestHandleError(t *testing.T) { } func TestNew(t *testing.T) { - expected := &Client{ + expected := Client{ URL: "https://sandbox.itunes.apple.com/verifyReceipt", TimeOut: time.Second * 5, } @@ -85,7 +85,7 @@ func TestNewWithConfig(t *testing.T) { TimeOut: time.Second * 2, } - expected := &Client{ + expected := Client{ URL: "https://buy.itunes.apple.com/verifyReceipt", TimeOut: time.Second * 2, } diff --git a/playstore/validator.go b/playstore/validator.go new file mode 100644 index 0000000..97164ea --- /dev/null +++ b/playstore/validator.go @@ -0,0 +1,127 @@ +package playstore + +import ( + "errors" + "net" + "net/http" + "os" + "time" + + "code.google.com/p/goauth2/oauth" + "code.google.com/p/google-api-go-client/androidpublisher/v2" +) + +const ( + scope = "https://www.googleapis.com/auth/androidpublisher" + authURL = "https://accounts.google.com/o/oauth2/auth" + tokenURL = "https://accounts.google.com/o/oauth2/token" + + timeout = time.Second * 5 +) + +var defaultConfig *oauth.Config +var defaultTimeout = timeout + +// Init initializes the global configuration +func Init() error { + defaultConfig = &oauth.Config{ + Scope: scope, + AuthURL: authURL, + TokenURL: tokenURL, + } + + clientID := os.Getenv("IAB_CLIENT_ID") + if clientID != "" { + defaultConfig.ClientId = clientID + } + if defaultConfig.ClientId == "" { + return errors.New("Client ID is required") + } + + clientSecret := os.Getenv("IAB_CLIENT_SECRET") + if clientSecret != "" { + defaultConfig.ClientSecret = clientSecret + } + if defaultConfig.ClientSecret == "" { + return errors.New("Client Secret Key is required") + } + + return nil +} + +// InitWithConfig initializes the global configuration with parameters +func InitWithConfig(config *oauth.Config) error { + if config.ClientId == "" { + return errors.New("Client ID is required") + } + + if config.ClientSecret == "" { + return errors.New("Client Secret Key is required") + } + + if config.Scope == "" { + config.Scope = scope + } + + if config.AuthURL == "" { + config.AuthURL = authURL + } + + if config.TokenURL == "" { + config.TokenURL = tokenURL + } + + defaultConfig = config + + return nil +} + +// SetTimeout sets dial timeout duration +func SetTimeout(t time.Duration) { + defaultTimeout = t +} + +// The IABClient type is an interface to verify purchase token +type IABClient interface { + VerifySubscription(string, string, string) (*androidpublisher.SubscriptionPurchase, error) +} + +// The Client type implements VerifySubscription method +type Client struct { + httpClient *http.Client +} + +// New returns http client which has oauth token +func New(token *oauth.Token) Client { + t := &oauth.Transport{ + Token: token, + Config: defaultConfig, + Transport: &http.Transport{ + Dial: dialTimeout, + }, + } + + httpClient := t.Client() + return Client{httpClient} +} + +// VerifySubscription Verifies subscription status +func (c *Client) VerifySubscription( + packageName string, + subscriptionID string, + token string, +) (*androidpublisher.SubscriptionPurchase, error) { + service, err := androidpublisher.New(c.httpClient) + if err != nil { + return nil, err + } + + ps := androidpublisher.NewPurchasesSubscriptionsService(service) + result, err := ps.Get(packageName, subscriptionID, token).Do() + + return result, err +} + +func dialTimeout(network, addr string) (net.Conn, error) { + return net.DialTimeout(network, addr, defaultTimeout) +} diff --git a/playstore/validator_test.go b/playstore/validator_test.go new file mode 100644 index 0000000..343d356 --- /dev/null +++ b/playstore/validator_test.go @@ -0,0 +1,107 @@ +package playstore + +import ( + "os" + "reflect" + "testing" + "time" + + "code.google.com/p/goauth2/oauth" +) + +func TestInit(t *testing.T) { + expected := &oauth.Config{ + ClientId: "dummyId", + ClientSecret: "dummySecret", + Scope: "https://www.googleapis.com/auth/androidpublisher", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", + } + os.Setenv("IAB_CLIENT_ID", "dummyId") + os.Setenv("IAB_CLIENT_SECRET", "dummySecret") + Init() + os.Clearenv() + actual := defaultConfig + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestInitWithConfig(t *testing.T) { + expected := &oauth.Config{ + ClientId: "dummyId", + ClientSecret: "dummySecret", + Scope: "https://www.googleapis.com/auth/androidpublisher", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", + } + + config := &oauth.Config{ + ClientId: "dummyId", + ClientSecret: "dummySecret", + Scope: "https://www.googleapis.com/auth/androidpublisher", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", + } + InitWithConfig(config) + actual := defaultConfig + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNew(t *testing.T) { + // Initialize config + _config := &oauth.Config{ + ClientId: "dummyId", + ClientSecret: "dummySecret", + } + InitWithConfig(_config) + + token := &oauth.Token{ + AccessToken: "accessToken", + RefreshToken: "refreshToken", + Expiry: time.Unix(1234567890, 0).UTC(), + } + + actual := New(token) + val, _ := actual.httpClient.Transport.(*oauth.Transport) + + if !reflect.DeepEqual(val.Config, _config) { + t.Errorf("got %v\nwant %v", val.Config, _config) + } + + if !reflect.DeepEqual(val.Token, token) { + t.Errorf("got %v\nwant %v", val.Token, token) + } +} + +func TestSetTimeout(t *testing.T) { + timeout := time.Second * 3 + SetTimeout(timeout) + + if defaultTimeout != timeout { + t.Errorf("got %#v\nwant %#v", defaultTimeout, timeout) + } +} + +func TestVerifySubscription(t *testing.T) { + Init() + + // Exception scenario + token := &oauth.Token{ + AccessToken: "accessToken", + RefreshToken: "refreshToken", + Expiry: time.Unix(1234567890, 0).UTC(), + } + + client := New(token) + expected := "Get https://www.googleapis.com/androidpublisher/v2/applications/package/purchases/subscriptions/subscriptionID/tokens/purchaseToken?alt=json: OAuthError: updateToken: Unexpected HTTP status 400 Bad Request" + _, err := client.VerifySubscription("package", "subscriptionID", "purchaseToken") + + if err.Error() != expected { + t.Errorf("got %v", err) + } + + // TODO Nomal scenario +}