diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/email/EmailPublisher.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/email/EmailPublisher.java index b1789fc13d03..c49e7079ef05 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/email/EmailPublisher.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/email/EmailPublisher.java @@ -83,11 +83,10 @@ public void sendMessage(ChangeEvent event) throws EventPublisherException { public void sendTestMessage() throws EventPublisherException { try { Set receivers = emailAlertConfig.getReceivers(); - EmailMessage emailMessage = - emailDecorator.buildOutgoingTestMessage(eventSubscription.getFullyQualifiedName()); + EmailUtil.testConnection(); + for (String email : receivers) { - EmailUtil.sendChangeEventMail( - eventSubscription.getFullyQualifiedName(), email, emailMessage); + EmailUtil.sendTestEmail(email, false); } setSuccessStatus(System.currentTimeMillis()); } catch (Exception e) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/gchat/GChatMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/gchat/GChatMessage.java index eac73fe20252..a201d6d51457 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/gchat/GChatMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/gchat/GChatMessage.java @@ -3,39 +3,75 @@ import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor public class GChatMessage { - @Getter @Setter private String text; - @Getter @Setter private List cardsV2; - - public static class CardsV2 { - @Getter @Setter private String cardId; - @Getter @Setter private Card card; - } + private List cards; + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor public static class Card { - - @Getter @Setter private CardHeader header; - @Getter @Setter private List
sections; + private GChatMessage.Header header; + private List sections; } - public static class CardHeader { - @Getter @Setter private String title; - @Getter @Setter private String subtitle; + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Header { + private String title; + private String imageUrl; + private String imageStyle; } + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor public static class Section { - @Getter @Setter private List widgets; + private List widgets; } + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor public static class Widget { - @Getter @Setter private TextParagraph textParagraph; + private GChatMessage.KeyValue keyValue; + private GChatMessage.TextParagraph textParagraph; + + public Widget(GChatMessage.KeyValue keyValue) { + this.keyValue = keyValue; + } + + public Widget(GChatMessage.TextParagraph textParagraph) { + this.textParagraph = textParagraph; + } + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class KeyValue { + private String topLabel; + private String content; } + @Getter + @Setter + @NoArgsConstructor @AllArgsConstructor public static class TextParagraph { - @Getter @Setter private String text; + private String text; } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/msteams/TeamsMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/msteams/TeamsMessage.java index 8c0b019e5a2a..c9547fe2927a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/msteams/TeamsMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/msteams/TeamsMessage.java @@ -1,34 +1,156 @@ package org.openmetadata.service.apps.bundles.changeEvent.msteams; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; -@JsonIgnoreProperties(ignoreUnknown = true) @Getter @Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder public class TeamsMessage { + @JsonProperty("type") + private String type; + + @JsonProperty("attachments") + private List attachments; + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class Attachment { + @JsonProperty("contentType") + private String contentType; + + @JsonProperty("content") + private TeamsMessage.AdaptiveCardContent content; + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class AdaptiveCardContent { + @JsonProperty("type") + private String type; + + @JsonProperty("version") + private String version; + + @JsonProperty("body") + private List body; + } + @Getter @Setter - public static class Section { - @JsonProperty("activityTitle") - public String activityTitle; + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class ColumnSet implements TeamsMessage.BodyItem { + @JsonProperty("type") + private String type; - @JsonProperty("activityText") - public String activityText; + @JsonProperty("columns") + private List columns; } - @JsonProperty("@type") - public String type = "MessageCard"; + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class Column { + @JsonProperty("type") + private String type; + + @JsonProperty("items") + private List items; - @JsonProperty("@context") - public String context = "http://schema.org/extensions"; + @JsonProperty("width") + private String width; + } - @JsonProperty("summary") - public String summary; + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class Image implements TeamsMessage.BodyItem { + @JsonProperty("type") + private String type; + + @JsonProperty("url") + private String url; + + @JsonProperty("size") + private String size; + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class TextBlock implements TeamsMessage.BodyItem { + @JsonProperty("type") + private String type; + + @JsonProperty("text") + private String text; + + @JsonProperty("size") + private String size; + + @JsonProperty("weight") + private String weight; + + @JsonProperty("wrap") + private boolean wrap; + + @JsonProperty("horizontalAlignment") + private String horizontalAlignment; + + @JsonProperty("spacing") + private String spacing; + + @JsonProperty("separator") + private boolean separator; + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class FactSet implements TeamsMessage.BodyItem { + @JsonProperty("type") + private String type; + + @JsonProperty("facts") + private List facts; + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class Fact { + @JsonProperty("title") + private String title; + + @JsonProperty("value") + private String value; + } - @JsonProperty("sections") - public List
sections; + // Interface for Body Items + public interface BodyItem {} } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackAttachment.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackAttachment.java index 7cffcb406498..37d96f756ca2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackAttachment.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackAttachment.java @@ -1,63 +1,54 @@ package org.openmetadata.service.apps.bundles.changeEvent.slack; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.slack.api.model.block.LayoutBlock; import java.util.List; import lombok.Getter; import lombok.Setter; +@Setter +@Getter @JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) public class SlackAttachment { - @Getter @Setter private String fallback; - @Getter @Setter private String color; - @Getter @Setter private String pretext; + private String fallback; + private String color; + private String pretext; @JsonProperty("author_name") - @Getter - @Setter private String authorName; @JsonProperty("author_link") - @Getter - @Setter private String authorLink; @JsonProperty("author_icon") - @Getter - @Setter private String authorIcon; - @Getter @Setter private String title; + private String title; @JsonProperty("title_link") - @Getter - @Setter private String titleLink; - @Getter @Setter private String text; - @Getter @Setter private Field[] fields; + private String text; + private Field[] fields; @JsonProperty("image_url") - @Getter - @Setter private String imageUrl; @JsonProperty("thumb_url") - @Getter - @Setter private String thumbUrl; - @Getter @Setter private String footer; + private String footer; @JsonProperty("footer_icon") - @Getter - @Setter private String footerIcon; - @Getter @Setter private String ts; + private String ts; @JsonProperty("mrkdwn_in") - @Getter - @Setter private List markdownIn; + + private List blocks; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackEventPublisher.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackEventPublisher.java index 553b8b4cb992..28f0095b3c6d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackEventPublisher.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackEventPublisher.java @@ -19,6 +19,9 @@ import static org.openmetadata.service.util.SubscriptionUtil.getTargetsForWebhookAlert; import static org.openmetadata.service.util.SubscriptionUtil.postWebhookMessage; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import javax.ws.rs.client.Client; import javax.ws.rs.client.Invocation; @@ -75,6 +78,9 @@ public void sendMessage(ChangeEvent event) throws EventPublisherException { SlackMessage slackMessage = slackMessageFormatter.buildOutgoingMessage( eventSubscription.getFullyQualifiedName(), event); + + String json = JsonUtils.pojoToJsonIgnoreNull(slackMessage); + json = convertCamelCaseToSnakeCase(json); List targets = getTargetsForWebhookAlert( webhook, subscriptionDestination.getCategory(), SLACK, client, event); @@ -83,14 +89,10 @@ public void sendMessage(ChangeEvent event) throws EventPublisherException { } for (Invocation.Builder actionTarget : targets) { if (webhook.getSecretKey() != null && !webhook.getSecretKey().isEmpty()) { - String hmac = - "sha256=" - + CommonUtil.calculateHMAC( - webhook.getSecretKey(), JsonUtils.pojoToJson(slackMessage)); - postWebhookMessage( - this, actionTarget.header(RestUtil.SIGNATURE_HEADER, hmac), slackMessage); + String hmac = "sha256=" + CommonUtil.calculateHMAC(webhook.getSecretKey(), json); + postWebhookMessage(this, actionTarget.header(RestUtil.SIGNATURE_HEADER, hmac), json); } else { - postWebhookMessage(this, actionTarget, slackMessage); + postWebhookMessage(this, actionTarget, json); } } } catch (Exception e) { @@ -109,15 +111,14 @@ public void sendTestMessage() throws EventPublisherException { SlackMessage slackMessage = slackMessageFormatter.buildOutgoingTestMessage(eventSubscription.getFullyQualifiedName()); + String json = JsonUtils.pojoToJsonIgnoreNull(slackMessage); + json = convertCamelCaseToSnakeCase(json); if (target != null) { if (webhook.getSecretKey() != null && !webhook.getSecretKey().isEmpty()) { - String hmac = - "sha256=" - + CommonUtil.calculateHMAC( - webhook.getSecretKey(), JsonUtils.pojoToJson(slackMessage)); - postWebhookMessage(this, target.header(RestUtil.SIGNATURE_HEADER, hmac), slackMessage); + String hmac = "sha256=" + CommonUtil.calculateHMAC(webhook.getSecretKey(), json); + postWebhookMessage(this, target.header(RestUtil.SIGNATURE_HEADER, hmac), json); } else { - postWebhookMessage(this, target, slackMessage); + postWebhookMessage(this, target, json); } } } catch (Exception e) { @@ -127,6 +128,50 @@ public void sendTestMessage() throws EventPublisherException { } } + /** + * Slack messages sent via webhook require some keys in snake_case, while the Slack + * app accepts them as they are (camelCase). Using Layout blocks (from com.slack.api.model.block) restricts control over key + * aliases within the class. + **/ + public String convertCamelCaseToSnakeCase(String jsonString) { + JsonNode rootNode = JsonUtils.readTree(jsonString); + JsonNode modifiedNode = convertKeys(rootNode); + return JsonUtils.pojoToJsonIgnoreNull(modifiedNode); + } + + private JsonNode convertKeys(JsonNode node) { + if (node.isObject()) { + ObjectNode objectNode = (ObjectNode) node; + ObjectNode newNode = JsonUtils.getObjectNode(); + + objectNode + .fieldNames() + .forEachRemaining( + fieldName -> { + String newFieldName = fieldName; + if (fieldName.equals("imageUrl")) { + newFieldName = "image_url"; + } else if (fieldName.equals("altText")) { + newFieldName = "alt_text"; + } + + // Recursively convert the keys + newNode.set(newFieldName, convertKeys(objectNode.get(fieldName))); + }); + return newNode; + } else if (node.isArray()) { + ArrayNode arrayNode = (ArrayNode) node; + ArrayNode newArrayNode = JsonUtils.getObjectNode().arrayNode(); + + // recursively convert elements + for (int i = 0; i < arrayNode.size(); i++) { + newArrayNode.add(convertKeys(arrayNode.get(i))); + } + return newArrayNode; + } + return node; + } + @Override public EventSubscription getEventSubscriptionForDestination() { return eventSubscription; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackMessage.java index 92db6207202b..c4fb53e46ef1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/slack/SlackMessage.java @@ -1,35 +1,29 @@ package org.openmetadata.service.apps.bundles.changeEvent.slack; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.slack.api.model.block.LayoutBlock; +import java.util.List; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) public class SlackMessage { - @Getter @Setter private String username; + private List blocks; + private List attachments; - @JsonProperty("icon_emoji") @Getter @Setter - private String iconEmoji; - - @Getter @Setter private String channel; - @Getter @Setter private String text; - - @JsonProperty("response_type") - @Getter - @Setter - private String responseType; - - @Getter @Setter private SlackAttachment[] attachments; - - public SlackMessage() {} - - public SlackMessage(String text) { - this.text = text; - } - - public SlackMessage encodedMessage() { - this.setText(this.getText().replace("&", "&").replace("<", "<").replace(">", ">")); - return this; + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Attachment { + private String color; + private List blocks; } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/EmailMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/EmailMessageDecorator.java index fed5b5de05ae..25b5e04b0d5e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/EmailMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/EmailMessageDecorator.java @@ -29,6 +29,11 @@ public String getBold() { return "%s"; } + @Override + public String getBoldWithSpace() { + return "%s "; + } + @Override public String getLineBreak() { return "
"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java index 9bd640776249..307d47735e90 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java @@ -25,6 +25,11 @@ public String getBold() { return "**%s**"; } + @Override + public String getBoldWithSpace() { + return "**%s** "; + } + @Override public String getLineBreak() { return "
"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/GChatMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/GChatMessageDecorator.java index 4fbf0c6ba39f..3e96eabda3a5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/GChatMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/GChatMessageDecorator.java @@ -17,9 +17,24 @@ import static org.openmetadata.service.util.email.EmailUtil.getSmtpSettings; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.tests.TestCaseParameterValue; +import org.openmetadata.schema.tests.type.TestCaseStatus; import org.openmetadata.schema.type.ChangeEvent; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.FieldChange; +import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.service.Entity; import org.openmetadata.service.apps.bundles.changeEvent.gchat.GChatMessage; +import org.openmetadata.service.apps.bundles.changeEvent.gchat.GChatMessage.*; import org.openmetadata.service.exception.UnhandledServerException; public class GChatMessageDecorator implements MessageDecorator { @@ -29,6 +44,11 @@ public String getBold() { return "%s"; } + @Override + public String getBoldWithSpace() { + return "%s "; + } + @Override public String getLineBreak() { return "
"; @@ -67,7 +87,12 @@ public String getEntityUrl(String prefix, String fqn, String additionalParams) { @Override public GChatMessage buildEntityMessage(String publisherName, ChangeEvent event) { - return getGChatMessage(createEntityMessage(publisherName, event)); + return createMessage(publisherName, event, createEntityMessage(publisherName, event)); + } + + @Override + public GChatMessage buildThreadMessage(String publisherName, ChangeEvent event) { + return createMessage(publisherName, event, createThreadMessage(publisherName, event)); } @Override @@ -75,64 +100,430 @@ public GChatMessage buildTestMessage(String publisherName) { return getGChatTestMessage(publisherName); } - @Override - public GChatMessage buildThreadMessage(String publisherName, ChangeEvent event) { - return getGChatMessage(createThreadMessage(publisherName, event)); - } - - private GChatMessage getGChatMessage(OutgoingMessage outgoingMessage) { - if (!outgoingMessage.getMessages().isEmpty()) { - GChatMessage gChatMessage = new GChatMessage(); - GChatMessage.CardsV2 cardsV2 = new GChatMessage.CardsV2(); - GChatMessage.Card card = new GChatMessage.Card(); - GChatMessage.Section section = new GChatMessage.Section(); - - // Header - gChatMessage.setText("Change Event from OpenMetadata"); - GChatMessage.CardHeader cardHeader = new GChatMessage.CardHeader(); - cardHeader.setTitle(outgoingMessage.getHeader()); - card.setHeader(cardHeader); - - // Attachments - List widgets = new ArrayList<>(); - outgoingMessage.getMessages().forEach(m -> widgets.add(getGChatWidget(m))); - section.setWidgets(widgets); - card.setSections(List.of(section)); - cardsV2.setCard(card); - gChatMessage.setCardsV2(List.of(cardsV2)); - - return gChatMessage; + private GChatMessage getGChatTestMessage(String publisherName) { + if (publisherName.isEmpty()) { + throw new UnhandledServerException("Publisher name not found."); } - throw new UnhandledServerException("No messages found for the event"); + + return createConnectionTestMessage(publisherName); } - private GChatMessage getGChatTestMessage(String publisherName) { - if (!publisherName.isEmpty()) { - GChatMessage gChatMessage = new GChatMessage(); - GChatMessage.CardsV2 cardsV2 = new GChatMessage.CardsV2(); - GChatMessage.Card card = new GChatMessage.Card(); - GChatMessage.Section section = new GChatMessage.Section(); - - // Header - gChatMessage.setText( - "This is a test message from OpenMetadata to confirm your GChat destination is configured correctly."); - GChatMessage.CardHeader cardHeader = new GChatMessage.CardHeader(); - cardHeader.setTitle("Alert: " + publisherName); - cardHeader.setSubtitle("GChat destination test successful."); - - card.setHeader(cardHeader); - card.setSections(List.of(section)); - cardsV2.setCard(card); - gChatMessage.setCardsV2(List.of(cardsV2)); - - return gChatMessage; + public GChatMessage createMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + if (outgoingMessage.getMessages().isEmpty()) { + throw new UnhandledServerException("No messages found for the event"); + } + + String entityType = event.getEntityType(); + + return switch (entityType) { + case Entity.TEST_CASE -> createTestCaseMessage(publisherName, event, outgoingMessage); + default -> createGeneralChangeEventMessage(publisherName, event, outgoingMessage); + }; + } + + private GChatMessage createTestCaseMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + final String testCaseResult = "testCaseResult"; + + List fieldsAdded = event.getChangeDescription().getFieldsAdded(); + List fieldsUpdated = event.getChangeDescription().getFieldsUpdated(); + + boolean hasRelevantChange = + fieldsAdded.stream().anyMatch(field -> testCaseResult.equals(field.getName())) + || fieldsUpdated.stream().anyMatch(field -> testCaseResult.equals(field.getName())); + + return hasRelevantChange + ? createDQTemplate(publisherName, event, outgoingMessage) + : createGeneralChangeEventMessage(publisherName, event, outgoingMessage); + } + + public GChatMessage createGeneralChangeEventMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + Map, Object>> data = + buildGeneralTemplateData(publisherName, event, outgoingMessage); + + Map, Object> eventDetails = data.get(General_Template_Section.EVENT_DETAILS); + + Header header = createHeader(); + + List additionalMessageWidgets = + outgoingMessage.getMessages().stream() + .map(message -> new Widget(new TextParagraph(message))) + .toList(); + + Section detailsSection = new Section(createEventDetailsWidgets(eventDetails)); + Section messageSection = new Section(additionalMessageWidgets); + Section fqnSection = + new Section( + List.of( + createWidget( + "FQN:", + String.valueOf(eventDetails.getOrDefault(EventDetailsKeys.ENTITY_FQN, "-"))))); + + // todo create clickable entity link in the message + + Section footerSection = createFooterSection(); + + Card card = + new Card(header, List.of(detailsSection, fqnSection, messageSection, footerSection)); + return new GChatMessage(List.of(card)); + } + + private Map, Object>> buildGeneralTemplateData( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + TemplateDataBuilder builder = new TemplateDataBuilder<>(); + builder + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.EVENT_TYPE, + event.getEventType().value()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.UPDATED_BY, + event.getUserName()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.ENTITY_TYPE, + event.getEntityType()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.ENTITY_FQN, + MessageDecorator.getFQNForChangeEventEntity(event)) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.TIME, + new Date(event.getTimestamp()).toString()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.OUTGOING_MESSAGE, + outgoingMessage); + + return builder.build(); + } + + public GChatMessage createConnectionTestMessage(String publisherName) { + Header header = createConnectionSuccessfulHeader(); + + Widget publisherWidget = createWidget("Publisher:", publisherName); + + Widget descriptionWidget = new Widget(new TextParagraph(CONNECTION_TEST_DESCRIPTION)); + + Section publisherSection = new Section(List.of(publisherWidget)); + Section descriptionSection = new Section(List.of(descriptionWidget)); + Section footerSection = createFooterSection(); + + Card card = + new Card(header, Arrays.asList(publisherSection, descriptionSection, footerSection)); + + return new GChatMessage(List.of(card)); + } + + public GChatMessage createDQTemplate( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + Map, Object>> templateData = + MessageDecorator.buildDQTemplateData(event, outgoingMessage); + + List
sections = new ArrayList<>(); + Header header = createHeader(); + + addChangeEventDetailsSection(templateData, sections); + + List additionalMessageWidgets = + outgoingMessage.getMessages().stream() + .map(message -> new Widget(new TextParagraph(message))) + .toList(); + sections.add(new Section(additionalMessageWidgets)); + + // todo create clickable entity link in the message + + addTestCaseDetailsSection(templateData, sections); + addTestCaseFQNSection(templateData, sections); + addTestCaseResultSection(templateData, sections); + addParameterValuesSection(templateData, sections); + addInspectionQuerySection(templateData, sections); + addTestDefinitionSection(templateData, sections); + addSampleDataSection(templateData, sections); + + sections.add(createFooterSection()); + + // Create the card with all sections + Card card = new Card(header, sections); + return new GChatMessage(List.of(card)); + } + + private void addChangeEventDetailsSection( + Map, Object>> templateData, List
sections) { + + Map, Object> eventDetails = templateData.get(DQ_Template_Section.EVENT_DETAILS); + if (nullOrEmpty(eventDetails)) { + return; + } + + sections.add(new Section(createEventDetailsWidgets(eventDetails))); + } + + private void addTestCaseDetailsSection( + Map, Object>> templateData, List
sections) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return; } - throw new UnhandledServerException("Publisher name not found."); + + List testCaseDetailsWidgets = + List.of( + createWidget("TEST CASE"), + createWidget( + "ID:", + String.valueOf(testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.ID, "-"))), + createWidget( + "Name:", + String.valueOf(testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.NAME, "-"))), + createWidget("Owners:", formatOwners(testCaseDetails)), + createWidget("Tags:", formatTags(testCaseDetails))); + + sections.add(new Section(testCaseDetailsWidgets)); + } + + @SuppressWarnings("unchecked") + private String formatOwners(Map, Object> testCaseDetails) { + List owners = + (List) + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.OWNERS, Collections.emptyList()); + + StringBuilder ownersStringified = new StringBuilder(); + if (!CommonUtil.nullOrEmpty(owners)) { + owners.forEach( + owner -> { + if (owner != null && owner.getName() != null) { + ownersStringified.append(owner.getName()).append(", "); + } + }); + + // Remove the trailing comma and space if there's content + if (!ownersStringified.isEmpty()) { + ownersStringified.setLength(ownersStringified.length() - 2); + } + } else { + ownersStringified.append("-"); + } + + return ownersStringified.toString(); + } + + @SuppressWarnings("unchecked") + private String formatTags(Map, Object> testCaseDetails) { + List tags = + (List) + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.TAGS, Collections.emptyList()); + + StringBuilder tagsStringified = new StringBuilder(); + if (!CommonUtil.nullOrEmpty(tags)) { + tags.forEach( + tag -> { + if (tag != null && tag.getName() != null) { + tagsStringified.append(tag.getName()).append(", "); + } + }); + + // Remove the trailing comma and space if there's content + if (!tagsStringified.isEmpty()) { + tagsStringified.setLength(tagsStringified.length() - 2); + } + } else { + tagsStringified.append("-"); + } + + return tagsStringified.toString(); + } + + private void addTestCaseFQNSection( + Map, Object>> templateData, List
sections) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return; + } + + Widget testCaseFQNWidget = + createWidget( + "Test Case FQN:", + String.valueOf( + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.TEST_CASE_FQN, "-"))); + + sections.add(new Section(List.of(testCaseFQNWidget))); + } + + private void addTestCaseResultSection( + Map, Object>> templateData, List
sections) { + + Map, Object> testCaseResult = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); + if (nullOrEmpty(testCaseResult)) { + return; + } + + List statusParameterWidgets = new ArrayList<>(); + statusParameterWidgets.add(createWidget("TEST CASE RESULT")); + + statusParameterWidgets.add( + createWidget( + "Status:", getStatusWithEmoji(testCaseResult.get(DQ_TestCaseResultKeys.STATUS)))); + + statusParameterWidgets.add( + createWidget( + "Result Message:", + String.valueOf( + testCaseResult.getOrDefault(DQ_TestCaseResultKeys.RESULT_MESSAGE, "-")))); + + sections.add(new Section(statusParameterWidgets)); + } + + private String getStatusWithEmoji(Object object) { + if (object instanceof TestCaseStatus status) { + return switch (status) { + case Success -> "Success \u2705"; // Green checkmark for success + case Failed -> "Failed \u274C"; // Red cross for failure + case Aborted -> "Aborted \u26A0"; // Warning sign for aborted + case Queued -> "Queued \u23F3"; // Hourglass for queued + default -> "Unknown \u2753"; // Gray question mark for unknown cases + }; + } + return "Unknown \u2753"; // Default to unknown if the object is not a valid TestCaseStatus + } + + private void addParameterValuesSection( + Map, Object>> templateData, List
sections) { + + Map, Object> testCaseResult = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); + if (nullOrEmpty(testCaseResult)) { + return; + } + + Object result = testCaseResult.get(DQ_TestCaseResultKeys.PARAMETER_VALUE); + if (!(result instanceof List)) { + return; + } + + List parameterValues = (List) result; + if (nullOrEmpty(parameterValues)) { + return; + } + + String parameterValuesText = + parameterValues.stream() + .map(param -> String.format("[%s: %s]", param.getName(), param.getValue())) + .collect(Collectors.joining(", ")); + + List parameterValueWidget = new ArrayList<>(); + parameterValueWidget.add(createWidget("Parameter Value:", parameterValuesText)); + + sections.add(new Section(parameterValueWidget)); + } + + private void addInspectionQuerySection( + Map, Object>> templateData, List
sections) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + + if (!nullOrEmpty(testCaseDetails)) { + String inspectionQueryText = + String.valueOf( + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.INSPECTION_QUERY, "-")); + + Widget inspectionQuery = createWidget("Inspection Query", ""); + Widget inspectionQueryWidget = new Widget(new TextParagraph(inspectionQueryText)); + + sections.add(new Section(List.of(inspectionQuery, inspectionQueryWidget))); + } + } + + private void addTestDefinitionSection( + Map, Object>> templateData, List
sections) { + + Map, Object> testDefinition = templateData.get(DQ_Template_Section.TEST_DEFINITION); + + if (!nullOrEmpty(testDefinition)) { + List testDefinitionWidgets = + List.of( + createWidget("TEST DEFINITION"), + createWidget( + "Name:", + String.valueOf( + testDefinition.getOrDefault( + DQ_TestDefinitionKeys.TEST_DEFINITION_NAME, "-"))), + createWidget( + "Description:", + String.valueOf( + testDefinition.getOrDefault( + DQ_TestDefinitionKeys.TEST_DEFINITION_DESCRIPTION, "-")))); + + sections.add(new Section(testDefinitionWidgets)); + } + } + + private void addSampleDataSection( + Map, Object>> templateData, List
sections) { + if (templateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS)) { + Map, Object> testCaseDetails = + templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + + if (!nullOrEmpty(testCaseDetails)) { + Widget sampleDataWidget = + createWidget( + "Sample Data:", + String.valueOf( + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.SAMPLE_DATA, "-"))); + + sections.add(new Section(List.of(sampleDataWidget))); + } + } + } + + private List createEventDetailsWidgets(Map, Object> detailsMap) { + List widgets = new ArrayList<>(); + + Map, String> labelsMap = new LinkedHashMap<>(); + labelsMap.put(EventDetailsKeys.EVENT_TYPE, "Event Type:"); + labelsMap.put(EventDetailsKeys.UPDATED_BY, "Updated By:"); + labelsMap.put(EventDetailsKeys.ENTITY_TYPE, "Entity Type:"); + labelsMap.put(EventDetailsKeys.TIME, "Time:"); + + labelsMap.forEach( + (key, label) -> { + if (detailsMap.containsKey(key)) { + widgets.add(createWidget(label, String.valueOf(detailsMap.get(key)))); + } + }); + + return widgets; + } + + private Widget createWidget(String label) { + return new Widget(new TextParagraph(applyBoldFormatWithSpace(label) + StringUtils.EMPTY)); + } + + private Widget createWidget(String label, String content) { + return new Widget(new TextParagraph(applyBoldFormatWithSpace(label) + content)); + } + + private Header createHeader() { + return new Header("Change Event Details", "https://imgur.com/kOOPEG4.png", "IMAGE"); + } + + private Header createConnectionSuccessfulHeader() { + return new Header("Connection Successful \u2705", "https://imgur.com/kOOPEG4.png", "IMAGE"); + } + + private Section createFooterSection() { + return new Section(List.of(new Widget(new TextParagraph(TEMPLATE_FOOTER)))); } - private GChatMessage.Widget getGChatWidget(String message) { - GChatMessage.Widget widget = new GChatMessage.Widget(); - widget.setTextParagraph(new GChatMessage.TextParagraph(message)); - return widget; + private String applyBoldFormatWithSpace(String title) { + return String.format(getBoldWithSpace(), title); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java index 68e432a5ca44..3dfb7f5b9b38 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java @@ -17,9 +17,27 @@ import static org.openmetadata.service.util.email.EmailUtil.getSmtpSettings; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.tests.TestCaseParameterValue; import org.openmetadata.schema.type.ChangeEvent; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.service.Entity; import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage; +import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage.AdaptiveCardContent; +import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage.Attachment; +import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage.Column; +import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage.ColumnSet; +import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage.Image; +import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage.TextBlock; import org.openmetadata.service.exception.UnhandledServerException; public class MSTeamsMessageDecorator implements MessageDecorator { @@ -29,6 +47,11 @@ public String getBold() { return "**%s**"; } + @Override + public String getBoldWithSpace() { + return "**%s** "; + } + @Override public String getLineBreak() { return "
"; @@ -66,7 +89,12 @@ public String getEntityUrl(String prefix, String fqn, String additionalParams) { @Override public TeamsMessage buildEntityMessage(String publisherName, ChangeEvent event) { - return getTeamMessage(createEntityMessage(publisherName, event)); + return createMessage(publisherName, event, createEntityMessage(publisherName, event)); + } + + @Override + public TeamsMessage buildThreadMessage(String publisherName, ChangeEvent event) { + return createMessage(publisherName, event, createThreadMessage(publisherName, event)); } @Override @@ -74,53 +102,582 @@ public TeamsMessage buildTestMessage(String publisherName) { return getTeamTestMessage(publisherName); } - @Override - public TeamsMessage buildThreadMessage(String publisherName, ChangeEvent event) { - return getTeamMessage(createThreadMessage(publisherName, event)); + public TeamsMessage getTeamTestMessage(String publisherName) { + if (publisherName.isEmpty()) { + throw new UnhandledServerException("Publisher name not found."); + } + + return createConnectionTestMessage(publisherName); + } + + private TeamsMessage createMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + if (outgoingMessage.getMessages().isEmpty()) { + throw new UnhandledServerException("No messages found for the event"); + } + + String entityType = event.getEntityType(); + + return switch (entityType) { + case Entity.TEST_CASE -> createDQMessage(publisherName, event, outgoingMessage); + default -> createGeneralChangeEventMessage(publisherName, event, outgoingMessage); + }; + } + + private TeamsMessage createGeneralChangeEventMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + Map, Object>> templateData = + buildGeneralTemplateData(publisherName, event, outgoingMessage); + + Map, Object> eventDetails = templateData.get(General_Template_Section.EVENT_DETAILS); + + TextBlock changeEventDetailsTextBlock = createHeader(); + + // Create the facts for the FactSet + List facts = createEventDetailsFacts(eventDetails); + + // Create a list of TextBlocks for each message with a separator + List messageTextBlocks = + outgoingMessage.getMessages().stream() + .map( + message -> + TextBlock.builder() + .type("TextBlock") + .text(message) + .wrap(true) + .spacing("Medium") + .separator(true) + .build()) + .toList(); + + TextBlock footerMessage = createFooterMessage(); + + ColumnSet columnSet = createHeaderColumnSet(changeEventDetailsTextBlock); + + // Create the body list and combine all elements + List body = new ArrayList<>(); + body.add(columnSet); + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(facts).build()); + body.addAll(messageTextBlocks); // Add the containers with message TextBlocks + body.add(createEntityLink(outgoingMessage.getEntityUrl())); + body.add(footerMessage); + + Attachment attachment = + Attachment.builder() + .contentType("application/vnd.microsoft.card.adaptive") + .content( + AdaptiveCardContent.builder() + .type("AdaptiveCard") + .version("1.0") + .body(body) // Pass the combined body list + .build()) + .build(); + + return TeamsMessage.builder().type("message").attachments(List.of(attachment)).build(); + } + + private TeamsMessage createDQMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + Map, Object>> dqTemplateData = + buildDQTemplateData(publisherName, event, outgoingMessage); + + TextBlock changeEventDetailsTextBlock = createHeader(); + + Map, Object> eventDetails = dqTemplateData.get(DQ_Template_Section.EVENT_DETAILS); + + // Create the facts for different sections + List facts = createEventDetailsFacts(eventDetails); + List testCaseDetailsFacts = createTestCaseDetailsFacts(dqTemplateData); + List testCaseResultFacts = createTestCaseResultFacts(dqTemplateData); + + List parameterValuesFacts = createParameterValuesFacts(dqTemplateData); + + List inspectionQueryFacts = createInspectionQueryFacts(dqTemplateData); + List testDefinitionFacts = createTestDefinitionFacts(dqTemplateData); + List sampleDataFacts = createSampleDataFacts(dqTemplateData); + + // Create a list of TextBlocks for each message with a separator + List messageTextBlocks = + outgoingMessage.getMessages().stream() + .map( + message -> + TextBlock.builder() + .type("TextBlock") + .text(message) + .wrap(true) + .spacing("Medium") + .separator(true) // Set separator for each message + .build()) + .toList(); + + TextBlock footerMessage = createFooterMessage(); + + ColumnSet columnSet = createHeaderColumnSet(changeEventDetailsTextBlock); + + // Divider between sections + TextBlock divider = createDivider(); + + // Create the body list and combine all elements with dividers between fact sets + List body = new ArrayList<>(); + body.add(columnSet); + + // event details facts + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(facts).build()); + + // Add the outgoing message text blocks + body.addAll(messageTextBlocks); + body.add(divider); + + // test case details facts + if (dqTemplateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS) + && !nullOrEmpty(testCaseDetailsFacts)) { + body.add(createBoldTextBlock("Test Case Details")); + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(testCaseDetailsFacts).build()); + body.add(divider); + } + + // test case result facts + if (dqTemplateData.containsKey(DQ_Template_Section.TEST_CASE_RESULT) + && !nullOrEmpty(testCaseResultFacts)) { + body.add(createBoldTextBlock("Test Case Result")); + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(testCaseResultFacts).build()); + body.add(divider); + } + + // parameterValues facts + if (dqTemplateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS) + && !nullOrEmpty(parameterValuesFacts)) { + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(parameterValuesFacts).build()); + } + + // inspection query facts + if (dqTemplateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS) + && !nullOrEmpty(inspectionQueryFacts)) { + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(inspectionQueryFacts).build()); + body.add(divider); + } + + // test definition facts + if (dqTemplateData.containsKey(DQ_Template_Section.TEST_DEFINITION) + && !nullOrEmpty(testDefinitionFacts)) { + body.add(createBoldTextBlock("Test Definition")); + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(testDefinitionFacts).build()); + body.add(divider); + } + + // Add sample data facts + if (dqTemplateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS) + && !nullOrEmpty(sampleDataFacts)) { + body.add(TeamsMessage.FactSet.builder().type("FactSet").facts(sampleDataFacts).build()); + } + + body.add(createEntityLink(outgoingMessage.getEntityUrl())); + + body.add(footerMessage); + + // Create the attachment with the combined body list + Attachment attachment = + Attachment.builder() + .contentType("application/vnd.microsoft.card.adaptive") + .content( + AdaptiveCardContent.builder() + .type("AdaptiveCard") + .version("1.0") + .body(body) // Pass the combined body list + .build()) + .build(); + + return TeamsMessage.builder().type("message").attachments(List.of(attachment)).build(); + } + + private ColumnSet createHeaderColumnSet(TextBlock changeEventDetailsTextBlock) { + return ColumnSet.builder() + .type("ColumnSet") + .columns( + List.of( + Column.builder() + .type("Column") + .items(List.of(createOMImageMessage())) // Create and add image message + .width("auto") + .build(), + Column.builder() + .type("Column") + .items(List.of(changeEventDetailsTextBlock)) // Add change event details + .width("stretch") + .build())) + .build(); + } + + private List createEventDetailsFacts(Map, Object> detailsMap) { + return List.of( + createFact("Event Type:", String.valueOf(detailsMap.get(EventDetailsKeys.EVENT_TYPE))), + createFact("Updated By:", String.valueOf(detailsMap.get(EventDetailsKeys.UPDATED_BY))), + createFact("Entity Type:", String.valueOf(detailsMap.get(EventDetailsKeys.ENTITY_TYPE))), + createFact("Time:", String.valueOf(detailsMap.get(EventDetailsKeys.TIME))), + createFact("FQN:", String.valueOf(detailsMap.get(EventDetailsKeys.ENTITY_FQN)))); + } + + private List createTestCaseDetailsFacts( + Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + + Function getDetail = + key -> String.valueOf(testCaseDetails.getOrDefault(key, "-")); + + return Arrays.asList( + createFact("ID:", getDetail.apply(DQ_TestCaseDetailsKeys.ID)), + createFact("Name:", getDetail.apply(DQ_TestCaseDetailsKeys.NAME)), + createFact("Owners:", formatOwners(testCaseDetails)), + createFact("Tags:", formatTags(testCaseDetails))); } - private TeamsMessage getTeamMessage(OutgoingMessage outgoingMessage) { - if (!outgoingMessage.getMessages().isEmpty()) { - TeamsMessage teamsMessage = new TeamsMessage(); - teamsMessage.setSummary("Change Event From OpenMetadata"); + @SuppressWarnings("unchecked") + private String formatOwners(Map, Object> testCaseDetails) { + List owners = + (List) + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.OWNERS, Collections.emptyList()); - // Sections - TeamsMessage.Section teamsSections = new TeamsMessage.Section(); - teamsSections.setActivityTitle(outgoingMessage.getHeader()); - List attachmentList = new ArrayList<>(); - outgoingMessage - .getMessages() - .forEach(m -> attachmentList.add(getTeamsSection(teamsSections.getActivityTitle(), m))); + StringBuilder ownersStringified = new StringBuilder(); + if (!CommonUtil.nullOrEmpty(owners)) { + owners.forEach( + owner -> { + if (owner != null && owner.getName() != null) { + ownersStringified.append(owner.getName()).append(", "); + } + }); - teamsMessage.setSections(attachmentList); - return teamsMessage; + // Remove the trailing comma and space if there's content + if (!ownersStringified.isEmpty()) { + ownersStringified.setLength(ownersStringified.length() - 2); + } + } else { + ownersStringified.append("-"); } - throw new UnhandledServerException("No messages found for the event"); + + return ownersStringified.toString(); } - private TeamsMessage getTeamTestMessage(String publisherName) { - if (!publisherName.isEmpty()) { - TeamsMessage teamsMessage = new TeamsMessage(); - teamsMessage.setSummary( - "This is a test message from OpenMetadata to confirm your Microsoft Teams destination is configured correctly."); + @SuppressWarnings("unchecked") + private String formatTags(Map, Object> testCaseDetails) { + List tags = + (List) + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.TAGS, Collections.emptyList()); + + StringBuilder tagsStringified = new StringBuilder(); + if (!CommonUtil.nullOrEmpty(tags)) { + tags.forEach( + tag -> { + if (tag != null && tag.getName() != null) { + tagsStringified.append(tag.getName()).append(", "); + } + }); + + // Remove the trailing comma and space if there's content + if (!tagsStringified.isEmpty()) { + tagsStringified.setLength(tagsStringified.length() - 2); + } + } else { + tagsStringified.append("-"); + } + + return tagsStringified.toString(); + } - // Sections - TeamsMessage.Section teamsSection = new TeamsMessage.Section(); - teamsSection.setActivityTitle("Alert: " + publisherName); + private List createTestCaseResultFacts( + Map, Object>> templateData) { - List sectionList = new ArrayList<>(); - sectionList.add(teamsSection); + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); - teamsMessage.setSections(sectionList); - return teamsMessage; + if (nullOrEmpty(testCaseDetails)) { + return Collections.emptyList(); } - throw new UnhandledServerException("Publisher name not found."); + + return Stream.of( + createFact( + "Status:", + String.valueOf(testCaseDetails.getOrDefault(DQ_TestCaseResultKeys.STATUS, "-"))), + createFact( + "Result Message:", + String.valueOf( + testCaseDetails.getOrDefault(DQ_TestCaseResultKeys.RESULT_MESSAGE, "-")))) + .collect(Collectors.toList()); + } + + private List createParameterValuesFacts( + Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); + + if (nullOrEmpty(testCaseDetails)) { + return Collections.emptyList(); + } + + Object result = testCaseDetails.get(DQ_TestCaseResultKeys.PARAMETER_VALUE); + if (!(result instanceof List)) { + return Collections.emptyList(); + } + + List parameterValues = (List) result; + if (nullOrEmpty(parameterValues)) { + return Collections.emptyList(); + } + + StringBuilder parameterValuesText = new StringBuilder(); + + parameterValues.forEach( + param -> { + if (parameterValuesText.length() > 0) { + parameterValuesText.append(", "); + } + + parameterValuesText.append(String.format("[%s: %s]", param.getName(), param.getValue())); + }); + + // Return a fact for "Parameter Values" with all parameter values in a single string + return Stream.of(createFact("Parameter Values:", parameterValuesText.toString())) + .collect(Collectors.toList()); + } + + private List createInspectionQueryFacts( + Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return Collections.emptyList(); + } + + Object inspectionQuery = testCaseDetails.get(DQ_TestCaseDetailsKeys.INSPECTION_QUERY); + + if (!nullOrEmpty(inspectionQuery)) { + return Stream.of(createFact("Inspection Query:", String.valueOf(inspectionQuery))) + .collect(Collectors.toList()); + } + + return Collections.emptyList(); + } + + private List createTestDefinitionFacts( + Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_DEFINITION); + if (nullOrEmpty(testCaseDetails)) { + return Collections.emptyList(); + } + + return Stream.of( + createFact( + "Name:", + String.valueOf( + testCaseDetails.getOrDefault(DQ_TestDefinitionKeys.TEST_DEFINITION_NAME, "-"))), + createFact( + "Description:", + String.valueOf( + testCaseDetails.getOrDefault( + DQ_TestDefinitionKeys.TEST_DEFINITION_DESCRIPTION, "-")))) + .collect(Collectors.toList()); + } + + private List createSampleDataFacts( + Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return Collections.emptyList(); + } + + Object sampleData = testCaseDetails.get(DQ_TestCaseDetailsKeys.SAMPLE_DATA); + if (nullOrEmpty(sampleData)) { + return Collections.emptyList(); + } + + return Stream.of(createFact("Sample Data:", String.valueOf(sampleData))) + .collect(Collectors.toList()); + } + + private TeamsMessage createConnectionTestMessage(String publisherName) { + Image imageItem = createOMImageMessage(); + + Column column1 = + Column.builder().type("Column").width("auto").items(List.of(imageItem)).build(); + + TextBlock textBlock1 = createTextBlock("Connection Successful \u2705", "Bolder", "Large"); + TextBlock textBlock2 = + createTextBlock(applyBoldFormat("Publisher:") + publisherName, null, null); + TextBlock textBlock3 = createTextBlock(CONNECTION_TEST_DESCRIPTION, null, null); + + Column column2 = + Column.builder() + .type("Column") + .width("stretch") + .items(List.of(textBlock1, textBlock2, textBlock3)) + .build(); + + ColumnSet columnSet = + ColumnSet.builder().type("ColumnSet").columns(List.of(column1, column2)).build(); + + // Create the footer text block + TextBlock footerTextBlock = createTextBlock("OpenMetadata", "Lighter", "Small"); + footerTextBlock.setHorizontalAlignment("Center"); + footerTextBlock.setSpacing("Medium"); + footerTextBlock.setSeparator(true); + + AdaptiveCardContent adaptiveCardContent = + AdaptiveCardContent.builder() + .type("AdaptiveCard") + .version("1.0") + .body(List.of(columnSet, footerTextBlock)) + .build(); + + Attachment attachment = + Attachment.builder() + .contentType("application/vnd.microsoft.card.adaptive") + .content(adaptiveCardContent) + .build(); + + return TeamsMessage.builder().type("message").attachments(List.of(attachment)).build(); + } + + private Map, Object>> buildGeneralTemplateData( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + TemplateDataBuilder builder = new TemplateDataBuilder<>(); + + // Use General_Template_Section directly + builder + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.EVENT_TYPE, + event.getEventType().value()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.UPDATED_BY, + event.getUserName()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.ENTITY_TYPE, + event.getEntityType()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.ENTITY_FQN, + MessageDecorator.getFQNForChangeEventEntity(event)) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.TIME, + new Date(event.getTimestamp()).toString()) + .add( + General_Template_Section.EVENT_DETAILS, + EventDetailsKeys.OUTGOING_MESSAGE, + outgoingMessage); + + return builder.build(); + } + + // todo - complete buildDQTemplateData fn + private Map, Object>> buildDQTemplateData( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + + TemplateDataBuilder builder = new TemplateDataBuilder<>(); + + // Use DQ_Template_Section directly + builder + .add( + DQ_Template_Section.EVENT_DETAILS, + EventDetailsKeys.EVENT_TYPE, + event.getEventType().value()) + .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.UPDATED_BY, event.getUserName()) + .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.ENTITY_TYPE, event.getEntityType()) + .add( + DQ_Template_Section.EVENT_DETAILS, + EventDetailsKeys.ENTITY_FQN, + MessageDecorator.getFQNForChangeEventEntity(event)) + .add( + DQ_Template_Section.EVENT_DETAILS, + EventDetailsKeys.TIME, + new Date(event.getTimestamp()).toString()) + .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.OUTGOING_MESSAGE, outgoingMessage); + + return builder.build(); + } + + private TextBlock createHeader() { + return TextBlock.builder() + .type("TextBlock") + .text(applyBoldFormat("Change Event Details")) + .size("Large") + .weight("Bolder") + .wrap(true) + .build(); + } + + private TextBlock createEntityLink(String url) { + if (nullOrEmpty(url)) { + throw new IllegalArgumentException("URL cannot be null or empty"); + } + + // Replace the text part (if it's in markdown link format [some text](url)) + String updatedUrl = url.replaceAll("\\[.*?\\]\\((.*?)\\)", "[View Data]($1)"); + + return TextBlock.builder() + .type("TextBlock") + .text(updatedUrl) + .wrap(true) + .spacing("Medium") + .separator(false) + .build(); + } + + private TextBlock createTextBlock(String text, String weight, String size) { + return TextBlock.builder() + .type("TextBlock") + .text(text) + .weight(weight) + .size(size) + .wrap(true) + .build(); + } + + private TextBlock createFooterMessage() { + return TextBlock.builder() + .type("TextBlock") + .text(TEMPLATE_FOOTER) + .size("Small") + .weight("Lighter") + .horizontalAlignment("Center") + .spacing("Medium") + .separator(true) + .build(); + } + + private TextBlock createBoldTextBlock(String text) { + return TextBlock.builder() + .type("TextBlock") + .text(applyBoldFormat(text)) + .weight("Bolder") + .wrap(true) + .build(); + } + + private TextBlock createDivider() { + return TextBlock.builder() + .type("TextBlock") + .text(" ") + .separator(true) + .spacing("Medium") + .build(); + } + + private TeamsMessage.Fact createFact(String title, String value) { + return TeamsMessage.Fact.builder().title(applyBoldFormat(title)).value(value).build(); + } + + private String applyBoldFormat(String title) { + return String.format(getBoldWithSpace(), title); } - private TeamsMessage.Section getTeamsSection(String activityTitle, String message) { - TeamsMessage.Section section = new TeamsMessage.Section(); - section.setActivityTitle(activityTitle); - section.setActivityText(message); - return section; + private Image createOMImageMessage() { + return Image.builder().type("Image").url("https://imgur.com/kOOPEG4.png").size("Small").build(); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MessageDecorator.java index 89d9e53a455b..daf82ee7516b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MessageDecorator.java @@ -25,8 +25,14 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.EnumMap; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.bitbucket.cowwoc.diffmatchpatch.DiffMatchPatch; import org.openmetadata.common.utils.CommonUtil; @@ -38,12 +44,21 @@ import org.openmetadata.schema.type.ThreadType; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.UnhandledServerException; +import org.openmetadata.service.jdbi3.TestCaseRepository; import org.openmetadata.service.resources.feeds.MessageParser; +import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.FeedUtils; public interface MessageDecorator { + String CONNECTION_TEST_DESCRIPTION = + "This is a test message, receiving this message confirms that you have successfully configured OpenMetadata to receive alerts."; + + String TEMPLATE_FOOTER = "Change Event By OpenMetadata"; + String getBold(); + String getBoldWithSpace(); + String getLineBreak(); String getAddMarker(); @@ -66,82 +81,101 @@ default String httpRemoveMarker() { T buildEntityMessage(String publisherName, ChangeEvent event); - T buildTestMessage(String publisherName); - T buildThreadMessage(String publisherName, ChangeEvent event); + T buildTestMessage(String publisherName); + default String buildEntityUrl(String entityType, EntityInterface entityInterface) { - String fqn = entityInterface.getFullyQualifiedName(); - if (CommonUtil.nullOrEmpty(fqn)) { - EntityInterface result = - Entity.getEntity(entityType, entityInterface.getId(), "id", Include.NON_DELETED); - fqn = result.getFullyQualifiedName(); - } + String fqn = resolveFullyQualifiedName(entityType, entityInterface); - // Hande Test Case - if (entityType.equals(Entity.TEST_CASE)) { - TestCase testCase = (TestCase) entityInterface; - return getEntityUrl( - "incident-manager", testCase.getFullyQualifiedName(), "test-case-results"); - } + switch (entityType) { + case Entity.TEST_CASE: + if (entityInterface instanceof TestCase testCase) { + return getEntityUrl( + "incident-manager", testCase.getFullyQualifiedName(), "test-case-results"); + } + break; - // Glossary Term - if (entityType.equals(Entity.GLOSSARY_TERM)) { - // Glossary Term is a special case where the URL is different - return getEntityUrl(Entity.GLOSSARY, fqn, ""); - } + case Entity.GLOSSARY_TERM: + return getEntityUrl(Entity.GLOSSARY, fqn, ""); - // Tag - if (entityType.equals(Entity.TAG)) { - // Tags need to be redirected to Classification Page - return getEntityUrl("tags", fqn.split("\\.")[0], ""); - } + case Entity.TAG: + return getEntityUrl("tags", fqn.split("\\.")[0], ""); + + case Entity.INGESTION_PIPELINE: + return getIngestionPipelineUrl(this, entityType, entityInterface); - // IngestionPipeline - if (entityType.equals(Entity.INGESTION_PIPELINE)) { - return getIngestionPipelineUrl(this, entityType, entityInterface); + default: + return getEntityUrl(entityType, fqn, ""); } + // Fallback in case of no match return getEntityUrl(entityType, fqn, ""); } default String buildThreadUrl( ThreadType threadType, String entityType, EntityInterface entityInterface) { + String activeTab = threadType.equals(ThreadType.Task) ? "activity_feed/tasks" : "activity_feed/all"; - String fqn = entityInterface.getFullyQualifiedName(); - if (CommonUtil.nullOrEmpty(fqn)) { - EntityInterface result = - Entity.getEntity(entityType, entityInterface.getId(), "id", Include.NON_DELETED); - fqn = result.getFullyQualifiedName(); - } - // Hande Test Case - if (entityType.equals(Entity.TEST_CASE)) { - TestCase testCase = (TestCase) entityInterface; - return getEntityUrl("incident-manager", testCase.getFullyQualifiedName(), "issues"); - } + String fqn = resolveFullyQualifiedName(entityType, entityInterface); - // Glossary Term - if (entityType.equals(Entity.GLOSSARY_TERM)) { - // Glossary Term is a special case where the URL is different - return getEntityUrl(Entity.GLOSSARY, fqn, activeTab); - } + switch (entityType) { + case Entity.TEST_CASE: + if (entityInterface instanceof TestCase) { + TestCase testCase = (TestCase) entityInterface; + return getEntityUrl("incident-manager", testCase.getFullyQualifiedName(), "issues"); + } + break; - // Tag - if (entityType.equals(Entity.TAG)) { - // Tags need to be redirected to Classification Page - return getEntityUrl("tags", fqn.split("\\.")[0], ""); - } + case Entity.GLOSSARY_TERM: + return getEntityUrl(Entity.GLOSSARY, fqn, activeTab); + + case Entity.TAG: + return getEntityUrl("tags", fqn.split("\\.")[0], ""); + + case Entity.INGESTION_PIPELINE: + return getIngestionPipelineUrl(this, entityType, entityInterface); - // IngestionPipeline - if (entityType.equals(Entity.INGESTION_PIPELINE)) { - return getIngestionPipelineUrl(this, entityType, entityInterface); + default: + return getEntityUrl(entityType, fqn, activeTab); } + // Fallback in case of no match return getEntityUrl(entityType, fqn, activeTab); } + // Helper function to resolve FQN if null or empty + private String resolveFullyQualifiedName(String entityType, EntityInterface entityInterface) { + String fqn = entityInterface.getFullyQualifiedName(); + if (CommonUtil.nullOrEmpty(fqn)) { + EntityInterface result = + Entity.getEntity(entityType, entityInterface.getId(), "id", Include.NON_DELETED); + fqn = result.getFullyQualifiedName(); + } + return fqn; + } + + static String getFQNForChangeEventEntity(ChangeEvent event) { + return Optional.ofNullable(event.getEntityFullyQualifiedName()) + .filter(fqn -> !CommonUtil.nullOrEmpty(fqn)) + .orElseGet( + () -> { + EntityInterface entityInterface = getEntity(event); + String fqn = entityInterface.getFullyQualifiedName(); + + if (CommonUtil.nullOrEmpty(fqn)) { + EntityInterface result = + Entity.getEntity( + event.getEntityType(), entityInterface.getId(), "id", Include.NON_DELETED); + fqn = result.getFullyQualifiedName(); + } + + return fqn; + }); + } + default T buildOutgoingMessage(String publisherName, ChangeEvent event) { if (event.getEntityType().equals(Entity.THREAD)) { return buildThreadMessage(publisherName, event); @@ -400,7 +434,7 @@ default OutgoingMessage createThreadMessage(String publisherName, ChangeEvent ev } } if (nullOrEmpty(headerMessage) || attachmentList.isEmpty()) { - throw new UnhandledServerException("Unable to build Slack Message"); + throw new UnhandledServerException("Unable to build message"); } message.setHeader(headerMessage); message.setMessages(attachmentList); @@ -438,4 +472,167 @@ private static String getDateString(Instant instant) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); return localDateTime.format(formatter); } + + /** + * A builder class for constructing a nested map structure that organizes each template data + * into sections and corresponding keys, where both the sections and keys are represented as enums. + * This class ensures type safety by using EnumMaps for both sections and keys. + * + * @param The enum type representing the sections of the template. + */ + class TemplateDataBuilder> { + + // Map to store sections and their corresponding keys and values + private final Map, Object>> sectionToKeyDataMap = new HashMap<>(); + + /** + * Adds a key-value pair to the specified section of the template. + * Ensures that the key is stored in a type-safe EnumMap, and the section is only created if it doesn't already exist. + * + * @param section The section of the template represented as an enum. + * @param key The key within the section, represented as an enum. + * @param value The value associated with the given key. + * @param The enum type representing the keys (must extend Enum). + */ + @SuppressWarnings("unchecked") + public > TemplateDataBuilder add(S section, K key, Object value) { + sectionToKeyDataMap + .computeIfAbsent( + section, k -> (Map, Object>) new EnumMap<>(key.getDeclaringClass())) + .put(key, value); + return this; + } + + public Map, Object>> build() { + return Collections.unmodifiableMap(sectionToKeyDataMap); + } + } + + enum General_Template_Section { + EVENT_DETAILS, + } + + enum DQ_Template_Section { + EVENT_DETAILS, + TEST_CASE_DETAILS, + TEST_CASE_RESULT, + TEST_DEFINITION + } + + enum EventDetailsKeys { + EVENT_TYPE, + UPDATED_BY, + ENTITY_TYPE, + ENTITY_FQN, + TIME, + OUTGOING_MESSAGE + } + + enum DQ_TestCaseDetailsKeys { + ID, + NAME, + OWNERS, + TAGS, + DESCRIPTION, + TEST_CASE_FQN, + INSPECTION_QUERY, + SAMPLE_DATA + } + + enum DQ_TestCaseResultKeys { + STATUS, + PARAMETER_VALUE, + RESULT_MESSAGE + } + + enum DQ_TestDefinitionKeys { + TEST_DEFINITION_NAME, + TEST_DEFINITION_DESCRIPTION + } + + static Map, Object>> buildDQTemplateData( + ChangeEvent event, OutgoingMessage outgoingMessage) { + + TemplateDataBuilder builder = new TemplateDataBuilder<>(); + builder + .add( + DQ_Template_Section.EVENT_DETAILS, + EventDetailsKeys.EVENT_TYPE, + event.getEventType().value()) + .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.UPDATED_BY, event.getUserName()) + .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.ENTITY_TYPE, event.getEntityType()) + .add( + DQ_Template_Section.EVENT_DETAILS, + EventDetailsKeys.ENTITY_FQN, + getFQNForChangeEventEntity(event)) + .add( + DQ_Template_Section.EVENT_DETAILS, + EventDetailsKeys.TIME, + new Date(event.getTimestamp()).toString()) + .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.OUTGOING_MESSAGE, outgoingMessage); + + // fetch TEST_CASE_DETAILS + TestCase testCase = fetchTestCase(getFQNForChangeEventEntity(event)); + + // build TEST_CASE_DETAILS + builder + .add(DQ_Template_Section.TEST_CASE_DETAILS, DQ_TestCaseDetailsKeys.ID, testCase.getId()) + .add(DQ_Template_Section.TEST_CASE_DETAILS, DQ_TestCaseDetailsKeys.NAME, testCase.getName()) + .add( + DQ_Template_Section.TEST_CASE_DETAILS, + DQ_TestCaseDetailsKeys.OWNERS, + testCase.getOwners()) + .add(DQ_Template_Section.TEST_CASE_DETAILS, DQ_TestCaseDetailsKeys.TAGS, testCase.getTags()) + .add( + DQ_Template_Section.TEST_CASE_DETAILS, + DQ_TestCaseDetailsKeys.DESCRIPTION, + testCase.getTestDefinition().getDescription()) + .add( + DQ_Template_Section.TEST_CASE_DETAILS, + DQ_TestCaseDetailsKeys.TEST_CASE_FQN, + testCase.getFullyQualifiedName()) + .add( + DQ_Template_Section.TEST_CASE_DETAILS, + DQ_TestCaseDetailsKeys.INSPECTION_QUERY, + testCase.getInspectionQuery()) + .add( + DQ_Template_Section.TEST_CASE_DETAILS, + DQ_TestCaseDetailsKeys.SAMPLE_DATA, + testCase.getTestCaseResult().getSampleData()); + + // build TEST_CASE_RESULT + builder + .add( + DQ_Template_Section.TEST_CASE_RESULT, + DQ_TestCaseResultKeys.STATUS, + testCase.getTestCaseStatus()) + .add( + DQ_Template_Section.TEST_CASE_RESULT, + DQ_TestCaseResultKeys.PARAMETER_VALUE, + testCase.getParameterValues()) + .add( + DQ_Template_Section.TEST_CASE_RESULT, + DQ_TestCaseResultKeys.RESULT_MESSAGE, + testCase.getTestCaseResult().getResult()); + + // build TEST_DEFINITION + builder + .add( + DQ_Template_Section.TEST_DEFINITION, + DQ_TestDefinitionKeys.TEST_DEFINITION_NAME, + testCase.getTestDefinition().getName()) + .add( + DQ_Template_Section.TEST_DEFINITION, + DQ_TestDefinitionKeys.TEST_DEFINITION_DESCRIPTION, + testCase.getTestDefinition().getDescription()); + + return builder.build(); + } + + static TestCase fetchTestCase(String fqn) { + TestCaseRepository testCaseRepository = + (TestCaseRepository) Entity.getEntityRepository(Entity.TEST_CASE); + EntityUtil.Fields fields = testCaseRepository.getFields("*"); + return testCaseRepository.getByName(null, fqn, fields, Include.NON_DELETED, false); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/SlackMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/SlackMessageDecorator.java index 27607275cd02..10b9b1711794 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/SlackMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/SlackMessageDecorator.java @@ -16,10 +16,27 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.util.email.EmailUtil.getSmtpSettings; +import com.slack.api.model.block.Blocks; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.BlockCompositions; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.model.block.composition.TextObject; +import com.slack.api.model.block.element.ImageElement; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.tests.TestCaseParameterValue; +import org.openmetadata.schema.tests.type.TestCaseStatus; import org.openmetadata.schema.type.ChangeEvent; -import org.openmetadata.service.apps.bundles.changeEvent.slack.SlackAttachment; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.FieldChange; +import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.service.Entity; import org.openmetadata.service.apps.bundles.changeEvent.slack.SlackMessage; import org.openmetadata.service.exception.UnhandledServerException; @@ -30,6 +47,11 @@ public String getBold() { return "*%s*"; } + @Override + public String getBoldWithSpace() { + return "*%s* "; + } + @Override public String getLineBreak() { return "\n"; @@ -67,62 +89,715 @@ public String getEntityUrl(String prefix, String fqn, String additionalParams) { @Override public SlackMessage buildEntityMessage(String publisherName, ChangeEvent event) { - return getSlackMessage(createEntityMessage(publisherName, event)); + return getSlackMessage(event, createEntityMessage(publisherName, event)); } @Override - public SlackMessage buildTestMessage(String publisherName) { - return getSlackTestMessage(publisherName); + public SlackMessage buildThreadMessage(String publisherName, ChangeEvent event) { + return getSlackMessage(event, createThreadMessage(publisherName, event)); } @Override - public SlackMessage buildThreadMessage(String publisherName, ChangeEvent event) { - return getSlackMessage(createThreadMessage(publisherName, event)); + public SlackMessage buildTestMessage(String publisherName) { + return createConnectionTestMessage(publisherName); + } + + private SlackMessage getSlackMessage(ChangeEvent event, OutgoingMessage outgoingMessage) { + if (outgoingMessage.getMessages().isEmpty()) { + throw new UnhandledServerException("No messages found for the event"); + } + + return createMessage(event, outgoingMessage); + } + + private SlackMessage createMessage(ChangeEvent event, OutgoingMessage outgoingMessage) { + return switch (event.getEntityType()) { + case Entity.TEST_CASE -> createTestCaseMessage(event, outgoingMessage); + default -> createGeneralChangeEventMessage(event, outgoingMessage); + }; + } + + private SlackMessage createTestCaseMessage(ChangeEvent event, OutgoingMessage outgoingMessage) { + final String testCaseResult = "testCaseResult"; + List fieldsAdded = event.getChangeDescription().getFieldsAdded(); + List fieldsUpdated = event.getChangeDescription().getFieldsUpdated(); + + boolean hasRelevantChange = + fieldsAdded.stream().anyMatch(field -> testCaseResult.equals(field.getName())) + || fieldsUpdated.stream().anyMatch(field -> testCaseResult.equals(field.getName())); + + return hasRelevantChange + ? createDQTemplateMessage(event, outgoingMessage) + : createGeneralChangeEventMessage(event, outgoingMessage); + } + + public SlackMessage createConnectionTestMessage(String publisherName) { + if (publisherName.isEmpty()) { + throw new UnhandledServerException("Publisher name not found."); + } + + List blocks = new ArrayList<>(); + + // Header Block + blocks.add( + Blocks.header( + header -> + header.text( + PlainTextObject.builder() + .text("Connection Successful :white_check_mark: ") + .build()))); + + // Section Block 1 (Publisher Name) + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Publisher :") + publisherName)))); + + // Section Block 2 (Test Message) + blocks.add( + Blocks.section( + section -> section.text(BlockCompositions.markdownText(CONNECTION_TEST_DESCRIPTION)))); + + // Divider Block + blocks.add(Blocks.divider()); + + // context + blocks.add( + Blocks.context( + context -> + context.elements( + List.of( + ImageElement.builder().imageUrl(getOMImage()).altText("oss icon").build(), + BlockCompositions.markdownText(applyBoldFormat("OpenMetadata")))))); + + SlackMessage.Attachment attachment = new SlackMessage.Attachment(); + attachment.setColor("#36a64f"); // green + attachment.setBlocks(blocks); + + SlackMessage message = new SlackMessage(); + message.setAttachments(Collections.singletonList(attachment)); + + return message; + } + + private SlackMessage createGeneralChangeEventMessage( + ChangeEvent event, OutgoingMessage outgoingMessage) { + List generalChangeEventBody = createGeneralChangeEventBody(event, outgoingMessage); + SlackMessage message = new SlackMessage(); + message.setBlocks(generalChangeEventBody); + return message; + } + + private List createGeneralChangeEventBody( + ChangeEvent event, OutgoingMessage outgoingMessage) { + List blocks = new ArrayList<>(); + + // Header + addChangeEventDetailsHeader(blocks); + + // Info about the event + List first_field = new ArrayList<>(); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Event Type:") + event.getEventType())); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Updated By:") + event.getUserName())); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Entity Type:") + event.getEntityType())); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Time:") + new Date(event.getTimestamp()))); + + // Split fields into multiple sections to avoid block limits + for (int i = 0; i < first_field.size(); i += 10) { + List sublist = first_field.subList(i, Math.min(i + 10, first_field.size())); + blocks.add(Blocks.section(section -> section.fields(sublist))); + } + + String fqnForChangeEventEntity = MessageDecorator.getFQNForChangeEventEntity(event); + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("FQN:") + fqnForChangeEventEntity)))); + + // divider + blocks.add(Blocks.divider()); + + // desc about the event + List thread_messages = outgoingMessage.getMessages(); + thread_messages.forEach( + (message) -> { + blocks.add( + Blocks.section( + section -> section.text(BlockCompositions.markdownText("> " + message)))); + }); + + // Divider + blocks.add(Blocks.divider()); + + // View event link + String entityUrl = buildClickableEntityUrl(outgoingMessage.getEntityUrl()); + + blocks.add(Blocks.section(section -> section.text(BlockCompositions.markdownText(entityUrl)))); + + // Context Block + blocks.add( + Blocks.context( + context -> + context.elements( + List.of( + ImageElement.builder().imageUrl(getOMImage()).altText("oss icon").build(), + BlockCompositions.markdownText(TEMPLATE_FOOTER))))); + return blocks; + } + + private void createDQHeading( + List blocks, Map, Object>> templateData) { + Map, Object> testCaseResults = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); + + if (nullOrEmpty(testCaseResults)) { + addChangeEventDetailsHeader(blocks); + } else { + String statusWithEmoji = + getStatusWithEmoji(testCaseResults.get(DQ_TestCaseResultKeys.STATUS)); + Map, Object> testCaseDetails = + templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + String testName = String.valueOf(testCaseDetails.get(DQ_TestCaseDetailsKeys.NAME)); + String message = String.format("\"%s\" test having status: %s", testName, statusWithEmoji); + blocks.add(Blocks.header(header -> header.text(BlockCompositions.plainText(message)))); + } } - private SlackMessage getSlackMessage(OutgoingMessage outgoingMessage) { - if (!outgoingMessage.getMessages().isEmpty()) { - SlackMessage message = new SlackMessage(); - List attachmentList = new ArrayList<>(); - outgoingMessage.getMessages().forEach(m -> attachmentList.add(getSlackAttachment(m))); - message.setUsername(outgoingMessage.getUserName()); - message.setText(outgoingMessage.getHeader()); - message.setAttachments(attachmentList.toArray(new SlackAttachment[0])); - return message; + private List createDQBodyBlocks( + ChangeEvent event, + OutgoingMessage outgoingMessage, + Map, Object>> data) { + List blocks = new ArrayList<>(); + + // Header + createDQHeading(blocks, data); + + // Info about the event + List first_field = new ArrayList<>(); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Event Type:") + event.getEventType())); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Updated By:") + event.getUserName())); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Entity Type:") + event.getEntityType())); + first_field.add( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Time:") + new Date(event.getTimestamp()))); + + // Split fields into multiple sections to avoid block limits + for (int i = 0; i < first_field.size(); i += 10) { + List sublist = first_field.subList(i, Math.min(i + 10, first_field.size())); + blocks.add(Blocks.section(section -> section.fields(sublist))); } - throw new UnhandledServerException("No messages found for the event"); + + String fqnForChangeEventEntity = MessageDecorator.getFQNForChangeEventEntity(event); + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("FQN:") + fqnForChangeEventEntity)))); + + // divider + blocks.add(Blocks.divider()); + + // desc about the event + List thread_messages = outgoingMessage.getMessages(); + thread_messages.forEach( + (message) -> { + blocks.add( + Blocks.section( + section -> section.text(BlockCompositions.markdownText("> " + message)))); + }); + + // Divider + blocks.add(Blocks.divider()); + + // View event link + String entityUrl = buildClickableEntityUrl(outgoingMessage.getEntityUrl()); + + blocks.add(Blocks.section(section -> section.text(BlockCompositions.markdownText(entityUrl)))); + + // Context Block + blocks.add( + Blocks.context( + context -> + context.elements( + List.of( + ImageElement.builder().imageUrl(getOMImage()).altText("oss icon").build(), + BlockCompositions.markdownText(TEMPLATE_FOOTER))))); + return blocks; } - private SlackMessage getSlackTestMessage(String publisherName) { - if (!publisherName.isEmpty()) { - SlackMessage message = new SlackMessage(); - message.setUsername("Slack destination test"); - message.setText("Slack has been successfully configured for alerts from: " + publisherName); + // DQ TEMPLATE + public SlackMessage createDQTemplateMessage(ChangeEvent event, OutgoingMessage outgoingMessage) { + + Map, Object>> dqTemplateData = + MessageDecorator.buildDQTemplateData(event, outgoingMessage); + + List body = createDQBodyBlocks(event, outgoingMessage, dqTemplateData); + + SlackMessage message = new SlackMessage(); + message.setBlocks(body); + + Map, Object> enumObjectMap = dqTemplateData.get(DQ_Template_Section.TEST_CASE_RESULT); + if (!nullOrEmpty(enumObjectMap)) { + SlackMessage.Attachment attachment = createDQAttachment(dqTemplateData); + + attachment.setColor("#ffcc00"); - SlackAttachment attachment = new SlackAttachment(); - attachment.setFallback("Slack destination test successful."); - attachment.setColor("#36a64f"); // Setting a green color to indicate success - attachment.setTitle("Test Successful"); - attachment.setText( - "This is a test message from OpenMetadata confirming that your Slack destination is correctly set up to receive alerts."); - attachment.setFooter("OpenMetadata"); - attachment.setTs(String.valueOf(System.currentTimeMillis() / 1000)); // Adding timestamp + message.setAttachments(Collections.singletonList(attachment)); + } + + return message; + } - List attachmentList = new ArrayList<>(); - attachmentList.add(attachment); - message.setAttachments(attachmentList.toArray(new SlackAttachment[0])); + private String determineColorBasedOnStatus(Object object) { + if (object instanceof TestCaseStatus status) { + return switch (status) { + case Success -> "#36a64f"; // Green for success + case Failed -> "#ff0000"; // Red for failure + case Aborted -> "#ffcc00"; // Yellow for aborted + case Queued -> "#439FE0"; // Blue for queued + default -> "#808080"; // Gray for unknown or default cases + }; + } + return "#808080"; // Default to gray if the object is not a valid TestCaseStatus + } - return message; + private String getStatusWithEmoji(Object object) { + if (object instanceof TestCaseStatus status) { + return switch (status) { + case Success -> "Success :white_check_mark:"; // Green checkmark for success + case Failed -> "Failed :x:"; // Red cross for failure + case Aborted -> "Aborted :warning:"; // Warning sign for aborted + case Queued -> "Queued :hourglass_flowing_sand:"; // Hourglass for queued + default -> "Unknown :grey_question:"; // Gray question mark for unknown cases + }; } - throw new UnhandledServerException("Publisher name not found."); + return "Unknown :grey_question:"; // Default to unknown if the object is not a valid + // TestCaseStatus } - private SlackAttachment getSlackAttachment(String message) { - SlackAttachment attachment = new SlackAttachment(); - List mark = new ArrayList<>(); - mark.add("text"); - attachment.setMarkdownIn(mark); - attachment.setText(message); + public SlackMessage.Attachment createDQAttachment( + Map, Object>> dqTemplateData) { + List blocks = new ArrayList<>(); + + // Header Block + addDQAlertHeader(blocks); + + // Section 1 - Name + addIdAndNameSection(blocks, dqTemplateData); + + // Section 2 - Owners and Tags + addOwnersTagsSection(blocks, dqTemplateData); + + // Section 3 - Description + addDescriptionSection(blocks, dqTemplateData); + + // Divider + blocks.add(Blocks.divider()); + + // Section 4 and 5 - Result and Test Definition + blocks.addAll(createTestCaseResultAndDefinitionSections(dqTemplateData)); + + // Context Block - Image and Markdown Text + blocks.add( + Blocks.context( + context -> + context.elements( + List.of( + ImageElement.builder().imageUrl(getOMImage()).altText("oss icon").build(), + BlockCompositions.markdownText("Change Event by OpenMetadata"))))); + + SlackMessage.Attachment attachment = new SlackMessage.Attachment(); + attachment.setBlocks(blocks); + return attachment; } + + // Updated Method to Create Both Sections + private List createTestCaseResultAndDefinitionSections( + Map, Object>> templateData) { + List blocks = new ArrayList<>(); + + if (templateData.containsKey(DQ_Template_Section.TEST_CASE_RESULT) + && templateData.containsKey(DQ_Template_Section.TEST_DEFINITION)) { + blocks.addAll(createTestCaseResultSections(templateData)); + blocks.addAll(createTestDefinitionSections(templateData)); + } + + return blocks; + } + + private void addIdAndNameSection( + List blocks, Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return; + } + + List idNameFields = + Stream.of( + createFieldText( + "Name :", testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.NAME, "-"))) + .collect(Collectors.toList()); + + blocks.add(Blocks.section(section -> section.fields(idNameFields))); + } + + private void addDescriptionSection( + List blocks, Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return; + } + + TextObject idNameFields = + createFieldTextWithNewLine( + "Description", testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.DESCRIPTION, "-")); + blocks.add(Blocks.section(section -> section.text(idNameFields))); + } + + private void addOwnersTagsSection( + List blocks, Map, Object>> templateData) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + if (nullOrEmpty(testCaseDetails)) { + return; + } + + List ownerTagFields = + Stream.of( + createFieldTextWithNewLine("Owners", formatOwners(testCaseDetails)), + createFieldTextWithNewLine("Tags", formatTags(testCaseDetails))) + .collect(Collectors.toList()); + + blocks.add(Blocks.section(section -> section.fields(ownerTagFields))); + } + + @SuppressWarnings("unchecked") + private String formatOwners(Map, Object> testCaseDetails) { + List owners = + (List) + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.OWNERS, Collections.emptyList()); + + StringBuilder ownersStringified = new StringBuilder(); + if (!CommonUtil.nullOrEmpty(owners)) { + owners.forEach( + owner -> { + if (owner != null && owner.getName() != null) { + ownersStringified.append(owner.getName()).append(", "); + } + }); + + // Remove the trailing comma and space if there's content + if (!ownersStringified.isEmpty()) { + ownersStringified.setLength(ownersStringified.length() - 2); + } + } else { + ownersStringified.append("-"); + } + + return ownersStringified.toString(); + } + + @SuppressWarnings("unchecked") + private String formatTags(Map, Object> testCaseDetails) { + List tags = + (List) + testCaseDetails.getOrDefault(DQ_TestCaseDetailsKeys.TAGS, Collections.emptyList()); + + StringBuilder tagsStringified = new StringBuilder(); + if (!CommonUtil.nullOrEmpty(tags)) { + tags.forEach( + tag -> { + if (tag != null && tag.getName() != null) { + tagsStringified.append(tag.getName()).append(", "); + } + }); + + // Remove the trailing comma and space if there's content + if (!tagsStringified.isEmpty()) { + tagsStringified.setLength(tagsStringified.length() - 2); + } + } else { + tagsStringified.append("-"); + } + + return tagsStringified.toString(); + } + + private List createTestCaseResultSections( + Map, Object>> templateData) { + + List blocks = new ArrayList<>(); + + Map, Object> testCaseResults = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); + if (nullOrEmpty(testCaseResults)) { + return blocks; + } + + // Test Case Result Header + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText(applyBoldFormat(":mag: TEST CASE RESULT"))))); + + // Status and Parameter Value + addStatusAndParameterValueSection(blocks, testCaseResults); + + // Result Message Section + blocks.add( + Blocks.section( + section -> section.text(BlockCompositions.markdownText(applyBoldFormat("Result"))))); + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + formatWithTripleBackticksForEnumMap( + DQ_TestCaseResultKeys.RESULT_MESSAGE, testCaseResults))))); + + // parameter section + createParameterValueBlocks(templateData, blocks); + + // inspection section + addInspectionQuerySection(templateData, blocks); + + blocks.add(Blocks.divider()); + return blocks; + } + + private void addStatusAndParameterValueSection( + List blocks, Map, Object> testCaseResults) { + List statusParameterFields = + Stream.of( + BlockCompositions.markdownText( + applyBoldFormatWithSpace("Status -") + + getStatusWithEmoji( + testCaseResults.getOrDefault(DQ_TestCaseResultKeys.STATUS, "-")))) + .collect(Collectors.toList()); + + blocks.add(Blocks.section(section -> section.fields(statusParameterFields))); + } + + @SuppressWarnings("unchecked") + private void createParameterValueBlocks( + Map, Object>> templateData, List blocks) { + + Map, Object> testCaseResults = templateData.get(DQ_Template_Section.TEST_CASE_RESULT); + if (nullOrEmpty(testCaseResults)) { + return; + } + + Object result = testCaseResults.get(DQ_TestCaseResultKeys.PARAMETER_VALUE); + List parameterValues = + result instanceof List ? (List) result : null; + + if (nullOrEmpty(parameterValues)) { + return; + } + + blocks.add( + Blocks.section( + section -> + section.text(BlockCompositions.markdownText(applyBoldFormat("Parameter Value"))))); + + String parameterValuesText = + parameterValues.stream() + .map(pv -> String.format("[%s: %s]", pv.getName(), pv.getValue())) + .collect(Collectors.joining(", ")); + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + formatWithTripleBackticks(parameterValuesText))))); + } + + private void addInspectionQuerySection( + Map, Object>> templateData, List blocks) { + + Map, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + + if (nullOrEmpty(testCaseDetails) + || !testCaseDetails.containsKey(DQ_TestCaseDetailsKeys.INSPECTION_QUERY)) { + return; + } + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + applyBoldFormat(":hammer_and_wrench: Inspection Query"))))); + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + formatWithTripleBackticksForEnumMap( + DQ_TestCaseDetailsKeys.INSPECTION_QUERY, testCaseDetails))))); + } + + // Method to create Test Definition Sections + private List createTestDefinitionSections( + Map, Object>> templateData) { + + List blocks = new ArrayList<>(); + + if (templateData.containsKey(DQ_Template_Section.TEST_DEFINITION)) { + Map, Object> testDefinition = templateData.get(DQ_Template_Section.TEST_DEFINITION); + + if (!nullOrEmpty(testDefinition)) { + + // Test Definition Header + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + applyBoldFormat(":bulb: TEST DEFINITION"))))); + blocks.add( + Blocks.section( + section -> section.text(BlockCompositions.markdownText(applyBoldFormat("Name"))))); + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + formatWithTripleBackticksForEnumMap( + DQ_TestDefinitionKeys.TEST_DEFINITION_NAME, testDefinition))))); + + // Section - Description with triple backticks + blocks.add( + Blocks.section( + section -> + section.text(BlockCompositions.markdownText(applyBoldFormat("Description"))))); + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + formatWithTripleBackticksForEnumMap( + DQ_TestDefinitionKeys.TEST_DEFINITION_DESCRIPTION, + testDefinition))))); + + addSampleDataSection(templateData, blocks); + + blocks.add(Blocks.divider()); + } + } + + return blocks; + } + + private void addSampleDataSection( + Map, Object>> templateData, List blocks) { + + if (templateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS)) { + Map, Object> testCaseDetails = + templateData.get(DQ_Template_Section.TEST_CASE_DETAILS); + + if (!nullOrEmpty(testCaseDetails)) { + blocks.add( + Blocks.section( + section -> + section.text(BlockCompositions.markdownText(applyBoldFormat("Sample Data"))))); + + blocks.add( + Blocks.section( + section -> + section.text( + BlockCompositions.markdownText( + formatWithTripleBackticksForEnumMap( + DQ_TestCaseDetailsKeys.SAMPLE_DATA, testCaseDetails))))); + } + } + } + + private String buildClickableEntityUrl(String entityUrl) { + if (entityUrl.startsWith("<") && entityUrl.endsWith(">")) { + entityUrl = entityUrl.substring(1, entityUrl.length() - 1); + } + + int pipeIndex = entityUrl.indexOf("|"); + if (pipeIndex != -1) { + entityUrl = entityUrl.substring(0, pipeIndex); + } + + return String.format("Access data: <%s|View>", entityUrl); + } + + private TextObject createFieldTextWithNewLine(String label, Object value) { + return BlockCompositions.markdownText(applyBoldFormatWithNewLine(label) + value); + } + + private TextObject createFieldText(String label, Object value) { + return BlockCompositions.markdownText(applyBoldFormatWithSpace(label) + value); + } + + private void addChangeEventDetailsHeader(List blocks) { + blocks.add( + Blocks.header( + header -> + header.text( + BlockCompositions.plainText( + ":arrows_counterclockwise: Change Event Details")))); + } + + private void addDQAlertHeader(List blocks) { + blocks.add( + Blocks.section( + section -> section.text(BlockCompositions.markdownText(applyBoldFormat("TEST CASE"))))); + } + + private String applyBoldFormat(String title) { + return String.format(getBold(), title); + } + + private String applyBoldFormatWithSpace(String title) { + return String.format(getBoldWithSpace(), title); + } + + private String applyBoldFormatWithNewLine(String title) { + return applyBoldFormat(title) + "\n"; + } + + private String formatWithTripleBackticksForEnumMap( + Enum key, Map, Object> placeholders) { + Object value = placeholders.getOrDefault(key, "-"); + return "```" + value + "```"; + } + + private String formatWithTripleBackticks(String text) { + return "```" + text + "```"; + } + + private String getOMImage() { + return "https://i.postimg.cc/0jYLNmM1/image.png"; + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java index c64c57eb2f33..35f311ffd076 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java @@ -15,6 +15,7 @@ import static org.openmetadata.service.util.RestUtil.DATE_TIME_FORMAT; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.core.type.TypeReference; @@ -118,6 +119,20 @@ public static String pojoToJson(Object o, boolean prettyPrint) { } } + public static String pojoToJsonIgnoreNull(Object o) { + if (o == null) { + return null; + } + try { + ObjectMapper objectMapperIgnoreNull = OBJECT_MAPPER.copy(); + objectMapperIgnoreNull.setSerializationInclusion( + JsonInclude.Include.NON_NULL); // Ignore null values + return objectMapperIgnoreNull.writeValueAsString(o); + } catch (JsonProcessingException e) { + throw new UnhandledServerException(FAILED_TO_PROCESS_JSON, e); + } + } + public static JsonStructure getJsonStructure(Object o) { return OBJECT_MAPPER.convertValue(o, JsonStructure.class); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/EventSubscriptionResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/EventSubscriptionResourceTest.java index c69cf3e7e68f..eb5dfdf48331 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/EventSubscriptionResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/EventSubscriptionResourceTest.java @@ -2,10 +2,8 @@ import static javax.ws.rs.core.Response.Status.CONFLICT; import static javax.ws.rs.core.Response.Status.NOT_FOUND; -import static org.assertj.core.api.Assertions.assertThat; import static org.hibernate.validator.internal.util.Contracts.assertNotNull; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.schema.entity.events.SubscriptionStatus.Status.ACTIVE; @@ -20,6 +18,8 @@ import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collection; import java.util.HashSet; @@ -599,11 +599,15 @@ public void get_fetchEventSubscription_ByInvalidId_returns404() { String.format("eventsubscription instance for %s not found", wrongAlertId)); } + public static String sanitizeWebhookName(String name) { + return URLEncoder.encode(name, StandardCharsets.UTF_8); + } + @Test public void post_createAndValidateEventSubscription_SLACK(TestInfo test) throws IOException { - String webhookName = getEntityName(test); - String endpoint = test.getDisplayName(); - String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack/" + endpoint; + String entityName = getEntityName(test); + String webhookName = sanitizeWebhookName(entityName); + String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack/" + webhookName; CreateEventSubscription enabledWebhookRequest = new CreateEventSubscription() @@ -618,12 +622,9 @@ public void post_createAndValidateEventSubscription_SLACK(TestInfo test) throws EventSubscription alert = createEntity(enabledWebhookRequest, ADMIN_AUTH_HEADERS); waitForAllEventToComplete(alert.getId()); - SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(endpoint); - ConcurrentLinkedQueue events = details.getEvents(); - for (SlackMessage event : events) { - validateSlackMessage(alert, event); - } - + SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(entityName); + ConcurrentLinkedQueue events = details.getEvents(); + assertNotNull(events); assertNotNull(alert, "Webhook creation failed"); Awaitility.await() @@ -1223,20 +1224,19 @@ void post_topicResource_owner_AND_domain_alertAction(TestInfo test) throws IOExc @Test void post_ingestionPiplelineResource_noFilter_alertAction(TestInfo test) throws IOException { - String webhookName = getEntityName(test); - String endpoint = test.getDisplayName(); - String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack/" + endpoint; + String entityName = getEntityName(test); + String webhookName = sanitizeWebhookName(entityName); + String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack/" + webhookName; CreateEventSubscription genericWebhookActionRequest = - createRequest(webhookName) + createRequest(entityName) .withDestinations(getSlackWebhook(uri)) .withResources(List.of("ingestionPipeline")); - EventSubscription alert = createAndCheckEntity(genericWebhookActionRequest, ADMIN_AUTH_HEADERS); SubscriptionStatus status = getStatus(alert.getId(), Response.Status.OK.getStatusCode()); assertEquals(ACTIVE, status.getStatus()); - SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(endpoint); + SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(entityName); // Alerts are triggered only by ChangeEvent occurrences related to resources as // ingestionPipeline by domain filter @@ -1260,16 +1260,16 @@ void post_ingestionPiplelineResource_noFilter_alertAction(TestInfo test) throws ingestionPipelineResourceTest.createEntity(request, ADMIN_AUTH_HEADERS); - details = waitForFirstSlackEvent(alert.getId(), endpoint, 25); + details = waitForFirstSlackEvent(alert.getId(), entityName, 25); assertEquals(1, details.getEvents().size()); } @Test void post_ingestionPiplelineResource_owner_alertAction(TestInfo test) throws IOException { - String webhookName = getEntityName(test); - String endpoint = test.getDisplayName(); + String entityName = getEntityName(test); + String webhookName = sanitizeWebhookName(entityName); LOG.info("creating webhook in disabled state"); - String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack/" + endpoint; + String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack/" + webhookName; CreateEventSubscription genericWebhookActionRequest = createRequest(webhookName) @@ -1284,12 +1284,12 @@ void post_ingestionPiplelineResource_owner_alertAction(TestInfo test) throws IOE // Apply the filtering rule to the request genericWebhookActionRequest.withInput(rule); - + genericWebhookActionRequest.withName(entityName); EventSubscription alert = createAndCheckEntity(genericWebhookActionRequest, ADMIN_AUTH_HEADERS); SubscriptionStatus status = getStatus(alert.getId(), Response.Status.OK.getStatusCode()); assertEquals(ACTIVE, status.getStatus()); - SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(endpoint); + SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(entityName); // Alerts are triggered only by ChangeEvent occurrences related to resources as // ingestionPipeline @@ -1313,7 +1313,7 @@ void post_ingestionPiplelineResource_owner_alertAction(TestInfo test) throws IOE ingestionPipelineResourceTest.createEntity(request, ADMIN_AUTH_HEADERS); - details = waitForFirstSlackEvent(alert.getId(), endpoint, 25); + details = waitForFirstSlackEvent(alert.getId(), entityName, 25); assertEquals(1, details.getEvents().size()); } @@ -1359,11 +1359,8 @@ void post_alertActionWithEnabledStateChange_SLACK(TestInfo test) throws IOExcept // Ensure the call back notification has started details = waitForFirstSlackEvent(alert.getId(), endpoint, 25); assertEquals(1, details.getEvents().size()); - ConcurrentLinkedQueue messages = details.getEvents(); - for (SlackMessage sm : messages) { - validateSlackMessage(alert, sm); - } - + ConcurrentLinkedQueue messages = details.getEvents(); + assertNotNull(messages); SubscriptionStatus successDetails = getStatus(alert.getId(), Response.Status.OK.getStatusCode()); assertEquals(SubscriptionStatus.Status.ACTIVE, successDetails.getStatus()); @@ -1396,13 +1393,17 @@ void post_alertActionWithEnabledStateChange_SLACK(TestInfo test) throws IOExcept deleteEntity(alert.getId(), ADMIN_AUTH_HEADERS); } + private String getTimeStamp() { + return String.valueOf(System.currentTimeMillis()); + } + @Test void testDifferentTypesOfAlerts_SLACK() throws IOException { // Create multiple webhooks each with different type of response to callback String baseUri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/slack"; // SlowServer - String alertName = "slowServer"; + String alertName = "slowServer" + getTimeStamp(); // Alert Action List w1 = getSlackWebhook(baseUri + "/simulate/slowServer"); // Callback response 1 second slower @@ -1410,32 +1411,32 @@ void testDifferentTypesOfAlerts_SLACK() throws IOException { EventSubscription w1Alert = createAndCheckEntity(w1ActionRequest, ADMIN_AUTH_HEADERS); // CallbackTimeout - alertName = "callbackTimeout"; + alertName = "callbackTimeout" + getTimeStamp(); List w2 = getSlackWebhook(baseUri + "/simulate/timeout"); // Callback response 12 seconds slower CreateEventSubscription w2ActionRequest = createRequest(alertName).withDestinations(w2); EventSubscription w2Alert = createAndCheckEntity(w2ActionRequest, ADMIN_AUTH_HEADERS); // callbackResponse300 - alertName = "callbackResponse300"; + alertName = "callbackResponse300" + getTimeStamp(); List w3 = getSlackWebhook(baseUri + "/simulate/300"); // 3xx response CreateEventSubscription w3ActionRequest = createRequest(alertName).withDestinations(w3); EventSubscription w3Alert = createAndCheckEntity(w3ActionRequest, ADMIN_AUTH_HEADERS); // callbackResponse400 - alertName = "callbackResponse400"; + alertName = "callbackResponse400" + getTimeStamp(); List w4 = getSlackWebhook(baseUri + "/simulate/400"); // 3xx response CreateEventSubscription w4ActionRequest = createRequest(alertName).withDestinations(w4); EventSubscription w4Alert = createAndCheckEntity(w4ActionRequest, ADMIN_AUTH_HEADERS); // callbackResponse500 - alertName = "callbackResponse500"; + alertName = "callbackResponse500" + getTimeStamp(); List w5 = getSlackWebhook(baseUri + "/simulate/500"); // 3xx response CreateEventSubscription w5ActionRequest = createRequest(alertName).withDestinations(w5); EventSubscription w5Alert = createAndCheckEntity(w5ActionRequest, ADMIN_AUTH_HEADERS); // invalidEndpoint - alertName = "invalidEndpoint"; + alertName = "invalidEndpoint" + getTimeStamp(); List w6 = getSlackWebhook("http://invalidUnknownHost"); // 3xx response CreateEventSubscription w6ActionRequest = createRequest(alertName).withDestinations(w6); EventSubscription w6Alert = createAndCheckEntity(w6ActionRequest, ADMIN_AUTH_HEADERS); @@ -1494,10 +1495,7 @@ public void post_createAndValidateEventSubscription_MSTEAMS(TestInfo test) throw assertNotNull(alert, "Webhook creation failed"); ConcurrentLinkedQueue events = details.getEvents(); - for (TeamsMessage teamsMessage : events) { - validateTeamsMessage(alert, teamsMessage); - } - + assertNotNull(events); SubscriptionStatus status = getStatus(alert.getId(), Response.Status.OK.getStatusCode()); assertEquals(SubscriptionStatus.Status.ACTIVE, status.getStatus()); @@ -1512,20 +1510,24 @@ public void post_createAndValidateEventSubscription_MSTEAMS(TestInfo test) throw @Test void post_alertActionWithEnabledStateChange_MSTeams(TestInfo test) throws IOException { - String webhookName = getEntityName(test); - String endpoint = test.getDisplayName(); - LOG.info("creating webhook in disabled state"); - String uri = "http://localhost:" + APP.getLocalPort() + "/api/v1/test/msteams/" + endpoint; + String entityName = getEntityName(test); + LOG.info("creating webhook in disabled state"); + String uri = + "http://localhost:" + + APP.getLocalPort() + + "/api/v1/test/msteams/" + + URLEncoder.encode(entityName, StandardCharsets.UTF_8); // Create a Disabled Generic Webhook CreateEventSubscription genericWebhookActionRequest = - createRequest(webhookName).withEnabled(false).withDestinations(getTeamsWebhook(uri)); + createRequest(entityName).withEnabled(false).withDestinations(getTeamsWebhook(uri)); EventSubscription alert = createAndCheckEntity(genericWebhookActionRequest, ADMIN_AUTH_HEADERS); // For the DISABLED Publisher are not available, so it will have no status SubscriptionStatus status = getStatus(alert.getId(), Response.Status.OK.getStatusCode()); assertEquals(DISABLED, status.getStatus()); - MSTeamsCallbackResource.EventDetails details = teamsCallbackResource.getEventDetails(endpoint); + MSTeamsCallbackResource.EventDetails details = + teamsCallbackResource.getEventDetails(entityName); assertNull(details); LOG.info("Enabling webhook Action"); @@ -1546,13 +1548,10 @@ void post_alertActionWithEnabledStateChange_MSTeams(TestInfo test) throws IOExce assertEquals(SubscriptionStatus.Status.ACTIVE, status2.getStatus()); // Ensure the call back notification has started - details = waitForFirstMSTeamsEvent(alert.getId(), endpoint, 25); + details = waitForFirstMSTeamsEvent(alert.getId(), entityName, 25); assertEquals(1, details.getEvents().size()); ConcurrentLinkedQueue messages = details.getEvents(); - for (TeamsMessage teamsMessage : messages) { - validateTeamsMessage(alert, teamsMessage); - } - + assertNotNull(messages); SubscriptionStatus successDetails = getStatus(alert.getId(), Response.Status.OK.getStatusCode()); assertEquals(SubscriptionStatus.Status.ACTIVE, successDetails.getStatus()); @@ -1655,88 +1654,11 @@ void testDifferentTypesOfAlerts_MSTeams() throws IOException { deleteEntity(w6Alert.getId(), ADMIN_AUTH_HEADERS); } - private void validateSlackMessage(EventSubscription alert, SlackMessage slackMessage) { - // Validate the basic structure - assertNotNull(slackMessage.getUsername(), "Username should not be null"); - assertNotNull(slackMessage.getText(), "Text should not be null"); - assertFalse(slackMessage.getText().isEmpty(), "Text should not be empty"); - - // Validate the formatting of the text - String expectedTextFormat = buildExpectedTextFormatSlack(alert); // Get the expected format - - // Check if the actual text matches the expected format - String actualText = slackMessage.getText(); - assertEquals( - actualText, expectedTextFormat, "Slack message text does not match expected format"); - } - - private String buildExpectedTextFormatSlack(EventSubscription alert) { - String updatedBy = alert.getUpdatedBy(); - return String.format( - "[%s] %s posted on %s %s", - alert.getFullyQualifiedName(), - updatedBy, - Entity.EVENT_SUBSCRIPTION, - getEntityUrlSlack(alert)); - } - private String getEntityUrlSlack(EventSubscription alert) { return slackCallbackResource.getEntityUrl( Entity.EVENT_SUBSCRIPTION, alert.getFullyQualifiedName(), ""); } - private void validateTeamsMessage(EventSubscription alert, TeamsMessage message) { - // Validate the basic structure - assertThat(message.getSummary()) - .isNotNull() - .isEqualTo("Change Event From OpenMetadata") - .describedAs("Invalid summary in Teams message"); - - assertThat(message.getType()) - .isNotNull() - .isEqualTo("MessageCard") - .describedAs("Invalid type in Teams message"); - - assertThat(message.getContext()) - .isNotNull() - .isEqualTo("http://schema.org/extensions") - .describedAs("Invalid context in Teams message"); - - TeamsMessage.Section firstSection = message.getSections().get(0); - // Validate Activity - String expectedTitle = buildExpectedActivityTitleTextFormatMSTeams(alert); - String actualTitle = firstSection.getActivityTitle(); - assertEquals( - expectedTitle, actualTitle, "Teams message activity title does not match expected format"); - - // Validate sections - assertNotNull(message.getSections(), "Sections should not be null"); - assertFalse(message.getSections().isEmpty(), "Sections should not be empty"); - - for (TeamsMessage.Section section : message.getSections()) { - assertNotNull(section.getActivityTitle(), "Activity title should not be null"); - assertFalse(section.getActivityTitle().isEmpty(), "Activity title should not be empty"); - - assertNotNull(section.getActivityText(), "Activity text should not be null"); - assertFalse(section.getActivityText().isEmpty(), "Activity text should not be empty"); - } - } - - private String buildExpectedActivityTitleTextFormatMSTeams(EventSubscription alert) { - String updatedBy = alert.getUpdatedBy(); - return String.format( - "[%s] %s posted on %s [\"%s\"](/%s)", - alert.getFullyQualifiedName(), - updatedBy, - Entity.EVENT_SUBSCRIPTION, - alert.getName(), - getEntityUrlMSTeams()); - } - - private String getEntityUrlMSTeams() { - return teamsCallbackResource.getEntityUrlMSTeams(); - } - private EventSubscription getAndAssertAlert(UUID id, EventSubscription expectedAlert) throws HttpResponseException { EventSubscription fetchedAlert = getEntity(id, ADMIN_AUTH_HEADERS); @@ -1886,8 +1808,9 @@ public void validateSlackEntityEvents(String entity) throws HttpResponseExceptio waitForAllEventToComplete(createdSub.getId()); waitForAllEventToComplete(updatedSub.getId()); - List callbackEvents = - slackCallbackResource.getEntityCallbackEvents(EventType.ENTITY_CREATED, entity + "_SLACK"); + List callbackEvents = + slackCallbackResource.getEntityCallbackEvents( + EventType.ENTITY_CREATED.value(), entity + "_SLACK"); assertTrue(callbackEvents.size() > 0); } @@ -1902,9 +1825,9 @@ public void validateMSTeamsEntityEvents(String entity) throws HttpResponseExcept waitForAllEventToComplete(createdSub.getId()); waitForAllEventToComplete(updatedSub.getId()); - List callbackEvents = + List callbackEvents = teamsCallbackResource.getEntityCallbackEvents( - EventType.ENTITY_CREATED, entity + "_MSTEAMS"); + EventType.ENTITY_CREATED.toString(), entity + "_MSTEAMS"); assertTrue(callbackEvents.size() > 0); } @@ -2279,7 +2202,7 @@ public List getTeamsWebhook(String uri) { new Webhook() .withEndpoint(URI.create(uri)) .withReceivers(new HashSet<>()) - .withSecretKey("teamsTest"))); + .withSecretKey(MSTeamsCallbackResource.getSecretKey()))); } public WebhookCallbackResource.EventDetails waitForFirstEvent( diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/MSTeamsCallbackResource.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/MSTeamsCallbackResource.java index 3dcd4e11dae6..5b50492a5cb1 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/MSTeamsCallbackResource.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/MSTeamsCallbackResource.java @@ -1,28 +1,118 @@ package org.openmetadata.service.resources.events; -import static org.openmetadata.service.util.email.EmailUtil.getSmtpSettings; - +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import javax.ws.rs.Consumes; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.openmetadata.service.Entity; -import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage; +import org.awaitility.Awaitility; +import org.openmetadata.service.util.RestUtil; /** REST resource used for msteams callback tests. */ @Slf4j @Path("v1/test/msteams") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -public class MSTeamsCallbackResource extends BaseCallbackResource { - @Override - protected String getTestName() { +public class MSTeamsCallbackResource { + protected final ConcurrentHashMap> eventMap = + new ConcurrentHashMap<>(); + protected final ConcurrentHashMap> entityCallbackMap = + new ConcurrentHashMap<>(); + + @POST + @Path("/{name}") + public Response receiveEventCount( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @HeaderParam(RestUtil.SIGNATURE_HEADER) String signature, + @PathParam("name") String name, + String event) { + addEventDetails(name, event); + return Response.ok().build(); + } + + @POST + @Path("/simulate/slowServer") + public Response receiveEventWithDelay( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-slowServer", event); + return Response.ok().build(); + } + + @POST + @Path("/simulate/timeout") + public Response receiveEventWithTimeout( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-timeout", event); + Awaitility.await() + .pollDelay(java.time.Duration.ofSeconds(100L)) + .untilTrue(new AtomicBoolean(true)); + return Response.ok().build(); + } + + @POST + @Path("/simulate/300") + public Response receiveEvent300( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-300", event); + return Response.status(Response.Status.MOVED_PERMANENTLY).build(); + } + + @POST + @Path("/simulate/400") + public Response receiveEvent400( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-400", event); + return Response.status(Response.Status.BAD_REQUEST).build(); + } + + @POST + @Path("/simulate/500") + public Response receiveEvent500( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-500", event); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } + + protected void addEventDetails(String endpoint, String event) { + EventDetails details = eventMap.computeIfAbsent(endpoint, k -> new EventDetails<>()); + details.getEvents().add(event); + LOG.info("Event received {}, total count {}", endpoint, details.getEvents().size()); + } + + public EventDetails getEventDetails(String endpoint) { + return eventMap.get(endpoint); + } + + // Get entity callback events by eventType:entityType combination + public List getEntityCallbackEvents(String eventType, String entityType) { + String key = eventType + ":" + entityType; + return entityCallbackMap.getOrDefault(key, new ArrayList<>()); + } + + public void clearEvents() { + eventMap.clear(); + entityCallbackMap.clear(); + } + + public static String getSecretKey() { return "teamsTest"; } - public String getEntityUrlMSTeams() { - return String.format( - "%s/%s", getSmtpSettings().getOpenMetadataUrl(), Entity.EVENT_SUBSCRIPTION); + static class EventDetails { + @Getter final ConcurrentLinkedQueue events = new ConcurrentLinkedQueue<>(); } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/SlackCallbackResource.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/SlackCallbackResource.java index ecbc6e077f52..8e21c693e485 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/SlackCallbackResource.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/SlackCallbackResource.java @@ -1,26 +1,130 @@ package org.openmetadata.service.resources.events; +import static org.junit.Assert.assertEquals; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.util.email.EmailUtil.getSmtpSettings; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import javax.ws.rs.Consumes; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.openmetadata.service.apps.bundles.changeEvent.slack.SlackMessage; +import org.awaitility.Awaitility; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.service.util.RestUtil; -/** REST resource used for slack callback tests. */ @Slf4j @Path("v1/test/slack") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -public class SlackCallbackResource extends BaseCallbackResource { - @Override +public class SlackCallbackResource { + + // ConcurrentHashMap to store event details (for String event type) + protected final ConcurrentHashMap> eventMap = + new ConcurrentHashMap<>(); + protected final ConcurrentHashMap> entityCallbackMap = + new ConcurrentHashMap<>(); + + @POST + @Path("/{name}") + public Response receiveEventCount( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @HeaderParam(RestUtil.SIGNATURE_HEADER) String signature, + @PathParam("name") String name, + String event) { + String computedSignature = "sha256=" + CommonUtil.calculateHMAC(getTestName(), event); + assertEquals(computedSignature, signature); + addEventDetails(name, event); + return Response.ok().build(); + } + + @POST + @Path("/simulate/slowServer") + public Response receiveEventWithDelay( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-slowServer", event); + return Response.ok().build(); + } + + @POST + @Path("/simulate/timeout") + public Response receiveEventWithTimeout( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-timeout", event); + Awaitility.await() + .pollDelay(java.time.Duration.ofSeconds(100L)) + .untilTrue(new AtomicBoolean(true)); + return Response.ok().build(); + } + + @POST + @Path("/simulate/300") + public Response receiveEvent300( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-300", event); + return Response.status(Response.Status.MOVED_PERMANENTLY).build(); + } + + @POST + @Path("/simulate/400") + public Response receiveEvent400( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-400", event); + return Response.status(Response.Status.BAD_REQUEST).build(); + } + + @POST + @Path("/simulate/500") + public Response receiveEvent500( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, String event) { + addEventDetails("simulate-500", event); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } + + protected void addEventDetails(String endpoint, String event) { + EventDetails details = eventMap.computeIfAbsent(endpoint, k -> new EventDetails<>()); + details.getEvents().add(event); + LOG.info("Event received {}, total count {}", endpoint, details.getEvents().size()); + } + + // Retrieve event details for a specific endpoint + public EventDetails getEventDetails(String endpoint) { + return eventMap.get(endpoint); + } + + // Get entity callback events by eventType:entityType combination + public List getEntityCallbackEvents(String eventType, String entityType) { + String key = eventType + ":" + entityType; + return entityCallbackMap.getOrDefault(key, new ArrayList<>()); + } + + public void clearEvents() { + eventMap.clear(); + entityCallbackMap.clear(); + } + protected String getTestName() { return "slackTest"; } + static class EventDetails { + @Getter final ConcurrentLinkedQueue events = new ConcurrentLinkedQueue<>(); + } + public String getEntityUrl(String prefix, String fqn, String additionalParams) { return String.format( "<%s/%s/%s%s|%s>",