diff --git a/CHANGELOG.md b/CHANGELOG.md index fff92b3..d03d7fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Numbers API +- Account API ### Changed - Explicit return types for all methods diff --git a/README.md b/README.md index 1cb79f2..11817a4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ You'll need to have [created a Vonage account](https://dashboard.nexmo.com/sign- * [Contribute!](#contribute) ## Supported APIs +- [Account](https://developer.vonage.com/en/account/overview) - [Conversion](https://developer.vonage.com/en/messaging/conversion-api/overview) - [Messages](https://developer.vonage.com/en/messages/overview) - [Number Insight](https://developer.vonage.com/en/number-insight/overview) diff --git a/src/main/kotlin/com/vonage/client/kt/Account.kt b/src/main/kotlin/com/vonage/client/kt/Account.kt new file mode 100644 index 0000000..da68253 --- /dev/null +++ b/src/main/kotlin/com/vonage/client/kt/Account.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.kt + +import com.vonage.client.account.* + +class Account internal constructor(private val accountClient: AccountClient) { + + fun getBalance(): BalanceResponse = accountClient.balance + + fun topUp(transactionId: String): Unit = accountClient.topUp(transactionId) + + fun updateSettings(incomingSmsUrl: String? = null, deliverReceiptUrl: String? = null): SettingsResponse = + accountClient.updateSettings(SettingsRequest(incomingSmsUrl, deliverReceiptUrl)) + + fun secrets(apiKey: String? = null): Secrets = Secrets(apiKey) + + inner class Secrets internal constructor(val apiKey: String? = null) { + + fun list(): List = ( + if (apiKey == null) accountClient.listSecrets() + else accountClient.listSecrets(apiKey) + ).secrets + + fun create(secret: String): SecretResponse = + if (apiKey == null) accountClient.createSecret(secret) + else accountClient.createSecret(apiKey, secret) + + fun get(secretId: String): SecretResponse = + if (apiKey == null) accountClient.getSecret(secretId) + else accountClient.getSecret(apiKey, secretId) + + fun delete(secretId: String): Unit = + if (apiKey == null) accountClient.revokeSecret(secretId) + else accountClient.revokeSecret(apiKey, secretId) + } +} diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt index 5fa5a7b..1060bff 100644 --- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt +++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt @@ -19,18 +19,20 @@ import com.vonage.client.HttpConfig import com.vonage.client.VonageClient class Vonage(init: VonageClient.Builder.() -> Unit) { - private val vonageClient : VonageClient = VonageClient.builder().apply(init).build(); - val messages = Messages(vonageClient.messagesClient) - val verify = Verify(vonageClient.verify2Client) - val voice = Voice(vonageClient.voiceClient) - val sms = Sms(vonageClient.smsClient) + private val vonageClient : VonageClient = VonageClient.builder().apply(init).build() + val account = Account(vonageClient.accountClient) val conversion = Conversion(vonageClient.conversionClient) - val redact = Redact(vonageClient.redactClient) - val verifyLegacy = VerifyLegacy(vonageClient.verifyClient) + val messages = Messages(vonageClient.messagesClient) val numberInsight = NumberInsight(vonageClient.insightClient) val numbers = Numbers(vonageClient.numbersClient) val numberVerification = NumberVerification(vonageClient.numberVerificationClient) + val redact = Redact(vonageClient.redactClient) val simSwap = SimSwap(vonageClient.simSwapClient) + val sms = Sms(vonageClient.smsClient) + val verify = Verify(vonageClient.verify2Client) + val verifyLegacy = VerifyLegacy(vonageClient.verifyClient) + val voice = Voice(vonageClient.voiceClient) + } fun VonageClient.Builder.authFromEnv(): VonageClient.Builder { diff --git a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt index e6bd8f3..745a8b4 100644 --- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt @@ -36,6 +36,7 @@ import kotlin.test.assertEquals abstract class AbstractTest { protected val apiKey = "a1b2c3d4" + protected val apiKey2 = "f9e8d7c6" protected val applicationId = "00000000-0000-4000-8000-000000000000" protected val accessToken = "abc123456def" private val apiSecret = "1234567890abcdef" @@ -77,6 +78,9 @@ abstract class AbstractTest { protected val currency = "EUR" protected val exampleUrlBase = "https://example.com" protected val callbackUrl = "$exampleUrlBase/callback" + protected val statusCallbackUrl = "$callbackUrl/status" + protected val moCallbackUrl = "$callbackUrl/inbound-sms" + protected val drCallbackUrl = "$callbackUrl/delivery-receipt" private val port = 8081 private val wiremock: WireMockServer = WireMockServer( @@ -102,9 +106,12 @@ abstract class AbstractTest { wiremock.stop() } - protected fun strToDate(dateStr: String): Date = + private fun strToDate(dateStr: String): Date = Date(Instant.parse(dateStr.replace(' ', 'T') + 'Z').toEpochMilli()) + protected fun linksSelfHref(url: String = "$exampleUrlBase/self"): Map = + mapOf("_links" to mapOf("self" to mapOf("href" to url))) + protected enum class ContentType(val mime: String) { APPLICATION_JSON("application/json"), FORM_URLENCODED("application/x-www-form-urlencoded"); @@ -264,6 +271,7 @@ abstract class AbstractTest { protected inline fun assertApiResponseException( url: String, requestMethod: HttpMethod, actualCall: () -> Any) { + assert401ApiResponseException(url, requestMethod, actualCall) assert402ApiResponseException(url, requestMethod, actualCall) assert429ApiResponseException(url, requestMethod, actualCall) } @@ -290,6 +298,16 @@ abstract class AbstractTest { return exception } + protected inline fun assert401ApiResponseException( + url: String, requestMethod: HttpMethod, actualCall: () -> Any): E = + + assertApiResponseException(url, requestMethod, actualCall, 401, + "https://developer.nexmo.com/api-errors#unauthorized", + "Unauthorized", + "You did not provide correct credentials.", + "bf0ca0bf927b3b52e3cb03217e1a1ddf" + ) + protected inline fun assert402ApiResponseException( url: String, requestMethod: HttpMethod, actualCall: () -> Any): E = diff --git a/src/test/kotlin/com/vonage/client/kt/AccountTest.kt b/src/test/kotlin/com/vonage/client/kt/AccountTest.kt new file mode 100644 index 0000000..f3ca5bd --- /dev/null +++ b/src/test/kotlin/com/vonage/client/kt/AccountTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.kt + +import com.vonage.client.account.* +import com.vonage.client.common.HttpMethod +import org.junit.jupiter.api.assertThrows +import kotlin.test.* + +class AccountTest : AbstractTest() { + private val account = vonage.account + private val authType = AuthType.API_KEY_SECRET_HEADER + private val secretId = "ad6dc56f-07b5-46e1-a527-85530e625800" + private val secret = "ABCDEFGH01234abc" + private val trx = "8ef2447e69604f642ae59363aa5f781b" + private val baseUrl = "/account" + private val secretsUrl = "${baseUrl}s/$apiKey/secrets" + private val secretsAltUrl = "${baseUrl}s/$apiKey2/secrets" + private val secretUrl = "$secretsUrl/$secretId" + private val altSecretUrl = "$secretsAltUrl/$secretId" + private val secretsNoApiKey = account.secrets() + private val secretsWithApiKey = account.secrets(apiKey2) + private val errorCode = 401 + private val secretResponse = linksSelfHref(secretUrl) + mapOf( + "id" to secretId, + "created_at" to timestampStr + ) + private val secretRequest = mapOf("secret" to secret) + private val errorResponse = mapOf( + "error-code" to errorCode.toString(), + "error-code-label" to "authentication failed" + ) + + private fun assertUpdateSettings(params: Map, invocation: Account.() -> SettingsResponse) { + val maxOutbound = 30 + val maxInbound = 16 + val maxCalls = 9 + mockPostQueryParams( + expectedUrl = "$baseUrl/settings", + authType = authType, + expectedRequestParams = params, + expectedResponseParams = mapOf( + "mo-callback-url" to moCallbackUrl, + "dr-callback-url" to drCallbackUrl, + "max-outbound-request" to maxOutbound, + "max-inbound-request" to maxInbound, + "max-calls-per-second" to maxCalls + ) + ) + + val response = invocation.invoke(account) + assertNotNull(response) + assertEquals(moCallbackUrl, response.incomingSmsUrl) + assertEquals(drCallbackUrl, response.deliveryReceiptUrl) + assertEquals(maxOutbound, response.maxOutboundMessagesPerSecond) + assertEquals(maxInbound, response.maxInboundMessagesPerSecond) + assertEquals(maxCalls, response.maxApiCallsPerSecond) + } + + private fun assertSecretResponse(response: SecretResponse) { + assertNotNull(response) + assertEquals(secretId, response.id) + assertEquals(timestamp, response.created) + } + + private fun getSecretsObj(withApiKey: Boolean) = + if (withApiKey) secretsWithApiKey else secretsNoApiKey + + private fun getSecretUrl(withApiKey: Boolean) = + if (withApiKey) altSecretUrl else secretUrl + + private fun getSecretsUrl(withApiKey: Boolean) = + if (withApiKey) secretsAltUrl else secretsUrl + + private fun assertListSecrets(withApiKey: Boolean) { + val url = getSecretsUrl(withApiKey) + mockGet( + expectedUrl = url, + authType = authType, + expectedResponseParams = linksSelfHref() + mapOf( + "_embedded" to mapOf("secrets" to listOf( + secretResponse, + mapOf() + )) + ) + ) + val invocation = { getSecretsObj(withApiKey).list() } + + val response = invocation.invoke() + assertNotNull(response) + assertEquals(2, response.size) + assertSecretResponse(response[0]) + val blank = response[1] + assertNotNull(blank) + assertNull(blank.created) + assertNull(blank.id) + + assert401ApiResponseException(url, HttpMethod.GET, invocation) + } + + private fun assertCreateSecret(withApiKey: Boolean) { + val url = getSecretsUrl(withApiKey) + val invocation = { getSecretsObj(withApiKey).create(secret) } + mockPost( + expectedUrl = url, authType = authType, + expectedRequestParams = secretRequest, + status = 201, expectedResponseParams = secretResponse + ) + assertSecretResponse(invocation.invoke()) + assert401ApiResponseException(url, HttpMethod.POST, invocation) + } + + private fun assertGetSecret(withApiKey: Boolean) { + val url = getSecretUrl(withApiKey) + mockGet( + expectedUrl = url, authType = authType, + expectedResponseParams = secretResponse + ) + val invocation = { getSecretsObj(withApiKey).get(secretId) } + assertSecretResponse(invocation.invoke()) + assert401ApiResponseException(url, HttpMethod.GET, invocation) + } + + private fun assertDeleteSecret(withApiKey: Boolean) { + val url = getSecretUrl(withApiKey) + val invocation = { getSecretsObj(withApiKey).delete(secretId) } + mockDelete(url, authType) + invocation.invoke() + + mockRequest(HttpMethod.DELETE, expectedUrl = url, authType = authType) + .mockReturn(status = errorCode, errorResponse) + + assertThrows(invocation) + } + + @Test + fun `get balance success`() { + val value = 10.28 + val autoReload = true + + mockGet( + expectedUrl = "$baseUrl/get-balance", + authType = authType, + expectedResponseParams = mapOf( + "value" to value, + "autoReload" to autoReload + ) + ) + + val response = account.getBalance() + assertNotNull(response) + assertEquals(value, response.value) + assertEquals(autoReload, response.isAutoReload) + } + + @Test + fun `get balance error`() { + mockGet( + expectedUrl = "$baseUrl/get-balance", + status = errorCode, authType = authType, + expectedResponseParams = errorResponse + ) + assertThrows { account.getBalance() } + } + + @Test + fun `top up balance success`() { + mockPostQueryParams( + expectedUrl = "$baseUrl/top-up", + authType = authType, + expectedRequestParams = mapOf("trx" to trx), + expectedResponseParams = mapOf( + "error-code" to "200", + "error-code-label" to "success" + ) + ) + account.topUp(trx) + } + + @Test + fun `top up balance error`() { + mockPostQueryParams( + expectedUrl = "$baseUrl/top-up", + authType = authType, status = errorCode, + expectedRequestParams = mapOf("trx" to trx), + expectedResponseParams = errorResponse + ) + + assertThrows { account.topUp(trx) } + } + + @Test + fun `update account settings no parameters`() { + assertUpdateSettings(mapOf()) { + updateSettings() + } + } + + @Test + fun `update account settings both parameters`() { + assertUpdateSettings(mapOf( + "moCallBackUrl" to moCallbackUrl, + "drCallBackUrl" to drCallbackUrl + )) { + updateSettings( + incomingSmsUrl = moCallbackUrl, + deliverReceiptUrl = drCallbackUrl + ) + } + } + + @Test + fun `list secrets default api key`() { + assertListSecrets(false) + } + + @Test + fun `list secrets alternate api key`() { + assertListSecrets(true) + } + + @Test + fun `create secret default api key`() { + assertCreateSecret(false) + } + + @Test + fun `create secret alternate api key`() { + assertCreateSecret(true) + } + + @Test + fun `get secret default api key`() { + assertGetSecret(false) + } + + @Test + fun `get secret alternate api key`() { + assertGetSecret(true) + } + + @Test + fun `revoke secret default api key`() { + assertDeleteSecret(false) + assertNull(secretsNoApiKey.apiKey) + } + + @Test + fun `revoke secret alternate api key`() { + assertDeleteSecret(true) + assertEquals(apiKey2, secretsWithApiKey.apiKey) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt b/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt index 14ae0cd..18e43c8 100644 --- a/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt @@ -98,8 +98,6 @@ class MessagesTest : AbstractTest() { fun `send SMS text all parameters`() { val webhookUrl = "https://example.com/status" val ttl = 9000 - val contentId = "1107457532145798767" - val entityId = "1101456324675322134" testSend(textBody("sms", mapOf( "client_ref" to clientRef, @@ -426,7 +424,6 @@ class MessagesTest : AbstractTest() { @Test fun `parse inbound MMS image`() { - val networkCode = "54123" val parsed = InboundMessage.fromJson( """ { diff --git a/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt b/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt index 41b4a59..a5330ed 100644 --- a/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt @@ -24,9 +24,7 @@ class NumbersTest : AbstractTest() { private val numbersClient = vonage.numbers private val country = "GB" private val targetApiKey = "1a2345b7" - private val moHttpUrl = "$callbackUrl/inbound-sms" private val moSmppSysType = "inbound" - private val voiceStatusCallback = "$callbackUrl/status" private val featureNames = Feature.entries.map(Feature::name) private val pattern = "1337*" private val count = 1247 @@ -66,7 +64,7 @@ class NumbersTest : AbstractTest() { "numbers" to listOf( mapOf(), baseRequestParams + mapOf( - "moHttpUrl" to moHttpUrl, + "moHttpUrl" to moCallbackUrl, "type" to type.name.lowercase().replace('_', '-'), "features" to featureNames, "messagesCallbackType" to "app", @@ -101,7 +99,7 @@ class NumbersTest : AbstractTest() { assertNotNull(main) assertEquals(country, main.country) assertEquals(toNumber, main.msisdn) - assertEquals(moHttpUrl, main.moHttpUrl) + assertEquals(moCallbackUrl, main.moHttpUrl) assertEquals(type, Type.fromString(main.type)) assertEquals(featureNames, main.features.toList()) assertEquals(UUID.fromString(messagesCallbackValue), main.messagesCallbackValue) @@ -206,16 +204,16 @@ class NumbersTest : AbstractTest() { fun `update all parameters`() { mockAction("update", mapOf( "app_id" to applicationId, - "moHttpUrl" to moHttpUrl, + "moHttpUrl" to moCallbackUrl, "moSmppSysType" to moSmppSysType, - "voiceStatusCallback" to voiceStatusCallback, + "voiceStatusCallback" to statusCallbackUrl, "voiceCallbackType" to "tel", "voiceCallbackValue" to altNumber )) existingNumber.update { applicationId(applicationId) - moHttpUrl(moHttpUrl); moSmppSysType(moSmppSysType) - voiceStatusCallback(voiceStatusCallback) + moHttpUrl(moCallbackUrl); moSmppSysType(moSmppSysType) + voiceStatusCallback(statusCallbackUrl) voiceCallback(CallbackType.TEL, altNumber) } } diff --git a/src/test/kotlin/com/vonage/client/kt/SmsTest.kt b/src/test/kotlin/com/vonage/client/kt/SmsTest.kt index 7d98294..004d4fb 100644 --- a/src/test/kotlin/com/vonage/client/kt/SmsTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/SmsTest.kt @@ -28,7 +28,6 @@ class SmsTest : AbstractTest() { private val accountRef = "customer1234" private val ttl = 900000 private val statusReport = true - private val callback = "$exampleUrlBase/sms-dlr" private val udhBinary = byteArrayOf(0x05, 0x00, 0x03, 0x7A, 0x02, 0x01) @OptIn(ExperimentalStdlibApi::class) private val udhHex = udhBinary.toHexString(HexFormat.UpperCase) @@ -97,7 +96,7 @@ class SmsTest : AbstractTest() { "to" to toNumber, "text" to text, "type" to "text", - "callback" to callback, + "callback" to moCallbackUrl, "status-report-req" to if (statusReport) 1 else 0, "message-class" to 1, "ttl" to ttl, @@ -109,7 +108,7 @@ class SmsTest : AbstractTest() { unicode = false, statusReport = statusReport, ttl = ttl, messageClass = Message.MessageClass.CLASS_1, clientRef = clientRef, contentId = contentId, entityId = entityId, - callbackUrl = callback + callbackUrl = moCallbackUrl ) } } @@ -133,7 +132,7 @@ class SmsTest : AbstractTest() { "type" to "binary", "udh" to udhHex.lowercase(), "protocol-id" to protocolId, - "callback" to callback, + "callback" to moCallbackUrl, "status-report-req" to if (statusReport) 1 else 0, "message-class" to 2, "ttl" to ttl, @@ -144,7 +143,7 @@ class SmsTest : AbstractTest() { smsClient.sendBinary(from, toNumber, text.encodeToByteArray(), udh = udhBinary, protocolId = protocolId, statusReport = statusReport, ttl = ttl, messageClass = Message.MessageClass.CLASS_2, clientRef = clientRef, - contentId = contentId, entityId = entityId, callbackUrl = callback + contentId = contentId, entityId = entityId, callbackUrl = moCallbackUrl ) } }