diff --git a/README.md b/README.md index 1cbf715..ef256c5 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,19 @@ func main() { } ``` +### In App Purchase (via Amazon App Store) + +``` +import( + "github.com/dogenzaka/go-iap/amazon" +) + +func main() { + client := amazon.New("developerSecret") + + resp, err := client.Verify("userID", "receiptID") +} +``` # ToDo - [x] Validator for In App Purchase Receipt (AppStore) @@ -82,6 +95,9 @@ This validator supports the receipt type for iOS7 or above. ### In App Billing This validator uses [Version 3 API](http://developer.android.com/google/play/billing/api.html). +### In App Purchase (Amazon) +This validator uses [RVS for IAP v2.0](https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/verifying-receipts-in-iap-2.0). + # License go-iap is licensed under the MIT. diff --git a/amazon/validator.go b/amazon/validator.go new file mode 100644 index 0000000..87dafc3 --- /dev/null +++ b/amazon/validator.go @@ -0,0 +1,106 @@ +package amazon + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/parnurzeal/gorequest" +) + +const ( + SandboxURL string = "http://localhost:8080/RVSSandbox" + ProductionURL string = "https://appstore-sdk.amazon.com" +) + +// Config is a configuration to initialize client +type Config struct { + IsProduction bool + Secret string + TimeOut time.Duration +} + +// The IAPResponse type has the response properties +type IAPResponse struct { + ReceiptID string `json:"receiptId"` + ProductType string `json:"productType"` + ProductID string `json:"productId"` + PurchaseDate int64 `json:"purchaseDate"` + CancelDate int64 `json:"cancelDate"` + TestTransaction bool `json:"testTransaction"` +} + +type IAPResponseError struct { + Message string `json:"message"` + Status bool `json:"status"` +} + +// IAPClient is an interface to call validation API in Amazon App Store +type IAPClient interface { + Verify(string, string) (IAPResponse, error) +} + +// Client implements IAPClient +type Client struct { + URL string + Secret string + TimeOut time.Duration +} + +// New creates a client object +func New(secret string) IAPClient { + client := Client{ + URL: SandboxURL, + Secret: secret, + 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, + Secret: config.Secret, + TimeOut: config.TimeOut, + } + if config.IsProduction { + client.URL = ProductionURL + } + + return client +} + +// Verify sends receipts and gets validation result +func (c Client) Verify(userID string, receiptID string) (IAPResponse, error) { + result := IAPResponse{} + url := fmt.Sprintf("%v/version/1.0/verifyReceiptId/developer/%v/user/%v/receiptId/%v", c.URL, c.Secret, userID, receiptID) + res, body, errs := gorequest.New(). + Get(url). + Timeout(c.TimeOut). + End() + + if errs != nil { + return result, fmt.Errorf("%v", errs) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + responseError := IAPResponseError{} + json.NewDecoder(strings.NewReader(body)).Decode(&responseError) + return result, errors.New(responseError.Message) + } + + err := json.NewDecoder(strings.NewReader(body)).Decode(&result) + + return result, err +} diff --git a/amazon/validator_test.go b/amazon/validator_test.go new file mode 100644 index 0000000..44b8dd8 --- /dev/null +++ b/amazon/validator_test.go @@ -0,0 +1,169 @@ +package amazon + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + "time" +) + +func TestHandle497Error(t *testing.T) { + var expected, actual error + client := New("developerSecret") + + server, client := testTools( + 497, + "{\"message\":\"Purchase token/app user mismatch\",\"status\":false}", + ) + defer server.Close() + + // status 400 + expected = errors.New("Purchase token/app user mismatch") + _, actual = client.Verify( + "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", + "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", + ) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestHandle400Error(t *testing.T) { + var expected, actual error + client := New("developerSecret") + + server, client := testTools( + 400, + "{\"message\":\"Failed to parse receipt Id\",\"status\":false}", + ) + defer server.Close() + + // status 400 + expected = errors.New("Failed to parse receipt Id") + _, actual = client.Verify( + "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", + "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", + ) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNew(t *testing.T) { + expected := Client{ + URL: SandboxURL, + TimeOut: time.Second * 5, + Secret: "developerSecret", + } + + actual := New("developerSecret") + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNewWithEnvironment(t *testing.T) { + expected := Client{ + URL: ProductionURL, + TimeOut: time.Second * 5, + Secret: "developerSecret", + } + + os.Setenv("IAP_ENVIRONMENT", "production") + actual := New("developerSecret") + os.Clearenv() + + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNewWithConfig(t *testing.T) { + config := Config{ + IsProduction: true, + Secret: "developerSecret", + TimeOut: time.Second * 2, + } + + expected := Client{ + URL: ProductionURL, + TimeOut: time.Second * 2, + Secret: "developerSecret", + } + + actual := NewWithConfig(config) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestNewWithConfigTimeout(t *testing.T) { + config := Config{ + IsProduction: true, + Secret: "developerSecret", + } + + expected := Client{ + URL: ProductionURL, + TimeOut: time.Second * 5, + Secret: "developerSecret", + } + + actual := NewWithConfig(config) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestVerify(t *testing.T) { + server, client := testTools( + 200, + "{\"purchaseDate\":1402008634018,\"receiptId\":\"q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ\",\"productId\":\"com.amazon.iapsamplev2.expansion_set_3\",\"parentProductId\":null,\"productType\":\"ENTITLED\",\"cancelDate\":null,\"quantity\":1,\"betaProduct\":false,\"testTransaction\":true}", + ) + defer server.Close() + + expected := IAPResponse{ + ReceiptID: "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", + ProductType: "ENTITLED", + ProductID: "com.amazon.iapsamplev2.expansion_set_3", + PurchaseDate: 1402008634018, + CancelDate: 0, + TestTransaction: true, + } + + actual, _ := client.Verify( + "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", + "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", + ) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func TestVerifyTimeout(t *testing.T) { + // HTTP 100 is "continue" so it will time out + server, client := testTools(100, "timeout response") + defer server.Close() + + expected := errors.New("") + _, actual := client.Verify("timeout", "timeout") + if !reflect.DeepEqual(reflect.TypeOf(actual), reflect.TypeOf(expected)) { + t.Errorf("got %v\nwant %v", actual, expected) + } +} + +func testTools(code int, body string) (*httptest.Server, *Client) { + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, body) + })) + + client := &Client{URL: server.URL, TimeOut: time.Second * 2, Secret: "developerSecret"} + return server, client +}