diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java index 597c2659dd4..2eb11bb690f 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java @@ -23,6 +23,8 @@ import java.util.Map; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; import lombok.Getter; @@ -62,7 +64,9 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { private static final String SOURCE_KEY = "Source"; - private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? '6db33f' : 'b32d36') : '439fe0'}"; + // For color definitions see: + // https://adaptivecards.microsoft.com/?topic=TextBlock#color + private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? 'Good' : 'Attention') : 'Accent'}"; private static final String DEFAULT_DEREGISTER_ACTIVITY_SUBTITLE_EXPRESSION = "#{instance.registration.name} with id #{instance.id} has de-registered from Spring Boot Admin"; @@ -197,24 +201,44 @@ protected Message getStatusChangedMessage(Instance instance, EvaluationContext c protected Message createMessage(Instance instance, String registeredTitle, String activitySubtitle, EvaluationContext context) { List facts = new ArrayList<>(); - facts.add(new Fact(STATUS_KEY, instance.getStatusInfo().getStatus())); - facts.add(new Fact(SERVICE_URL_KEY, instance.getRegistration().getServiceUrl())); - facts.add(new Fact(HEALTH_URL_KEY, instance.getRegistration().getHealthUrl())); - facts.add(new Fact(MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl())); - facts.add(new Fact(SOURCE_KEY, instance.getRegistration().getSource())); - - Section section = Section.builder() - .activityTitle(instance.getRegistration().getName()) - .activitySubtitle(activitySubtitle) - .facts(facts) - .build(); + addFactIfNotNull(facts, STATUS_KEY, instance.getStatusInfo().getStatus()); + addFactIfNotNull(facts, SERVICE_URL_KEY, instance.getRegistration().getServiceUrl()); + addFactIfNotNull(facts, HEALTH_URL_KEY, instance.getRegistration().getHealthUrl()); + addFactIfNotNull(facts, MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl()); + addFactIfNotNull(facts, SOURCE_KEY, instance.getRegistration().getSource()); - return Message.builder() - .title(registeredTitle) - .summary(messageSummary) - .themeColor(evaluateExpression(context, themeColor)) - .sections(singletonList(section)) - .build(); + String themeColorValue = evaluateExpression(context, themeColor); + + List cardBody = new ArrayList<>(); + + // Title + cardBody.add(CardElement.builder() + .type("TextBlock") + .text(registeredTitle) + .size("Large") + .weight("Bolder") + .color(themeColorValue) + .build()); + + // Service Name + cardBody.add(CardElement.builder() + .type("TextBlock") + .text(instance.getRegistration().getName()) + .size("Medium") + .weight("Bolder") + .build()); + + // Activity Subtitle + cardBody.add(CardElement.builder().type("TextBlock").text(activitySubtitle).wrap(true).build()); + + // Facts + cardBody.add(CardElement.builder().type("FactSet").facts(facts).build()); + + AdaptiveCard adaptiveCard = AdaptiveCard.builder().body(cardBody).build(); + + Attachment attachment = Attachment.builder().content(adaptiveCard).build(); + + return Message.builder().attachments(singletonList(attachment)).build(); } protected String evaluateExpression(EvaluationContext context, Expression expression) { @@ -232,6 +256,12 @@ protected EvaluationContext createEvaluationContext(InstanceEvent event, Instanc .build(); } + private void addFactIfNotNull(List facts, String title, @Nullable String value) { + if (value != null && !value.isBlank()) { + facts.add(new Fact(title, value)); + } + } + @Nullable public URI getWebhookUrl() { return webhookUrl; } @@ -276,33 +306,69 @@ public void setStatusActivitySubtitle(String statusActivitySubtitle) { @Data @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) public static class Message { - private final String summary; + private final String type = "message"; - private final String themeColor; + @Builder.Default + private final List attachments = new ArrayList<>(); - private final String title; + } - @Builder.Default - private final List
sections = new ArrayList<>(); + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Attachment { + + private final String contentType = "application/vnd.microsoft.card.adaptive"; + + @Nullable private final String contentUrl = null; + + private final AdaptiveCard content; } @Data @Builder - public static class Section { + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class AdaptiveCard { + + @Builder.Default + @JsonProperty("$schema") + private final String schema = "http://adaptivecards.io/schemas/adaptive-card.json"; - private final String activityTitle; + private final String type = "AdaptiveCard"; - private final String activitySubtitle; + private final String version = "1.2"; @Builder.Default - private final List facts = new ArrayList<>(); + private final List body = new ArrayList<>(); + + } + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CardElement { + + private final String type; + + @Nullable private final String text; + + @Nullable private final String size; + + @Nullable private final String weight; + + @Nullable private final String color; + + @Nullable private final Boolean wrap; + + @Nullable private final List facts; } - public record Fact(String name, @Nullable String value) { + public record Fact(String title, @Nullable String value) { } } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java index 2ce0e862c55..d819ffc0eb6 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java @@ -21,11 +21,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; import org.springframework.http.HttpEntity; import org.springframework.http.MediaType; import org.springframework.web.client.RestTemplate; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import tools.jackson.databind.json.JsonMapper; import de.codecentric.boot.admin.server.domain.entities.Instance; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; @@ -45,12 +48,6 @@ class MicrosoftTeamsNotifierTest { - private static final String BLUE = "439fe0"; - - private static final String RED = "b32d36"; - - private static final String GREEN = "6db33f"; - private static final String APP_NAME = "Test App"; private static final String APP_ID = "TestAppId"; @@ -95,8 +92,8 @@ void test_onClientApplicationDeRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", BLUE); + assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), + "Test App with id TestAppId has de-registered from Spring Boot Admin", "Accent"); } @Test @@ -111,8 +108,8 @@ void test_onApplicationRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has registered with Spring Boot Admin", BLUE); + assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), + "Test App with id TestAppId has registered with Spring Boot Admin", "Accent"); } @Test @@ -127,8 +124,8 @@ void test_onApplicationStatusChangedEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UNKNOWN to UP", GREEN); + assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), + "Test App with id TestAppId changed status from UNKNOWN to UP", "Good"); } @Test @@ -148,8 +145,8 @@ void test_getDeregisteredMessageForAppReturns_correctContent() { Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getDeRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", BLUE); + assertMessage(message, notifier.getDeRegisteredTitle(), + "Test App with id TestAppId has de-registered from Spring Boot Admin", "Accent"); } @Test @@ -157,8 +154,8 @@ void test_getRegisteredMessageForAppReturns_correctContent() { Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has registered with Spring Boot Admin", BLUE); + assertMessage(message, notifier.getRegisteredTitle(), + "Test App with id TestAppId has registered with Spring Boot Admin", "Accent"); } @Test @@ -166,8 +163,8 @@ void test_getStatusChangedMessageForAppReturns_correctContent() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); - assertMessage(message, notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UNKNOWN to DOWN", RED); + assertMessage(message, notifier.getStatusChangedTitle(), + "Test App with id TestAppId changed status from UNKNOWN to DOWN", "Attention"); } @Test @@ -177,8 +174,8 @@ void test_getStatusChangedMessageForAppReturns_UP_to_DOWN() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); - assertMessage(message, notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UP to DOWN", RED); + assertMessage(message, notifier.getStatusChangedTitle(), + "Test App with id TestAppId changed status from UP to DOWN", "Attention"); } @Test @@ -187,7 +184,7 @@ void test_getStatusChangedMessageWithExtraFormatArgumentReturns_activitySubtitle Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -196,8 +193,7 @@ void test_getRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatte Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) - .isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -206,8 +202,7 @@ void test_getDeRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePat Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) - .isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -218,35 +213,125 @@ void test_getStatusChangedMessage_parsesThemeColorFromSpelExpression() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofUp()), instance)); - assertThat(message.getThemeColor()).isEqualTo("green"); - } - - private void assertMessage(Message message, String expectedTitle, String expectedSummary, String expectedSubTitle, - String expectedColor) { - assertThat(message.getTitle()).isEqualTo(expectedTitle); - assertThat(message.getSummary()).isEqualTo(expectedSummary); - assertThat(message.getThemeColor()).isEqualTo(expectedColor); - - assertThat(message.getSections()).hasSize(1).anySatisfy((section) -> { - assertThat(section.getActivityTitle()).isEqualTo(instance.getRegistration().getName()); - assertThat(section.getActivitySubtitle()).isEqualTo(expectedSubTitle); - - assertThat(section.getFacts()).hasSize(5).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Status"); - assertThat(fact.value()).isEqualTo("UNKNOWN"); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Service URL"); - assertThat(fact.value()).isEqualTo(SERVICE_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Health URL"); - assertThat(fact.value()).isEqualTo(HEALTH_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Management URL"); - assertThat(fact.value()).isEqualTo(MANAGEMENT_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Source"); - assertThat(fact.value()).isNull(); - }); + assertThat(getColorFromMessage(message)).isEqualTo("green"); + } + + @Test + void test_messageSerializesToExpectedJsonStructure() throws Exception { + // Update instance to UP status + Instance upInstance = Instance.create(instance.getId()) + .register(instance.getRegistration()) + .withStatusInfo(StatusInfo.ofUp()); + + Message message = notifier.getStatusChangedMessage(upInstance, notifier.createEvaluationContext( + new InstanceStatusChangedEvent(upInstance.getId(), 1L, StatusInfo.ofUp()), upInstance)); + + JsonMapper mapper = JsonMapper.builder().build(); + String actual = mapper.writeValueAsString(message); + + // Build expected JSON structure + String expectedJson = """ + { + "type": "message", + "attachments": [{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "text": "Status Changed", + "size": "Large", + "weight": "Bolder", + "color": "Good" + }, + { + "type": "TextBlock", + "text": "Test App", + "size": "Medium", + "weight": "Bolder" + }, + { + "type": "TextBlock", + "text": "Test App with id TestAppId changed status from UNKNOWN to UP", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + {"title": "Status", "value": "UP"}, + {"title": "Service URL", "value": "https://service"}, + {"title": "Health URL", "value": "https://health"}, + {"title": "Management URL", "value": "https://management"} + ] + } + ] + } + }] + } + """; + + JSONAssert.assertEquals(expectedJson, actual, JSONCompareMode.NON_EXTENSIBLE); + } + + private String getActivitySubtitleFromMessage(Message message) { + return message.getAttachments().get(0).getContent().getBody().get(2).getText(); + } + + private String getColorFromMessage(Message message) { + return message.getAttachments().get(0).getContent().getBody().get(0).getColor(); + } + + private void assertMessage(Message message, String expectedTitle, String expectedSubTitle, String expectedColor) { + assertThat(message.getType()).isEqualTo("message"); + assertThat(message.getAttachments()).hasSize(1); + + var attachment = message.getAttachments().get(0); + assertThat(attachment.getContentType()).isEqualTo("application/vnd.microsoft.card.adaptive"); + assertThat(attachment.getContentUrl()).isNull(); + + var card = attachment.getContent(); + assertThat(card.getType()).isEqualTo("AdaptiveCard"); + assertThat(card.getVersion()).isEqualTo("1.2"); + assertThat(card.getSchema()).isEqualTo("http://adaptivecards.io/schemas/adaptive-card.json"); + + var body = card.getBody(); + assertThat(body).hasSize(4); + + // Title + assertThat(body.get(0).getType()).isEqualTo("TextBlock"); + assertThat(body.get(0).getText()).isEqualTo(expectedTitle); + assertThat(body.get(0).getSize()).isEqualTo("Large"); + assertThat(body.get(0).getWeight()).isEqualTo("Bolder"); + assertThat(body.get(0).getColor()).isEqualTo(expectedColor); + + // Service Name + assertThat(body.get(1).getType()).isEqualTo("TextBlock"); + assertThat(body.get(1).getText()).isEqualTo(instance.getRegistration().getName()); + assertThat(body.get(1).getSize()).isEqualTo("Medium"); + assertThat(body.get(1).getWeight()).isEqualTo("Bolder"); + + // Activity Subtitle + assertThat(body.get(2).getType()).isEqualTo("TextBlock"); + assertThat(body.get(2).getText()).isEqualTo(expectedSubTitle); + assertThat(body.get(2).getWrap()).isTrue(); + + // Facts + assertThat(body.get(3).getType()).isEqualTo("FactSet"); + assertThat(body.get(3).getFacts()).hasSize(4).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Status"); + assertThat(fact.value()).isEqualTo("UNKNOWN"); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Service URL"); + assertThat(fact.value()).isEqualTo(SERVICE_URL); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Health URL"); + assertThat(fact.value()).isEqualTo(HEALTH_URL); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Management URL"); + assertThat(fact.value()).isEqualTo(MANAGEMENT_URL); }); }