diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/EventSubscriptionResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/EventSubscriptionResourceIT.java index eb8a7e5e0844..e065dd093c42 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/EventSubscriptionResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/EventSubscriptionResourceIT.java @@ -7,11 +7,17 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.StreamSupport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; @@ -286,6 +292,77 @@ void test_eventSubscriptionWithResources_200_OK(TestNamespace ns) { assertNotNull(subscription.getFilteringRules()); } + @Test + void test_unreachableEventType_400(TestNamespace ns) { + CreateEventSubscription request = + eventTypeSubscription(ns, "sub_bad_event", "glossaryTerm", "suggestionCreated"); + + Exception exception = assertThrows(Exception.class, () -> createEntity(request)); + assertTrue( + exception.getMessage().contains("suggestionCreated"), + "the error must name the offending event type, was: " + exception.getMessage()); + } + + @Test + void test_reachableEventType_200_OK(TestNamespace ns) { + EventSubscription subscription = + createEntity( + eventTypeSubscription(ns, "sub_thread_event", "glossaryTerm", "threadCreated")); + assertNotNull(subscription.getFilteringRules()); + } + + @Test + void test_notificationResourcesServeSupportedEventTypes() throws Exception { + HttpRequest request = + HttpRequest.newBuilder() + .uri( + URI.create( + SdkClients.getServerUrl() + "/v1/events/subscriptions/notification/resources")) + .header("Authorization", "Bearer " + SdkClients.getAdminToken()) + .GET() + .build(); + HttpResponse response = + HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + + JsonNode glossaryTerm = + StreamSupport.stream( + new ObjectMapper().readTree(response.body()).get("data").spliterator(), false) + .filter(descriptor -> "glossaryTerm".equals(descriptor.get("name").asText())) + .findFirst() + .orElseThrow(); + List eventTypes = + StreamSupport.stream(glossaryTerm.get("supportedEventTypes").spliterator(), false) + .map(JsonNode::asText) + .toList(); + assertTrue(eventTypes.contains("threadCreated")); + assertTrue(eventTypes.contains("entityCreated")); + assertFalse(eventTypes.contains("suggestionCreated")); + } + + private CreateEventSubscription eventTypeSubscription( + TestNamespace ns, String name, String resource, String... eventTypes) { + return new CreateEventSubscription() + .withName(ns.prefix(name)) + .withDescription("Subscription filtering on event types") + .withAlertType(CreateEventSubscription.AlertType.NOTIFICATION) + .withResources(List.of(resource)) + .withEnabled(false) + .withInput( + new AlertFilteringInput() + .withFilters( + List.of( + new ArgumentsInput() + .withName("filterByEventType") + .withEffect(ArgumentsInput.Effect.INCLUDE) + .withArguments( + List.of( + new Argument() + .withName("eventTypeList") + .withInput(List.of(eventTypes))))))) + .withDestinations(getWebhookDestination(ns)); + } + @Test void test_slackDestination_200_OK(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/AlertUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/AlertUtil.java index acc971b054b5..3492ac8624db 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/AlertUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/AlertUtil.java @@ -47,6 +47,8 @@ import org.openmetadata.schema.entity.events.TestDestinationStatus; import org.openmetadata.schema.entity.feed.Thread; import org.openmetadata.schema.type.ChangeEvent; +import org.openmetadata.schema.type.EventType; +import org.openmetadata.schema.type.FilterResourceDescriptor; import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.CatalogExceptionMessage; @@ -60,6 +62,9 @@ public final class AlertUtil { private static final Cache COMPILED_CONDITIONS = Caffeine.newBuilder().maximumSize(1000).build(); + private static final String FILTER_BY_EVENT_TYPE = "filterByEventType"; + private static final String EVENT_TYPE_LIST_ARGUMENT = "eventTypeList"; + private AlertUtil() {} public static void validateExpression(String condition, Class clz) { @@ -290,10 +295,11 @@ public static FilteringRules validateAndBuildFilteringConditions( } if (alertType.equals(CreateEventSubscription.AlertType.NOTIFICATION)) { + FilterResourceDescriptor descriptor = + EventsSubscriptionRegistry.getEntityNotificationDescriptor(resource.get(0)); Map supportedFilters = - buildFilteringRulesMap( - EventsSubscriptionRegistry.getEntityNotificationDescriptor(resource.get(0)) - .getSupportedFilters()); + buildFilteringRulesMap(descriptor.getSupportedFilters()); + validateEventTypes(descriptor, input); // Input validation if (input != null) { return new FilteringRules() @@ -328,6 +334,47 @@ public static FilteringRules validateAndBuildFilteringConditions( .withActions(Collections.emptyList()); } + private static void validateEventTypes( + FilterResourceDescriptor descriptor, AlertFilteringInput input) { + if (input == null) { + return; + } + for (ArgumentsInput filter : listOrEmpty(input.getFilters())) { + // Excluding an event type the resource cannot produce is a no-op, not a silent no-op alert. + if (FILTER_BY_EVENT_TYPE.equals(filter.getName()) + && filter.getEffect() != ArgumentsInput.Effect.EXCLUDE) { + eventTypeValues(filter).forEach(value -> validateEventType(descriptor, value)); + } + } + } + + private static List eventTypeValues(ArgumentsInput filter) { + return listOrEmpty(filter.getArguments()).stream() + .filter(argument -> EVENT_TYPE_LIST_ARGUMENT.equals(argument.getName())) + .map(Argument::getInput) + .flatMap(input -> listOrEmpty(input).stream()) + .toList(); + } + + private static void validateEventType(FilterResourceDescriptor descriptor, String value) { + EventType eventType = parse(value); + if (eventType == null + || !listOrEmpty(descriptor.getSupportedEventTypes()).contains(eventType)) { + throw new BadRequestException( + CatalogExceptionMessage.eventTypeNotSupportedByResource(descriptor.getName(), value)); + } + } + + private static EventType parse(String value) { + EventType eventType = null; + try { + eventType = EventType.fromValue(value); + } catch (IllegalArgumentException e) { + LOG.debug("Unknown event type '{}' in an alert filter", value); + } + return eventType; + } + private static Map buildFilteringRulesMap( List filteringRules) { return filteringRules.stream() diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/ResourceEventTypes.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/ResourceEventTypes.java new file mode 100644 index 000000000000..32861cf4bd25 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/ResourceEventTypes.java @@ -0,0 +1,112 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.events.subscription; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.openmetadata.schema.type.EventType; +import org.openmetadata.service.Entity; + +/** + * Populates {@code FilterResourceDescriptor.supportedEventTypes}: the event types an alert on a + * given notification resource can actually receive, derived from what the emitters produce rather + * than from the {@link EventType} enum. Consumers read the descriptor, never this class. + * + *

An alert matches an event when {@code AlertUtil.shouldTriggerAlert} lets it through: the "all" + * resource takes everything; a THREAD event matches {@code thread.type} for the thread-type + * resources and {@code thread.entityRef.type} for every other resource; anything else matches on + * {@code entityType}. Values no emitter produces, or that never reach {@code change_event}, are + * deliberately absent — see {@code UNREACHABLE}. + */ +public final class ResourceEventTypes { + public static final String ALL_RESOURCE = "all"; + private static final String CONVERSATION = "conversation"; + + /** Emitted for every entity by the generic CRUD paths in EntityRepository. */ + private static final List ENTITY_EVENTS = + List.of( + EventType.ENTITY_CREATED, + EventType.ENTITY_UPDATED, + EventType.ENTITY_SOFT_DELETED, + EventType.ENTITY_DELETED, + EventType.ENTITY_RESTORED); + + /** Emitted with entityType=THREAD; reaches an entity resource via the thread's parent entity. */ + private static final List THREAD_EVENTS = + List.of( + EventType.THREAD_CREATED, + EventType.THREAD_UPDATED, + EventType.POST_CREATED, + EventType.POST_UPDATED); + + /** Only usage reporting emits entityFieldsChanged, and only for these entities. */ + private static final Set USAGE_RESOURCES = + Set.of(Entity.TABLE, Entity.DASHBOARD, Entity.PIPELINE, Entity.CHART, Entity.MLMODEL); + + /** Reachable only through "all": their entity types are not notification resources. */ + private static final List ALL_ONLY_EVENTS = + List.of( + EventType.LOGICAL_TEST_CASE_ADDED, + EventType.ENTITY_LINEAGE_ADDED, + EventType.ENTITY_LINEAGE_UPDATED, + EventType.ENTITY_LINEAGE_DELETED); + + /** Legacy thread-tasks still emit these; retired with the Recognizer migration (#30559). */ + private static final List LEGACY_TASK_EVENTS = + List.of(EventType.TASK_RESOLVED, EventType.TASK_CLOSED); + + /** + * Values no resource can advertise: {@code ENTITY_NO_CHANGE} is a sentinel that ChangeEventHandler + * never inserts, {@code USER_LOGIN}/{@code USER_LOGOUT} are written to the audit log only, and the + * rest lost their emitter in the task redesign (#29039). + */ + public static final Set UNREACHABLE = + Set.of( + EventType.ENTITY_NO_CHANGE, + EventType.TASK_CREATED, + EventType.TASK_UPDATED, + EventType.SUGGESTION_CREATED, + EventType.SUGGESTION_UPDATED, + EventType.SUGGESTION_ACCEPTED, + EventType.SUGGESTION_REJECTED, + EventType.SUGGESTION_DELETED, + EventType.USER_LOGIN, + EventType.USER_LOGOUT); + + private ResourceEventTypes() {} + + public static List forResource(String resource) { + Set eventTypes = new LinkedHashSet<>(); + if (ALL_RESOURCE.equalsIgnoreCase(resource)) { + eventTypes.addAll(ENTITY_EVENTS); + eventTypes.add(EventType.ENTITY_FIELDS_CHANGED); + eventTypes.addAll(THREAD_EVENTS); + eventTypes.addAll(LEGACY_TASK_EVENTS); + eventTypes.addAll(ALL_ONLY_EVENTS); + } else if (CONVERSATION.equalsIgnoreCase(resource)) { + eventTypes.addAll(THREAD_EVENTS); + } else { + eventTypes.addAll(ENTITY_EVENTS); + eventTypes.addAll(THREAD_EVENTS); + if (USAGE_RESOURCES.contains(resource)) { + eventTypes.add(EventType.ENTITY_FIELDS_CHANGED); + } + if (Entity.TASK.equalsIgnoreCase(resource)) { + eventTypes.addAll(LEGACY_TASK_EVENTS); + } + } + return List.copyOf(eventTypes); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java index ed238528e31b..4d2f1faaaf98 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java @@ -153,6 +153,12 @@ public static String resourceTypeNotFound(String resourceType) { return String.format("Resource type %s not found", resourceType); } + public static String eventTypeNotSupportedByResource(String resourceType, String eventType) { + return String.format( + "Resource %s does not produce event type %s, so an alert filtering on it can never fire", + resourceType, eventType); + } + public static String entityTypeNotSupported(String entityType) { return String.format("Entity type %s not supported", entityType); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java index bb4a0b8c2eae..d219df7f5f6f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java @@ -84,6 +84,7 @@ import org.openmetadata.service.events.errors.EventPublisherException; import org.openmetadata.service.events.scheduled.EventSubscriptionScheduler; import org.openmetadata.service.events.subscription.EventsSubscriptionRegistry; +import org.openmetadata.service.events.subscription.ResourceEventTypes; import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.EventSubscriptionRepository; @@ -1586,7 +1587,9 @@ public static List getNotificationsFilterDescriptors() return new FilterResourceDescriptor() .withName(descriptor.getName()) .withSupportedFilters(rules) - .withContainerEntities(descriptor.getContainerEntities()); + .withContainerEntities(descriptor.getContainerEntities()) + .withSupportedEventTypes( + ResourceEventTypes.forResource(descriptor.getName())); }) .toList(); setAllResourceContainerEntities(descriptors); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/events/subscription/EventTypeValidationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/events/subscription/EventTypeValidationTest.java new file mode 100644 index 000000000000..8e10fd79fcd9 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/events/subscription/EventTypeValidationTest.java @@ -0,0 +1,103 @@ +package org.openmetadata.service.events.subscription; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.ws.rs.BadRequestException; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.api.events.AlertFilteringInput; +import org.openmetadata.schema.api.events.CreateEventSubscription; +import org.openmetadata.schema.entity.events.Argument; +import org.openmetadata.schema.entity.events.ArgumentsInput; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.events.subscription.EventSubscriptionResource; + +class EventTypeValidationTest { + + @BeforeAll + static void loadDescriptors() throws IOException { + EventsSubscriptionRegistry.initialize( + EventSubscriptionResource.getNotificationsFilterDescriptors(), + EventSubscriptionResource.getObservabilityFilterDescriptors()); + } + + @Test + void unreachableEventTypeIsRejectedNamingTheValue() { + BadRequestException exception = + assertThrows( + BadRequestException.class, () -> validate(Entity.GLOSSARY_TERM, "suggestionCreated")); + assertTrue(exception.getMessage().contains("suggestionCreated")); + assertTrue(exception.getMessage().contains(Entity.GLOSSARY_TERM)); + } + + @Test + void eventTypesTheResourceProducesAreAccepted() { + assertDoesNotThrow(() -> validate(Entity.GLOSSARY_TERM, "entityCreated")); + assertDoesNotThrow(() -> validate(Entity.GLOSSARY_TERM, "threadCreated")); + assertDoesNotThrow(() -> validate(Entity.TABLE, "entityCreated", "postCreated")); + assertDoesNotThrow(() -> validate("all", "entityLineageAdded")); + } + + @Test + void fieldsChangedIsAcceptedOnlyWhereUsageEmitsIt() { + assertDoesNotThrow(() -> validate(Entity.TABLE, "entityFieldsChanged")); + assertThrows( + BadRequestException.class, () -> validate(Entity.GLOSSARY_TERM, "entityFieldsChanged")); + } + + @Test + void unknownEventTypeIsRejected() { + assertThrows(BadRequestException.class, () -> validate(Entity.TABLE, "notAnEventType")); + } + + @Test + void excludingAnUnreachableEventTypeIsAccepted() { + assertDoesNotThrow( + () -> + AlertUtil.validateAndBuildFilteringConditions( + List.of(Entity.GLOSSARY_TERM), + CreateEventSubscription.AlertType.NOTIFICATION, + eventTypeFilterWithEffect(ArgumentsInput.Effect.EXCLUDE, "suggestionCreated"))); + } + + @Test + void anAbsentEffectIsValidatedLikeInclude() { + assertThrows( + BadRequestException.class, + () -> + AlertUtil.validateAndBuildFilteringConditions( + List.of(Entity.GLOSSARY_TERM), + CreateEventSubscription.AlertType.NOTIFICATION, + eventTypeFilterWithEffect(null, "suggestionCreated"))); + } + + private static void validate(String resource, String... eventTypes) { + AlertUtil.validateAndBuildFilteringConditions( + List.of(resource), + CreateEventSubscription.AlertType.NOTIFICATION, + eventTypeFilter(eventTypes)); + } + + private static AlertFilteringInput eventTypeFilter(String... eventTypes) { + return eventTypeFilterWithEffect(ArgumentsInput.Effect.INCLUDE, eventTypes); + } + + private static AlertFilteringInput eventTypeFilterWithEffect( + ArgumentsInput.Effect effect, String... eventTypes) { + return new AlertFilteringInput() + .withFilters( + List.of( + new ArgumentsInput() + .withName("filterByEventType") + .withEffect(effect) + .withArguments( + List.of( + new Argument() + .withName("eventTypeList") + .withInput(List.of(eventTypes)))))); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/events/subscription/ResourceEventTypesTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/events/subscription/ResourceEventTypesTest.java new file mode 100644 index 000000000000..cb32a71fdbab --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/events/subscription/ResourceEventTypesTest.java @@ -0,0 +1,119 @@ +package org.openmetadata.service.events.subscription; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.type.EventType; +import org.openmetadata.schema.type.FilterResourceDescriptor; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.events.subscription.EventSubscriptionResource; + +class ResourceEventTypesTest { + + private static List notificationResources() throws IOException { + return EventSubscriptionResource.getNotificationsFilterDescriptors().stream() + .map(FilterResourceDescriptor::getName) + .toList(); + } + + @Test + void everyNotificationResourceDeclaresEventTypes() throws IOException { + for (String resource : notificationResources()) { + assertFalse( + ResourceEventTypes.forResource(resource).isEmpty(), + "Resource " + resource + " declares no event type"); + } + } + + @Test + void entityResourceDeclaresEntityAndThreadEvents() { + List glossaryTerm = ResourceEventTypes.forResource(Entity.GLOSSARY_TERM); + assertTrue(glossaryTerm.contains(EventType.ENTITY_CREATED)); + assertTrue(glossaryTerm.contains(EventType.ENTITY_DELETED)); + // #28122 routes a conversation to the alert of the entity the thread is about + assertTrue(glossaryTerm.contains(EventType.THREAD_CREATED)); + assertTrue(glossaryTerm.contains(EventType.POST_CREATED)); + } + + @Test + void onlyUsageReportingEntitiesDeclareFieldsChanged() { + for (String resource : List.of(Entity.TABLE, Entity.DASHBOARD, Entity.PIPELINE, Entity.CHART)) { + assertTrue( + ResourceEventTypes.forResource(resource).contains(EventType.ENTITY_FIELDS_CHANGED), + resource); + } + assertFalse( + ResourceEventTypes.forResource(Entity.GLOSSARY_TERM) + .contains(EventType.ENTITY_FIELDS_CHANGED)); + assertFalse( + ResourceEventTypes.forResource(Entity.TOPIC).contains(EventType.ENTITY_FIELDS_CHANGED)); + } + + @Test + void conversationDeclaresThreadEventsOnly() { + List conversation = ResourceEventTypes.forResource("conversation"); + assertEquals( + List.of( + EventType.THREAD_CREATED, + EventType.THREAD_UPDATED, + EventType.POST_CREATED, + EventType.POST_UPDATED), + conversation); + } + + @Test + void taskDeclaresEntityEventsAndTheLegacyThreadTaskEvents() { + List task = ResourceEventTypes.forResource(Entity.TASK); + assertTrue(task.contains(EventType.ENTITY_UPDATED)); + assertTrue(task.contains(EventType.TASK_RESOLVED)); + assertTrue(task.contains(EventType.TASK_CLOSED)); + } + + @Test + void allResourceIsASupersetOfEveryOtherResource() throws IOException { + Set all = + Set.copyOf(ResourceEventTypes.forResource(ResourceEventTypes.ALL_RESOURCE)); + for (String resource : notificationResources()) { + assertTrue( + all.containsAll(ResourceEventTypes.forResource(resource)), + "Resource " + resource + " declares an event type that 'all' does not"); + } + // reachable only through "all": their entity types are not notification resources + assertTrue(all.contains(EventType.LOGICAL_TEST_CASE_ADDED)); + assertTrue(all.contains(EventType.ENTITY_LINEAGE_ADDED)); + } + + @Test + void noResourceDeclaresAnUnreachableEventType() throws IOException { + for (String resource : notificationResources()) { + for (EventType eventType : ResourceEventTypes.forResource(resource)) { + assertFalse( + ResourceEventTypes.UNREACHABLE.contains(eventType), + resource + " declares " + eventType.value() + ", which nothing emits"); + } + } + } + + // Adding an EventType without deciding which resources can deliver it must fail here. + @Test + void declaredAndUnreachableTogetherCoverTheWholeEnum() throws IOException { + Set declared = + notificationResources().stream() + .flatMap(resource -> ResourceEventTypes.forResource(resource).stream()) + .collect(Collectors.toSet()); + Set covered = EnumSet.copyOf(declared); + covered.addAll(ResourceEventTypes.UNREACHABLE); + assertEquals( + EnumSet.copyOf(Arrays.asList(EventType.values())), + covered, + "every EventType must be declared by some resource or listed as unreachable"); + } +} diff --git a/openmetadata-spec/src/main/resources/json/schema/events/filterResourceDescriptor.json b/openmetadata-spec/src/main/resources/json/schema/events/filterResourceDescriptor.json index 7e7c1c5e05ff..dbadb94229e0 100644 --- a/openmetadata-spec/src/main/resources/json/schema/events/filterResourceDescriptor.json +++ b/openmetadata-spec/src/main/resources/json/schema/events/filterResourceDescriptor.json @@ -30,6 +30,13 @@ "items": { "type": "string" } + }, + "supportedEventTypes": { + "description": "Event types an alert on this resource can actually receive. An event type outside this list is rejected when the subscription is saved, so the alert builder must offer only these. Derived server-side from what the event emitters produce, not declared in EventSubResourceDescriptor.json.", + "type": "array", + "items": { + "$ref": "../type/changeEventType.json" + } } }, "additionalProperties": false diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx index 621a04c180df..108b7f1fe262 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails.tsx @@ -70,19 +70,23 @@ function AlertConfigDetails({ const [templateResourcePermission, setTemplateResourcePermission] = useState(DEFAULT_ENTITY_PERMISSION); - const { supportedFilters, supportedTriggers, containerEntities } = - useMemo(() => { - const resource = filterResources.find( - (resource) => - resource.name === alertDetails.filteringRules?.resources[0] - ); - - return { - supportedFilters: resource?.supportedFilters, - supportedTriggers: resource?.supportedActions, - containerEntities: resource?.containerEntities, - }; - }, [filterResources, alertDetails]); + const { + supportedFilters, + supportedTriggers, + containerEntities, + supportedEventTypes, + } = useMemo(() => { + const resource = filterResources.find( + (resource) => resource.name === alertDetails.filteringRules?.resources[0] + ); + + return { + supportedFilters: resource?.supportedFilters, + supportedTriggers: resource?.supportedActions, + containerEntities: resource?.containerEntities, + supportedEventTypes: resource?.supportedEventTypes, + }; + }, [filterResources, alertDetails]); const fetchFunctions = useCallback(async () => { try { @@ -173,6 +177,7 @@ function AlertConfigDetails({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts index c59be0e1acbe..c11be3dd6d92 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.interface.ts @@ -12,9 +12,11 @@ */ import { EventFilterRule } from '../../../generated/events/eventSubscription'; +import { EventType } from '../../../generated/type/changeEvent'; export interface ObservabilityFormFiltersItemProps { supportedFilters?: EventFilterRule[]; containerEntities?: string[]; + supportedEventTypes?: EventType[]; isViewMode?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx index b3455e2adf5d..a88c95942d8d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem.tsx @@ -31,6 +31,7 @@ import { ObservabilityFormFiltersItemProps } from './ObservabilityFormFiltersIte function ObservabilityFormFiltersItem({ supportedFilters, containerEntities, + supportedEventTypes, isViewMode = false, }: Readonly) { const { t } = useTranslation(); @@ -111,7 +112,8 @@ function ObservabilityFormFiltersItem({ name, selectedTrigger, supportedFilters, - containerEntities + containerEntities, + supportedEventTypes )} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/events/filterResourceDescriptor.ts b/openmetadata-ui/src/main/resources/ui/src/generated/events/filterResourceDescriptor.ts index d020f43fa233..0152e4bec237 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/events/filterResourceDescriptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/events/filterResourceDescriptor.ts @@ -29,6 +29,13 @@ export interface FilterResourceDescriptor { * List of actions supported filters by the resource. */ supportedActions?: EventFilterRule[]; + /** + * Event types an alert on this resource can actually receive. An event type outside this + * list is rejected when the subscription is saved, so the alert builder must offer only + * these. Derived server-side from what the event emitters produce, not declared in + * EventSubResourceDescriptor.json. + */ + supportedEventTypes?: EventType[]; /** * List of operations supported filters by the resource. */ @@ -90,3 +97,35 @@ export enum PrefixCondition { And = "AND", Or = "OR", } + +/** + * Type of event. + */ +export enum EventType { + EntityCreated = "entityCreated", + EntityDeleted = "entityDeleted", + EntityFieldsChanged = "entityFieldsChanged", + EntityLineageAdded = "entityLineageAdded", + EntityLineageDeleted = "entityLineageDeleted", + EntityLineageUpdated = "entityLineageUpdated", + EntityNoChange = "entityNoChange", + EntityRestored = "entityRestored", + EntitySoftDeleted = "entitySoftDeleted", + EntityUpdated = "entityUpdated", + LogicalTestCaseAdded = "logicalTestCaseAdded", + PostCreated = "postCreated", + PostUpdated = "postUpdated", + SuggestionAccepted = "suggestionAccepted", + SuggestionCreated = "suggestionCreated", + SuggestionDeleted = "suggestionDeleted", + SuggestionRejected = "suggestionRejected", + SuggestionUpdated = "suggestionUpdated", + TaskClosed = "taskClosed", + TaskCreated = "taskCreated", + TaskResolved = "taskResolved", + TaskUpdated = "taskUpdated", + ThreadCreated = "threadCreated", + ThreadUpdated = "threadUpdated", + UserLogin = "userLogin", + UserLogout = "userLogout", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx index 97c32fac9b50..3d2f0a847acc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx @@ -238,6 +238,13 @@ const AddNotificationPage = () => { [entityFunctions, selectedTrigger] ); + const supportedEventTypes = useMemo( + () => + entityFunctions.find((resource) => resource.name === selectedTrigger) + ?.supportedEventTypes, + [entityFunctions, selectedTrigger] + ); + const shouldShowFiltersSection = useMemo( () => (selectedTrigger ? !isEmpty(supportedFilters) : true), [selectedTrigger, supportedFilters] @@ -381,6 +388,7 @@ const AddNotificationPage = () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.test.tsx index 5c02de98971c..58f0a8011f03 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.test.tsx @@ -514,6 +514,51 @@ describe('getFieldByArgumentType tests', () => { expect(selectDiv).toBeInTheDocument(); }); + it('should offer only the event types the resource declares', async () => { + const field = getFieldByArgumentType( + 0, + 'eventTypeList', + 0, + 'glossaryTerm', + [], + [EventType.EntityCreated, EventType.ThreadCreated] + ); + + render(field); + const select = screen.getByTestId('event-type-select'); + fireEvent.mouseDown( + select.querySelector('.ant-select-selector') as HTMLElement + ); + + fireEvent.change(select.querySelector('input') as HTMLElement, { + target: { value: 'Thread' }, + }); + + expect(await screen.findByTitle('Thread Created')).toBeInTheDocument(); + + fireEvent.change(select.querySelector('input') as HTMLElement, { + target: { value: 'Suggestion' }, + }); + + expect(screen.queryByTitle('Suggestion Created')).not.toBeInTheDocument(); + }); + + it('should fall back to every event type when the resource declares none', async () => { + const field = getFieldByArgumentType(0, 'eventTypeList', 0, 'glossaryTerm'); + + render(field); + const select = screen.getByTestId('event-type-select'); + fireEvent.mouseDown( + select.querySelector('.ant-select-selector') as HTMLElement + ); + + fireEvent.change(select.querySelector('input') as HTMLElement, { + target: { value: 'Suggestion' }, + }); + + expect(await screen.findByTitle('Suggestion Created')).toBeInTheDocument(); + }); + it('should return correct fields for argumentType entityIdList', async () => { const { AsyncSelect: MockedAsyncSelect } = jest.requireMock( '../../components/common/AsyncSelect/AsyncSelect' diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx index 1ca8589af691..c18d72011a68 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx @@ -36,7 +36,7 @@ import { } from 'antd'; import Form from 'antd/lib/form'; import { AxiosError } from 'axios'; -import { uniqBy } from 'lodash'; +import { isEmpty, uniqBy } from 'lodash'; import { Fragment } from 'react'; import { ReactComponent as AlertIcon } from '../../assets/svg/alert.svg'; import { ReactComponent as AllActivityIcon } from '../../assets/svg/all-activity.svg'; @@ -89,6 +89,7 @@ import { getAlertEventsFilterLabels, getMessageFromArgumentName, getSelectOptionsFromEnum, + getSelectOptionsFromValues, } from './AlertsUtilPure'; export const getAlertsActionTypeIcon = (type?: SubscriptionType) => { @@ -879,7 +880,8 @@ export const getFieldByArgumentType = ( argument: string, index: number, selectedTrigger: string, - containerEntities: string[] = [] + containerEntities: string[] = [], + supportedEventTypes: EventType[] = [] ) => { let field: JSX.Element; @@ -1054,7 +1056,11 @@ export const getFieldByArgumentType = ( className="w-full" data-testid="event-type-select" mode="multiple" - options={getSelectOptionsFromEnum(EventType)} + options={ + isEmpty(supportedEventTypes) + ? getSelectOptionsFromEnum(EventType) + : getSelectOptionsFromValues(supportedEventTypes) + } placeholder={t('label.search-by-type', { type: t('label.event-type-lowercase'), })} @@ -1205,7 +1211,8 @@ export const getConditionalField = ( name: number, selectedTrigger: string, supportedActions?: EventFilterRule[], - containerEntities?: string[] + containerEntities?: string[], + supportedEventTypes?: EventType[] ) => { const selectedAction = supportedActions?.find( (action) => action.name === condition @@ -1225,7 +1232,8 @@ export const getConditionalField = ( argument, index, selectedTrigger, - containerEntities + containerEntities, + supportedEventTypes ); })} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtilPure.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtilPure.ts index 3d4ae2c9f2e4..ab7361b31699 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtilPure.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtilPure.ts @@ -123,6 +123,13 @@ export const getSelectOptionsFromEnum = (type: { [s: number]: string }) => value, })); +// Same shape as getSelectOptionsFromEnum, for values a resource declares at runtime +export const getSelectOptionsFromValues = (values: string[]) => + values.map((value) => ({ + label: startCase(value), + value, + })); + // Disabling all options except Email for SubscriptionCategory Users, Followers and Admins // Since there is no provision for webhook subscription for users export const getSubscriptionTypeOptions = (destinationType: string) => {