diff --git a/example/src/main/java/com/stripe/example/controller/AsyncTaskTokenController.kt b/example/src/main/java/com/stripe/example/controller/AsyncTaskTokenController.kt index 00a12695ccd..6afb4fef8f0 100644 --- a/example/src/main/java/com/stripe/example/controller/AsyncTaskTokenController.kt +++ b/example/src/main/java/com/stripe/example/controller/AsyncTaskTokenController.kt @@ -45,7 +45,7 @@ class AsyncTaskTokenController( } progressDialogController.show(R.string.progressMessage) - stripe.createToken(cardToSave, tokenCallback) + stripe.createToken(cardToSave, callback = tokenCallback) } private class TokenCallbackImpl constructor( diff --git a/stripe/src/main/java/com/stripe/android/ApiRequest.kt b/stripe/src/main/java/com/stripe/android/ApiRequest.kt index fbad911f5f7..a50f21acda9 100644 --- a/stripe/src/main/java/com/stripe/android/ApiRequest.kt +++ b/stripe/src/main/java/com/stripe/android/ApiRequest.kt @@ -39,6 +39,10 @@ internal class ApiRequest internal constructor( options.stripeAccount?.let { mapOf("Stripe-Account" to it) }.orEmpty() + ).plus( + options.idempotencyKey?.let { + mapOf("Idempotency-Key" to it) + }.orEmpty() ).plus( languageTag?.let { mapOf("Accept-Language" to it) }.orEmpty() ) @@ -67,7 +71,7 @@ internal class ApiRequest internal constructor( @Throws(UnsupportedEncodingException::class, InvalidRequestException::class) override fun getOutputBytes(): ByteArray { - return createQuery().toByteArray(charset(CHARSET)) + return query.toByteArray(charset(CHARSET)) } override fun toString(): String { @@ -93,7 +97,8 @@ internal class ApiRequest internal constructor( */ internal data class Options internal constructor( val apiKey: String, - val stripeAccount: String? = null + internal val stripeAccount: String? = null, + internal val idempotencyKey: String? = null ) { init { ApiKeyValidator().requireValid(apiKey) diff --git a/stripe/src/main/java/com/stripe/android/Stripe.kt b/stripe/src/main/java/com/stripe/android/Stripe.kt index 8388fbf3234..e9aaf7e278a 100644 --- a/stripe/src/main/java/com/stripe/android/Stripe.kt +++ b/stripe/src/main/java/com/stripe/android/Stripe.kt @@ -501,7 +501,7 @@ class Stripe internal constructor( * See [Retrieve a source](https://stripe.com/docs/api/sources/retrieve). * * @param sourceId the [Source.id] field of the desired Source object - * @param clientSecret the [Source.getClientSecret] field of the desired Source object + * @param clientSecret the [Source.clientSecret] field of the desired Source object * @return a [Source] if one could be found based on the input params, or `null` if * no such Source could be found. * @throws AuthenticationException failure to properly authenticate yourself (check your key) @@ -530,11 +530,14 @@ class Stripe internal constructor( * See [Create an account token](https://stripe.com/docs/api/tokens/create_account). * * @param accountParams the [AccountParams] used to create this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) * @param callback a [ApiResultCallback] to receive the result or error */ @UiThread + @JvmOverloads fun createAccountToken( accountParams: AccountParams, + idempotencyKey: String? = null, callback: ApiResultCallback ) { val params = accountParams.toParamMap() @@ -542,6 +545,7 @@ class Stripe internal constructor( createTokenFromParams( params, Token.TokenType.ACCOUNT, + idempotencyKey, callback ) } @@ -553,7 +557,10 @@ class Stripe internal constructor( * See [Create an account token](https://stripe.com/docs/api/tokens/create_account). * * @param accountParams params to use for this token. + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) + * * @return a [Token] that can be used for this account. + * * @throws AuthenticationException failure to properly authenticate yourself (check your key) * @throws InvalidRequestException your request has invalid parameters * @throws APIConnectionException failure to connect to Stripe's API @@ -563,11 +570,15 @@ class Stripe internal constructor( @Throws(AuthenticationException::class, InvalidRequestException::class, APIConnectionException::class, APIException::class) @WorkerThread - fun createAccountTokenSynchronous(accountParams: AccountParams): Token? { + @JvmOverloads + fun createAccountTokenSynchronous( + accountParams: AccountParams, + idempotencyKey: String? = null + ): Token? { return try { stripeRepository.createToken( accountParams.toParamMap(), - ApiRequest.Options(publishableKey, stripeAccountId), + ApiRequest.Options(publishableKey, stripeAccountId, idempotencyKey), Token.TokenType.ACCOUNT ) } catch (exception: CardException) { @@ -582,11 +593,14 @@ class Stripe internal constructor( * See [Create a bank account token](https://stripe.com/docs/api/tokens/create_bank_account). * * @param bankAccount the [BankAccount] used to create this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) * @param callback a [ApiResultCallback] to receive the result or error */ @UiThread + @JvmOverloads fun createBankAccountToken( bankAccount: BankAccount, + idempotencyKey: String? = null, callback: ApiResultCallback ) { val params = bankAccount.toParamMap() @@ -594,6 +608,7 @@ class Stripe internal constructor( createTokenFromParams( params, Token.TokenType.BANK_ACCOUNT, + idempotencyKey, callback ) } @@ -605,7 +620,10 @@ class Stripe internal constructor( * See [Create a bank account token](https://stripe.com/docs/api/tokens/create_bank_account). * * @param bankAccount the [Card] to use for this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) + * * @return a [Token] that can be used for this [BankAccount] + * * @throws AuthenticationException failure to properly authenticate yourself (check your key) * @throws InvalidRequestException your request has invalid parameters * @throws APIConnectionException failure to connect to Stripe's API @@ -617,12 +635,16 @@ class Stripe internal constructor( @Throws(AuthenticationException::class, InvalidRequestException::class, APIConnectionException::class, CardException::class, APIException::class) @WorkerThread - fun createBankAccountTokenSynchronous(bankAccount: BankAccount): Token? { + @JvmOverloads + fun createBankAccountTokenSynchronous( + bankAccount: BankAccount, + idempotencyKey: String? = null + ): Token? { val params = bankAccount.toParamMap() .plus(stripeNetworkUtils.createUidParams()) return stripeRepository.createToken( params, - ApiRequest.Options(publishableKey, stripeAccountId), + ApiRequest.Options(publishableKey, stripeAccountId, idempotencyKey), Token.TokenType.BANK_ACCOUNT ) } @@ -630,20 +652,23 @@ class Stripe internal constructor( /** * Create a PII token asynchronously. * - * * See [Create a PII account token](https://stripe.com/docs/api/tokens/create_pii). * * @param personalId the personal id used to create this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) * @param callback a [ApiResultCallback] to receive the result or error */ @UiThread + @JvmOverloads fun createPiiToken( personalId: String, + idempotencyKey: String? = null, callback: ApiResultCallback ) { createTokenFromParams( PiiTokenParams(personalId).toParamMap(), Token.TokenType.PII, + idempotencyKey, callback ) } @@ -655,7 +680,10 @@ class Stripe internal constructor( * See [Create a PII account token](https://stripe.com/docs/api/tokens/create_pii). * * @param personalId the personal ID to use for this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) + * * @return a [Token] that can be used for this card + * * @throws AuthenticationException failure to properly authenticate yourself (check your key) * @throws InvalidRequestException your request has invalid parameters * @throws APIConnectionException failure to connect to Stripe's API @@ -665,10 +693,14 @@ class Stripe internal constructor( @Throws(AuthenticationException::class, InvalidRequestException::class, APIConnectionException::class, CardException::class, APIException::class) @WorkerThread - fun createPiiTokenSynchronous(personalId: String): Token? { + @JvmOverloads + fun createPiiTokenSynchronous( + personalId: String, + idempotencyKey: String? = null + ): Token? { return stripeRepository.createToken( PiiTokenParams(personalId).toParamMap(), - ApiRequest.Options(publishableKey, stripeAccountId), + ApiRequest.Options(publishableKey, stripeAccountId, idempotencyKey), Token.TokenType.PII ) } @@ -679,13 +711,20 @@ class Stripe internal constructor( * See [Create a card token](https://stripe.com/docs/api/tokens/create_card). * * @param card the [Card] used to create this payment token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) * @param callback a [ApiResultCallback] to receive the result or error */ @UiThread - fun createToken(card: Card, callback: ApiResultCallback) { + @JvmOverloads + fun createToken( + card: Card, + idempotencyKey: String? = null, + callback: ApiResultCallback + ) { createTokenFromParams( stripeNetworkUtils.createCardTokenParams(card), Token.TokenType.CARD, + idempotencyKey, callback ) } @@ -697,6 +736,8 @@ class Stripe internal constructor( * See [Create a card token](https://stripe.com/docs/api/tokens/create_card). * * @param card the [Card] to use for this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) + * * @return a [Token] that can be used for this card * @throws AuthenticationException failure to properly authenticate yourself (check your key) * @throws InvalidRequestException your request has invalid parameters @@ -708,10 +749,14 @@ class Stripe internal constructor( @Throws(AuthenticationException::class, InvalidRequestException::class, APIConnectionException::class, CardException::class, APIException::class) @WorkerThread - fun createCardTokenSynchronous(card: Card): Token? { + @JvmOverloads + fun createCardTokenSynchronous( + card: Card, + idempotencyKey: String? = null + ): Token? { return stripeRepository.createToken( stripeNetworkUtils.createCardTokenParams(card), - ApiRequest.Options(publishableKey, stripeAccountId), + ApiRequest.Options(publishableKey, stripeAccountId, idempotencyKey), Token.TokenType.CARD ) } @@ -720,16 +765,20 @@ class Stripe internal constructor( * Create a CVC update token asynchronously. * * @param cvc the CVC used to create this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) * @param callback a [ApiResultCallback] to receive the result or error */ @UiThread + @JvmOverloads fun createCvcUpdateToken( @Size(min = 3, max = 4) cvc: String, + idempotencyKey: String? = null, callback: ApiResultCallback ) { createTokenFromParams( CvcTokenParams(cvc).toParamMap(), Token.TokenType.CVC_UPDATE, + idempotencyKey, callback ) } @@ -739,7 +788,10 @@ class Stripe internal constructor( * or your app will crash. * * @param cvc the CVC to use for this token + * @param idempotencyKey optional, see [Idempotent Requests](https://stripe.com/docs/api/idempotent_requests) + * * @return a [Token] that can be used for this card + * * @throws AuthenticationException failure to properly authenticate yourself (check your key) * @throws InvalidRequestException your request has invalid parameters * @throws APIConnectionException failure to connect to Stripe's API @@ -749,10 +801,14 @@ class Stripe internal constructor( @Throws(AuthenticationException::class, InvalidRequestException::class, APIConnectionException::class, CardException::class, APIException::class) @WorkerThread - fun createCvcUpdateTokenSynchronous(cvc: String): Token? { + @JvmOverloads + fun createCvcUpdateTokenSynchronous( + cvc: String, + idempotencyKey: String? = null + ): Token? { return stripeRepository.createToken( CvcTokenParams(cvc).toParamMap(), - ApiRequest.Options(publishableKey, stripeAccountId), + ApiRequest.Options(publishableKey, stripeAccountId, idempotencyKey), Token.TokenType.CVC_UPDATE ) } @@ -760,11 +816,12 @@ class Stripe internal constructor( private fun createTokenFromParams( tokenParams: Map, @Token.TokenType tokenType: String, + idempotencyKey: String? = null, callback: ApiResultCallback ) { tokenCreator.create( tokenParams, - ApiRequest.Options(publishableKey, stripeAccountId), + ApiRequest.Options(publishableKey, stripeAccountId, idempotencyKey), tokenType, null, callback ) diff --git a/stripe/src/main/java/com/stripe/android/StripeRequest.kt b/stripe/src/main/java/com/stripe/android/StripeRequest.kt index ed04e20631a..98a10794ca8 100644 --- a/stripe/src/main/java/com/stripe/android/StripeRequest.kt +++ b/stripe/src/main/java/com/stripe/android/StripeRequest.kt @@ -22,14 +22,14 @@ internal abstract class StripeRequest( * @return if the HTTP method is [Method.GET], return URL with query string; * otherwise, return the URL */ - val url: String + internal val url: String @Throws(UnsupportedEncodingException::class, InvalidRequestException::class) get() = if (Method.GET == method) urlWithQuery() else baseUrl - val contentType: String + internal val contentType: String get() = "$mimeType; charset=$CHARSET" - val headers: Map + internal val headers: Map get() { return createHeaders() .plus(HEADER_USER_AGENT to getUserAgent()) @@ -40,21 +40,22 @@ internal abstract class StripeRequest( @Throws(UnsupportedEncodingException::class, InvalidRequestException::class) internal abstract fun getOutputBytes(): ByteArray - val baseHashCode: Int + internal val baseHashCode: Int get() = Objects.hash(method, baseUrl, params) internal abstract fun createHeaders(): Map - @Throws(InvalidRequestException::class, UnsupportedEncodingException::class) - fun createQuery(): String { - return flattenParams(params).joinToString("&") { - urlEncodePair(it.key, it.value) + internal val query: String + @Throws(InvalidRequestException::class, UnsupportedEncodingException::class) + get() { + return flattenParams(params).joinToString("&") { + urlEncodePair(it.key, it.value) + } } - } @Throws(InvalidRequestException::class, UnsupportedEncodingException::class) private fun urlWithQuery(): String { - val query = createQuery() + val query = this.query return if (query.isEmpty()) { baseUrl } else { diff --git a/stripe/src/test/java/com/stripe/android/ApiRequestTest.kt b/stripe/src/test/java/com/stripe/android/ApiRequestTest.kt index b5418e589af..274ca004df8 100644 --- a/stripe/src/test/java/com/stripe/android/ApiRequestTest.kt +++ b/stripe/src/test/java/com/stripe/android/ApiRequestTest.kt @@ -34,7 +34,7 @@ internal class ApiRequestTest { val stripeAccount = "acct_123abc" val headers = ApiRequest.createGet(StripeApiRepository.sourcesUrl, ApiRequest.Options(ApiKeyFixtures.FAKE_PUBLISHABLE_KEY, - stripeAccount), null) + stripeAccount)) .headers assertEquals( @@ -49,7 +49,7 @@ internal class ApiRequestTest { @Test fun getHeaders_withOnlyRequiredOptions_doesNotAddEmptyOptions() { val headerMap = ApiRequest.createGet(StripeApiRepository.sourcesUrl, - ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), null) + ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY)) .headers assertTrue(headerMap.containsKey("Stripe-Version")) @@ -81,7 +81,7 @@ internal class ApiRequestTest { @Test fun getHeaders_correctlyAddsExpectedAdditionalParameters() { val headerMap = ApiRequest.createGet(StripeApiRepository.sourcesUrl, - ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), null) + ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY)) .headers val expectedUserAgent = "Stripe/v1 AndroidBindings/${BuildConfig.VERSION_NAME}" @@ -94,9 +94,11 @@ internal class ApiRequestTest { @Throws(UnsupportedEncodingException::class, InvalidRequestException::class) fun createQuery_withCardData_createsProperQueryString() { val cardMap = NETWORK_UTILS.createCardTokenParams(CardFixtures.MINIMUM_CARD) - val query = ApiRequest.createGet(StripeApiRepository.sourcesUrl, ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), - cardMap, null) - .createQuery() + val query = ApiRequest.createGet( + StripeApiRepository.sourcesUrl, + ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), + cardMap + ).query val expectedValue = "muid=BF3BF4D775100923AAAFA82884FB759001162E28&product_usage=&guid=6367C48DD193D56EA7B0BAAD25B19455E529F5EE&card%5Bexp_month%5D=1&card%5Bexp_year%5D=2050&card%5Bnumber%5D=4242424242424242&card%5Bcvc%5D=123" assertEquals(expectedValue, query) @@ -105,7 +107,7 @@ internal class ApiRequestTest { @Test fun getContentType() { val contentType = ApiRequest.createGet(StripeApiRepository.sourcesUrl, - ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), null) + ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY)) .contentType assertEquals("application/x-www-form-urlencoded; charset=UTF-8", contentType) } @@ -114,7 +116,7 @@ internal class ApiRequestTest { @Throws(UnsupportedEncodingException::class, InvalidRequestException::class) fun getOutputBytes_withEmptyBody_shouldHaveZeroLength() { val output = ApiRequest.createPost(StripeApiRepository.paymentMethodsUrl, - ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), null) + ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY)) .getOutputBytes() assertEquals(0, output.size) } @@ -124,10 +126,11 @@ internal class ApiRequestTest { fun getOutputBytes_withNonEmptyBody_shouldHaveNonZeroLength() { val params = mapOf("customer" to "cus_123") - val output = ApiRequest.createPost(StripeApiRepository.paymentMethodsUrl, + val output = ApiRequest.createPost( + StripeApiRepository.paymentMethodsUrl, ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), - params, null) - .getOutputBytes() + params + ).getOutputBytes() assertEquals(16, output.size) } @@ -135,22 +138,29 @@ internal class ApiRequestTest { fun testEquals() { val params = mapOf("customer" to "cus_123") assertEquals( - ApiRequest.createPost(StripeApiRepository.paymentMethodsUrl, + ApiRequest.createPost( + StripeApiRepository.paymentMethodsUrl, ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), - params, null), - ApiRequest.createPost(StripeApiRepository.paymentMethodsUrl, + params + ), + ApiRequest.createPost( + StripeApiRepository.paymentMethodsUrl, ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), - params, null) + params + ) ) assertNotEquals( - ApiRequest.createPost(StripeApiRepository.paymentMethodsUrl, + ApiRequest.createPost( + StripeApiRepository.paymentMethodsUrl, ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY), - params, null), - ApiRequest.createPost(StripeApiRepository.paymentMethodsUrl, - ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY, - "acct"), - params, null) + params + ), + ApiRequest.createPost( + StripeApiRepository.paymentMethodsUrl, + ApiRequest.Options(ApiKeyFixtures.DEFAULT_PUBLISHABLE_KEY, "acct"), + params + ) ) } diff --git a/stripe/src/test/java/com/stripe/android/StripeTest.java b/stripe/src/test/java/com/stripe/android/StripeTest.java index 7eb011967c2..539fae15a47 100644 --- a/stripe/src/test/java/com/stripe/android/StripeTest.java +++ b/stripe/src/test/java/com/stripe/android/StripeTest.java @@ -14,6 +14,7 @@ import com.stripe.android.model.Address; import com.stripe.android.model.BankAccount; import com.stripe.android.model.Card; +import com.stripe.android.model.CardFixtures; import com.stripe.android.model.PaymentMethod; import com.stripe.android.model.PaymentMethodCreateParams; import com.stripe.android.model.PaymentMethodCreateParamsFixtures; @@ -30,6 +31,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.UUID; import java.util.concurrent.Executor; import org.junit.Before; @@ -45,6 +47,7 @@ import static com.stripe.android.CardNumberFixtures.VALID_VISA_NO_SPACES; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; @@ -1100,6 +1103,27 @@ public void run() throws Throwable { cardException.getMessage()); } + @Test + public void createTokenSynchronousTwice_withIdempotencyKey_returnsSameToken() + throws StripeException { + final Stripe stripe = createStripe(); + final String idempotencyKey = UUID.randomUUID().toString(); + assertEquals( + stripe.createCardTokenSynchronous(CardFixtures.MINIMUM_CARD, idempotencyKey), + stripe.createCardTokenSynchronous(CardFixtures.MINIMUM_CARD, idempotencyKey) + ); + } + + @Test + public void createTokenSynchronousTwice_withoutIdempotencyKey_returnsDifferentToken() + throws StripeException { + final Stripe stripe = createStripe(); + assertNotEquals( + stripe.createCardTokenSynchronous(CardFixtures.MINIMUM_CARD), + stripe.createCardTokenSynchronous(CardFixtures.MINIMUM_CARD) + ); + } + @Test public void createPaymentMethodSynchronous_withCard() throws StripeException {