Improve Templates for Slack, GChat, and MSTeams (#17771)

* Add default ChangeEvent template and Slack destination test template

* fix conflict

* initial ingestionPipeline template

* clean templates.

* clean templates

* teams templates.

* add getBoldWithSpace()

* java check style

* google chat templates.

* Refactor GChat and Teams message for template data handling for DQ_Template_Section and General_Template_Section.

* Refactor slack message for template data handling for DQ_Template_Section and General_Template_Section.

* improvements gChatMessageDecorator

* improvements slackMessageDecorator

* improve MSTeamsMessageDecorator

* Call templates per entityType and refactor code

* Update EmailPublisher to use testEmail template instead of changeEvent template for sendTestMessage

* add parameterValues sections for teams

* Update SlackMessage to have attachments.
Update DQ template.

* Update dq templates, build dq template data in MessageDecorator

* remove IngestionPipeline template

* move SlackCallbackResource into a separate class. Fix tests.

* simulate timeout and 300.

* remove the validation of messages.

* fix teams MessageDecorator

* remove unused code from SlackMessageDecorator

* fix owners and tags data population in the template

* fix: changes in test case and test case result should load different templates.
This commit is contained in:
Siddhant 2024-11-11 11:05:31 +05:30 committed by GitHub
parent 687b564ef6
commit 2437d0124e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2586 additions and 437 deletions

View File

@ -83,11 +83,10 @@ public class EmailPublisher implements Destination<ChangeEvent> {
public void sendTestMessage() throws EventPublisherException {
try {
Set<String> 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) {

View File

@ -3,39 +3,75 @@ package org.openmetadata.service.apps.bundles.changeEvent.gchat;
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> cardsV2;
public static class CardsV2 {
@Getter @Setter private String cardId;
@Getter @Setter private Card card;
}
private List<GChatMessage.Card> cards;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class Card {
@Getter @Setter private CardHeader header;
@Getter @Setter private List<Section> sections;
private GChatMessage.Header header;
private List<GChatMessage.Section> 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<Widget> widgets;
private List<GChatMessage.Widget> 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;
}
}

View File

@ -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<TeamsMessage.Attachment> attachments;
@Getter
@Setter
public static class Section {
@JsonProperty("activityTitle")
public String activityTitle;
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Attachment {
@JsonProperty("contentType")
private String contentType;
@JsonProperty("activityText")
public String activityText;
@JsonProperty("content")
private TeamsMessage.AdaptiveCardContent content;
}
@JsonProperty("@type")
public String type = "MessageCard";
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class AdaptiveCardContent {
@JsonProperty("type")
private String type;
@JsonProperty("@context")
public String context = "http://schema.org/extensions";
@JsonProperty("version")
private String version;
@JsonProperty("summary")
public String summary;
@JsonProperty("body")
private List<TeamsMessage.BodyItem> body;
}
@JsonProperty("sections")
public List<Section> sections;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class ColumnSet implements TeamsMessage.BodyItem {
@JsonProperty("type")
private String type;
@JsonProperty("columns")
private List<TeamsMessage.Column> columns;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Column {
@JsonProperty("type")
private String type;
@JsonProperty("items")
private List<TeamsMessage.BodyItem> items;
@JsonProperty("width")
private String width;
}
@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<TeamsMessage.Fact> facts;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Fact {
@JsonProperty("title")
private String title;
@JsonProperty("value")
private String value;
}
// Interface for Body Items
public interface BodyItem {}
}

View File

@ -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<String> markdownIn;
private List<LayoutBlock> blocks;
}

View File

@ -19,6 +19,9 @@ import static org.openmetadata.service.util.SubscriptionUtil.getClient;
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 class SlackEventPublisher implements Destination<ChangeEvent> {
SlackMessage slackMessage =
slackMessageFormatter.buildOutgoingMessage(
eventSubscription.getFullyQualifiedName(), event);
String json = JsonUtils.pojoToJsonIgnoreNull(slackMessage);
json = convertCamelCaseToSnakeCase(json);
List<Invocation.Builder> targets =
getTargetsForWebhookAlert(
webhook, subscriptionDestination.getCategory(), SLACK, client, event);
@ -83,14 +89,10 @@ public class SlackEventPublisher implements Destination<ChangeEvent> {
}
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 class SlackEventPublisher implements Destination<ChangeEvent> {
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 class SlackEventPublisher implements Destination<ChangeEvent> {
}
}
/**
* 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;

View File

@ -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<LayoutBlock> blocks;
private List<Attachment> 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"));
return this;
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Attachment {
private String color;
private List<LayoutBlock> blocks;
}
}

View File

@ -29,6 +29,11 @@ public class EmailMessageDecorator implements MessageDecorator<EmailMessage> {
return "<b>%s</b>";
}
@Override
public String getBoldWithSpace() {
return "<b>%s</b> ";
}
@Override
public String getLineBreak() {
return " <br/> ";

View File

@ -25,6 +25,11 @@ public class FeedMessageDecorator implements MessageDecorator<FeedMessage> {
return "**%s**";
}
@Override
public String getBoldWithSpace() {
return "**%s** ";
}
@Override
public String getLineBreak() {
return " <br/> ";

View File

@ -17,9 +17,24 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
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<GChatMessage> {
@ -29,6 +44,11 @@ public class GChatMessageDecorator implements MessageDecorator<GChatMessage> {
return "<b>%s</b>";
}
@Override
public String getBoldWithSpace() {
return "<b>%s</b> ";
}
@Override
public String getLineBreak() {
return " <br/> ";
@ -67,7 +87,12 @@ public class GChatMessageDecorator implements MessageDecorator<GChatMessage> {
@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 class GChatMessageDecorator implements MessageDecorator<GChatMessage> {
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<GChatMessage.Widget> 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;
}
throw new UnhandledServerException("No messages found for the event");
}
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;
if (publisherName.isEmpty()) {
throw new UnhandledServerException("Publisher name not found.");
}
throw new UnhandledServerException("Publisher name not found.");
return createConnectionTestMessage(publisherName);
}
private GChatMessage.Widget getGChatWidget(String message) {
GChatMessage.Widget widget = new GChatMessage.Widget();
widget.setTextParagraph(new GChatMessage.TextParagraph(message));
return widget;
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<FieldChange> fieldsAdded = event.getChangeDescription().getFieldsAdded();
List<FieldChange> 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<General_Template_Section, Map<Enum<?>, Object>> data =
buildGeneralTemplateData(publisherName, event, outgoingMessage);
Map<Enum<?>, Object> eventDetails = data.get(General_Template_Section.EVENT_DETAILS);
Header header = createHeader();
List<Widget> 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<General_Template_Section, Map<Enum<?>, Object>> buildGeneralTemplateData(
String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) {
TemplateDataBuilder<General_Template_Section> 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData =
MessageDecorator.buildDQTemplateData(event, outgoingMessage);
List<Section> sections = new ArrayList<>();
Header header = createHeader();
addChangeEventDetailsSection(templateData, sections);
List<Widget> 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, Object> eventDetails = templateData.get(DQ_Template_Section.EVENT_DETAILS);
if (nullOrEmpty(eventDetails)) {
return;
}
sections.add(new Section(createEventDetailsWidgets(eventDetails)));
}
private void addTestCaseDetailsSection(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS);
if (nullOrEmpty(testCaseDetails)) {
return;
}
List<Widget> 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<Enum<?>, Object> testCaseDetails) {
List<EntityReference> owners =
(List<EntityReference>)
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<Enum<?>, Object> testCaseDetails) {
List<TagLabel> tags =
(List<TagLabel>)
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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, Object> testCaseResult = templateData.get(DQ_Template_Section.TEST_CASE_RESULT);
if (nullOrEmpty(testCaseResult)) {
return;
}
List<Widget> 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, 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<TestCaseParameterValue> parameterValues = (List<TestCaseParameterValue>) result;
if (nullOrEmpty(parameterValues)) {
return;
}
String parameterValuesText =
parameterValues.stream()
.map(param -> String.format("[%s: %s]", param.getName(), param.getValue()))
.collect(Collectors.joining(", "));
List<Widget> parameterValueWidget = new ArrayList<>();
parameterValueWidget.add(createWidget("Parameter Value:", parameterValuesText));
sections.add(new Section(parameterValueWidget));
}
private void addInspectionQuerySection(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
Map<Enum<?>, Object> testDefinition = templateData.get(DQ_Template_Section.TEST_DEFINITION);
if (!nullOrEmpty(testDefinition)) {
List<Widget> 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<Section> sections) {
if (templateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS)) {
Map<Enum<?>, 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<Widget> createEventDetailsWidgets(Map<Enum<?>, Object> detailsMap) {
List<Widget> widgets = new ArrayList<>();
Map<Enum<?>, 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 String applyBoldFormatWithSpace(String title) {
return String.format(getBoldWithSpace(), title);
}
}

View File

@ -17,9 +17,27 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
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<TeamsMessage> {
@ -29,6 +47,11 @@ public class MSTeamsMessageDecorator implements MessageDecorator<TeamsMessage> {
return "**%s**";
}
@Override
public String getBoldWithSpace() {
return "**%s** ";
}
@Override
public String getLineBreak() {
return " <br/> ";
@ -66,7 +89,12 @@ public class MSTeamsMessageDecorator implements MessageDecorator<TeamsMessage> {
@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 class MSTeamsMessageDecorator implements MessageDecorator<TeamsMessage> {
return getTeamTestMessage(publisherName);
}
@Override
public TeamsMessage buildThreadMessage(String publisherName, ChangeEvent event) {
return getTeamMessage(createThreadMessage(publisherName, event));
}
private TeamsMessage getTeamMessage(OutgoingMessage outgoingMessage) {
if (!outgoingMessage.getMessages().isEmpty()) {
TeamsMessage teamsMessage = new TeamsMessage();
teamsMessage.setSummary("Change Event From OpenMetadata");
// Sections
TeamsMessage.Section teamsSections = new TeamsMessage.Section();
teamsSections.setActivityTitle(outgoingMessage.getHeader());
List<TeamsMessage.Section> attachmentList = new ArrayList<>();
outgoingMessage
.getMessages()
.forEach(m -> attachmentList.add(getTeamsSection(teamsSections.getActivityTitle(), m)));
teamsMessage.setSections(attachmentList);
return teamsMessage;
public TeamsMessage getTeamTestMessage(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 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.");
// Sections
TeamsMessage.Section teamsSection = new TeamsMessage.Section();
teamsSection.setActivityTitle("Alert: " + publisherName);
List<TeamsMessage.Section> sectionList = new ArrayList<>();
sectionList.add(teamsSection);
teamsMessage.setSections(sectionList);
return teamsMessage;
private TeamsMessage createMessage(
String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) {
if (outgoingMessage.getMessages().isEmpty()) {
throw new UnhandledServerException("No messages found for the event");
}
throw new UnhandledServerException("Publisher name not found.");
String entityType = event.getEntityType();
return switch (entityType) {
case Entity.TEST_CASE -> createDQMessage(publisherName, event, outgoingMessage);
default -> createGeneralChangeEventMessage(publisherName, event, outgoingMessage);
};
}
private TeamsMessage.Section getTeamsSection(String activityTitle, String message) {
TeamsMessage.Section section = new TeamsMessage.Section();
section.setActivityTitle(activityTitle);
section.setActivityText(message);
return section;
private TeamsMessage createGeneralChangeEventMessage(
String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) {
Map<General_Template_Section, Map<Enum<?>, Object>> templateData =
buildGeneralTemplateData(publisherName, event, outgoingMessage);
Map<Enum<?>, Object> eventDetails = templateData.get(General_Template_Section.EVENT_DETAILS);
TextBlock changeEventDetailsTextBlock = createHeader();
// Create the facts for the FactSet
List<TeamsMessage.Fact> facts = createEventDetailsFacts(eventDetails);
// Create a list of TextBlocks for each message with a separator
List<TextBlock> 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<TeamsMessage.BodyItem> 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<DQ_Template_Section, Map<Enum<?>, Object>> dqTemplateData =
buildDQTemplateData(publisherName, event, outgoingMessage);
TextBlock changeEventDetailsTextBlock = createHeader();
Map<Enum<?>, Object> eventDetails = dqTemplateData.get(DQ_Template_Section.EVENT_DETAILS);
// Create the facts for different sections
List<TeamsMessage.Fact> facts = createEventDetailsFacts(eventDetails);
List<TeamsMessage.Fact> testCaseDetailsFacts = createTestCaseDetailsFacts(dqTemplateData);
List<TeamsMessage.Fact> testCaseResultFacts = createTestCaseResultFacts(dqTemplateData);
List<TeamsMessage.Fact> parameterValuesFacts = createParameterValuesFacts(dqTemplateData);
List<TeamsMessage.Fact> inspectionQueryFacts = createInspectionQueryFacts(dqTemplateData);
List<TeamsMessage.Fact> testDefinitionFacts = createTestDefinitionFacts(dqTemplateData);
List<TeamsMessage.Fact> sampleDataFacts = createSampleDataFacts(dqTemplateData);
// Create a list of TextBlocks for each message with a separator
List<TextBlock> 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<TeamsMessage.BodyItem> 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<TeamsMessage.Fact> createEventDetailsFacts(Map<Enum<?>, 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<TeamsMessage.Fact> createTestCaseDetailsFacts(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS);
Function<DQ_TestCaseDetailsKeys, String> 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)));
}
@SuppressWarnings("unchecked")
private String formatOwners(Map<Enum<?>, Object> testCaseDetails) {
List<EntityReference> owners =
(List<EntityReference>)
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<Enum<?>, Object> testCaseDetails) {
List<TagLabel> tags =
(List<TagLabel>)
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<TeamsMessage.Fact> createTestCaseResultFacts(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_RESULT);
if (nullOrEmpty(testCaseDetails)) {
return Collections.emptyList();
}
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<TeamsMessage.Fact> createParameterValuesFacts(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, 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<TestCaseParameterValue> parameterValues = (List<TestCaseParameterValue>) 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<TeamsMessage.Fact> createInspectionQueryFacts(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, 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<TeamsMessage.Fact> createTestDefinitionFacts(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, 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<TeamsMessage.Fact> createSampleDataFacts(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, 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<General_Template_Section, Map<Enum<?>, Object>> buildGeneralTemplateData(
String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) {
TemplateDataBuilder<General_Template_Section> 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<DQ_Template_Section, Map<Enum<?>, Object>> buildDQTemplateData(
String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) {
TemplateDataBuilder<DQ_Template_Section> 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 Image createOMImageMessage() {
return Image.builder().type("Image").url("https://imgur.com/kOOPEG4.png").size("Small").build();
}
}

View File

@ -25,8 +25,14 @@ import java.time.LocalDateTime;
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.Include;
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<T> {
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,80 +81,99 @@ public interface MessageDecorator<T> {
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();
}
// Hande Test Case
if (entityType.equals(Entity.TEST_CASE)) {
TestCase testCase = (TestCase) entityInterface;
return getEntityUrl(
"incident-manager", testCase.getFullyQualifiedName(), "test-case-results");
}
// Glossary Term
if (entityType.equals(Entity.GLOSSARY_TERM)) {
// Glossary Term is a special case where the URL is different
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], "");
}
// IngestionPipeline
if (entityType.equals(Entity.INGESTION_PIPELINE)) {
return getIngestionPipelineUrl(this, entityType, entityInterface);
String fqn = resolveFullyQualifiedName(entityType, entityInterface);
switch (entityType) {
case Entity.TEST_CASE:
if (entityInterface instanceof TestCase testCase) {
return getEntityUrl(
"incident-manager", testCase.getFullyQualifiedName(), "test-case-results");
}
break;
case Entity.GLOSSARY_TERM:
return getEntityUrl(Entity.GLOSSARY, fqn, "");
case Entity.TAG:
return getEntityUrl("tags", fqn.split("\\.")[0], "");
case 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 = resolveFullyQualifiedName(entityType, entityInterface);
switch (entityType) {
case Entity.TEST_CASE:
if (entityInterface instanceof TestCase) {
TestCase testCase = (TestCase) entityInterface;
return getEntityUrl("incident-manager", testCase.getFullyQualifiedName(), "issues");
}
break;
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);
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;
}
// Hande Test Case
if (entityType.equals(Entity.TEST_CASE)) {
TestCase testCase = (TestCase) entityInterface;
return getEntityUrl("incident-manager", testCase.getFullyQualifiedName(), "issues");
}
static String getFQNForChangeEventEntity(ChangeEvent event) {
return Optional.ofNullable(event.getEntityFullyQualifiedName())
.filter(fqn -> !CommonUtil.nullOrEmpty(fqn))
.orElseGet(
() -> {
EntityInterface entityInterface = getEntity(event);
String fqn = entityInterface.getFullyQualifiedName();
// 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);
}
if (CommonUtil.nullOrEmpty(fqn)) {
EntityInterface result =
Entity.getEntity(
event.getEntityType(), entityInterface.getId(), "id", Include.NON_DELETED);
fqn = result.getFullyQualifiedName();
}
// Tag
if (entityType.equals(Entity.TAG)) {
// Tags need to be redirected to Classification Page
return getEntityUrl("tags", fqn.split("\\.")[0], "");
}
// IngestionPipeline
if (entityType.equals(Entity.INGESTION_PIPELINE)) {
return getIngestionPipelineUrl(this, entityType, entityInterface);
}
return getEntityUrl(entityType, fqn, activeTab);
return fqn;
});
}
default T buildOutgoingMessage(String publisherName, ChangeEvent event) {
@ -400,7 +434,7 @@ public interface MessageDecorator<T> {
}
}
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 @@ public interface MessageDecorator<T> {
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 <S> The enum type representing the sections of the template.
*/
class TemplateDataBuilder<S extends Enum<S>> {
// Map to store sections and their corresponding keys and values
private final Map<S, Map<Enum<?>, 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 <K> The enum type representing the keys (must extend Enum).
*/
@SuppressWarnings("unchecked")
public <K extends Enum<K>> TemplateDataBuilder<S> add(S section, K key, Object value) {
sectionToKeyDataMap
.computeIfAbsent(
section, k -> (Map<Enum<?>, Object>) new EnumMap<>(key.getDeclaringClass()))
.put(key, value);
return this;
}
public Map<S, Map<Enum<?>, 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<DQ_Template_Section, Map<Enum<?>, Object>> buildDQTemplateData(
ChangeEvent event, OutgoingMessage outgoingMessage) {
TemplateDataBuilder<DQ_Template_Section> 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);
}
}

View File

@ -16,10 +16,27 @@ package org.openmetadata.service.formatter.decorators;
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 class SlackMessageDecorator implements MessageDecorator<SlackMessage> {
return "*%s*";
}
@Override
public String getBoldWithSpace() {
return "*%s* ";
}
@Override
public String getLineBreak() {
return "\n";
@ -67,62 +89,715 @@ public class SlackMessageDecorator implements MessageDecorator<SlackMessage> {
@Override
public SlackMessage buildEntityMessage(String publisherName, ChangeEvent event) {
return getSlackMessage(createEntityMessage(publisherName, event));
}
@Override
public SlackMessage buildTestMessage(String publisherName) {
return getSlackTestMessage(publisherName);
return getSlackMessage(event, createEntityMessage(publisherName, event));
}
@Override
public SlackMessage buildThreadMessage(String publisherName, ChangeEvent event) {
return getSlackMessage(createThreadMessage(publisherName, event));
return getSlackMessage(event, createThreadMessage(publisherName, event));
}
private SlackMessage getSlackMessage(OutgoingMessage outgoingMessage) {
if (!outgoingMessage.getMessages().isEmpty()) {
SlackMessage message = new SlackMessage();
List<SlackAttachment> 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;
@Override
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");
}
throw new UnhandledServerException("No messages found for the event");
return createMessage(event, outgoingMessage);
}
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);
private SlackMessage createMessage(ChangeEvent event, OutgoingMessage outgoingMessage) {
return switch (event.getEntityType()) {
case Entity.TEST_CASE -> createTestCaseMessage(event, outgoingMessage);
default -> createGeneralChangeEventMessage(event, outgoingMessage);
};
}
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
private SlackMessage createTestCaseMessage(ChangeEvent event, OutgoingMessage outgoingMessage) {
final String testCaseResult = "testCaseResult";
List<FieldChange> fieldsAdded = event.getChangeDescription().getFieldsAdded();
List<FieldChange> fieldsUpdated = event.getChangeDescription().getFieldsUpdated();
List<SlackAttachment> attachmentList = new ArrayList<>();
attachmentList.add(attachment);
message.setAttachments(attachmentList.toArray(new SlackAttachment[0]));
boolean hasRelevantChange =
fieldsAdded.stream().anyMatch(field -> testCaseResult.equals(field.getName()))
|| fieldsUpdated.stream().anyMatch(field -> testCaseResult.equals(field.getName()));
return message;
return hasRelevantChange
? createDQTemplateMessage(event, outgoingMessage)
: createGeneralChangeEventMessage(event, outgoingMessage);
}
public SlackMessage createConnectionTestMessage(String publisherName) {
if (publisherName.isEmpty()) {
throw new UnhandledServerException("Publisher name not found.");
}
throw new UnhandledServerException("Publisher name not found.");
List<LayoutBlock> 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 SlackAttachment getSlackAttachment(String message) {
SlackAttachment attachment = new SlackAttachment();
List<String> mark = new ArrayList<>();
mark.add("text");
attachment.setMarkdownIn(mark);
attachment.setText(message);
private SlackMessage createGeneralChangeEventMessage(
ChangeEvent event, OutgoingMessage outgoingMessage) {
List<LayoutBlock> generalChangeEventBody = createGeneralChangeEventBody(event, outgoingMessage);
SlackMessage message = new SlackMessage();
message.setBlocks(generalChangeEventBody);
return message;
}
private List<LayoutBlock> createGeneralChangeEventBody(
ChangeEvent event, OutgoingMessage outgoingMessage) {
List<LayoutBlock> blocks = new ArrayList<>();
// Header
addChangeEventDetailsHeader(blocks);
// Info about the event
List<TextObject> 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<TextObject> 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<String> 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<LayoutBlock> blocks, Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, 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<Enum<?>, 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 List<LayoutBlock> createDQBodyBlocks(
ChangeEvent event,
OutgoingMessage outgoingMessage,
Map<DQ_Template_Section, Map<Enum<?>, Object>> data) {
List<LayoutBlock> blocks = new ArrayList<>();
// Header
createDQHeading(blocks, data);
// Info about the event
List<TextObject> 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<TextObject> 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<String> 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;
}
// DQ TEMPLATE
public SlackMessage createDQTemplateMessage(ChangeEvent event, OutgoingMessage outgoingMessage) {
Map<DQ_Template_Section, Map<Enum<?>, Object>> dqTemplateData =
MessageDecorator.buildDQTemplateData(event, outgoingMessage);
List<LayoutBlock> body = createDQBodyBlocks(event, outgoingMessage, dqTemplateData);
SlackMessage message = new SlackMessage();
message.setBlocks(body);
Map<Enum<?>, Object> enumObjectMap = dqTemplateData.get(DQ_Template_Section.TEST_CASE_RESULT);
if (!nullOrEmpty(enumObjectMap)) {
SlackMessage.Attachment attachment = createDQAttachment(dqTemplateData);
attachment.setColor("#ffcc00");
message.setAttachments(Collections.singletonList(attachment));
}
return message;
}
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
}
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
};
}
return "Unknown :grey_question:"; // Default to unknown if the object is not a valid
// TestCaseStatus
}
public SlackMessage.Attachment createDQAttachment(
Map<DQ_Template_Section, Map<Enum<?>, Object>> dqTemplateData) {
List<LayoutBlock> 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<LayoutBlock> createTestCaseResultAndDefinitionSections(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
List<LayoutBlock> 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<LayoutBlock> blocks, Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS);
if (nullOrEmpty(testCaseDetails)) {
return;
}
List<TextObject> 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<LayoutBlock> blocks, Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, 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<LayoutBlock> blocks, Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
Map<Enum<?>, Object> testCaseDetails = templateData.get(DQ_Template_Section.TEST_CASE_DETAILS);
if (nullOrEmpty(testCaseDetails)) {
return;
}
List<TextObject> 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<Enum<?>, Object> testCaseDetails) {
List<EntityReference> owners =
(List<EntityReference>)
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<Enum<?>, Object> testCaseDetails) {
List<TagLabel> tags =
(List<TagLabel>)
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<LayoutBlock> createTestCaseResultSections(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
List<LayoutBlock> blocks = new ArrayList<>();
Map<Enum<?>, 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<LayoutBlock> blocks, Map<Enum<?>, Object> testCaseResults) {
List<TextObject> 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<LayoutBlock> blocks) {
Map<Enum<?>, Object> testCaseResults = templateData.get(DQ_Template_Section.TEST_CASE_RESULT);
if (nullOrEmpty(testCaseResults)) {
return;
}
Object result = testCaseResults.get(DQ_TestCaseResultKeys.PARAMETER_VALUE);
List<TestCaseParameterValue> parameterValues =
result instanceof List<?> ? (List<TestCaseParameterValue>) 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<LayoutBlock> blocks) {
Map<Enum<?>, 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<LayoutBlock> createTestDefinitionSections(
Map<DQ_Template_Section, Map<Enum<?>, Object>> templateData) {
List<LayoutBlock> blocks = new ArrayList<>();
if (templateData.containsKey(DQ_Template_Section.TEST_DEFINITION)) {
Map<Enum<?>, 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<DQ_Template_Section, Map<Enum<?>, Object>> templateData, List<LayoutBlock> blocks) {
if (templateData.containsKey(DQ_Template_Section.TEST_CASE_DETAILS)) {
Map<Enum<?>, 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<LayoutBlock> blocks) {
blocks.add(
Blocks.header(
header ->
header.text(
BlockCompositions.plainText(
":arrows_counterclockwise: Change Event Details"))));
}
private void addDQAlertHeader(List<LayoutBlock> 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<Enum<?>, 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";
}
}

View File

@ -15,6 +15,7 @@ package org.openmetadata.service.util;
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 final class JsonUtils {
}
}
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);
}

View File

@ -2,10 +2,8 @@ package org.openmetadata.service.resources.events;
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 static org.openmetadata.service.util.TestUtils.assertResponse;
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 class EventSubscriptionResourceTest
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 class EventSubscriptionResourceTest
EventSubscription alert = createEntity(enabledWebhookRequest, ADMIN_AUTH_HEADERS);
waitForAllEventToComplete(alert.getId());
SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(endpoint);
ConcurrentLinkedQueue<SlackMessage> events = details.getEvents();
for (SlackMessage event : events) {
validateSlackMessage(alert, event);
}
SlackCallbackResource.EventDetails details = slackCallbackResource.getEventDetails(entityName);
ConcurrentLinkedQueue<String> events = details.getEvents();
assertNotNull(events);
assertNotNull(alert, "Webhook creation failed");
Awaitility.await()
@ -1223,20 +1224,19 @@ public class EventSubscriptionResourceTest
@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 @@ public class EventSubscriptionResourceTest
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 @@ public class EventSubscriptionResourceTest
// 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 @@ public class EventSubscriptionResourceTest
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 @@ public class EventSubscriptionResourceTest
// Ensure the call back notification has started
details = waitForFirstSlackEvent(alert.getId(), endpoint, 25);
assertEquals(1, details.getEvents().size());
ConcurrentLinkedQueue<SlackMessage> messages = details.getEvents();
for (SlackMessage sm : messages) {
validateSlackMessage(alert, sm);
}
ConcurrentLinkedQueue<String> messages = details.getEvents();
assertNotNull(messages);
SubscriptionStatus successDetails =
getStatus(alert.getId(), Response.Status.OK.getStatusCode());
assertEquals(SubscriptionStatus.Status.ACTIVE, successDetails.getStatus());
@ -1396,13 +1393,17 @@ public class EventSubscriptionResourceTest
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<SubscriptionDestination> w1 =
getSlackWebhook(baseUri + "/simulate/slowServer"); // Callback response 1 second slower
@ -1410,32 +1411,32 @@ public class EventSubscriptionResourceTest
EventSubscription w1Alert = createAndCheckEntity(w1ActionRequest, ADMIN_AUTH_HEADERS);
// CallbackTimeout
alertName = "callbackTimeout";
alertName = "callbackTimeout" + getTimeStamp();
List<SubscriptionDestination> 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<SubscriptionDestination> 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<SubscriptionDestination> 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<SubscriptionDestination> 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<SubscriptionDestination> w6 = getSlackWebhook("http://invalidUnknownHost"); // 3xx response
CreateEventSubscription w6ActionRequest = createRequest(alertName).withDestinations(w6);
EventSubscription w6Alert = createAndCheckEntity(w6ActionRequest, ADMIN_AUTH_HEADERS);
@ -1494,10 +1495,7 @@ public class EventSubscriptionResourceTest
assertNotNull(alert, "Webhook creation failed");
ConcurrentLinkedQueue<TeamsMessage> 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 class EventSubscriptionResourceTest
@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 @@ public class EventSubscriptionResourceTest
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<TeamsMessage> 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 @@ public class EventSubscriptionResourceTest
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 class EventSubscriptionResourceTest
waitForAllEventToComplete(createdSub.getId());
waitForAllEventToComplete(updatedSub.getId());
List<SlackMessage> callbackEvents =
slackCallbackResource.getEntityCallbackEvents(EventType.ENTITY_CREATED, entity + "_SLACK");
List<String> callbackEvents =
slackCallbackResource.getEntityCallbackEvents(
EventType.ENTITY_CREATED.value(), entity + "_SLACK");
assertTrue(callbackEvents.size() > 0);
}
@ -1902,9 +1825,9 @@ public class EventSubscriptionResourceTest
waitForAllEventToComplete(createdSub.getId());
waitForAllEventToComplete(updatedSub.getId());
List<TeamsMessage> callbackEvents =
List<String> callbackEvents =
teamsCallbackResource.getEntityCallbackEvents(
EventType.ENTITY_CREATED, entity + "_MSTEAMS");
EventType.ENTITY_CREATED.toString(), entity + "_MSTEAMS");
assertTrue(callbackEvents.size() > 0);
}
@ -2279,7 +2202,7 @@ public class EventSubscriptionResourceTest
new Webhook()
.withEndpoint(URI.create(uri))
.withReceivers(new HashSet<>())
.withSecretKey("teamsTest")));
.withSecretKey(MSTeamsCallbackResource.getSecretKey())));
}
public WebhookCallbackResource.EventDetails waitForFirstEvent(

View File

@ -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<TeamsMessage> {
@Override
protected String getTestName() {
public class MSTeamsCallbackResource {
protected final ConcurrentHashMap<String, EventDetails<String>> eventMap =
new ConcurrentHashMap<>();
protected final ConcurrentHashMap<String, List<String>> 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<String> details = eventMap.computeIfAbsent(endpoint, k -> new EventDetails<>());
details.getEvents().add(event);
LOG.info("Event received {}, total count {}", endpoint, details.getEvents().size());
}
public EventDetails<String> getEventDetails(String endpoint) {
return eventMap.get(endpoint);
}
// Get entity callback events by eventType:entityType combination
public List<String> 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<T> {
@Getter final ConcurrentLinkedQueue<T> events = new ConcurrentLinkedQueue<>();
}
}

View File

@ -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<SlackMessage> {
@Override
public class SlackCallbackResource {
// ConcurrentHashMap to store event details (for String event type)
protected final ConcurrentHashMap<String, EventDetails<String>> eventMap =
new ConcurrentHashMap<>();
protected final ConcurrentHashMap<String, List<String>> 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<String> 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<String> getEventDetails(String endpoint) {
return eventMap.get(endpoint);
}
// Get entity callback events by eventType:entityType combination
public List<String> 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<T> {
@Getter final ConcurrentLinkedQueue<T> events = new ConcurrentLinkedQueue<>();
}
public String getEntityUrl(String prefix, String fqn, String additionalParams) {
return String.format(
"<%s/%s/%s%s|%s>",