diff --git a/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql index 5aab11ec43d..486770c4aec 100644 --- a/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql @@ -28,6 +28,23 @@ CREATE INDEX idx_metric_custom_unit ON metric_entity(customUnitOfMeasurement); -- Fetch updated searchSettings DELETE FROM openmetadata_settings WHERE configType = 'searchSettings'; +-- Create notification_template_entity table following OpenMetadata patterns +CREATE TABLE IF NOT EXISTS notification_template_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.id'))) STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.name'))) VIRTUAL NOT NULL, + fqnHash VARCHAR(768) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + json JSON NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedAt'))) VIRTUAL NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedBy'))) VIRTUAL NOT NULL, + deleted TINYINT(1) GENERATED ALWAYS AS (json_extract(json, '$.deleted')) VIRTUAL, + provider VARCHAR(32) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.provider'))) VIRTUAL, + + PRIMARY KEY (id), + UNIQUE KEY fqnHash (fqnHash), + INDEX idx_notification_template_name (name), + INDEX idx_notification_template_provider (provider) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + -- Increase Flowable ACTIVITY_ID_ column size to support longer user-defined workflow node names ALTER TABLE ACT_RU_EVENT_SUBSCR MODIFY ACTIVITY_ID_ varchar(255); diff --git a/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql index 688e16cbe60..e0f14b451b6 100644 --- a/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql @@ -30,6 +30,24 @@ CREATE INDEX idx_metric_custom_unit ON metric_entity(customUnitOfMeasurement); -- Fetch updated searchSettings DELETE FROM openmetadata_settings WHERE configType = 'searchSettings'; +-- Create notification_template_entity table following OpenMetadata patterns +CREATE TABLE IF NOT EXISTS notification_template_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + fqnHash VARCHAR(768) NOT NULL, + json JSONB NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + provider VARCHAR(32) GENERATED ALWAYS AS (json ->> 'provider') STORED, + + PRIMARY KEY (id), + UNIQUE (fqnHash) +); + +CREATE INDEX IF NOT EXISTS idx_notification_template_name ON notification_template_entity(name); +CREATE INDEX IF NOT EXISTS idx_notification_template_provider ON notification_template_entity(provider); + -- Increase Flowable ACTIVITY_ID_ column size to support longer user-defined workflow node names ALTER TABLE ACT_RU_EVENT_SUBSCR ALTER COLUMN ACTIVITY_ID_ TYPE varchar(255); diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index b77dd567418..4350a45af4c 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -917,6 +917,11 @@ client-java ${kubernetes-client.version} + + + com.github.jknack + handlebars + com.slack.api bolt-servlet diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index 6ba2c89ff52..799917f2918 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -236,6 +236,7 @@ public final class Entity { // // Other entities public static final String EVENT_SUBSCRIPTION = "eventsubscription"; + public static final String NOTIFICATION_TEMPLATE = "notificationTemplate"; public static final String THREAD = "THREAD"; public static final String SUGGESTION = "SUGGESTION"; public static final String WORKFLOW = "workflow"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/cache/CachedCollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/cache/CachedCollectionDAO.java index 477e12f0eb4..e6890d735d3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/cache/CachedCollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/cache/CachedCollectionDAO.java @@ -284,6 +284,11 @@ public class CachedCollectionDAO implements CollectionDAO { return delegate.eventSubscriptionDAO(); } + @Override + public NotificationTemplateDAO notificationTemplateDAO() { + return delegate.notificationTemplateDAO(); + } + @Override public IngestionPipelineDAO ingestionPipelineDAO() { return delegate.ingestionPipelineDAO(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index ff0fca5cd6b..cdbb9013801 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -116,6 +116,7 @@ import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.entity.events.EventSubscription; import org.openmetadata.schema.entity.events.FailedEvent; import org.openmetadata.schema.entity.events.FailedEventResponse; +import org.openmetadata.schema.entity.events.NotificationTemplate; import org.openmetadata.schema.entity.policies.Policy; import org.openmetadata.schema.entity.services.ApiService; import org.openmetadata.schema.entity.services.DashboardService; @@ -297,6 +298,9 @@ public interface CollectionDAO { @CreateSqlObject EventSubscriptionDAO eventSubscriptionDAO(); + @CreateSqlObject + NotificationTemplateDAO notificationTemplateDAO(); + @CreateSqlObject PolicyDAO policyDAO(); @@ -2971,6 +2975,23 @@ public interface CollectionDAO { @Bind("eventSubscriptionId") String eventSubscriptionId); } + interface NotificationTemplateDAO extends EntityDAO { + @Override + default String getTableName() { + return "notification_template_entity"; + } + + @Override + default Class getEntityClass() { + return NotificationTemplate.class; + } + + @Override + default String getNameHashColumn() { + return "fqnHash"; + } + } + interface ChartDAO extends EntityDAO { @Override default String getTableName() { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/NotificationTemplateRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/NotificationTemplateRepository.java new file mode 100644 index 00000000000..3c8c4088361 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/NotificationTemplateRepository.java @@ -0,0 +1,102 @@ +/* + * Copyright 2024 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.jdbi3; + +import com.github.jknack.handlebars.Handlebars; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.entity.events.NotificationTemplate; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.events.NotificationTemplateResource; +import org.openmetadata.service.util.EntityUtil.Fields; + +@Slf4j +public class NotificationTemplateRepository extends EntityRepository { + + static final String PATCH_FIELDS = "templateBody,description,displayName"; + static final String UPDATE_FIELDS = "templateBody,description,displayName"; + + public NotificationTemplateRepository() { + super( + NotificationTemplateResource.COLLECTION_PATH, + Entity.NOTIFICATION_TEMPLATE, + NotificationTemplate.class, + Entity.getCollectionDAO().notificationTemplateDAO(), + PATCH_FIELDS, + UPDATE_FIELDS); + } + + @Override + public void setFields(NotificationTemplate entity, Fields fields) { + /* Nothing to do */ + } + + @Override + public void clearFields(NotificationTemplate entity, Fields fields) { + /* Nothing to do */ + } + + @Override + public void prepare(NotificationTemplate entity, boolean update) { + // Validate template syntax + if (entity.getTemplateBody() != null) { + validateTemplateBody(entity.getTemplateBody()); + } + } + + @Override + public void storeEntity(NotificationTemplate entity, boolean update) { + // Store the entity using the standard mechanism + store(entity, update); + } + + @Override + public void storeRelationships(NotificationTemplate entity) { + // No relationships to store beyond what is stored in the super class + } + + @Override + public EntityRepository.EntityUpdater getUpdater( + NotificationTemplate original, + NotificationTemplate updated, + Operation operation, + ChangeSource changeSource) { + return new NotificationTemplateUpdater(original, updated, operation); + } + + private void validateTemplateBody(String templateBody) { + try { + // Use Handlebars to validate the template syntax + Handlebars handlebars = new Handlebars(); + handlebars.compileInline(templateBody); + } catch (Exception e) { + // Provide clean user message (detailed error available in server logs) + throw new IllegalArgumentException("Invalid template syntax"); + } + } + + public class NotificationTemplateUpdater extends EntityUpdater { + public NotificationTemplateUpdater( + NotificationTemplate original, NotificationTemplate updated, Operation operation) { + super(original, updated, operation); + } + + @Override + protected void entitySpecificUpdate(boolean consolidatingChanges) { + // Only record changes for fields specific to NotificationTemplate + // Description and displayName are handled by the parent class + recordChange("templateBody", original.getTemplateBody(), updated.getTemplateBody()); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/NotificationTemplateMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/NotificationTemplateMapper.java new file mode 100644 index 00000000000..6eda22ea30e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/NotificationTemplateMapper.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.resources.events; + +import org.openmetadata.schema.api.events.CreateNotificationTemplate; +import org.openmetadata.schema.entity.events.NotificationTemplate; +import org.openmetadata.service.mapper.EntityMapper; + +public class NotificationTemplateMapper + implements EntityMapper { + + @Override + public NotificationTemplate createToEntity(CreateNotificationTemplate create, String user) { + return copy(new NotificationTemplate(), create, user) + .withTemplateBody(create.getTemplateBody()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/NotificationTemplateResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/NotificationTemplateResource.java new file mode 100644 index 00000000000..b36fd141212 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/NotificationTemplateResource.java @@ -0,0 +1,528 @@ +/* + * 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.resources.events; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.api.events.CreateNotificationTemplate; +import org.openmetadata.schema.entity.events.NotificationTemplate; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.ProviderType; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.jdbi3.NotificationTemplateRepository; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Slf4j +@Path("/v1/notificationTemplates") +@Tag( + name = "Notification Templates", + description = "Notification templates for customizing event notifications") +@Collection(name = "notificationTemplates") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class NotificationTemplateResource + extends EntityResource { + + public static final String COLLECTION_PATH = "/v1/notificationTemplates"; + public static final String FIELDS = ""; + + // Mapper for converting DTOs to entities + private final NotificationTemplateMapper mapper = new NotificationTemplateMapper(); + + public NotificationTemplateResource(Authorizer authorizer, Limits limits) { + super(Entity.NOTIFICATION_TEMPLATE, authorizer, limits); + } + + @Override + protected List getEntitySpecificOperations() { + addViewOperation("templateBody", MetadataOperation.VIEW_BASIC); + return null; + } + + public static class NotificationTemplateList extends ResultList { + /* Required for serde */ + } + + @GET + @Operation( + operationId = "listNotificationTemplates", + summary = "List notification templates", + description = "Get a list of notification templates", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of notification templates", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplateList.class))) + }) + public ResultList list( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @Parameter( + description = "Filter templates by provider type (SYSTEM or USER)", + schema = @Schema(implementation = ProviderType.class)) + @QueryParam("provider") + ProviderType provider, + @Parameter(description = "Limit the number of results. (1 to 1000000, default = 10)") + @DefaultValue("10") + @QueryParam("limit") + @Min(0) + @Max(1000000) + int limitParam, + @Parameter( + description = "Returns list of entities before this cursor", + schema = @Schema(type = "string")) + @QueryParam("before") + String before, + @Parameter( + description = "Returns list of entities after this cursor", + schema = @Schema(type = "string")) + @QueryParam("after") + String after, + @Parameter( + description = "Include all, deleted, or non-deleted entities.", + schema = @Schema(implementation = Include.class)) + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + ListFilter filter = new ListFilter(include); + if (provider != null) { + filter.addQueryParam("provider", provider.value()); + } + return super.listInternal( + uriInfo, securityContext, fieldsParam, filter, limitParam, before, after); + } + + @GET + @Path("/{id}") + @Operation( + operationId = "getNotificationTemplateById", + summary = "Get a notification template by Id", + description = "Get a notification template by `Id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse( + responseCode = "404", + description = "Notification template for instance {id} is not found") + }) + public NotificationTemplate get( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @Parameter( + description = "Include all, deleted, or non-deleted entities.", + schema = @Schema(implementation = Include.class)) + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getInternal(uriInfo, securityContext, id, fieldsParam, include); + } + + @GET + @Path("/name/{fqn}") + @Operation( + operationId = "getNotificationTemplateByFQN", + summary = "Get a notification template by fully qualified name", + description = "Get a notification template by `fullyQualifiedName`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse( + responseCode = "404", + description = "Notification template for instance {fqn} is not found") + }) + public NotificationTemplate getByName( + @Context UriInfo uriInfo, + @Parameter( + description = "Fully qualified name of the notification template", + schema = @Schema(type = "string")) + @PathParam("fqn") + String fqn, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @Parameter( + description = "Include all, deleted, or non-deleted entities.", + schema = @Schema(implementation = Include.class)) + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include); + } + + @GET + @Path("/{id}/versions") + @Operation( + operationId = "listAllNotificationTemplateVersions", + summary = "List notification template versions", + description = "Get a list of all the versions of a notification template identified by `Id`", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of notification template versions", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = EntityHistory.class))) + }) + public EntityHistory listVersions( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return super.listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + operationId = "getSpecificNotificationTemplateVersion", + summary = "Get a version of the notification template", + description = "Get a version of the notification template by given `Id`", + responses = { + @ApiResponse( + responseCode = "200", + description = "notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse( + responseCode = "404", + description = + "Notification template for instance {id} and version {version} is not found") + }) + public NotificationTemplate getVersion( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter( + description = "Notification template version number in the form `major`.`minor`", + schema = @Schema(type = "string", example = "0.1 or 1.1")) + @PathParam("version") + String version) { + return super.getVersionInternal(securityContext, id, version); + } + + @POST + @Operation( + operationId = "createNotificationTemplate", + summary = "Create a notification template", + description = "Create a new notification template", + responses = { + @ApiResponse( + responseCode = "200", + description = "The notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse(responseCode = "400", description = "Bad request") + }) + public Response create( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateNotificationTemplate create) { + NotificationTemplate template = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + return create(uriInfo, securityContext, template); + } + + @PATCH + @Path("/{id}") + @Operation( + operationId = "patchNotificationTemplate", + summary = "Update a notification template", + description = "Update an existing notification template using JsonPatch.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully updated the notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "404", description = "Notification template not found") + }) + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + public Response patch( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = { + @ExampleObject( + "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"new description\"}]") + })) + JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch, ChangeSource.MANUAL); + } + + @PATCH + @Path("/name/{fqn}") + @Operation( + operationId = "patchNotificationTemplateByFQN", + summary = "Update a notification template by name", + description = "Update an existing notification template using JsonPatch.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully updated the notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "404", description = "Notification template not found") + }) + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + public Response patch( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fully qualified name of the notification template", + schema = @Schema(type = "string")) + @PathParam("fqn") + String fqn, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = { + @ExampleObject( + "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"new description\"}]") + })) + JsonPatch patch) { + return patchInternal(uriInfo, securityContext, fqn, patch, ChangeSource.MANUAL); + } + + @PUT + @Operation( + operationId = "createOrUpdateNotificationTemplate", + summary = "Create or update a notification template", + description = + "Create a notification template, if it does not exist or update an existing notification template.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The notification template", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = NotificationTemplate.class))), + @ApiResponse(responseCode = "400", description = "Bad request") + }) + public Response createOrUpdate( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateNotificationTemplate create) { + NotificationTemplate template = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + return createOrUpdate(uriInfo, securityContext, template); + } + + @DELETE + @Path("/async/{id}") + @Operation( + operationId = "deleteNotificationTemplateAsync", + summary = "Asynchronously delete a notification template by Id", + description = "Asynchronously delete a notification template by `Id`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse( + responseCode = "404", + description = "Notification template for instance {id} is not found") + }) + public Response deleteByIdAsync( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Hard delete the entity. (Default = `false`)") + @QueryParam("hardDelete") + @DefaultValue("false") + boolean hardDelete, + @Parameter( + description = "Recursively delete this entity and it's children. (Default `false`)") + @QueryParam("recursive") + @DefaultValue("false") + boolean recursive, + @Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return deleteByIdAsync(uriInfo, securityContext, id, recursive, hardDelete); + } + + @DELETE + @Path("/{id}") + @Operation( + operationId = "deleteNotificationTemplate", + summary = "Delete a notification template by Id", + description = "Delete a notification template by `Id`. System templates cannot be deleted.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse( + responseCode = "400", + description = "Bad request - System templates cannot be deleted"), + @ApiResponse( + responseCode = "404", + description = "Notification template for instance {id} is not found") + }) + public Response delete( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter( + description = "Recursively delete this entity and it's children. (Default `false`)") + @QueryParam("recursive") + @DefaultValue("false") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = `false`)") + @QueryParam("hardDelete") + @DefaultValue("false") + boolean hardDelete) { + return super.delete(uriInfo, securityContext, id, recursive, hardDelete); + } + + @DELETE + @Path("/name/{fqn}") + @Operation( + operationId = "deleteNotificationTemplateByFQN", + summary = "Delete a notification template by fully qualified name", + description = + "Delete a notification template by `fullyQualifiedName`. System templates cannot be deleted.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse( + responseCode = "400", + description = "Bad request - System templates cannot be deleted"), + @ApiResponse( + responseCode = "404", + description = "Notification template for instance {fqn} is not found") + }) + public Response delete( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fully qualified name of the notification template", + schema = @Schema(type = "string")) + @PathParam("fqn") + String fqn, + @Parameter( + description = "Recursively delete this entity and it's children. (Default `false`)") + @QueryParam("recursive") + @DefaultValue("false") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = `false`)") + @QueryParam("hardDelete") + @DefaultValue("false") + boolean hardDelete) { + return deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation( + operationId = "restore", + summary = "Restore a soft deleted notification template", + description = "Restore a soft deleted notification template.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully restored the notification template") + }) + public Response restoreEntity( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java index f52af1b87dc..35619cd9f4f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java @@ -594,7 +594,8 @@ public class SchemaFieldExtractor { "pipeline", "data", "votes", "data", "dataProduct", "domains", - "domain", "domains"); + "domain", "domains", + "notificationTemplate", "events"); return entityTypeToSubdirectory.getOrDefault(entityType, "data"); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/NotificationTemplateResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/NotificationTemplateResourceTest.java new file mode 100644 index 00000000000..9ef7f20c778 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/events/NotificationTemplateResourceTest.java @@ -0,0 +1,353 @@ +/* + * 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.resources.events; + +import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; +import static jakarta.ws.rs.core.Response.Status.CONFLICT; +import static jakarta.ws.rs.core.Response.Status.OK; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openmetadata.service.util.EntityUtil.fieldUpdated; +import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS; +import static org.openmetadata.service.util.TestUtils.UpdateType.MINOR_UPDATE; +import static org.openmetadata.service.util.TestUtils.assertResponse; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.client.HttpResponseException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.openmetadata.schema.api.events.CreateNotificationTemplate; +import org.openmetadata.schema.entity.events.NotificationTemplate; +import org.openmetadata.schema.type.ChangeDescription; +import org.openmetadata.schema.type.ProviderType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.EntityResourceTest; + +@Slf4j +public class NotificationTemplateResourceTest + extends EntityResourceTest { + + public NotificationTemplateResourceTest() { + super( + Entity.NOTIFICATION_TEMPLATE, + NotificationTemplate.class, + NotificationTemplateResource.NotificationTemplateList.class, + "notificationTemplates", + NotificationTemplateResource.FIELDS); + supportsFieldsQueryParam = false; + } + + @Override + public CreateNotificationTemplate createRequest(String name) { + return new CreateNotificationTemplate() + .withName(name) + .withDisplayName(name != null ? "Display " + name : null) + .withDescription(name != null ? "Template for " + name : null) + .withTemplateBody("
{{entity.name}} has been updated by {{updatedBy}}
"); + } + + @Override + public void validateCreatedEntity( + NotificationTemplate template, + CreateNotificationTemplate createRequest, + Map authHeaders) { + assertEquals(createRequest.getName(), template.getName()); + assertEquals(createRequest.getDisplayName(), template.getDisplayName()); + assertEquals(createRequest.getDescription(), template.getDescription()); + assertEquals(createRequest.getTemplateBody(), template.getTemplateBody()); + assertNotNull(template.getVersion()); + assertNotNull(template.getUpdatedAt()); + assertNotNull(template.getUpdatedBy()); + assertNotNull(template.getHref()); + assertNotNull(template.getFullyQualifiedName()); + // FQN may be quoted if name contains special characters + String expectedFqn = createRequest.getName(); + String actualFqn = template.getFullyQualifiedName(); + // Check if FQN matches, handling potential quoting + assertTrue( + actualFqn.equals(expectedFqn) || actualFqn.equals("\"" + expectedFqn + "\""), + "FQN mismatch: expected " + expectedFqn + " or quoted version, got " + actualFqn); + } + + @Override + public void compareEntities( + NotificationTemplate expected, + NotificationTemplate updated, + Map authHeaders) { + assertEquals(expected.getName(), updated.getName()); + assertEquals(expected.getFullyQualifiedName(), updated.getFullyQualifiedName()); + assertEquals(expected.getDescription(), updated.getDescription()); + assertEquals(expected.getDisplayName(), updated.getDisplayName()); + assertEquals(expected.getProvider(), updated.getProvider()); + assertEquals(expected.getTemplateBody(), updated.getTemplateBody()); + } + + @Override + public void assertFieldChange(String fieldName, Object expected, Object actual) { + if (expected == actual) { + return; + } + switch (fieldName) { + case "templateBody": + case "description": + case "displayName": + assertEquals(expected, actual); + break; + default: + assertCommonFieldChange(fieldName, expected, actual); + break; + } + } + + @Override + public NotificationTemplate validateGetWithDifferentFields( + NotificationTemplate template, boolean byName) throws HttpResponseException { + String fields = ""; + template = + byName + ? getEntityByName(template.getFullyQualifiedName(), fields, ADMIN_AUTH_HEADERS) + : getEntity(template.getId(), fields, ADMIN_AUTH_HEADERS); + assertNotNull(template.getName()); + assertNotNull(template.getFullyQualifiedName()); + assertNotNull(template.getDescription()); + assertNotNull(template.getProvider()); + assertNotNull(template.getTemplateBody()); + return template; + } + + @Test + void post_validNotificationTemplate_200(TestInfo test) throws IOException { + CreateNotificationTemplate create = + createRequest(getEntityName(test)) + .withTemplateBody( + "

Pipeline {{entity.name}} Status Update

" + + "

Status: {{entity.pipelineStatus}}

"); + NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS); + + assertEquals(getEntityName(test), template.getFullyQualifiedName().replaceAll("^\"|\"$", "")); + assertEquals(ProviderType.USER, template.getProvider()); + } + + @Test + void post_invalidHandlebarsTemplate_400(TestInfo test) { + CreateNotificationTemplate create = + createRequest(getEntityName(test)).withTemplateBody("{{#if entity.name}} Missing end if"); + + assertResponse( + () -> createEntity(create, ADMIN_AUTH_HEADERS), BAD_REQUEST, "Invalid template syntax"); + } + + @Test + void post_duplicateNotificationTemplate_409(TestInfo test) throws IOException { + CreateNotificationTemplate create = createRequest(getEntityName(test)); + createEntity(create, ADMIN_AUTH_HEADERS); + + assertResponse( + () -> createEntity(create, ADMIN_AUTH_HEADERS), CONFLICT, "Entity already exists"); + } + + @Test + void patch_notificationTemplateAttributes_200(TestInfo test) throws IOException { + NotificationTemplate template = + createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS); + + String origTemplateBody = template.getTemplateBody(); + String origDescription = template.getDescription(); + String origDisplayName = template.getDisplayName(); + + String newTemplateBody = "
{{entity.name}} - Updated Template
"; + String newDescription = "Updated description"; + String newDisplayName = "Updated Display Name"; + + String json = + String.format( + "[{\"op\":\"replace\",\"path\":\"/templateBody\",\"value\":%s}]", + JsonUtils.pojoToJson(newTemplateBody)); + template = patchEntity(template.getId(), JsonUtils.readTree(json), ADMIN_AUTH_HEADERS); + assertEquals(newTemplateBody, template.getTemplateBody()); + + String json2 = + String.format( + "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":%s}]", + JsonUtils.pojoToJson(newDescription)); + template = patchEntity(template.getId(), JsonUtils.readTree(json2), ADMIN_AUTH_HEADERS); + assertEquals(newDescription, template.getDescription()); + + String json3 = + String.format( + "[{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":%s}]", + JsonUtils.pojoToJson(newDisplayName)); + template = patchEntity(template.getId(), JsonUtils.readTree(json3), ADMIN_AUTH_HEADERS); + assertEquals(newDisplayName, template.getDisplayName()); + + ChangeDescription change = getChangeDescription(template, MINOR_UPDATE); + fieldUpdated(change, "templateBody", origTemplateBody, newTemplateBody); + fieldUpdated(change, "description", origDescription, newDescription); + fieldUpdated(change, "displayName", origDisplayName, newDisplayName); + } + + @Test + void patch_invalidTemplateBody_400(TestInfo test) throws IOException { + NotificationTemplate template = + createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS); + + String invalidTemplateBody = "{{#each items}} Missing end each"; + assertResponse( + () -> { + String json = + String.format( + "[{\"op\":\"replace\",\"path\":\"/templateBody\",\"value\":%s}]", + JsonUtils.pojoToJson(invalidTemplateBody)); + patchEntity(template.getId(), JsonUtils.readTree(json), ADMIN_AUTH_HEADERS); + }, + BAD_REQUEST, + "Invalid template syntax"); + } + + @Test + void put_updateNotificationTemplate_200(TestInfo test) throws IOException { + CreateNotificationTemplate request = createRequest(getEntityName(test)); + NotificationTemplate template = createEntity(request, ADMIN_AUTH_HEADERS); + + String newTemplateBody = "

Updated: {{entity.name}}

"; + String newDescription = "Updated via PUT"; + + CreateNotificationTemplate updateRequest = + createRequest(template.getName()) + .withDisplayName(template.getDisplayName()) + .withDescription(newDescription) + .withTemplateBody(newTemplateBody); + + NotificationTemplate updatedTemplate = updateEntity(updateRequest, OK, ADMIN_AUTH_HEADERS); + assertEquals(newTemplateBody, updatedTemplate.getTemplateBody()); + assertEquals(newDescription, updatedTemplate.getDescription()); + assertEquals(template.getId(), updatedTemplate.getId()); + + ChangeDescription change = getChangeDescription(updatedTemplate, MINOR_UPDATE); + fieldUpdated(change, "templateBody", template.getTemplateBody(), newTemplateBody); + fieldUpdated(change, "description", template.getDescription(), newDescription); + } + + @Test + void get_notificationTemplateByFQN_200(TestInfo test) throws IOException { + CreateNotificationTemplate create = createRequest(getEntityName(test)); + NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS); + + // Use the actual FQN from the created template for fetching + NotificationTemplate fetched = + getEntityByName(template.getFullyQualifiedName(), ADMIN_AUTH_HEADERS); + assertEquals(template.getId(), fetched.getId()); + assertEquals(template.getFullyQualifiedName(), fetched.getFullyQualifiedName()); + } + + @Test + void test_multipleTemplates(TestInfo test) throws IOException { + // Test creating multiple templates + String[] templateNames = {"entity_change", "test_change", "custom_alert"}; + + for (String templateName : templateNames) { + String name = getEntityName(test) + "_" + templateName; + CreateNotificationTemplate create = + createRequest(name).withTemplateBody("

Template for {{entity.name}}

"); + + NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS); + assertEquals(name, template.getFullyQualifiedName().replaceAll("^\"|\"$", "")); + assertEquals(ProviderType.USER, template.getProvider()); + } + } + + @Test + void test_templateValidationWithComplexHandlebars(TestInfo test) throws IOException { + String complexTemplate = + "{{#if entity.owner}}" + + "

Owner: {{entity.owner.name}}

" + + "{{else}}" + + "

No owner assigned

" + + "{{/if}}" + + "{{#each entity.tags as |tag|}}" + + "{{tag.tagFQN}}" + + "{{/each}}"; + + CreateNotificationTemplate create = + createRequest(getEntityName(test)).withTemplateBody(complexTemplate); + + NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS); + assertEquals(complexTemplate, template.getTemplateBody()); + } + + @Test + void test_listFilterByProvider(TestInfo test) throws IOException { + // Create multiple templates with USER provider + String userTemplate1 = getEntityName(test) + "_user1"; + String userTemplate2 = getEntityName(test) + "_user2"; + + CreateNotificationTemplate create1 = + createRequest(userTemplate1).withTemplateBody("

User template 1

"); + CreateNotificationTemplate create2 = + createRequest(userTemplate2).withTemplateBody("

User template 2

"); + + NotificationTemplate template1 = createEntity(create1, ADMIN_AUTH_HEADERS); + NotificationTemplate template2 = createEntity(create2, ADMIN_AUTH_HEADERS); + + // Verify both templates have USER provider + assertEquals(ProviderType.USER, template1.getProvider()); + assertEquals(ProviderType.USER, template2.getProvider()); + + // List all templates (no filter) + Map params = new HashMap<>(); + ResultList allTemplates = listEntities(params, ADMIN_AUTH_HEADERS); + assertTrue(allTemplates.getData().size() >= 2); + + // List only USER templates (use lowercase value as stored in JSON) + params.put("provider", ProviderType.USER.value()); + params.put("limit", "1000"); // Increase limit to ensure we get all templates + ResultList userTemplates = listEntities(params, ADMIN_AUTH_HEADERS); + + // Verify all returned templates are USER provider + for (NotificationTemplate template : userTemplates.getData()) { + assertEquals(ProviderType.USER, template.getProvider()); + } + + // Verify our created templates are in the results + boolean found1 = + userTemplates.getData().stream().anyMatch(t -> t.getId().equals(template1.getId())); + boolean found2 = + userTemplates.getData().stream().anyMatch(t -> t.getId().equals(template2.getId())); + + assertTrue(found1, "Template1 should be in USER filtered results"); + assertTrue(found2, "Template2 should be in USER filtered results"); + + // List only SYSTEM templates (use lowercase value as stored in JSON) + params.put("provider", "system"); + ResultList systemTemplates = listEntities(params, ADMIN_AUTH_HEADERS); + + // Verify all returned templates are SYSTEM provider + for (NotificationTemplate template : systemTemplates.getData()) { + assertEquals(ProviderType.SYSTEM, template.getProvider()); + } + + // Verify our USER templates are NOT in SYSTEM results + assertFalse( + systemTemplates.getData().stream().anyMatch(t -> t.getId().equals(template1.getId()))); + assertFalse( + systemTemplates.getData().stream().anyMatch(t -> t.getId().equals(template2.getId()))); + } +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/events/createNotificationTemplate.json b/openmetadata-spec/src/main/resources/json/schema/api/events/createNotificationTemplate.json new file mode 100644 index 00000000000..46eef4e0bb3 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/events/createNotificationTemplate.json @@ -0,0 +1,43 @@ +{ + "$id": "https://open-metadata.org/schema/api/events/createNotificationTemplate.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CreateNotificationTemplate", + "description": "Create request for Notification Template", + "type": "object", + "javaType": "org.openmetadata.schema.api.events.CreateNotificationTemplate", + "javaInterfaces": ["org.openmetadata.schema.CreateEntity"], + "properties": { + "name": { + "description": "Name that uniquely identifies this notification template (e.g., 'entity_change', 'test_change')", + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "description": "Display name for this notification template", + "type": "string" + }, + "description": { + "description": "Description of this notification template", + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "templateBody": { + "description": "Handlebars template content for rendering notifications", + "type": "string", + "minLength": 1, + "maxLength": 10240 + }, + "owners": { + "description": "Owners of this template", + "$ref": "../../type/entityReferenceList.json", + "default": null + }, + "domains": { + "description": "Fully qualified names of the domains the template belongs to", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "templateBody"], + "additionalProperties": false +} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/events/notificationTemplate.json b/openmetadata-spec/src/main/resources/json/schema/entity/events/notificationTemplate.json new file mode 100644 index 00000000000..798c2263d0e --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/events/notificationTemplate.json @@ -0,0 +1,79 @@ +{ + "$id": "https://open-metadata.org/schema/entity/events/notificationTemplate.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NotificationTemplate", + "$comment": "@om-entity-type", + "description": "A NotificationTemplate defines the default formatting template for notifications of a specific entity type.", + "type": "object", + "javaType": "org.openmetadata.schema.entity.events.NotificationTemplate", + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface" + ], + "properties": { + "id": { + "description": "Unique identifier of this template instance.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "description": "Name for the notification template (e.g., 'Default Table Template', 'Custom Pipeline Alerts').", + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "description": "Display Name that identifies this template.", + "type": "string" + }, + "fullyQualifiedName": { + "description": "Fully qualified name for the template.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "description": { + "description": "Description of the template purpose and usage.", + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "version": { + "description": "Metadata version of the template.", + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt": { + "description": "Last update time corresponding to the new version of the template.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "updatedBy": { + "description": "User who made the update.", + "type": "string" + }, + "href": { + "description": "Link to this template resource.", + "$ref": "../../type/basic.json#/definitions/href" + }, + "templateBody": { + "description": "Handlebars HTML template body with placeholders.", + "type": "string", + "minLength": 1, + "maxLength": 10240 + }, + "provider": { + "description": "Provider of the template. System templates are pre-loaded and cannot be deleted. User templates are created by users and can be deleted.", + "$ref": "../../type/basic.json#/definitions/providerType" + }, + "changeDescription": { + "description": "Change that lead to this version of the template.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "incrementalChangeDescription": { + "description": "Change that lead to this version of the entity.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted": { + "description": "When `true` indicates the template has been soft deleted.", + "type": "boolean", + "default": false + } + }, + "required": [ + "id", + "name", + "templateBody" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts b/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts index 73c63a60944..0a6c794bef6 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts @@ -44,7 +44,7 @@ export default defineConfig({ } }, sourcemap: true, - minify: 'terser', + minify: 'esbuild', target: 'es2020' }, resolve: { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts index ada54ffeb68..d9a2a939dcb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts @@ -95,7 +95,6 @@ test.describe('Explore Sort Order Filter', () => { await page.getByTestId('update-btn').click(); await selectSortOrder(page, 'Name'); - await page.waitForLoadState('networkidle'); await verifyEntitiesAreSorted(page); const clearFilters = page.getByTestId('clear-filters'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts index 0188fc9fe12..67a52e60723 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts @@ -257,31 +257,42 @@ export const selectSortOrder = async (page: Page, sortOrder: string) => { await page.waitForSelector(`role=menuitem[name="${sortOrder}"]`, { state: 'visible', }); + const nameFilter = page.waitForResponse( + `/api/v1/search/query?q=&index=dataAsset&*sort_field=displayName.keyword&sort_order=desc*` + ); await page.getByRole('menuitem', { name: sortOrder }).click(); + await nameFilter; await expect(page.getByTestId('sorting-dropdown-label')).toHaveText( sortOrder ); + const ascSortOrder = page.waitForResponse( + `/api/v1/search/query?q=&index=dataAsset&*sort_field=displayName.keyword&sort_order=asc*` + ); await page.getByTestId('sort-order-button').click(); + await ascSortOrder; await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); }; export const verifyEntitiesAreSorted = async (page: Page) => { + // Wait for search results to be stable after sort + await page.waitForSelector('[data-testid="search-results"]', { + state: 'visible', + }); + await page.waitForLoadState('networkidle'); + const entityNames = await page.$$eval( '[data-testid="search-results"] .explore-search-card [data-testid="entity-link"]', (elements) => elements.map((el) => el.textContent?.trim() ?? '') ); - // Normalize for case insensitivity, but retain punctuation - const normalize = (str: string) => str.toLowerCase().trim(); - - // Sort using ASCII-based string comparison (ES behavior) + // Elasticsearch keyword field with case-insensitive sorting const sortedEntityNames = [...entityNames].sort((a, b) => { - const normA = normalize(a); - const normB = normalize(b); + const aLower = a.toLowerCase(); + const bLower = b.toLowerCase(); - return normA < normB ? -1 : normA > normB ? 1 : 0; + return aLower < bLower ? -1 : aLower > bLower ? 1 : 0; }); expect(entityNames).toEqual(sortedEntityNames); diff --git a/pom.xml b/pom.xml index fb02e8fb427..abcf8e3d9ff 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,7 @@ 2.21.0 5.9.3 4.0.14 + 4.3.1 1.5.0 4.13.2 @@ -608,6 +609,11 @@ com.github.java-json-tools json-patch ${json-patch.version} +
+ + com.github.jknack + handlebars + ${handlebars.version} com.microsoft.azure