diff --git a/CHANGELOG.md b/CHANGELOG.md index f862754..a088761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.7.0] - 2024-08-?? + +### Added +- Numbers API + ## [0.6.0] - 2024-07-30 ### Added diff --git a/README.md b/README.md index a3f884e..088b948 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ You'll need to have [created a Vonage account](https://dashboard.nexmo.com/sign- - [Voice](https://developer.vonage.com/en/voice/voice-api/overview) - [SIM Swap](https://developer.vonage.com/en/sim-swap/overview) - [Number Verification](https://developer.vonage.com/en/number-verification/overview) +- [Number Management](https://developer.vonage.com/en/numbers/overview) - [Number Insight](https://developer.vonage.com/en/number-insight/overview) - [SMS](https://developer.vonage.com/en/messaging/sms/overview) - [Conversion](https://developer.vonage.com/en/messaging/conversion-api/overview) diff --git a/pom.xml b/pom.xml index 14ebdf1..859036c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.vonage server-sdk-kotlin - 0.6.0 + 0.7.0 Vonage Kotlin Server SDK Kotlin client for Vonage APIs @@ -59,7 +59,7 @@ com.vonage server-sdk - 8.9.4 + 8.10.0 org.jetbrains.kotlin diff --git a/src/main/kotlin/com/vonage/client/kt/Numbers.kt b/src/main/kotlin/com/vonage/client/kt/Numbers.kt new file mode 100644 index 0000000..b73d59b --- /dev/null +++ b/src/main/kotlin/com/vonage/client/kt/Numbers.kt @@ -0,0 +1,41 @@ +/* + * 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.numbers.* + +class Numbers(private val numbersClient: NumbersClient) { + + fun number(countryCode: String, msisdn: String) = ExistingNumber(countryCode, msisdn) + + inner class ExistingNumber internal constructor(val countryCode: String, val msisdn: String) { + + fun buy(targetApiKey: String? = null) = + numbersClient.buyNumber(countryCode, msisdn, targetApiKey) + + fun cancel(targetApiKey: String? = null) = + numbersClient.cancelNumber(countryCode, msisdn, targetApiKey) + + fun update(properties: UpdateNumberRequest.Builder.() -> Unit) = + numbersClient.updateNumber(UpdateNumberRequest.builder(msisdn, countryCode).apply(properties).build()) + } + + fun listOwned(filter: ListNumbersFilter.Builder.() -> Unit = {}) = + numbersClient.listNumbers(ListNumbersFilter.builder().apply(filter).build()) + + fun searchAvailable(filter: SearchNumbersFilter.Builder.() -> Unit) = + numbersClient.searchNumbers(SearchNumbersFilter.builder().apply(filter).build()) +} diff --git a/src/main/kotlin/com/vonage/client/kt/Voice.kt b/src/main/kotlin/com/vonage/client/kt/Voice.kt index fb5068e..42b4939 100644 --- a/src/main/kotlin/com/vonage/client/kt/Voice.kt +++ b/src/main/kotlin/com/vonage/client/kt/Voice.kt @@ -25,9 +25,7 @@ class Voice(private val voiceClient: VoiceClient) { fun call(callId: String): ExistingCall = ExistingCall(callId) - fun call(callId: UUID): ExistingCall = call(callId.toString()) - - inner class ExistingCall(val callId: String) { + inner class ExistingCall internal constructor(val callId: String) { fun info(): CallInfo = voiceClient.getCallDetails(callId) diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt index 9688632..5fa5a7b 100644 --- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt +++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt @@ -28,8 +28,9 @@ class Vonage(init: VonageClient.Builder.() -> Unit) { val redact = Redact(vonageClient.redactClient) val verifyLegacy = VerifyLegacy(vonageClient.verifyClient) val numberInsight = NumberInsight(vonageClient.insightClient) - val simSwap = SimSwap(vonageClient.simSwapClient) + val numbers = Numbers(vonageClient.numbersClient) val numberVerification = NumberVerification(vonageClient.numberVerificationClient) + val simSwap = SimSwap(vonageClient.simSwapClient) } 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 7f6c02e..e6bd8f3 100644 --- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt @@ -52,8 +52,13 @@ abstract class AbstractTest { protected val testUuid: UUID = UUID.fromString(testUuidStr) protected val toNumber = "447712345689" protected val altNumber = "447700900001" + protected val brand = "Nexmo KT" protected val text = "Hello, World!" + protected val sipUri = "sip:rebekka@sip.example.com" + protected val clientRef = "my-personal-reference" protected val textHexEncoded = "48656c6c6f2c20576f726c6421" + protected val entityId = "1101407360000017170" + protected val contentId = "1107158078772563946" protected val smsMessageId = "0C000000217B7F02" protected val callIdStr = "63f61863-4a51-4f6b-86e1-46edebcf9356" protected val networkCode = "65512" diff --git a/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt b/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt index b46f40a..14ae0cd 100644 --- a/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt @@ -96,7 +96,6 @@ class MessagesTest : AbstractTest() { @Test fun `send SMS text all parameters`() { - val clientRef = "My reference" val webhookUrl = "https://example.com/status" val ttl = 9000 val contentId = "1107457532145798767" diff --git a/src/test/kotlin/com/vonage/client/kt/NumberVerificationTest.kt b/src/test/kotlin/com/vonage/client/kt/NumberVerificationTest.kt index d2e439e..b549219 100644 --- a/src/test/kotlin/com/vonage/client/kt/NumberVerificationTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/NumberVerificationTest.kt @@ -62,8 +62,7 @@ class NumberVerificationTest : AbstractTest() { URLEncoder.encode(redirectUrl, "UTF-8") }&response_type=code" - val expectedUrlWithoutState = URI.create("$expectedUrlStr&state=null") - assertEquals(expectedUrlWithoutState, nvClient.createVerificationUrl(toNumber, redirectUrl)) + assertEquals(URI.create(expectedUrlStr), nvClient.createVerificationUrl(toNumber, redirectUrl)) val expectedUrlWithState = URI.create("$expectedUrlStr&state=$state") assertEquals(expectedUrlWithState, nvClient.createVerificationUrl(toNumber, redirectUrl, state)) diff --git a/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt b/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt new file mode 100644 index 0000000..41b4a59 --- /dev/null +++ b/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt @@ -0,0 +1,274 @@ +/* + * 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.numbers.* +import com.vonage.client.numbers.UpdateNumberRequest.CallbackType +import java.util.* +import kotlin.test.* + +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 + private val size = 25 + private val index = 6 + private val existingNumber = numbersClient.number(country, toNumber) + private val baseRequestParams = mapOf( + "country" to existingNumber.countryCode, + "msisdn" to existingNumber.msisdn + ) + private val targetApiKeyMap = mapOf("target_api_key" to targetApiKey) + private val successResponseMap = mapOf( + "error-code" to "200", + "error-code-label" to "success" + ) + + private fun mockAction(endpoint: String, additionalParams: Map = mapOf()) { + mockPostQueryParams( + expectedUrl = "/number/$endpoint", + expectedRequestParams = baseRequestParams + additionalParams, + authType = AuthType.API_KEY_SECRET_HEADER, + expectedResponseParams = successResponseMap + ) + } + + private fun assertOwnedNumbers(params: Map, invocation: Numbers.() -> ListNumbersResponse) { + val type = Type.MOBILE_LVN + val voiceCallbackType = CallbackType.SIP + val messagesCallbackValue = "aaaaaaaa-bbbb-cccc-dddd-0123456789ab" + + mockGet( + expectedUrl = "/account/numbers", + expectedQueryParams = params, + authType = AuthType.API_KEY_SECRET_HEADER, + expectedResponseParams = mapOf( + "count" to count, + "numbers" to listOf( + mapOf(), + baseRequestParams + mapOf( + "moHttpUrl" to moHttpUrl, + "type" to type.name.lowercase().replace('_', '-'), + "features" to featureNames, + "messagesCallbackType" to "app", + "messagesCallbackValue" to messagesCallbackValue, + "voiceCallbackType" to voiceCallbackType.name.lowercase(), + "voiceCallbackValue" to sipUri, + "app_id" to applicationId + ) + ) + ) + ) + + val response = invocation.invoke(numbersClient) + assertNotNull(response) + assertEquals(count, response.count) + val numbers = response.numbers + assertNotNull(numbers) + assertEquals(2, numbers.size) + + val empty = numbers[0] + assertNotNull(empty) + assertNull(empty.msisdn) + assertNull(empty.country) + assertNull(empty.voiceCallbackType) + assertNull(empty.voiceCallbackValue) + assertNull(empty.messagesCallbackValue) + assertNull(empty.moHttpUrl) + assertNull(empty.type) + assertNull(empty.features) + + val main = numbers[1] + assertNotNull(main) + assertEquals(country, main.country) + assertEquals(toNumber, main.msisdn) + assertEquals(moHttpUrl, main.moHttpUrl) + assertEquals(type, Type.fromString(main.type)) + assertEquals(featureNames, main.features.toList()) + assertEquals(UUID.fromString(messagesCallbackValue), main.messagesCallbackValue) + assertEquals(voiceCallbackType, CallbackType.fromString(main.voiceCallbackType)) + assertEquals(sipUri, main.voiceCallbackValue) + } + + private fun assertAvailableNumbers(params: Map, invocation: Numbers.() -> SearchNumbersResponse) { + val landline = "44800123456" + mockGet( + expectedUrl = "/number/search", + expectedQueryParams = params, + authType = AuthType.API_KEY_SECRET_HEADER, + expectedResponseParams = mapOf( + "count" to count, + "numbers" to listOf( + mapOf("cost" to "1.29"), + mapOf( + "country" to country, + "msisdn" to landline, + "type" to "landline-toll-free", + "cost" to "3.80", + "features" to listOf("VOICE") + ), + baseRequestParams + mapOf( + "features" to listOf("SMS", "MMS"), + "type" to "mobile-lvn" + ) + ) + ) + ) + + val response = invocation.invoke(numbersClient) + assertNotNull(response) + assertEquals(count, response.count) + val numbers = response.numbers + assertNotNull(numbers) + assertEquals(3, numbers.size) + + val costOnly = numbers[0] + assertNotNull(costOnly) + assertEquals(1.29, costOnly.cost.toDouble()) + assertNull(costOnly.type) + assertNull(costOnly.country) + assertNull(costOnly.msisdn) + assertNull(costOnly.features) + + val main = numbers[1] + assertNotNull(main) + assertEquals(Type.LANDLINE_TOLL_FREE, Type.fromString(main.type)) + assertEquals(3.80, main.cost.toDouble()) + assertEquals(landline, main.msisdn) + assertEquals(country, main.country) + val mainFeatures = main.features + assertNotNull(mainFeatures) + assertEquals(1, mainFeatures.size) + assertEquals(Feature.VOICE, Feature.fromString(mainFeatures[0])) + + val mobile = numbers[2] + assertEquals(country, mobile.country) + assertEquals(toNumber, mobile.msisdn) + assertEquals(Type.MOBILE_LVN, Type.fromString(mobile.type)) + val mobileFeatures = mobile.features + assertNotNull(mobileFeatures) + assertEquals(2, mobileFeatures.size) + assertEquals(Feature.SMS, Feature.fromString(mobileFeatures[0])) + assertEquals(Feature.MMS, Feature.fromString(mobileFeatures[1])) + assertNull(mobile.cost) + } + + @Test + fun `buy number`() { + mockAction("buy") + existingNumber.buy() + } + + @Test + fun `buy number with target api key`() { + mockAction("buy", targetApiKeyMap) + existingNumber.buy(targetApiKey) + } + + @Test + fun `cancel number`() { + mockAction("cancel") + existingNumber.cancel() + } + + @Test + fun `cancel number with target api key`() { + mockAction("cancel", targetApiKeyMap) + existingNumber.cancel(targetApiKey) + } + + @Test + fun `update no parameters`() { + mockAction("update") + existingNumber.update {} + } + + @Test + fun `update all parameters`() { + mockAction("update", mapOf( + "app_id" to applicationId, + "moHttpUrl" to moHttpUrl, + "moSmppSysType" to moSmppSysType, + "voiceStatusCallback" to voiceStatusCallback, + "voiceCallbackType" to "tel", + "voiceCallbackValue" to altNumber + )) + existingNumber.update { + applicationId(applicationId) + moHttpUrl(moHttpUrl); moSmppSysType(moSmppSysType) + voiceStatusCallback(voiceStatusCallback) + voiceCallback(CallbackType.TEL, altNumber) + } + } + + @Test + fun `list owned numbers no parameters`() { + assertOwnedNumbers(mapOf()) { listOwned() } + } + + @Test + fun `list owned numbers all parameters`() { + val hasApplication = true + assertOwnedNumbers(mapOf( + "country" to country, + "application_id" to applicationId, + "has_application" to hasApplication, + "pattern" to pattern, + "search_pattern" to 2, + "size" to size, + "index" to index + )) { + listOwned { + country(country) + applicationId(applicationId) + hasApplication(hasApplication) + pattern(SearchPattern.ENDS_WITH, pattern) + size(size); index(index) + } + } + } + + @Test + fun `search available numbers no parameters`() { + assertAvailableNumbers(mapOf()) { + searchAvailable { } + } + } + + @Test + fun `search available numbers all parameters`() { + assertAvailableNumbers(mapOf( + "country" to country, + "pattern" to pattern, + "search_pattern" to 0, + "features" to featureNames.joinToString(","), + "size" to size, + "index" to index + )) { + searchAvailable { + country(country); size(size); index(index) + pattern(SearchPattern.STARTS_WITH, pattern) + features(Feature.SMS, Feature.MMS, Feature.VOICE) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/vonage/client/kt/SmsTest.kt b/src/test/kotlin/com/vonage/client/kt/SmsTest.kt index b7c80cd..7d98294 100644 --- a/src/test/kotlin/com/vonage/client/kt/SmsTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/SmsTest.kt @@ -24,14 +24,11 @@ import kotlin.test.* class SmsTest : AbstractTest() { private val smsClient = vonage.sms private val sendUrl = "/sms/json" - private val from = "Nexmo" - private val clientRef = "my-personal-reference" + private val from = brand private val accountRef = "customer1234" private val ttl = 900000 private val statusReport = true - private val callback = "https://example.com/sms-dlr" - private val entityId = "1101456324675322134" - private val contentId = "1107457532145798767" + 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) diff --git a/src/test/kotlin/com/vonage/client/kt/VerifyTest.kt b/src/test/kotlin/com/vonage/client/kt/VerifyTest.kt index 0586cec..0d429f1 100644 --- a/src/test/kotlin/com/vonage/client/kt/VerifyTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/VerifyTest.kt @@ -29,8 +29,6 @@ class VerifyTest : AbstractTest() { private val requestIdStr = "c11236f4-00bf-4b89-84ba-88b25df97315" private val requestId = UUID.fromString(requestIdStr) private val requestIdUrl = "$baseUrl/$requestIdStr" - private val brand = "Nexmo KT" - private val clientRef = "my-personal-reference" private val timeout = 60 private val fraudCheck = false private val sandbox = true @@ -38,8 +36,6 @@ class VerifyTest : AbstractTest() { private val code = "1228864" private val locale = "ja-jp" private val whatsappNumber = "447700400080" - private val entityId = "1101407360000017170" - private val contentId = "1107158078772563946" private val appHash = "ABC123def45" private val toEmail = "alice@example.com" private val fromEmail = "bob@example.org" diff --git a/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt b/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt index 80ba092..54bc226 100644 --- a/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt @@ -27,7 +27,7 @@ class VoiceTest : AbstractTest() { private val voiceClient = vonage.voice private val callsBaseUrl = "/v1/calls" private val callUrl = "$callsBaseUrl/$callIdStr" - private val callObj = voiceClient.call(UUID.fromString(callIdStr)) + private val callObj = voiceClient.call(callIdStr) private val conversationId = "CON-f972836a-550f-45fa-956c-12a2ab5b7d22" private val price = "23.40" private val duration = 60 @@ -45,7 +45,6 @@ class VoiceTest : AbstractTest() { private val onAnswerUrl = "https://example.com/ncco.json" private val websocketUri = "wss://example.com/socket" private val ringbackTone = "http://example.com/ringbackTone.wav" - private val sipUri = "sip:rebekka@sip.example.com" private val wsContentType = "audio/l16;rate=8000" private val userToUserHeader = "56a390f3d2b7310023a" private val conversationName = "selective-audio Demo"