Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow passing elements in proxy body #51

Merged
merged 1 commit into from
Mar 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,37 +73,6 @@ class BasisTheoryElements internal constructor(
}
}

private fun replaceElementRefs(map: MutableMap<String, Any?>): MutableMap<String, Any?> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved this to DataManipulationUtils to reuse w/ proxy.

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()
Expand Down
14 changes: 13 additions & 1 deletion lib/src/main/java/com/basistheory/android/service/ProxyApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,14 +72,22 @@ 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",
proxyRequest.path ?: "",
method,
proxyRequest.queryParams?.toPairs(),
emptyList(),
proxyRequest.body,
body,
proxyRequest.headers,
emptyMap(),
emptyMap(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,5 +15,36 @@ fun transformResponseToValueReferences(data: Any?): Any? =
map
}

internal fun replaceElementRefs(map: MutableMap<String, Any?>): MutableMap<String, Any?> {
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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<Call>()
every { apiClient.execute<Any>(capture(callSlot), any()) } returns ApiResponse(
200,
emptyMap(),
"Hello World"
)

val result = runBlocking {
testProxyApi.post(proxyRequest)
}

verify(exactly = 1) { apiClient.execute<Any>(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<ElementValueReference>()
}

@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<Call>()
every { apiClient.execute<Any>(capture(callSlot), any()) } returns ApiResponse(
200,
emptyMap(),
"Hello World"
)

val result = runBlocking {
testProxyApi.post(proxyRequest)
}

verify(exactly = 1) { apiClient.execute<Any>(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<ElementValueReference>()
}

@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<Call>()
every { apiClient.execute<Any>(capture(callSlot), any()) } returns ApiResponse(
200,
emptyMap(),
"Hello World"
)

val result = runBlocking {
testProxyApi.post(proxyRequest)
}

verify(exactly = 1) { apiClient.execute<Any>(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<ElementValueReference>()
}

@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<Call>()
every { apiClient.execute<Any>(capture(callSlot), any()) } returns ApiResponse(
200,
emptyMap(),
"Hello World"
)

val result = runBlocking {
testProxyApi.post(proxyRequest)
}

verify(exactly = 1) { apiClient.execute<Any>(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<ElementValueReference>()
}

// 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`() =
Expand Down