Skip to content

Commit

Permalink
Card: Remove try/catch from Vault Flow (#303)
Browse files Browse the repository at this point in the history
* Convert UpdateSetupTokenResult into a sealed class Success/Failure result type.

* Migrate CardClient vault flow away from throws.

* Rename GraphQLResponse to GraphQLResult.

* Migrate GraphQL client to result type.

* Write test to cover JSON parsing error.

* Fix test.

* Migrate resource loader away from throw.

* Fix DataVaultPaymentMethodTokensAPIUnitTest.

* Clean up detekt errors.

* Refactor DataVaultPaymentMethodTokensAPI to isolate JSON parsing code.

* Add test cases for error scenarios with UpdateSetupTokenAPI.

* Fix lint error.
  • Loading branch information
sshropshire authored Dec 4, 2024
1 parent d6028a0 commit 41f3f56
Show file tree
Hide file tree
Showing 14 changed files with 276 additions and 141 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import androidx.activity.ComponentActivity
import com.paypal.android.cardpayments.api.CheckoutOrdersAPI
import com.paypal.android.cardpayments.api.ConfirmPaymentSourceResult
import com.paypal.android.corepayments.CoreConfig
import com.paypal.android.corepayments.CoreCoroutineExceptionHandler
import com.paypal.android.corepayments.PayPalSDKError
import com.paypal.android.corepayments.analytics.AnalyticsService
import kotlinx.coroutines.CoroutineDispatcher
Expand Down Expand Up @@ -40,12 +39,6 @@ class CardClient internal constructor(

private var approveOrderId: String? = null

// TODO: remove once try-catch is removed
private val vaultExceptionHandler = CoreCoroutineExceptionHandler { error ->
analytics.notifyVaultUnknownError(null)
cardVaultListener?.onVaultFailure(error)
}

/**
* CardClient constructor
*
Expand Down Expand Up @@ -110,28 +103,31 @@ class CardClient internal constructor(
analytics.notifyVaultStarted(cardVaultRequest.setupTokenId)

val applicationContext = context.applicationContext
CoroutineScope(dispatcher).launch(vaultExceptionHandler) {
try {
val updateSetupTokenResult = cardVaultRequest.run {
paymentMethodTokensAPI.updateSetupToken(applicationContext, setupTokenId, card)
CoroutineScope(dispatcher).launch {
val updateSetupTokenResult = cardVaultRequest.run {
paymentMethodTokensAPI.updateSetupToken(applicationContext, setupTokenId, card)
}
when (updateSetupTokenResult) {
is UpdateSetupTokenResult.Success -> {
val approveHref = updateSetupTokenResult.approveHref
if (approveHref == null) {
analytics.notifyVaultSucceeded(updateSetupTokenResult.setupTokenId)
val result =
updateSetupTokenResult.run { CardVaultResult(setupTokenId, status) }
cardVaultListener?.onVaultSuccess(result)
} else {
analytics.notifyVaultAuthChallengeReceived(updateSetupTokenResult.setupTokenId)
val url = Uri.parse(approveHref)
val authChallenge =
CardAuthChallenge.Vault(url = url, request = cardVaultRequest)
cardVaultListener?.onVaultAuthorizationRequired(authChallenge)
}
}

val approveHref = updateSetupTokenResult.approveHref
if (approveHref == null) {
analytics.notifyVaultSucceeded(updateSetupTokenResult.setupTokenId)
val result =
updateSetupTokenResult.run { CardVaultResult(setupTokenId, status) }
cardVaultListener?.onVaultSuccess(result)
} else {
analytics.notifyVaultAuthChallengeReceived(updateSetupTokenResult.setupTokenId)
val url = Uri.parse(approveHref)
val authChallenge =
CardAuthChallenge.Vault(url = url, request = cardVaultRequest)
cardVaultListener?.onVaultAuthorizationRequired(authChallenge)
is UpdateSetupTokenResult.Failure -> {
analytics.notifyVaultFailed(cardVaultRequest.setupTokenId)
cardVaultListener?.onVaultFailure(updateSetupTokenResult.error)
}
} catch (error: PayPalSDKError) {
analytics.notifyVaultFailed(cardVaultRequest.setupTokenId)
throw error
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.paypal.android.cardpayments

import com.paypal.android.corepayments.PayPalSDKError
import com.paypal.android.corepayments.graphql.GraphQLError

internal object CardError {

Expand All @@ -27,4 +28,10 @@ internal object CardError {
code = CardErrorCode.BROWSER_SWITCH.ordinal,
errorDescription = cause.message ?: "Unable to Browser Switch"
)

fun updateSetupTokenResponseBodyMissing(errors: List<GraphQLError>?, correlationId: String?) = PayPalSDKError(
0,
"Error updating setup token: $errors",
correlationId
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package com.paypal.android.cardpayments

import android.content.Context
import com.paypal.android.corepayments.CoreConfig
import com.paypal.android.corepayments.LoadRawResourceResult
import com.paypal.android.corepayments.PayPalSDKError
import com.paypal.android.corepayments.ResourceLoader
import com.paypal.android.corepayments.graphql.GraphQLClient
import com.paypal.android.corepayments.graphql.GraphQLResult
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject

internal class DataVaultPaymentMethodTokensAPI internal constructor(
Expand All @@ -20,8 +23,23 @@ internal class DataVaultPaymentMethodTokensAPI internal constructor(
ResourceLoader()
)

suspend fun updateSetupToken(context: Context, setupTokenId: String, card: Card): UpdateSetupTokenResult {
val query = resourceLoader.loadRawResource(context, R.raw.graphql_query_update_setup_token)
suspend fun updateSetupToken(
context: Context,
setupTokenId: String,
card: Card
): UpdateSetupTokenResult = when (val result =
resourceLoader.loadRawResource(context, R.raw.graphql_query_update_setup_token)) {
is LoadRawResourceResult.Success ->
sendUpdateSetupTokenGraphQLRequest(result.value, setupTokenId, card)

is LoadRawResourceResult.Failure -> UpdateSetupTokenResult.Failure(result.error)
}

private suspend fun sendUpdateSetupTokenGraphQLRequest(
query: String,
setupTokenId: String,
card: Card
): UpdateSetupTokenResult {

val cardNumber = card.number.replace("\\s".toRegex(), "")
val cardExpiry = "${card.expirationYear}-${card.expirationMonth}"
Expand Down Expand Up @@ -57,33 +75,55 @@ internal class DataVaultPaymentMethodTokensAPI internal constructor(
.put("variables", variables)
val graphQLResponse =
graphQLClient.send(graphQLRequest, queryName = "UpdateVaultSetupToken")
graphQLResponse.data?.let { responseJSON ->
val setupTokenJSON = responseJSON.getJSONObject("updateVaultSetupToken")
return when (graphQLResponse) {
is GraphQLResult.Success -> {
val responseJSON = graphQLResponse.data
if (responseJSON == null) {
val error = graphQLResponse.run {
CardError.updateSetupTokenResponseBodyMissing(errors, correlationId)
}
UpdateSetupTokenResult.Failure(error)
} else {
parseSuccessfulUpdateSuccessJSON(responseJSON, graphQLResponse.correlationId)
}
}

is GraphQLResult.Failure -> {
UpdateSetupTokenResult.Failure(graphQLResponse.error)
}
}
}

private fun parseSuccessfulUpdateSuccessJSON(
responseBody: JSONObject,
correlationId: String?
): UpdateSetupTokenResult {
return try {
val setupTokenJSON = responseBody.getJSONObject("updateVaultSetupToken")
val status = setupTokenJSON.getString("status")
val approveHref = if (status == "PAYER_ACTION_REQUIRED") {
findLinkHref(setupTokenJSON, "approve")
} else {
null
}
return UpdateSetupTokenResult(
UpdateSetupTokenResult.Success(
setupTokenId = setupTokenJSON.getString("id"),
status = status,
approveHref = approveHref
)
} catch (jsonError: JSONException) {
val message = "Update Setup Token Failed: GraphQL JSON body was invalid."
val error = PayPalSDKError(0, message, correlationId, reason = jsonError)
UpdateSetupTokenResult.Failure(error)
}
throw PayPalSDKError(
0,
"Error updating setup token: ${graphQLResponse.errors}",
graphQLResponse.correlationId
)
}

private fun findLinkHref(responseJSON: JSONObject, rel: String): String? {
val linksJSON = responseJSON.optJSONArray("links") ?: JSONArray()
for (i in 0 until linksJSON.length()) {
val link = linksJSON.getJSONObject(i)
if (link.getString("rel") == rel) {
return link.getString("href")
if (link.optString("rel") == rel) {
return link.optString("href")
}
}
return null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.paypal.android.cardpayments

internal data class UpdateSetupTokenResult(
val setupTokenId: String,
val status: String,
val approveHref: String?
)
import com.paypal.android.corepayments.PayPalSDKError

internal sealed class UpdateSetupTokenResult {

data class Success(
val setupTokenId: String,
val status: String,
val approveHref: String?
) : UpdateSetupTokenResult()

data class Failure(val error: PayPalSDKError) : UpdateSetupTokenResult()
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class CardClientUnitTest {
val sut = createCardClient(testScheduler)

val updateSetupTokenResult =
UpdateSetupTokenResult("fake-setup-token-id-from-result", "fake-status", null)
UpdateSetupTokenResult.Success("fake-setup-token-id-from-result", "fake-status", null)
coEvery {
paymentMethodTokensAPI.updateSetupToken(applicationContext, "fake-setup-token-id", card)
} returns updateSetupTokenResult
Expand All @@ -171,7 +171,7 @@ class CardClientUnitTest {
val error = PayPalSDKError(0, "mock_error_message")
coEvery {
paymentMethodTokensAPI.updateSetupToken(applicationContext, "fake-setup-token-id", card)
} throws error
} returns UpdateSetupTokenResult.Failure(error)

sut.vault(activity, cardVaultRequest)
advanceUntilIdle()
Expand All @@ -187,7 +187,7 @@ class CardClientUnitTest {
fun `vault notifies listener when authorization is required`() = runTest {
val sut = createCardClient(testScheduler)

val updateSetupTokenResult = UpdateSetupTokenResult(
val updateSetupTokenResult = UpdateSetupTokenResult.Success(
"fake-setup-token-id-from-result",
"fake-status",
"/payer/action/href"
Expand Down
Loading

0 comments on commit 41f3f56

Please sign in to comment.