From fb4e1ca401235bef23a40ebdb15817edad1fbaf7 Mon Sep 17 00:00:00 2001 From: Sina Madani Date: Thu, 4 Jul 2024 13:45:54 +0100 Subject: [PATCH] feat: SMS API --- CHANGELOG.md | 8 + src/main/kotlin/com/vonage/client/kt/SMS.kt | 45 ++++ .../kotlin/com/vonage/client/kt/Vonage.kt | 3 +- .../com/vonage/client/kt/AbstractTest.kt | 125 ++++++----- .../com/vonage/client/kt/MessagesTest.kt | 2 +- .../kotlin/com/vonage/client/kt/SmsTest.kt | 211 ++++++++++++++++++ .../kotlin/com/vonage/client/kt/VoiceTest.kt | 1 - 7 files changed, 342 insertions(+), 53 deletions(-) create mode 100644 src/main/kotlin/com/vonage/client/kt/SMS.kt create mode 100644 src/test/kotlin/com/vonage/client/kt/SmsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 136d27e..f671cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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.3.0] - 2024-07-?? + +### Added +- SMS API + +### Removed +- `parseInboundMessage` + ## [0.2.0] - 2024-07-02 ### Added diff --git a/src/main/kotlin/com/vonage/client/kt/SMS.kt b/src/main/kotlin/com/vonage/client/kt/SMS.kt new file mode 100644 index 0000000..7de4e78 --- /dev/null +++ b/src/main/kotlin/com/vonage/client/kt/SMS.kt @@ -0,0 +1,45 @@ +package com.vonage.client.kt + +import com.vonage.client.messages.InboundMessage +import com.vonage.client.sms.* +import com.vonage.client.sms.messages.* + +class SMS(private val smsClient: SmsClient) { + + private fun send(msgObj: Message, statusReport: Boolean?, ttl: Int?, + messageClass: Message.MessageClass?, clientRef: String?, + contentId: String?, entityId: String?, + callbackUrl: String?): List { + if (statusReport != null) msgObj.statusReportRequired = statusReport + msgObj.timeToLive = ttl?.toLong() + msgObj.messageClass = messageClass + if (clientRef != null) msgObj.clientReference = clientRef + msgObj.contentId = contentId + msgObj.entityId = entityId + msgObj.callbackUrl = callbackUrl + return smsClient.submitMessage(msgObj).messages + } + + fun sendText(from: String, to: String, message: String, unicode: Boolean = false, + statusReport: Boolean? = null, ttl: Int? = null, + messageClass: Message.MessageClass? = null, clientRef: String? = null, + contentId: String? = null, entityId: String? = null, + callbackUrl: String? = null): List = + send( + TextMessage(from, to, message, unicode), + statusReport, ttl, messageClass, clientRef, contentId, entityId, callbackUrl + ) + + fun sendBinary(from: String, to: String, body: ByteArray, udh: ByteArray, + protocolId: Int? = null, statusReport: Boolean? = null, ttl: Int? = null, + messageClass: Message.MessageClass? = null, clientRef: String? = null, + contentId: String? = null, entityId: String? = null, callbackUrl: String? = null + ): List { + val msgObj = BinaryMessage(from, to, body, udh) + if (protocolId != null) msgObj.protocolId = protocolId + return send(msgObj, statusReport, ttl, messageClass, clientRef, contentId, entityId, callbackUrl) + } + + fun wasSuccessfullySent(response: List): Boolean = + response.all { ssrm -> ssrm.status == MessageStatus.OK } +} diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt index 0c079dd..bc6d195 100644 --- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt +++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt @@ -3,11 +3,12 @@ package com.vonage.client.kt import com.vonage.client.HttpConfig import com.vonage.client.VonageClient -class Vonage constructor(init: VonageClient.Builder.() -> Unit) { +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) } 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 0588ad3..b5f234b 100644 --- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt @@ -12,6 +12,8 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.assertThrows import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.util.* import kotlin.test.assertEquals @@ -27,6 +29,7 @@ abstract class AbstractTest { protected val toNumber = "447712345689" protected val altNumber = "447700900001" protected val text = "Hello, World!" + protected val textHexEncoded = "48656c6c6f2c20576f726c6421" protected val networkCode = "65512" protected val startTime = "2020-09-17T12:34:56Z" protected val endTime = "2021-09-17T12:35:28Z" @@ -38,9 +41,8 @@ abstract class AbstractTest { ) val vonage = Vonage { - apiKey(apiKey); apiSecret(apiSecret); - signatureSecret(signatureSecret); applicationId(applicationId) - privateKeyPath(privateKeyPath) + apiKey(apiKey); apiSecret(apiSecret); signatureSecret(signatureSecret); + applicationId(applicationId); privateKeyPath(privateKeyPath) httpConfig { baseUri("http://localhost:$port") } @@ -68,7 +70,14 @@ abstract class AbstractTest { } protected enum class AuthType { - JWT, API_KEY_SECRET_HEADER, API_KEY_SECRET_QUERY_PARAMS + JWT, API_KEY_SECRET_HEADER, API_KEY_SECRET_QUERY_PARAMS, API_KEY_SIGNATURE_SECRET + } + + protected fun Map.toFormEncodedString(): String { + val utf8 = StandardCharsets.UTF_8.toString() + return entries.joinToString("&") { (key, value) -> + "${URLEncoder.encode(key, utf8)}=${URLEncoder.encode(value.toString(), utf8)}" + } } protected fun mockRequest( @@ -79,38 +88,65 @@ abstract class AbstractTest { authType: AuthType? = null, expectedParams: Map? = null): BuildingStep = wiremock.requestServerBuilderStep({ - url equalTo expectedUrl + urlPath equalTo expectedUrl headers contains "User-Agent" like "vonage-java-sdk\\/.+ java\\/.+" + if (contentType != null) { + headers contains "Content-Type" equalTo contentType.mime + } + if (accept != null) { + headers contains "Accept" equalTo accept.mime + } + val formEncodedParams = if ( + contentType == ContentType.FORM_URLENCODED && httpMethod != HttpMethod.GET + ) mutableMapOf() else null + if (authType != null) { val authHeaderName = "Authorization" + val apiKeyName = "api_key" when (authType) { AuthType.JWT -> headers contains authHeaderName like - "Bearer eyJ0eXBlIjoiSldUIiwiYWxnIjoiUlMyNTYifQ(\\..+){2}" + "Bearer eyJ0eXBlIjoiSldUIiwiYWxnIjoiUlMyNTYifQ(\\..+){2}" + AuthType.API_KEY_SECRET_HEADER -> headers contains authHeaderName equalTo "Basic $apiKeySecretEncoded" + AuthType.API_KEY_SECRET_QUERY_PARAMS -> { - headers contains "api_key" equalTo apiKey - headers contains "api_secret" equalTo apiSecret + val apiSecretName = "api_secret" + if (formEncodedParams != null) { + formEncodedParams[apiKeyName] = apiKey + formEncodedParams[apiSecretName] = apiSecret + } + else { + queryParams contains apiKeyName equalTo apiKey + queryParams contains apiSecretName equalTo apiSecret + } + } + + AuthType.API_KEY_SIGNATURE_SECRET -> { + val signatureName = "sig" + if (formEncodedParams != null) { + formEncodedParams[apiKeyName] = apiKey + formEncodedParams[signatureName] = signatureSecret + } + else { + queryParams contains apiKeyName equalTo apiKey + queryParams contains signatureName equalTo signatureSecret + } } } } - if (contentType != null) { - headers contains "Content-Type" equalTo contentType.mime - } - if (accept != null) { - headers contains "Accept" equalTo accept.mime - } - if (expectedParams != null) { - if (contentType == ContentType.APPLICATION_JSON) { + if (expectedParams != null) when (contentType) { + ContentType.APPLICATION_JSON -> { body equalTo ObjectMapper().writeValueAsString(expectedParams) } - else { - url like "$expectedUrl\\?.+" - expectedParams.forEach { (k, v) -> - queryParams contains k equalTo v.toString() - } + ContentType.FORM_URLENCODED -> { + formEncodedParams?.putAll(expectedParams) + } + else -> { + expectedParams.forEach { (k, v) -> queryParams contains k equalTo v.toString() } } } + // TODO: assertion on the body once it's supported by the WireMock DSL }, when (httpMethod) { HttpMethod.GET -> WireMock::get HttpMethod.POST -> WireMock::post @@ -123,46 +159,35 @@ abstract class AbstractTest { private fun mockP(requestMethod: HttpMethod, expectedUrl: String, expectedRequestParams: Map? = null, status: Int = 200, authType: AuthType? = AuthType.JWT, - expectedResponseParams: Map? = null) = + contentType: ContentType?, expectedResponseParams: Map? = null) = - mockRequest(requestMethod, expectedUrl, - contentType = if (expectedRequestParams != null) ContentType.APPLICATION_JSON else null, + mockRequest(requestMethod, expectedUrl, if (expectedRequestParams != null) contentType else null, accept = if (expectedResponseParams != null && status < 400) ContentType.APPLICATION_JSON else null, authType = authType, expectedRequestParams ).mockReturn(status, expectedResponseParams) - protected fun mockPost(expectedUrl: String, - expectedRequestParams: Map? = null, - status: Int = 200, - authType: AuthType? = AuthType.JWT, - expectedResponseParams: Map? = null) = - mockP(HttpMethod.POST, expectedUrl, expectedRequestParams, status, authType, expectedResponseParams) - - protected fun mockPut(expectedUrl: String, - expectedRequestParams: Map? = null, - status: Int = 200, - authType: AuthType? = AuthType.JWT, - expectedResponseParams: Map? = null) = - mockP(HttpMethod.PUT, expectedUrl, expectedRequestParams, status, authType, expectedResponseParams) - - protected fun mockPatch(expectedUrl: String, - expectedRequestParams: Map? = null, - status: Int = 200, - authType: AuthType? = AuthType.JWT, - expectedResponseParams: Map? = null) = - mockP(HttpMethod.PUT, expectedUrl, expectedRequestParams, status, authType, expectedResponseParams) + protected fun mockPost(expectedUrl: String, expectedRequestParams: Map? = null, + status: Int = 200, contentType: ContentType? = ContentType.APPLICATION_JSON, + authType: AuthType? = AuthType.JWT, expectedResponseParams: Map? = null) = + mockP(HttpMethod.POST, expectedUrl, expectedRequestParams, status, authType, contentType, expectedResponseParams) + + protected fun mockPut(expectedUrl: String, expectedRequestParams: Map? = null, + status: Int = 200, contentType: ContentType? = ContentType.APPLICATION_JSON, + authType: AuthType? = AuthType.JWT, expectedResponseParams: Map? = null) = + mockP(HttpMethod.PUT, expectedUrl, expectedRequestParams, status, authType, contentType, expectedResponseParams) + + protected fun mockPatch(expectedUrl: String, expectedRequestParams: Map? = null, + status: Int = 200, contentType: ContentType? = ContentType.APPLICATION_JSON, + authType: AuthType? = AuthType.JWT, expectedResponseParams: Map? = null) = + mockP(HttpMethod.PUT, expectedUrl, expectedRequestParams, status, authType, contentType, expectedResponseParams) protected fun mockDelete(expectedUrl: String, authType: AuthType? = null, expectedResponseParams: Map? = null) = mockRequest(HttpMethod.DELETE, expectedUrl, authType = authType) .mockReturn(if (expectedResponseParams == null) 204 else 200, expectedResponseParams) - protected fun mockGet(expectedUrl: String, - expectedQueryParams: Map? = null, - status: Int = 200, - authType: AuthType? = AuthType.JWT, - expectedResponseParams: Map) = - + protected fun mockGet(expectedUrl: String, expectedQueryParams: Map? = null, status: Int = 200, + authType: AuthType? = AuthType.JWT, expectedResponseParams: Map) = mockRequest(HttpMethod.GET, expectedUrl, accept = ContentType.APPLICATION_JSON, authType = authType, expectedParams = expectedQueryParams).mockReturn(status, expectedResponseParams) diff --git a/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt b/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt index 343a6b1..c9aea46 100644 --- a/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/MessagesTest.kt @@ -419,7 +419,7 @@ class MessagesTest : AbstractTest() { fun `parse inbound MMS image`() { val timestampStr = "2020-01-29T14:08:30.201Z" val networkCode = "54123" - val parsed = parseInboundMessage( + val parsed = InboundMessage.fromJson( """ { "channel": "$mmsChannel", diff --git a/src/test/kotlin/com/vonage/client/kt/SmsTest.kt b/src/test/kotlin/com/vonage/client/kt/SmsTest.kt new file mode 100644 index 0000000..143c7b7 --- /dev/null +++ b/src/test/kotlin/com/vonage/client/kt/SmsTest.kt @@ -0,0 +1,211 @@ +package com.vonage.client.kt + +import com.vonage.client.sms.* +import com.vonage.client.sms.MessageStatus.* +import com.vonage.client.sms.messages.Message +import java.math.BigDecimal +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 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 udhBinary = byteArrayOf(0x05, 0x00, 0x03, 0x7A, 0x02, 0x01) + @OptIn(ExperimentalStdlibApi::class) + private val udhHex = udhBinary.toHexString(HexFormat.UpperCase) + private val protocolId = 127 + + private fun testSuccessSingleMessage(requestParams: Map, + invocation: () -> List) { + + val messageId = "0C000000217B7F02" + val remainingBalance = "15.53590000" + val messagePrice = "0.03330000" + val network = "23410" + + mockPost(sendUrl, requestParams, authType = AuthType.API_KEY_SECRET_QUERY_PARAMS, + contentType = ContentType.FORM_URLENCODED, expectedResponseParams = mapOf( + "message-count" to "1", + "messages" to listOf( + mapOf( + "to" to toNumber, + "message-id" to messageId, + "status" to "0", + "remaining-balance" to remainingBalance, + "message-price" to messagePrice, + "network" to network, + "client-ref" to clientRef, + "account-ref" to accountRef + ) + ) + ) + ) + + val response = invocation.invoke() + assertNotNull(response) + assertEquals(1, response.size) + val first = response.first() + assertNotNull(first) + assertEquals(toNumber, first.to) + assertEquals(messageId, first.id) + assertEquals(OK, first.status) + assertEquals(BigDecimal(remainingBalance), first.remainingBalance) + assertEquals(BigDecimal(messagePrice), first.messagePrice) + assertEquals(network, first.network) + assertEquals(clientRef, first.clientRef) + assertEquals(accountRef, first.accountRef) + assertTrue(smsClient.wasSuccessfullySent(response)) + } + + private fun errorStatus(code: Int, text: String) = mapOf("status" to code, "error-text" to text) + + @Test + fun `send regular text message success required parameters`() { + testSuccessSingleMessage(mapOf("from" to from, "to" to toNumber, "text" to text, "type" to "unicode")) { + smsClient.sendText(from, toNumber, text, unicode = true) + } + } + + @Test + fun `send unicode text message success required parameters`() { + testSuccessSingleMessage(mapOf("from" to from, "to" to toNumber, "text" to text, "type" to "text")) { + smsClient.sendText(from, toNumber, text) + } + } + + @Test + fun `send regular text message success all parameters`() { + testSuccessSingleMessage(mapOf( + "from" to from, + "to" to toNumber, + "text" to text, + "type" to "text", + "callback" to callback, + "status-report-req" to statusReport, + "message-class" to 1, + "ttl" to ttl, + "client-ref" to clientRef, + "entity-id" to entityId, + "content-id" to contentId + )) { + smsClient.sendText(from, toNumber, text, + unicode = false, statusReport = statusReport, + ttl = ttl, messageClass = Message.MessageClass.CLASS_1, + clientRef = clientRef, contentId = contentId, entityId = entityId, + callbackUrl = callback + ) + } + } + + @Test + fun `send binary message success required parameters`() { + testSuccessSingleMessage(mapOf( + "from" to from, "to" to toNumber, "type" to "binary", + "body" to textHexEncoded, "udh" to udhHex + )) { + smsClient.sendBinary(from, toNumber, text.encodeToByteArray(), udhBinary) + } + } + + @Test + fun `send binary message success all parameters`() { + testSuccessSingleMessage(mapOf( + "from" to from, + "to" to toNumber, + "body" to textHexEncoded, + "type" to "binary", + "udh" to udhHex, + "protocol-id" to protocolId, + "callback" to callback, + "status-report-req" to statusReport, + "message-class" to 2, + "ttl" to ttl, + "client-ref" to clientRef, + "entity-id" to entityId, + "content-id" to contentId + )) { + 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 + ) + } + } + + @Test + fun `send text message all statuses`() { + val expectedRequestParams = mapOf( + "from" to from, "to" to toNumber, + "text" to text, "type" to "text", + ) + val successMap = mapOf("status" to "0") + + mockPost(sendUrl, expectedRequestParams, authType = AuthType.API_KEY_SECRET_QUERY_PARAMS, + contentType = ContentType.FORM_URLENCODED, expectedResponseParams = mapOf( + "message-count" to "2147483647", + "messages" to listOf( + successMap, successMap, successMap, successMap, + errorStatus(1, "Throttled."), + errorStatus(2, "Missing Parameters."), + errorStatus(3, "Invalid Parameters."), + errorStatus(4, "Invalid Credentials."), + errorStatus(5, "Internal Error."), + errorStatus(6, "Invalid Message."), + errorStatus(7, "Number Barred."), + errorStatus(8, "Partner Account Barred."), + errorStatus(9, "Partner Quota Violation."), + errorStatus(10, "Too Many Existing Binds."), + errorStatus(11, "Account Not Enabled For HTTP."), + errorStatus(12, "Message Too Long."), + errorStatus(14, "Invalid Signature."), + errorStatus(15, "Invalid Sender Address."), + //errorStatus(17, "Message Blocked by Provider."), + errorStatus(22, "Invalid Network Code."), + errorStatus(23, "Invalid Callback Url."), + errorStatus(29, "Non-Whitelisted Destination."), + errorStatus(32, "Signature And API Secret Disallowed."), + errorStatus(33, "Number De-activated."), successMap + ) + ) + ) + val response = smsClient.sendText(from, toNumber, text) + assertNotNull(response) + assertFalse(smsClient.wasSuccessfullySent(response)) + + assertEquals(24, response.size) + var offset = 0 + assertEquals(OK, response[offset].status) + assertEquals(OK, response[++offset].status) + assertEquals(OK, response[++offset].status) + assertEquals(OK, response[++offset].status) + assertEquals(THROTTLED, response[++offset].status) + assertEquals("Throttled.", response[offset].errorText) + assertEquals(MISSING_PARAMS, response[++offset].status) + assertEquals(INVALID_PARAMS, response[++offset].status) + assertEquals(INVALID_CREDENTIALS, response[++offset].status) + assertEquals(INTERNAL_ERROR, response[++offset].status) + assertEquals(INVALID_MESSAGE, response[++offset].status) + assertEquals(NUMBER_BARRED, response[++offset].status) + assertEquals(PARTNER_ACCOUNT_BARRED, response[++offset].status) + assertEquals(PARTNER_QUOTA_EXCEEDED, response[++offset].status) + assertEquals(TOO_MANY_BINDS, response[++offset].status) + assertEquals(ACCOUNT_NOT_HTTP, response[++offset].status) + assertEquals(MESSAGE_TOO_LONG, response[++offset].status) + assertEquals(INVALID_SIGNATURE, response[++offset].status) + assertEquals(INVALID_FROM_ADDRESS, response[++offset].status) + assertEquals(INVALID_NETWORK_CODE, response[++offset].status) + assertEquals(INVALID_CALLBACK, response[++offset].status) + assertEquals(NON_WHITELISTED_DESTINATION, response[++offset].status) + assertEquals(SIGNATURE_API_SECRET_DISALLOWED, response[++offset].status) + assertEquals(NUMBER_DEACTIVATED, response[++offset].status) + assertEquals("Number De-activated.", response[offset].errorText) + assertEquals(OK, response[++offset].status) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt b/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt index 6858687..fad84a6 100644 --- a/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt @@ -367,7 +367,6 @@ class VoiceTest : AbstractTest() { "record_index" to recordIndex, "order" to "desc", "conversation_uuid" to conversationId - ), expectedResponseParams = listCallsResponse) val callsPage = voiceClient.listCalls {