From 86b66ad28e9435e39fcc5cf374e646c1c41ab20f Mon Sep 17 00:00:00 2001 From: Sina Madani Date: Fri, 16 Aug 2024 12:26:43 +0100 Subject: [PATCH] feat: Add Application API (#8) * feat: Add Application API * build: Bump GPG plugin version * test: 401 Application endpoints --- README.md | 1 + pom.xml | 2 +- .../com/vonage/client/kt/Application.kt | 100 +++++ .../kotlin/com/vonage/client/kt/Vonage.kt | 1 + .../com/vonage/client/kt/AbstractTest.kt | 1 + .../com/vonage/client/kt/ApplicationTest.kt | 375 ++++++++++++++++++ .../kotlin/com/vonage/client/kt/VoiceTest.kt | 7 +- 7 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/com/vonage/client/kt/Application.kt create mode 100644 src/test/kotlin/com/vonage/client/kt/ApplicationTest.kt diff --git a/README.md b/README.md index 7f5a336..b035ef5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ You'll need to have [created a Vonage account](https://dashboard.nexmo.com/sign- ## Supported APIs - [Account](https://developer.vonage.com/en/account/overview) +- [Application](https://developer.vonage.com/en/application/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/pom.xml b/pom.xml index ade1112..19a8134 100644 --- a/pom.xml +++ b/pom.xml @@ -229,7 +229,7 @@ maven-gpg-plugin - 3.2.4 + 3.2.5 sign-artifacts diff --git a/src/main/kotlin/com/vonage/client/kt/Application.kt b/src/main/kotlin/com/vonage/client/kt/Application.kt new file mode 100644 index 0000000..148c63a --- /dev/null +++ b/src/main/kotlin/com/vonage/client/kt/Application.kt @@ -0,0 +1,100 @@ +/* + * 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.application.* +import com.vonage.client.application.Application +import com.vonage.client.application.capabilities.* +import com.vonage.client.application.capabilities.Messages +import com.vonage.client.application.capabilities.Verify +import com.vonage.client.application.capabilities.Voice +import com.vonage.client.common.Webhook +import java.util.* + +class Application internal constructor(private val client: ApplicationClient) { + + fun application(applicationId: String): ExistingApplication = ExistingApplication(applicationId) + + fun application(applicationId: UUID) = application(applicationId.toString()) + + inner class ExistingApplication internal constructor(val id: String) { + fun get(): Application = client.getApplication(id) + + fun update(properties: Application.Builder.() -> Unit): Application = + client.updateApplication(Application.builder(get()).apply(properties).build()) + + fun delete(): Unit = client.deleteApplication(id) + } + + fun listAll(): List = client.listAllApplications() + + fun list(page: Int? = null, pageSize: Int? = null): List { + val filter = ListApplicationRequest.builder() + if (page != null) filter.page(page.toLong()) + if (pageSize != null) filter.pageSize(pageSize.toLong()) + return client.listApplications(filter.build()).applications + } + + fun create(properties: Application.Builder.() -> Unit): Application = + client.createApplication(Application.builder().apply(properties).build()) +} + +private fun webhookBuilder(properties: Webhook.Builder.() -> Unit): Webhook = + Webhook.builder().apply(properties).build() + +fun Webhook.Builder.url(url: String): Webhook.Builder = address(url) + +fun Voice.Builder.answer(properties: Webhook.Builder.() -> Unit): Voice.Builder = + addWebhook(Webhook.Type.ANSWER, webhookBuilder(properties)) + +fun Voice.Builder.fallbackAnswer(properties: Webhook.Builder.() -> Unit): Voice.Builder = + addWebhook(Webhook.Type.FALLBACK_ANSWER, webhookBuilder(properties)) + +fun Voice.Builder.event(properties: Webhook.Builder.() -> Unit): Voice.Builder = + addWebhook(Webhook.Type.EVENT, webhookBuilder(properties)) + +fun Rtc.Builder.event(properties: Webhook.Builder.() -> Unit): Rtc.Builder = + addWebhook(Webhook.Type.EVENT, webhookBuilder(properties)) + +fun Verify.Builder.status(properties: Webhook.Builder.() -> Unit): Verify.Builder = + addWebhook(Webhook.Type.STATUS, webhookBuilder(properties)) + +fun Messages.Builder.inbound(properties: Webhook.Builder.() -> Unit): Messages.Builder = + addWebhook(Webhook.Type.INBOUND, webhookBuilder(properties)) + +fun Messages.Builder.status(properties: Webhook.Builder.() -> Unit): Messages.Builder = + addWebhook(Webhook.Type.STATUS, webhookBuilder(properties)) + +fun Application.Builder.removeCapabilities(vararg capabilities: Capability.Type): Application.Builder { + for (capability in capabilities) { + removeCapability(capability) + } + return this +} + +fun Application.Builder.voice(capability: Voice.Builder.() -> Unit): Application.Builder = + addCapability(Voice.builder().apply(capability).build()) + +fun Application.Builder.messages(capability: Messages.Builder.() -> Unit): Application.Builder = + addCapability(Messages.builder().apply(capability).build()) + +fun Application.Builder.verify(capability: Verify.Builder.() -> Unit): Application.Builder = + addCapability(Verify.builder().apply(capability).build()) + +fun Application.Builder.rtc(capability: Rtc.Builder.() -> Unit): Application.Builder = + addCapability(Rtc.builder().apply(capability).build()) + +fun Application.Builder.vbc(): Application.Builder = addCapability(Vbc.builder().build()) diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt index 37efcb4..9c0a304 100644 --- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt +++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt @@ -21,6 +21,7 @@ import com.vonage.client.VonageClient class Vonage(init: VonageClient.Builder.() -> Unit) { private val client : VonageClient = VonageClient.builder().apply(init).build() val account = Account(client.accountClient) + val application = Application(client.applicationClient) val conversion = Conversion(client.conversionClient) val messages = Messages(client.messagesClient) val numberInsight = NumberInsight(client.insightClient) diff --git a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt index f23f7e1..a6c384c 100644 --- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt @@ -79,6 +79,7 @@ abstract class AbstractTest { protected val timestamp2: Instant = Instant.parse(timestamp2Str) protected val currency = "EUR" protected val exampleUrlBase = "https://example.com" + protected val eventUrl = "$exampleUrlBase/event" protected val callbackUrl = "$exampleUrlBase/callback" protected val statusCallbackUrl = "$callbackUrl/status" protected val moCallbackUrl = "$callbackUrl/inbound-sms" diff --git a/src/test/kotlin/com/vonage/client/kt/ApplicationTest.kt b/src/test/kotlin/com/vonage/client/kt/ApplicationTest.kt new file mode 100644 index 0000000..ecb32f7 --- /dev/null +++ b/src/test/kotlin/com/vonage/client/kt/ApplicationTest.kt @@ -0,0 +1,375 @@ +/* + * 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.application.Application +import com.vonage.client.application.ApplicationResponseException +import com.vonage.client.application.capabilities.Capability +import com.vonage.client.application.capabilities.Region +import com.vonage.client.common.HttpMethod +import com.vonage.client.common.Webhook +import kotlin.test.* + +class ApplicationTest : AbstractTest() { + private val ac = vonage.application + private val authType = AuthType.API_KEY_SECRET_HEADER + private val existingApplication = ac.application(testUuid) + private val baseUrl = "/v2/applications" + private val appUrl = "$baseUrl/$testUuid" + private val answerUrl = "$exampleUrlBase/answer" + private val page = 3 + private val pageSize = 25 + private val connectionTimeout = 500 + private val socketTimeout = 3000 + private val region = Region.EU_WEST + private val signedCallbacks = true + private val answerMethod = HttpMethod.GET + private val eventMethod = HttpMethod.POST + private val statusMethod = HttpMethod.POST + private val inboundMethod = HttpMethod.POST + private val fallbackAnswerUrl = "$answerUrl-fallback" + private val conversationsTtl = 12 + private val name = "My Application" + private val publicKey = "-----BEGIN PUBLIC KEY-----\npublic key\n-----END PUBLIC KEY-----" + private val improveAi = false + private val basicApplicationRequest = mapOf( + "name" to name, + "keys" to mapOf( + "public_key" to publicKey + ) + ) + private val advancedApplicationProperties = mapOf( + "capabilities" to mapOf( + "voice" to mapOf( + "webhooks" to mapOf( + "answer_url" to mapOf( + "address" to answerUrl, + "http_method" to answerMethod, + "connection_timeout" to connectionTimeout, + "socket_timeout" to socketTimeout + ), + "fallback_answer_url" to mapOf( + "address" to fallbackAnswerUrl, + "http_method" to answerMethod, + "connection_timeout" to connectionTimeout, + "socket_timeout" to socketTimeout + ), + "event_url" to mapOf( + "address" to eventUrl, + "http_method" to eventMethod, + "connection_timeout" to connectionTimeout, + "socket_timeout" to socketTimeout + ) + ), + "signed_callbacks" to signedCallbacks, + "conversations_ttl" to conversationsTtl, + "region" to region + ), + "rtc" to mapOf( + "webhooks" to mapOf( + "event_url" to mapOf( + "address" to eventUrl, + "http_method" to eventMethod + ) + ) + ), + "messages" to mapOf( + "webhooks" to mapOf( + "inbound_url" to mapOf( + "address" to moCallbackUrl, + "http_method" to inboundMethod + ), + "status_url" to mapOf( + "address" to statusCallbackUrl, + "http_method" to statusMethod + ) + ) + ), + "vbc" to emptyMap(), + "verify" to mapOf( + "webhooks" to mapOf( + "status_url" to mapOf( + "address" to statusCallbackUrl, + "http_method" to statusMethod + ) + ) + ) + ), + "privacy" to mapOf( + "improve_ai" to improveAi + ) + ) + private val advancedApplicationRequest = basicApplicationRequest + advancedApplicationProperties + private val applicationResponseIdOnly = mapOf("id" to testUuid) + private val basicApplicationResponse = applicationResponseIdOnly + basicApplicationRequest + private val fullApplicationResponse = applicationResponseIdOnly + advancedApplicationRequest + + + private fun assertEqualsIdOnlyApplication(parsed: Application) { + assertNotNull(parsed) + assertEquals(testUuidStr, parsed.id) + } + + private fun assertEqualsBasicApplication(parsed: Application, name: String = this.name) { + assertEqualsIdOnlyApplication(parsed) + assertEquals(name, parsed.name) + assertNotNull(parsed.keys) + assertEquals(publicKey, parsed.keys.publicKey) + } + + private fun assertEqualsFullApplication(parsed: Application) { + assertEqualsBasicApplication(parsed) + assertNotNull(parsed.privacy) + assertEquals(improveAi, parsed.privacy.improveAi) + val capabilities = parsed.capabilities + assertNotNull(capabilities) + + val voice = capabilities.voice + assertNotNull(voice) + val voiceWebhooks = voice.webhooks + + assertNotNull(voiceWebhooks) + val voiceAnswer = voiceWebhooks[Webhook.Type.ANSWER] + assertNotNull(voiceAnswer) + assertEquals(answerUrl, voiceAnswer.address) + assertEquals(answerMethod, voiceAnswer.method) + assertEquals(connectionTimeout, voiceAnswer.connectionTimeout) + assertEquals(socketTimeout, voiceAnswer.socketTimeout) + + val fallbackAnswer = voiceWebhooks[Webhook.Type.FALLBACK_ANSWER] + assertNotNull(fallbackAnswer) + assertEquals(fallbackAnswerUrl, fallbackAnswer.address) + assertEquals(answerMethod, fallbackAnswer.method) + assertEquals(connectionTimeout, fallbackAnswer.connectionTimeout) + assertEquals(socketTimeout, fallbackAnswer.socketTimeout) + + val voiceEvent = voiceWebhooks[Webhook.Type.EVENT] + assertNotNull(voiceEvent) + assertEquals(eventUrl, voiceEvent.address) + assertEquals(eventMethod, voiceEvent.method) + assertEquals(connectionTimeout, voiceEvent.connectionTimeout) + assertEquals(socketTimeout, voiceEvent.socketTimeout) + + assertEquals(signedCallbacks, voice.signedCallbacks) + assertEquals(conversationsTtl, voice.conversationsTtl) + assertEquals(region, voice.region) + + val rtc = capabilities.rtc + assertNotNull(rtc) + val rtcEvent = rtc.webhooks[Webhook.Type.EVENT] + assertNotNull(rtcEvent) + assertEquals(eventUrl, rtcEvent.address) + assertEquals(eventMethod, rtcEvent.method) + + val messages = capabilities.messages + assertNotNull(messages) + val inbound = messages.webhooks[Webhook.Type.INBOUND] + assertNotNull(inbound) + assertEquals(moCallbackUrl, inbound.address) + assertEquals(inboundMethod, inbound.method) + + val status = messages.webhooks[Webhook.Type.STATUS] + assertNotNull(status) + assertEquals(statusCallbackUrl, status.address) + assertEquals(statusMethod, status.method) + + val verify = capabilities.verify + assertNotNull(verify) + val verifyStatus = verify.webhooks[Webhook.Type.STATUS] + assertNotNull(verifyStatus) + assertEquals(statusCallbackUrl, verifyStatus.address) + assertEquals(statusMethod, verifyStatus.method) + + assertNotNull(capabilities.vbc) + assertEquals(improveAi, parsed.privacy.improveAi) + } + + private fun assertEqualsBlankApplication(parsed: Application) { + assertNotNull(parsed) + assertNull(parsed.id) + assertNull(parsed.name) + assertNull(parsed.capabilities) + assertNull(parsed.keys) + assertNull(parsed.privacy) + } + + private fun assertListApplications(filter: Map = mapOf(), invocation: () -> List) { + val totalItems = 1337 + val totalPages = 54 + mockGet( + expectedUrl = baseUrl, authType = authType, expectedQueryParams = filter, + expectedResponseParams = mapOf( + "page_size" to pageSize, + "page" to page, + "total_items" to totalItems, + "total_pages" to totalPages, + "_embedded" to mapOf( + "applications" to listOf( + applicationResponseIdOnly, + emptyMap(), + fullApplicationResponse, + basicApplicationResponse + ) + ) + ) + ) + val response = invocation.invoke() + assertNotNull(response) + assertEquals(4, response.size) + assertEqualsIdOnlyApplication(response[0]) + assertEqualsBlankApplication(response[1]) + assertEqualsFullApplication(response[2]) + assertEqualsBasicApplication(response[3]) + + assert401ApiResponseException(baseUrl, HttpMethod.GET, invocation) + } + + @BeforeTest + fun init() { + mockGet( + expectedUrl = appUrl, authType = authType, + expectedResponseParams = fullApplicationResponse + ) + } + + @Test + fun `get application all parameters`() { + assertEqualsFullApplication(existingApplication.get()) + + assert401ApiResponseException(appUrl, HttpMethod.GET) { + existingApplication.get() + } + } + + @Test + fun `delete application`() { + mockDelete(expectedUrl = appUrl, authType = authType) + existingApplication.delete() + + assert401ApiResponseException(appUrl, HttpMethod.DELETE) { + existingApplication.delete() + } + } + + @Test + fun `list all applications`() { + assertListApplications { ac.listAll() } + } + + @Test + fun `list applications no filter`() { + assertListApplications { ac.list() } + } + + @Test + fun `list applications all filters`() { + assertListApplications(mapOf("page" to page, "page_size" to pageSize)) { + ac.list(page, pageSize) + } + } + + @Test + fun `update application`() { + val newName = "New Name" + val plainApp = basicApplicationResponse.toMutableMap() + plainApp["name"] = newName + plainApp["capabilities"] = mapOf("vbc" to emptyMap()) + + mockPut( + expectedUrl = appUrl, authType = authType, + expectedRequestParams = plainApp, + expectedResponseParams = plainApp + ) + val response = existingApplication.update { + name(newName) + removeCapabilities( + Capability.Type.VOICE, + Capability.Type.RTC, + Capability.Type.MESSAGES, + Capability.Type.VERIFY + ) + } + assertEqualsBasicApplication(response, newName) + + assert401ApiResponseException(appUrl, HttpMethod.PUT) { + existingApplication.update {} + } + } + + @Test + fun `create application with all capabilities and webhooks`() { + mockPost( + expectedUrl = baseUrl, authType = authType, + expectedRequestParams = advancedApplicationRequest, + expectedResponseParams = fullApplicationResponse + ) + assertEqualsFullApplication(ac.create { + name(name) + publicKey(publicKey) + voice { + answer { + url(answerUrl) + method(answerMethod) + connectionTimeout(connectionTimeout) + socketTimeout(socketTimeout) + } + fallbackAnswer { + url(fallbackAnswerUrl) + method(answerMethod) + connectionTimeout(connectionTimeout) + socketTimeout(socketTimeout) + } + event { + url(eventUrl) + method(eventMethod) + connectionTimeout(connectionTimeout) + socketTimeout(socketTimeout) + } + signedCallbacks(signedCallbacks) + conversationsTtl(conversationsTtl) + region(region) + } + rtc { + event { + url(eventUrl) + method(eventMethod) + } + } + messages { + inbound { + url(moCallbackUrl) + method(inboundMethod) + } + status { + url(statusCallbackUrl) + method(statusMethod) + } + } + vbc() + verify { + status { + url(statusCallbackUrl) + method(statusMethod) + } + } + improveAi(improveAi) + }) + + assert401ApiResponseException(baseUrl, HttpMethod.POST) { + ac.create { name(name) } + } + } +} \ 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 54bc226..2bfe797 100644 --- a/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/VoiceTest.kt @@ -40,11 +40,10 @@ class VoiceTest : AbstractTest() { private val fromPstn = "14155550100" private val user = "Sam" private val vbcExt = "4321" - private val eventUrl = "https://example.com/event" - private val streamUrl = "https://example.com/waiting.mp3" - private val onAnswerUrl = "https://example.com/ncco.json" + private val streamUrl = "$exampleUrlBase/waiting.mp3" + private val onAnswerUrl = "$exampleUrlBase/ncco.json" private val websocketUri = "wss://example.com/socket" - private val ringbackTone = "http://example.com/ringbackTone.wav" + private val ringbackTone = "http://example.org/ringbackTone.wav" private val wsContentType = "audio/l16;rate=8000" private val userToUserHeader = "56a390f3d2b7310023a" private val conversationName = "selective-audio Demo"