Merge pull request #1 from jun06t/master

Added go client to verify purchase receipt via AppStore or GooglePlay
This commit is contained in:
Junpei Tsuji
2014-12-05 02:21:14 +09:00
8 changed files with 593 additions and 0 deletions

12
.travis.yml Normal file
View File

@@ -0,0 +1,12 @@
language: go
go:
- 1.3
before_install:
sudo pip install codecov
install:
- make setup
script:
- make cover
after_success:
codecov

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
.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
go test -v -coverprofile=playstore.txt -covermode=count ./playstore
cat playstore.txt | grep -v "mode: count" >> coverage.txt
rm playstore.txt

View File

@@ -2,3 +2,40 @@ go-iap
====== ======
go-iap verifies the purchase receipt via AppStore or GooglePlayStore 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.

62
appstore/model.go Normal file
View File

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

118
appstore/validator.go Normal file
View File

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

112
appstore/validator_test.go Normal file
View File

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

127
playstore/validator.go Normal file
View File

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

107
playstore/validator_test.go Normal file
View File

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