diff --git a/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt b/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt index 9597b3f9..2b0450ec 100644 --- a/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt +++ b/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt @@ -5,10 +5,11 @@ import com.basistheory.CreateTokenRequest import com.basistheory.CreateTokenResponse import com.basistheory.Token import com.basistheory.android.model.ElementValueReference -import com.basistheory.android.model.exceptions.IncompleteElementException import com.basistheory.android.util.isPrimitiveType import com.basistheory.android.util.toMap import com.basistheory.android.util.transformResponseToValueReferences +import com.basistheory.android.util.replaceElementRefs +import com.basistheory.android.util.tryGetTextToTokenize import com.basistheory.android.view.TextElement import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -72,37 +73,6 @@ class BasisTheoryElements internal constructor( } } - private fun replaceElementRefs(map: MutableMap): MutableMap { - for ((key, value) in map) { - if (value == null) continue - val fieldType = value::class.java - if (!fieldType.isPrimitiveType()) { - when (value) { - is TextElement -> { - map[key] = value.tryGetTextToTokenize() - } - is ElementValueReference -> { - map[key] = value.getValue() - } - else -> { - val children = value.toMap() - map[key] = children - replaceElementRefs(children) - } - } - } - } - - return map - } - - private fun TextElement.tryGetTextToTokenize(): String? { - if (!this.isComplete) - throw IncompleteElementException(this.id) - - return this.getTransformedText() - } - companion object { @JvmStatic fun builder(): BasisTheoryElementsBuilder = BasisTheoryElementsBuilder() diff --git a/lib/src/main/java/com/basistheory/android/service/ProxyApi.kt b/lib/src/main/java/com/basistheory/android/service/ProxyApi.kt index c809f372..3840780e 100644 --- a/lib/src/main/java/com/basistheory/android/service/ProxyApi.kt +++ b/lib/src/main/java/com/basistheory/android/service/ProxyApi.kt @@ -3,6 +3,10 @@ package com.basistheory.android.service import com.basistheory.ApiClient import com.basistheory.android.model.ElementValueReference import com.basistheory.android.util.transformResponseToValueReferences +import com.basistheory.android.util.isPrimitiveType +import com.basistheory.android.util.toMap +import com.basistheory.android.util.replaceElementRefs +import com.basistheory.android.view.TextElement import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -68,6 +72,14 @@ class ProxyApi( private fun proxy(method: String, proxyRequest: ProxyRequest, apiKeyOverride: String?): Any? { val apiClient = apiClientProvider(apiKeyOverride) + var body = proxyRequest.body + + if (body != null) { + body = if (body::class.java.isPrimitiveType()) body + else if (body is TextElement) body.getTransformedText() + else if (body is ElementValueReference) body.getValue() + else replaceElementRefs(body.toMap()) + } val call = apiClient.buildCall( "${apiClient.basePath}/proxy", @@ -75,7 +87,7 @@ class ProxyApi( method, proxyRequest.queryParams?.toPairs(), emptyList(), - proxyRequest.body, + body, proxyRequest.headers, emptyMap(), emptyMap(), diff --git a/lib/src/main/java/com/basistheory/android/util/DataManipulationUtils.kt b/lib/src/main/java/com/basistheory/android/util/DataManipulationUtils.kt index 3bcae9c6..745903d6 100644 --- a/lib/src/main/java/com/basistheory/android/util/DataManipulationUtils.kt +++ b/lib/src/main/java/com/basistheory/android/util/DataManipulationUtils.kt @@ -1,6 +1,8 @@ package com.basistheory.android.util import com.basistheory.android.model.ElementValueReference +import com.basistheory.android.model.exceptions.IncompleteElementException +import com.basistheory.android.view.TextElement fun transformResponseToValueReferences(data: Any?): Any? = if (data == null) null @@ -13,5 +15,36 @@ fun transformResponseToValueReferences(data: Any?): Any? = map } +internal fun replaceElementRefs(map: MutableMap): MutableMap { + for ((key, value) in map) { + if (value == null) continue + val fieldType = value::class.java + if (!fieldType.isPrimitiveType()) { + when (value) { + is TextElement -> { + map[key] = value.tryGetTextToTokenize() + } + is ElementValueReference -> { + map[key] = value.getValue() + } + else -> { + val children = value.toMap() + map[key] = children + replaceElementRefs(children) + } + } + } + } + + return map +} + +internal fun TextElement.tryGetTextToTokenize(): String? { + if (!this.isComplete) + throw IncompleteElementException(this.id) + + return this.getTransformedText() +} + private fun String.toElementValueReference(): ElementValueReference = ElementValueReference { this } \ No newline at end of file diff --git a/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt b/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt index b44dc8b6..a3030d3d 100644 --- a/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt +++ b/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt @@ -7,6 +7,9 @@ import com.basistheory.SessionsApi import com.basistheory.Token import com.basistheory.TokenizeApi import com.basistheory.TokensApi +import com.basistheory.ApiResponse +import com.basistheory.ApiClient +import com.basistheory.android.model.ElementValueReference import com.basistheory.android.model.exceptions.IncompleteElementException import com.basistheory.android.view.CardExpirationDateElement import com.basistheory.android.view.CardNumberElement @@ -17,10 +20,15 @@ import io.mockk.Called import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.impl.annotations.SpyK import io.mockk.junit4.MockKRule +import io.mockk.spyk import io.mockk.verify +import io.mockk.slot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import okhttp3.Call +import okio.Buffer import org.junit.Before import org.junit.Rule import org.junit.Test @@ -33,6 +41,7 @@ import strikt.assertions.isA import strikt.assertions.isEqualTo import strikt.assertions.isFailure import strikt.assertions.isNotEqualTo +import strikt.assertions.isNull import java.time.Instant import java.time.LocalDate import java.time.temporal.ChronoUnit @@ -90,6 +99,13 @@ class BasisTheoryElementsTests { @InjectMockKs private lateinit var bt: BasisTheoryElements + @SpyK + private var apiClient: ApiClient = spyk() + + private val testProxyApi: ProxyApi = ProxyApi(Dispatchers.IO) { apiClient } + + private var proxyRequest: ProxyRequest = ProxyRequest() + @Before fun setUp() { val activity = Robolectric.buildActivity(Activity::class.java).get() @@ -473,6 +489,196 @@ class BasisTheoryElementsTests { verify { tokensApi.create(expectedCreateTokenRequest) } } + @Test + fun `proxy should replace Element refs within request object with underlying data values`() { + val name = faker.name().fullName() + nameElement.setText(name) + + val phoneNumber = faker.phoneNumber().phoneNumber() + phoneNumberElement.setText(phoneNumber) + + + var data = object { + val name = nameElement + val phone = phoneNumberElement + } + + val stringifiedData = "{\"name\":\"${name}\",\"phone\":\"${phoneNumber}\"}" + + proxyRequest = proxyRequest.apply { + headers = mapOf( + "BT-PROXY-URL" to "https://echo.basistheory.com/post", + "Content-Type" to "application/json" + ) + body = data + } + + val callSlot = slot() + every { apiClient.execute(capture(callSlot), any()) } returns ApiResponse( + 200, + emptyMap(), + "Hello World" + ) + + val result = runBlocking { + testProxyApi.post(proxyRequest) + } + + verify(exactly = 1) { apiClient.execute(any(), any()) } + + expectThat(callSlot.captured.request()) { + get { headers["BT-PROXY-URL"] }.isEqualTo("https://echo.basistheory.com/post") + get { body?.contentType()?.type }.isEqualTo("application") + get { body?.contentType()?.subtype }.isEqualTo("json") + + if (this.subject.body != null) { + val buffer = Buffer() + this.subject.body!!.writeTo(buffer) + val bodyInRequest = buffer.readUtf8() + expectThat(bodyInRequest).isEqualTo(stringifiedData) + } else { + get { body }.isNull() + } + } + + expectThat(result).isA() + } + + @Test + fun `proxy should replace top level TextElement ref with underlying data value`() { + val name = faker.name().fullName() + nameElement.setText(name) + + proxyRequest = proxyRequest.apply { + headers = mapOf( + "BT-PROXY-URL" to "https://echo.basistheory.com/post", + "Content-Type" to "text/plain" + ) + body = nameElement + } + + val callSlot = slot() + every { apiClient.execute(capture(callSlot), any()) } returns ApiResponse( + 200, + emptyMap(), + "Hello World" + ) + + val result = runBlocking { + testProxyApi.post(proxyRequest) + } + + verify(exactly = 1) { apiClient.execute(any(), any()) } + + expectThat(callSlot.captured.request()) { + get { headers["BT-PROXY-URL"] }.isEqualTo("https://echo.basistheory.com/post") + get { body?.contentType()?.type }.isEqualTo("text") + get { body?.contentType()?.subtype }.isEqualTo("plain") + + if (this.subject.body != null) { + val buffer = Buffer() + this.subject.body!!.writeTo(buffer) + val bodyInRequest = buffer.readUtf8() + expectThat(bodyInRequest).isEqualTo(name) + } else { + get { body }.isNull() + } + } + + expectThat(result).isA() + } + + @Test + fun `proxy should replace top level CardNumberElement ref with underlying data value`() { + val cardNumber = testCardNumbers.random() + cardNumberElement.setText(cardNumber) + + proxyRequest = proxyRequest.apply { + headers = mapOf( + "BT-PROXY-URL" to "https://echo.basistheory.com/post", + "Content-Type" to "text/plain" + ) + body = cardNumberElement + } + + val callSlot = slot() + every { apiClient.execute(capture(callSlot), any()) } returns ApiResponse( + 200, + emptyMap(), + "Hello World" + ) + + val result = runBlocking { + testProxyApi.post(proxyRequest) + } + + verify(exactly = 1) { apiClient.execute(any(), any()) } + + expectThat(callSlot.captured.request()) { + get { headers["BT-PROXY-URL"] }.isEqualTo("https://echo.basistheory.com/post") + get { body?.contentType()?.type }.isEqualTo("text") + get { body?.contentType()?.subtype }.isEqualTo("plain") + + if (this.subject.body != null) { + val buffer = Buffer() + this.subject.body!!.writeTo(buffer) + val bodyInRequest = buffer.readUtf8() + expectThat(bodyInRequest).isEqualTo(cardNumber.replace(Regex("""[^\d]"""), "")) + } else { + get { body }.isNull() + } + } + + expectThat(result).isA() + } + + @Test + fun `proxy should replace top level CardExpirationDateElement ref with underlying data value`() { + val expDate = LocalDate.now().plus(2, ChronoUnit.YEARS) + val month = expDate.monthValue.toString().padStart(2, '0') + val year = expDate.year.toString() + val expDateString = "$month/${year.takeLast(2)}" + cardExpElement.setText(expDateString) + + proxyRequest = proxyRequest.apply { + headers = mapOf( + "BT-PROXY-URL" to "https://echo.basistheory.com/post", + "Content-Type" to "text/plain" + ) + body = cardExpElement + } + + val callSlot = slot() + every { apiClient.execute(capture(callSlot), any()) } returns ApiResponse( + 200, + emptyMap(), + "Hello World" + ) + + val result = runBlocking { + testProxyApi.post(proxyRequest) + } + + verify(exactly = 1) { apiClient.execute(any(), any()) } + + expectThat(callSlot.captured.request()) { + get { headers["BT-PROXY-URL"] }.isEqualTo("https://echo.basistheory.com/post") + get { body?.contentType()?.type }.isEqualTo("text") + get { body?.contentType()?.subtype }.isEqualTo("plain") + + if (this.subject.body != null) { + val buffer = Buffer() + this.subject.body!!.writeTo(buffer) + val bodyInRequest = buffer.readUtf8() + expectThat(bodyInRequest).isEqualTo(expDateString) + } else { + get { body }.isNull() + } + } + + expectThat(result).isA() + } + // note: junit only supports one @RunWith class per test class, so we can't use JUnitParamsRunner here @Test fun `throws IncompleteElementException when attempting to tokenize luhn-invalid card`() =