diff --git a/release-notes/opensearch-common-utils.release-notes-1.0.0.0.md b/release-notes/opensearch-common-utils.release-notes-1.0.0.0.md new file mode 100644 index 00000000..71d1a7db --- /dev/null +++ b/release-notes/opensearch-common-utils.release-notes-1.0.0.0.md @@ -0,0 +1,29 @@ +## Version 1.0.0.0 2021-07-01 + +Compatible with OpenSearch 1.0.0 + +### Enhancements + + * Notification plugin interface and models ([#31](https://github.com/opensearch-project/common-utils/pull/31)) + +### Infrastructure + + * Support for kotlin and JUnit5 with mockito ([#29](https://github.com/opensearch-project/common-utils/pull/29)) + * Removing Kotlin Runtime library bundled into library ([#30](https://github.com/opensearch-project/common-utils/pull/30)) + * Bump to version 1.0.0.0 #34 ([#34](https://github.com/opensearch-project/common-utils/pull/34)) + +### Documentation + + * Update OpenSearch branch to 1.0 ([#28](https://github.com/opensearch-project/common-utils/pull/28)) + * Cleanup READMEs. ([#32](https://github.com/opensearch-project/common-utils/pull/32)) + +### Maintainence + + * Update issue template with multiple labels ([#18](https://github.com/opensearch-project/common-utils/pull/18)) + * Rename namespaces from OpenDistro to OpenSearch ([#20](https://github.com/opensearch-project/common-utils/pull/20)) + * Rename classes, variables, methods to incorporate OpenSearch ([#21](https://github.com/opensearch-project/common-utils/pull/21)) + * Rename remaining identifiers to OpenSearch ([#23](https://github.com/opensearch-project/common-utils/pull/23)) + * Version changed to rc1 #24 ([#24](https://github.com/opensearch-project/common-utils/pull/24)) + * Rename consts as per changes in security plugin ([#25](https://github.com/opensearch-project/common-utils/pull/25)) + * Move workflow tags to rc1 ([#26](https://github.com/opensearch-project/common-utils/pull/26)) + * Add rc1 release notes ([#27](https://github.com/opensearch-project/common-utils/pull/27)) diff --git a/src/main/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterface.kt b/src/main/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterface.kt index e82aaba8..2f043351 100644 --- a/src/main/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterface.kt +++ b/src/main/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterface.kt @@ -27,8 +27,11 @@ package org.opensearch.commons.notifications import org.opensearch.action.ActionListener +import org.opensearch.action.ActionResponse import org.opensearch.client.node.NodeClient +import org.opensearch.common.io.stream.Writeable import org.opensearch.commons.ConfigConstants.OPENSEARCH_SECURITY_USER_INFO_THREAD_CONTEXT +import org.opensearch.commons.notifications.action.BaseResponse import org.opensearch.commons.notifications.action.CreateNotificationConfigRequest import org.opensearch.commons.notifications.action.CreateNotificationConfigResponse import org.opensearch.commons.notifications.action.DeleteNotificationConfigRequest @@ -56,6 +59,7 @@ import org.opensearch.commons.notifications.action.UpdateNotificationConfigRespo import org.opensearch.commons.notifications.model.ChannelMessage import org.opensearch.commons.notifications.model.EventSource import org.opensearch.commons.utils.SecureClientWrapper +import org.opensearch.commons.utils.recreateObject /** * All the transport action plugin interfaces for the Notification plugin @@ -76,7 +80,7 @@ object NotificationsPluginInterface { client.execute( CREATE_NOTIFICATION_CONFIG_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { CreateNotificationConfigResponse(it) } } ) } @@ -94,7 +98,7 @@ object NotificationsPluginInterface { client.execute( UPDATE_NOTIFICATION_CONFIG_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { UpdateNotificationConfigResponse(it) } } ) } @@ -112,7 +116,7 @@ object NotificationsPluginInterface { client.execute( DELETE_NOTIFICATION_CONFIG_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { DeleteNotificationConfigResponse(it) } } ) } @@ -130,7 +134,7 @@ object NotificationsPluginInterface { client.execute( GET_NOTIFICATION_CONFIG_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { GetNotificationConfigResponse(it) } } ) } @@ -148,7 +152,7 @@ object NotificationsPluginInterface { client.execute( GET_NOTIFICATION_EVENT_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { GetNotificationEventResponse(it) } } ) } @@ -166,7 +170,7 @@ object NotificationsPluginInterface { client.execute( GET_PLUGIN_FEATURES_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { GetPluginFeaturesResponse(it) } } ) } @@ -184,7 +188,7 @@ object NotificationsPluginInterface { client.execute( GET_FEATURE_CHANNEL_LIST_ACTION_TYPE, request, - listener + wrapActionListener(listener) { response -> recreateObject(response) { GetFeatureChannelListResponse(it) } } ) } @@ -209,7 +213,30 @@ object NotificationsPluginInterface { wrapper.execute( SEND_NOTIFICATION_ACTION_TYPE, SendNotificationRequest(eventSource, channelMessage, channelIds, threadContext), - listener + wrapActionListener(listener) { response -> recreateObject(response) { SendNotificationResponse(it) } } ) } + + /** + * Wrap action listener on concrete response class by a new created one on ActionResponse. + * This is required because the response may be loaded by different classloader across plugins. + * The onResponse(ActionResponse) avoids type cast exception and give a chance to recreate + * the response object. + */ + @Suppress("UNCHECKED_CAST") + private fun wrapActionListener( + listener: ActionListener, + recreate: (Writeable) -> Response + ): ActionListener { + return object : ActionListener { + override fun onResponse(response: ActionResponse) { + val recreated = response as? Response ?: recreate(response) + listener.onResponse(recreated) + } + + override fun onFailure(exception: java.lang.Exception) { + listener.onFailure(exception) + } + } as ActionListener + } } diff --git a/src/main/kotlin/org/opensearch/commons/notifications/model/HttpMethodType.kt b/src/main/kotlin/org/opensearch/commons/notifications/model/HttpMethodType.kt new file mode 100644 index 00000000..8485e4fd --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/notifications/model/HttpMethodType.kt @@ -0,0 +1,36 @@ +package org.opensearch.commons.notifications.model + +import org.opensearch.commons.utils.EnumParser + +enum class HttpMethodType(val tag: String) { + POST("POST") { + override fun toString(): String { + return tag + } + }, + PUT("PUT") { + override fun toString(): String { + return tag + } + }, + PATCH("PATCH") { + override fun toString(): String { + return tag + } + }; + + companion object { + private val tagMap = values().associateBy { it.tag } + + val enumParser = EnumParser { fromTagOrDefault(it) } + + /** + * Get HttpMethodType from tag or POST if not found + * @param tag the tag + * @return MethodType corresponding to tag. POST if invalid tag. + */ + fun fromTagOrDefault(tag: String): HttpMethodType { + return tagMap[tag] ?: POST + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/notifications/model/Webhook.kt b/src/main/kotlin/org/opensearch/commons/notifications/model/Webhook.kt index 55a4cb3e..09dbfe9d 100644 --- a/src/main/kotlin/org/opensearch/commons/notifications/model/Webhook.kt +++ b/src/main/kotlin/org/opensearch/commons/notifications/model/Webhook.kt @@ -35,6 +35,7 @@ import org.opensearch.common.xcontent.XContentBuilder import org.opensearch.common.xcontent.XContentParser import org.opensearch.common.xcontent.XContentParserUtils import org.opensearch.commons.notifications.NotificationConstants.HEADER_PARAMS_TAG +import org.opensearch.commons.notifications.NotificationConstants.METHOD_TAG import org.opensearch.commons.notifications.NotificationConstants.URL_TAG import org.opensearch.commons.utils.STRING_READER import org.opensearch.commons.utils.STRING_WRITER @@ -47,7 +48,8 @@ import java.io.IOException */ data class Webhook( val url: String, - val headerParams: Map = mapOf() + val headerParams: Map = mapOf(), + val method: HttpMethodType = HttpMethodType.POST ) : BaseConfigData { init { @@ -77,6 +79,7 @@ data class Webhook( fun parse(parser: XContentParser): Webhook { var url: String? = null var headerParams: Map = mapOf() + var method = HttpMethodType.POST XContentParserUtils.ensureExpectedToken( XContentParser.Token.START_OBJECT, @@ -89,6 +92,7 @@ data class Webhook( when (fieldName) { URL_TAG -> url = parser.text() HEADER_PARAMS_TAG -> headerParams = parser.mapStrings() + METHOD_TAG -> method = HttpMethodType.fromTagOrDefault(parser.text()) else -> { parser.skipChildren() log.info("Unexpected field: $fieldName, while parsing Webhook destination") @@ -96,7 +100,7 @@ data class Webhook( } } url ?: throw IllegalArgumentException("$URL_TAG field absent") - return Webhook(url, headerParams) + return Webhook(url, headerParams, method) } } @@ -108,6 +112,7 @@ data class Webhook( return builder.startObject() .field(URL_TAG, url) .field(HEADER_PARAMS_TAG, headerParams) + .field(METHOD_TAG, method.tag) .endObject() } @@ -117,7 +122,8 @@ data class Webhook( */ constructor(input: StreamInput) : this( url = input.readString(), - headerParams = input.readMap(STRING_READER, STRING_READER) + headerParams = input.readMap(STRING_READER, STRING_READER), + method = input.readEnum(HttpMethodType::class.java) ) /** @@ -126,5 +132,6 @@ data class Webhook( override fun writeTo(output: StreamOutput) { output.writeString(url) output.writeMap(headerParams, STRING_WRITER, STRING_WRITER) + output.writeEnum(method) } } diff --git a/src/test/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterfaceTests.kt b/src/test/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterfaceTests.kt new file mode 100644 index 00000000..99f558f4 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/notifications/NotificationsPluginInterfaceTests.kt @@ -0,0 +1,268 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.commons.notifications + +import com.nhaarman.mockitokotlin2.whenever +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Answers +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension +import org.opensearch.action.ActionListener +import org.opensearch.action.ActionType +import org.opensearch.client.node.NodeClient +import org.opensearch.commons.notifications.action.CreateNotificationConfigRequest +import org.opensearch.commons.notifications.action.CreateNotificationConfigResponse +import org.opensearch.commons.notifications.action.DeleteNotificationConfigRequest +import org.opensearch.commons.notifications.action.DeleteNotificationConfigResponse +import org.opensearch.commons.notifications.action.GetFeatureChannelListRequest +import org.opensearch.commons.notifications.action.GetFeatureChannelListResponse +import org.opensearch.commons.notifications.action.GetNotificationConfigRequest +import org.opensearch.commons.notifications.action.GetNotificationConfigResponse +import org.opensearch.commons.notifications.action.GetNotificationEventRequest +import org.opensearch.commons.notifications.action.GetNotificationEventResponse +import org.opensearch.commons.notifications.action.GetPluginFeaturesRequest +import org.opensearch.commons.notifications.action.GetPluginFeaturesResponse +import org.opensearch.commons.notifications.action.SendNotificationResponse +import org.opensearch.commons.notifications.action.UpdateNotificationConfigRequest +import org.opensearch.commons.notifications.action.UpdateNotificationConfigResponse +import org.opensearch.commons.notifications.model.ChannelMessage +import org.opensearch.commons.notifications.model.ConfigType +import org.opensearch.commons.notifications.model.DeliveryStatus +import org.opensearch.commons.notifications.model.EventSource +import org.opensearch.commons.notifications.model.EventStatus +import org.opensearch.commons.notifications.model.Feature +import org.opensearch.commons.notifications.model.FeatureChannel +import org.opensearch.commons.notifications.model.FeatureChannelList +import org.opensearch.commons.notifications.model.NotificationConfig +import org.opensearch.commons.notifications.model.NotificationConfigInfo +import org.opensearch.commons.notifications.model.NotificationConfigSearchResult +import org.opensearch.commons.notifications.model.NotificationEvent +import org.opensearch.commons.notifications.model.NotificationEventInfo +import org.opensearch.commons.notifications.model.NotificationEventSearchResult +import org.opensearch.commons.notifications.model.SeverityType +import org.opensearch.commons.notifications.model.Slack +import org.opensearch.rest.RestStatus +import java.time.Instant +import java.util.EnumSet + +@Suppress("UNCHECKED_CAST") +@ExtendWith(MockitoExtension::class) +internal class NotificationsPluginInterfaceTests { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private lateinit var client: NodeClient + + @Test + fun createNotificationConfig() { + val request = mock(CreateNotificationConfigRequest::class.java) + val response = CreateNotificationConfigResponse("configId") + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.createNotificationConfig(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun updateNotificationConfig() { + val request = mock(UpdateNotificationConfigRequest::class.java) + val response = UpdateNotificationConfigResponse("configId") + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.updateNotificationConfig(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun deleteNotificationConfig() { + val request = mock(DeleteNotificationConfigRequest::class.java) + val response = DeleteNotificationConfigResponse(mapOf(Pair("sample_config_id", RestStatus.OK))) + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.deleteNotificationConfig(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun getNotificationConfig() { + val request = mock(GetNotificationConfigRequest::class.java) + val response = mockGetNotificationConfigResponse() + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.getNotificationConfig(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun getNotificationEvent() { + val request = mock(GetNotificationEventRequest::class.java) + val response = mockGetNotificationEventResponse() + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.getNotificationEvent(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun getPluginFeatures() { + val request = mock(GetPluginFeaturesRequest::class.java) + val response = GetPluginFeaturesResponse( + listOf("config_type_1", "config_type_2", "config_type_3"), + mapOf( + Pair("FeatureKey1", "FeatureValue1"), + Pair("FeatureKey2", "FeatureValue2"), + Pair("FeatureKey3", "FeatureValue3") + ) + ) + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.getPluginFeatures(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun getFeatureChannelList() { + val sampleConfig = FeatureChannel( + "config_id", + "name", + "description", + ConfigType.SLACK + ) + + val request = mock(GetFeatureChannelListRequest::class.java) + val response = GetFeatureChannelListResponse(FeatureChannelList(sampleConfig)) + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.getFeatureChannelList(client, request, listener) + verify(listener, times(1)).onResponse(eq(response)) + } + + @Test + fun sendNotification() { + val notificationInfo = EventSource( + "title", + "reference_id", + Feature.REPORTS, + SeverityType.HIGH, + listOf("tag1", "tag2") + ) + val channelMessage = ChannelMessage( + "text_description", + "htmlDescription", + null + ) + + val response = SendNotificationResponse("configId") + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(any(ActionType::class.java), any(), any()) + + NotificationsPluginInterface.sendNotification( + client, notificationInfo, channelMessage, listOf("channelId1", "channelId2"), listener + ) + verify(listener, times(1)).onResponse(eq(response)) + } + + private fun mockGetNotificationConfigResponse(): GetNotificationConfigResponse { + val sampleSlack = Slack("https://domain.com/sample_url#1234567890") + val sampleConfig = NotificationConfig( + "name", + "description", + ConfigType.SLACK, + EnumSet.of(Feature.REPORTS), + configData = sampleSlack + ) + val configInfo = NotificationConfigInfo( + "config_id", + Instant.now(), + Instant.now(), + "tenant", + sampleConfig + ) + return GetNotificationConfigResponse(NotificationConfigSearchResult(configInfo)) + } + + private fun mockGetNotificationEventResponse(): GetNotificationEventResponse { + val sampleEventSource = EventSource( + "title", + "reference_id", + Feature.ALERTING, + severity = SeverityType.INFO + ) + val sampleStatus = EventStatus( + "config_id", + "name", + ConfigType.SLACK, + deliveryStatus = DeliveryStatus("404", "invalid recipient") + ) + val sampleEvent = NotificationEvent(sampleEventSource, listOf(sampleStatus)) + val eventInfo = NotificationEventInfo( + "event_id", + Instant.now(), + Instant.now(), + "tenant", + sampleEvent + ) + return GetNotificationEventResponse(NotificationEventSearchResult(eventInfo)) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/notifications/model/WebhookTests.kt b/src/test/kotlin/org/opensearch/commons/notifications/model/WebhookTests.kt index 9ca39c7c..48dedf49 100644 --- a/src/test/kotlin/org/opensearch/commons/notifications/model/WebhookTests.kt +++ b/src/test/kotlin/org/opensearch/commons/notifications/model/WebhookTests.kt @@ -46,7 +46,11 @@ internal class WebhookTests { @Test fun `Webhook serialize and deserialize using json object should be equal`() { - val sampleWebhook = Webhook("https://domain.com/sample_url#1234567890", mapOf(Pair("key", "value"))) + val sampleWebhook = Webhook( + "https://domain.com/sample_url#1234567890", + mapOf(Pair("key", "value")), + HttpMethodType.PUT + ) val jsonString = getJsonString(sampleWebhook) val recreatedObject = createObjectFromJsonString(jsonString) { Webhook.parse(it) } assertEquals(sampleWebhook, recreatedObject) @@ -54,13 +58,18 @@ internal class WebhookTests { @Test fun `Webhook should deserialize json object using parser`() { - val sampleWebhook = Webhook("https://domain.com/sample_url#1234567890", mapOf(Pair("key", "value"))) + val sampleWebhook = Webhook( + "https://domain.com/sample_url#1234567890", + mapOf(Pair("key", "value")), + HttpMethodType.PATCH + ) val jsonString = """ { "url":"${sampleWebhook.url}", "header_params":{ "key":"value" - } + }, + "method":"PATCH" } """.trimIndent() val recreatedObject = createObjectFromJsonString(jsonString) { Webhook.parse(it) } diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..ca6ee9ce --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file