From f441a5c4e67ab2ebe17229a293779e8d5ace36a6 Mon Sep 17 00:00:00 2001 From: Josue Leon Sarkis Date: Wed, 21 Dec 2022 13:54:09 -0600 Subject: [PATCH] feat: support create token endpoint (#23) --- README.md | 2 +- docs/BasisTheoryElements.md | 28 ++- .../android/service/ApiClientProvider.kt | 4 + .../android/service/BasisTheoryElements.kt | 42 +++- .../service/BasisTheoryElementsTests.kt | 196 +++++++++++++++++- 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8f232e6d..32d2f6af 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Simply include one or more elements within your application's views: ``` Then tokenize the user input by referencing these elements. This can be wired up in response to a -button click, or any other user action. +button click, or any other user action. ```kotlin val cardNumberElement = findViewById(R.id.card_number) diff --git a/docs/BasisTheoryElements.md b/docs/BasisTheoryElements.md index 362741a4..4e630445 100644 --- a/docs/BasisTheoryElements.md +++ b/docs/BasisTheoryElements.md @@ -44,6 +44,28 @@ val tokenizeResponse = bt.tokenize(object { }) ``` -As you can see from this example, the `tokenize` function is capable of resolving the raw data -from references to element inputs. This enables your application to tokenize sensitive data values -without needing to touch the raw data directly. +As you can see from this example, the `tokenize` function is capable of resolving the raw data +from references to Element inputs. This enables your application to tokenize sensitive data values without +needing to touch the raw data directly. + +### createToken + +This function wraps our **`[create token API](https://docs.basistheory.com/#tokens-create-token)`** to +be able to create a single strongly typed token. It also provides added support for referencing +instances of Elements within your request payload. + +```kotlin +val createTokenResponse = bt.createToken(CreateTokenRequest().apply { + this.type = "token" + this.data = object { + val name = nameElement // an instance of TextElement + val phoneNumber = phoneNumberElement // an instance of TextElement + val note = "Non sensitive value" // plaintext strings can also be included in the token body + } + this.expires_at = "2022-11-03T19:14:21.4333333Z" // all standard token attributes are supported +}) +``` + +As you can see from this example, the `createToken` function is capable of resolving the raw data +from references to Element inputs. This enables your application to tokenize sensitive data values without +needing to touch the raw data directly. diff --git a/lib/src/main/java/com/basistheory/android/service/ApiClientProvider.kt b/lib/src/main/java/com/basistheory/android/service/ApiClientProvider.kt index ed99b748..68a5fe31 100644 --- a/lib/src/main/java/com/basistheory/android/service/ApiClientProvider.kt +++ b/lib/src/main/java/com/basistheory/android/service/ApiClientProvider.kt @@ -3,6 +3,7 @@ package com.basistheory.android.service import com.basistheory.ApiClient import com.basistheory.Configuration import com.basistheory.TokenizeApi +import com.basistheory.TokensApi import com.basistheory.android.BuildConfig import com.basistheory.auth.ApiKeyAuth @@ -14,6 +15,9 @@ internal class ApiClientProvider( fun getTokenizeApi(apiKeyOverride: String? = null): TokenizeApi = TokenizeApi(getApiClient(apiKeyOverride)) + fun getTokensApi(apiKeyOverride: String? = null): TokensApi = + TokensApi(getApiClient(apiKeyOverride)) + private fun getApiClient(apiKeyOverride: String? = null): ApiClient { val apiKey = apiKeyOverride ?: defaultApiKey requireNotNull(apiKey) 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 bf75a564..0eb45711 100644 --- a/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt +++ b/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt @@ -1,5 +1,7 @@ package com.basistheory.android.service +import com.basistheory.CreateTokenRequest +import com.basistheory.CreateTokenResponse import com.basistheory.android.model.ElementValueReference import com.basistheory.android.util.isPrimitiveType import com.basistheory.android.util.toMap @@ -26,20 +28,42 @@ class BasisTheoryElements internal constructor( tokenizeApiClient.tokenize(request) } + @JvmOverloads + suspend fun createToken( + createTokenRequest: CreateTokenRequest, + apiKeyOverride: String? = null + ): CreateTokenResponse = + withContext(ioDispatcher) { + val tokensApi = apiClientProvider.getTokensApi(apiKeyOverride) + val data = + if (createTokenRequest.data == null) null + else if (createTokenRequest.data!!::class.java.isPrimitiveType()) createTokenRequest.data + else if (createTokenRequest.data is TextElement) (createTokenRequest.data as TextElement).getText() + else if (createTokenRequest.data is ElementValueReference) (createTokenRequest.data as ElementValueReference).getValue() + else replaceElementRefs(createTokenRequest.data!!.toMap()) + + createTokenRequest.data = data + + tokensApi.create(createTokenRequest) + } + private fun replaceElementRefs(map: MutableMap): MutableMap { for ((key, value) in map) { if (value == null) continue val fieldType = value::class.java if (!fieldType.isPrimitiveType()) { - if (value is TextElement) { - map[key] = value.getText() - } - else if (value is ElementValueReference) { - map[key] = value.getValue() - } else { - val children = value.toMap() - map[key] = children - replaceElementRefs(children) + when (value) { + is TextElement -> { + map[key] = value.getText() + } + is ElementValueReference -> { + map[key] = value.getValue() + } + else -> { + val children = value.toMap() + map[key] = children + replaceElementRefs(children) + } } } } 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 4cb1bd4f..29a4993c 100644 --- a/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt +++ b/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt @@ -1,7 +1,9 @@ package com.basistheory.android.service import android.app.Activity +import com.basistheory.CreateTokenRequest import com.basistheory.TokenizeApi +import com.basistheory.TokensApi import com.basistheory.android.view.CardExpirationDateElement import com.basistheory.android.view.CardNumberElement import com.basistheory.android.view.CardVerificationCodeElement @@ -23,9 +25,7 @@ import org.robolectric.RobolectricTestRunner import java.time.Instant import java.time.LocalDate import java.time.temporal.ChronoUnit -import java.time.temporal.TemporalUnit import java.util.* -import java.util.concurrent.TimeUnit import javax.inject.Inject @RunWith(RobolectricTestRunner::class) @@ -43,6 +43,9 @@ class BasisTheoryElementsTests { @RelaxedMockK private lateinit var tokenizeApi: TokenizeApi + @RelaxedMockK + private lateinit var tokensApi: TokensApi + @RelaxedMockK private lateinit var provider: ApiClientProvider @@ -251,4 +254,193 @@ class BasisTheoryElementsTests { verify { tokenizeApi.tokenize(expectedRequest) } } + + @Test + fun `createToken should pass api key override to ApiClientProvider`() = runBlocking { + val apiKeyOverride = UUID.randomUUID().toString() + + every { provider.getTokensApi(any()) } returns tokensApi + + bt.createToken(CreateTokenRequest(), apiKeyOverride) + + verify { provider.getTokensApi(apiKeyOverride) } + } + + @Test + fun `createToken should forward top level primitive value without modification`() = + runBlocking { + every { provider.getTokensApi(any()) } returns tokensApi + + val name = faker.name().fullName() + val createTokenRequest = createTokenRequest(name) + bt.createToken(createTokenRequest) + + verify { tokensApi.create(createTokenRequest) } + } + + @Test + fun `createToken should forward complex data values within request without modification`() = + runBlocking { + every { provider.getTokensApi(any()) } returns tokensApi + + val data = object { + val string = faker.lorem().word() + val int = faker.random().nextInt(10, 100) + val nullValue = null + val nested = object { + val double = faker.random().nextDouble() + val bool = faker.random().nextBoolean() + val timestamp = Instant.now().toString() + val nullValue = null + } + } + val request = createTokenRequest(data) + + bt.createToken(request) + + val expectedData = mapOf( + "string" to data.string, + "int" to data.int, + "nullValue" to null, + "nested" to mapOf( + "double" to data.nested.double, + "bool" to data.nested.bool, + "timestamp" to data.nested.timestamp, + "nullValue" to null + ) + ) + val expectedRequest = createTokenRequest(expectedData) + + verify { tokensApi.create(expectedRequest) } + } + + @Test + fun `createToken should replace top level TextElement ref with underlying data value`() = + runBlocking { + every { provider.getTokensApi(any()) } returns tokensApi + + val name = faker.name().fullName() + nameElement.setText(name) + + val createTokenRequest = createTokenRequest(nameElement) + + bt.createToken(createTokenRequest) + + val expectedRequest = createTokenRequest(name) + + verify { tokensApi.create(expectedRequest) } + } + + @Test + fun `createToken should replace top level CardElement ref with underlying data value`() = + runBlocking { + every { provider.getTokensApi(any()) } returns tokensApi + + val cardNumber = faker.business().creditCardNumber() + cardNumberElement.setText(cardNumber) + + val createTokenRequest = createTokenRequest(cardNumberElement) + + bt.createToken(createTokenRequest) + + val expectedRequest = createTokenRequest(cardNumber.replace(Regex("""[^\d]"""), "")) + + verify { tokensApi.create(expectedRequest) } + } + + @Test + fun `createToken should replace top level CardExpirationDateElement refs with underlying data value`() = + runBlocking { + every { provider.getTokensApi(any()) } returns tokensApi + + val expDate = LocalDate.now().plus(2, ChronoUnit.YEARS) + val month = expDate.monthValue.toString().padStart(2, '0') + val year = expDate.year.toString() + cardExpElement.setText("$month/${year.takeLast(2)}") + + val createTokenRequestMonth = createTokenRequest(cardExpElement.month()) + val createTokenRequestYear = createTokenRequest(cardExpElement.year()) + + bt.createToken(createTokenRequestMonth) + + val expectedMonthRequest = createTokenRequest(month) + verify { tokensApi.create(expectedMonthRequest) } + + bt.createToken(createTokenRequestYear) + + val expectedYearRequest = createTokenRequest(year) + verify { tokensApi.create(expectedYearRequest) } + } + + @Test + fun `createToken should replace Element refs within request object with underlying data values`() = + runBlocking { + every { provider.getTokensApi(any()) } returns tokensApi + + val name = faker.name().fullName() + nameElement.setText(name) + + val phoneNumber = faker.phoneNumber().phoneNumber() + phoneNumberElement.setText(phoneNumber) + + val cardNumber = faker.business().creditCardNumber() + cardNumberElement.setText(cardNumber) + + val expDate = LocalDate.now().plus(2, ChronoUnit.YEARS) + val expMonth = expDate.monthValue.toString().padStart(2, '0') + val expYear = expDate.year.toString() + cardExpElement.setText("$expMonth/${expYear.takeLast(2)}") + + val cvc = faker.random().nextInt(100, 999).toString() + cvcElement.setText(cvc) + + val data = object { + val type = "token" + val data = object { + val raw = faker.lorem().word() + val name = nameElement + val card = object { + val number = cardNumberElement + val expMonth = cardExpElement.month() + val expYear = cardExpElement.year() + val cvc = cvcElement + } + val nested = object { + val raw = faker.lorem().word() + val phoneNumber = phoneNumberElement + } + } + } + val createTokenRequest = createTokenRequest(data) + + bt.createToken(createTokenRequest) + + val expectedData = mapOf( + "type" to data.type, + "data" to mapOf( + "raw" to data.data.raw, + "name" to name, + "card" to mapOf( + "number" to cardNumber.replace(Regex("""[^\d]"""), ""), + "expMonth" to expMonth, + "expYear" to expYear, + "cvc" to cvc + ), + "nested" to mapOf( + "raw" to data.data.nested.raw, + "phoneNumber" to phoneNumber + ) + ) + ) + + val expectedCreateTokenRequest = createTokenRequest(expectedData) + + verify { tokensApi.create(expectedCreateTokenRequest) } + } + + private fun createTokenRequest(data: Any): CreateTokenRequest = + CreateTokenRequest().apply { + this.type = "token" + this.data = data + } } \ No newline at end of file