diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/FeedRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/FeedRepository.java index 371db50c140..009c0acfb0a 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/FeedRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/FeedRepository.java @@ -16,6 +16,7 @@ package org.openmetadata.catalog.jdbi3; import static org.openmetadata.catalog.Entity.helper; import static org.openmetadata.catalog.util.EntityUtil.toBoolean; +import com.fasterxml.jackson.core.JsonProcessingException; import java.io.IOException; import java.text.ParseException; import java.util.ArrayList; @@ -23,13 +24,18 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import javax.json.JsonPatch; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.UriInfo; import org.apache.commons.lang3.StringUtils; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.catalog.Entity; import org.openmetadata.catalog.api.feed.EntityLinkThreadCount; import org.openmetadata.catalog.api.feed.ThreadCount; import org.openmetadata.catalog.entity.feed.Thread; +import org.openmetadata.catalog.resources.feeds.FeedResource; import org.openmetadata.catalog.resources.feeds.FeedUtil; import org.openmetadata.catalog.resources.feeds.MessageParser; import org.openmetadata.catalog.resources.feeds.MessageParser.EntityLink; @@ -39,6 +45,8 @@ import org.openmetadata.catalog.type.Post; import org.openmetadata.catalog.type.Relationship; import org.openmetadata.catalog.util.EntityUtil; import org.openmetadata.catalog.util.JsonUtils; +import org.openmetadata.catalog.util.RestUtil; +import org.openmetadata.catalog.util.RestUtil.PatchResponse; public class FeedRepository { private final CollectionDAO dao; @@ -230,6 +238,47 @@ public class FeedRepository { return limitPostsInThreads(threads, limitPosts); } + @Transaction + public final PatchResponse patch(UriInfo uriInfo, UUID id, String user, JsonPatch patch) + throws IOException, ParseException { + // Get all the fields in the original thread that can be updated during PATCH operation + Thread original = get(id.toString()); + + // Apply JSON patch to the original thread to get the updated thread + Thread updated = JsonUtils.applyPatch(original, patch, Thread.class); + // update the "updatedBy" and "updatedAt" fields + updated.withUpdatedAt(System.currentTimeMillis()).withUpdatedBy(user); + + restorePatchAttributes(original, updated); + + // Update the attributes + String change = patchUpdate(original, updated) ? RestUtil.ENTITY_UPDATED : RestUtil.ENTITY_NO_CHANGE; + Thread updatedHref = FeedResource.addHref(uriInfo, updated); + return new PatchResponse<>(Status.OK, updatedHref, change); + } + + private void restorePatchAttributes(Thread original, Thread updated) { + // Patch can't make changes to following fields. Ignore the changes + updated.withId(original.getId()).withAbout(original.getAbout()); + } + + private boolean patchUpdate(Thread original, Thread updated) throws JsonProcessingException { + updated.setId(original.getId()); + + // store the updated thread + // if there is no change, there is no need to apply patch + if (fieldsChanged(original, updated)) { + dao.feedDAO().update(updated.getId().toString(), JsonUtils.pojoToJson(updated)); + return true; + } + return false; + } + + private boolean fieldsChanged(Thread original, Thread updated) { + // Patch supports only isResolved and message for now + return original.getResolved() != updated.getResolved() || !original.getMessage().equals(updated.getMessage()); + } + private List limitPostsInThreads(List threads, int limitPosts) { for (Thread t : threads) { List posts = t.getPosts(); diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/feeds/FeedResource.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/feeds/FeedResource.java index ce84935fbe8..9f156fb417c 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/feeds/FeedResource.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/feeds/FeedResource.java @@ -13,25 +13,33 @@ package org.openmetadata.catalog.resources.feeds; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.ExternalDocumentation; 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 java.io.IOException; import java.io.UnsupportedEncodingException; import java.security.GeneralSecurityException; import java.text.ParseException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.UUID; +import javax.json.JsonPatch; import javax.validation.Valid; import javax.validation.constraints.Max; import javax.validation.constraints.Min; import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; +import javax.ws.rs.PATCH; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -51,6 +59,7 @@ import org.openmetadata.catalog.resources.Collection; import org.openmetadata.catalog.security.Authorizer; import org.openmetadata.catalog.type.Post; import org.openmetadata.catalog.util.RestUtil; +import org.openmetadata.catalog.util.RestUtil.PatchResponse; import org.openmetadata.catalog.util.ResultList; @Path("/v1/feed") @@ -61,8 +70,15 @@ import org.openmetadata.catalog.util.ResultList; public class FeedResource { // TODO add /v1/feed?user=userid public static final String COLLECTION_PATH = "/v1/feed/"; + public static final List ALLOWED_FIELDS = getAllowedFields(); + private final FeedRepository dao; + private static List getAllowedFields() { + JsonPropertyOrder propertyOrder = Thread.class.getAnnotation(JsonPropertyOrder.class); + return new ArrayList<>(Arrays.asList(propertyOrder.value())); + } + public static List addHref(UriInfo uriInfo, List threads) { threads.forEach(t -> addHref(uriInfo, t)); return threads; @@ -148,6 +164,33 @@ public class FeedResource { return addHref(uriInfo, dao.get(id)); } + @PATCH + @Path("/{id}") + @Operation( + summary = "Update a thread by `id`.", + tags = "feeds", + description = "Update an existing thread using JsonPatch.", + externalDocs = @ExternalDocumentation(description = "JsonPatch RFC", url = "https://tools.ietf.org/html/rfc6902")) + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + public Response updateThread( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @PathParam("id") String id, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = { + @ExampleObject("[" + "{op:remove, path:/a}," + "{op:add, path: /b, value: val}" + "]") + })) + JsonPatch patch) + throws IOException, ParseException { + PatchResponse response = + dao.patch(uriInfo, UUID.fromString(id), securityContext.getUserPrincipal().getName(), patch); + return response.toResponse(); + } + @GET @Path("/count") @Operation( diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/feeds/FeedResourceTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/feeds/FeedResourceTest.java index 2fd06260e60..d482e05c8f0 100644 --- a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/feeds/FeedResourceTest.java +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/feeds/FeedResourceTest.java @@ -21,15 +21,18 @@ import static org.openmetadata.catalog.exception.CatalogExceptionMessage.entityN import static org.openmetadata.catalog.security.SecurityUtil.authHeaders; import static org.openmetadata.catalog.util.TestUtils.ADMIN_AUTH_HEADERS; import static org.openmetadata.catalog.util.TestUtils.NON_EXISTENT_ENTITY; +import static org.openmetadata.catalog.util.TestUtils.assertListNotNull; import static org.openmetadata.catalog.util.TestUtils.assertResponse; import static org.openmetadata.catalog.util.TestUtils.assertResponseContains; +import com.fasterxml.jackson.core.JsonProcessingException; import java.io.IOException; import java.net.URISyntaxException; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; +import javax.json.JsonPatch; import javax.ws.rs.client.WebTarget; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -55,6 +58,7 @@ import org.openmetadata.catalog.resources.feeds.FeedResource.ThreadList; import org.openmetadata.catalog.type.Column; import org.openmetadata.catalog.type.ColumnDataType; import org.openmetadata.catalog.type.Post; +import org.openmetadata.catalog.util.JsonUtils; import org.openmetadata.catalog.util.TestUtils; @Slf4j @@ -266,6 +270,38 @@ public class FeedResourceTest extends CatalogApplicationTest { assertEquals(POST_COUNT, postList.getData().size()); } + @Test + void patch_thread_200() throws IOException { + // create a thread + CreateThread create = create().withMessage("message"); + Thread thread = createAndCheck(create, ADMIN_AUTH_HEADERS); + + String originalJson = JsonUtils.pojoToJson(thread); + + // update message and resolved state + Thread updated = thread.withMessage("updated message").withResolved(true); + + patchThreadAndCheck(updated, originalJson, ADMIN_AUTH_HEADERS); + } + + @Test + void patch_thread_not_allowed_fields() throws IOException { + // create a thread + CreateThread create = create().withMessage("message"); + Thread thread = createAndCheck(create, ADMIN_AUTH_HEADERS); + + String originalJson = JsonUtils.pojoToJson(thread); + + // update the About of the thread + String originalAbout = thread.getAbout(); + Thread updated = thread.withAbout("<#E/user>"); + + patchThread(updated.getId(), originalJson, updated, ADMIN_AUTH_HEADERS); + updated = getThread(thread.getId(), ADMIN_AUTH_HEADERS); + // verify that the "About" is not changed + validateThread(updated, thread.getMessage(), thread.getCreatedBy(), originalAbout); + } + @Test void list_threadsWithPostsLimit() throws HttpResponseException { Thread thread = createAndCheck(create(), AUTH_HEADERS); @@ -402,4 +438,33 @@ public class FeedResourceTest extends CatalogApplicationTest { linkThreadCount.stream().filter(l -> l.getEntityLink().equals(entityLink)).findFirst().orElseThrow(); return threadCount.getCount(); } + + protected final Thread patchThreadAndCheck(Thread updated, String originalJson, Map authHeaders) + throws IOException { + + // Validate information returned in patch response has the updates + Thread returned = patchThread(updated.getId(), originalJson, updated, authHeaders); + + compareEntities(updated, returned, authHeaders); + + // GET the entity and Validate information returned + Thread getEntity = getThread(updated.getId(), authHeaders); + compareEntities(updated, getEntity, authHeaders); + + return returned; + } + + public final Thread patchThread(UUID id, String originalJson, Thread updated, Map authHeaders) + throws JsonProcessingException, HttpResponseException { + String updatedThreadJson = JsonUtils.pojoToJson(updated); + JsonPatch patch = JsonUtils.getJsonPatch(originalJson, updatedThreadJson); + return TestUtils.patch(getResource(String.format("feed/%s", id)), patch, Thread.class, authHeaders); + } + + public void compareEntities(Thread expected, Thread patched, Map authHeaders) { + assertListNotNull(patched.getId(), patched.getHref(), patched.getAbout()); + assertEquals(expected.getMessage(), patched.getMessage()); + assertEquals(expected.getResolved(), patched.getResolved()); + assertEquals(TestUtils.getPrincipal(authHeaders), patched.getUpdatedBy()); + } }