-
Notifications
You must be signed in to change notification settings - Fork 2k
Add changeSummary API endpoint and UI components #26533
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
12383a7
f2b7b66
b77ce50
3392ba8
145d0b7
a4aaabc
2511acc
7db09ab
857a8ee
2c4591e
71de2cb
17a13a3
8d5e201
0f1f4ce
1c4a1ea
892495d
a18bfcb
48181a7
fd88967
b49dcdb
bec52d6
2da3ae0
532033e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6193,4 +6193,153 @@ void test_listEntityHistoryByTimestamp_completePaginationCycle(TestNamespace ns) | |
| } | ||
| } | ||
| } | ||
|
|
||
| // =================================================================== | ||
| // CHANGE SUMMARY TESTS | ||
| // =================================================================== | ||
|
|
||
| /** | ||
| * Test: Retrieve changeSummary by entity ID after updating the entity. | ||
| * The changeSummary API returns metadata about who changed each field, | ||
| * the source of the change, and when it was changed. | ||
| */ | ||
| @Test | ||
| void get_changeSummaryById_200(TestNamespace ns) throws Exception { | ||
| K createRequest = createMinimalRequest(ns); | ||
| T created = createEntity(createRequest); | ||
|
|
||
| created.setDescription("Updated description for changeSummary test"); | ||
| T updated = patchEntity(created.getId().toString(), created); | ||
|
|
||
| OpenMetadataClient client = SdkClients.adminClient(); | ||
| String response = | ||
| client | ||
| .getHttpClient() | ||
| .executeForString( | ||
| HttpMethod.GET, | ||
| "/v1/changeSummary/" + getEntityType() + "/" + updated.getId(), | ||
| null); | ||
| assertNotNull(response, "ChangeSummary response should not be null"); | ||
| JsonNode result = MAPPER.readTree(response); | ||
| assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); | ||
| assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); | ||
| } | ||
|
|
||
| /** | ||
| * Test: Retrieve changeSummary by entity FQN after updating the entity. | ||
| */ | ||
| @Test | ||
| void get_changeSummaryByFqn_200(TestNamespace ns) throws Exception { | ||
| K createRequest = createMinimalRequest(ns); | ||
| T created = createEntity(createRequest); | ||
|
|
||
| created.setDescription("Updated description for changeSummary FQN test"); | ||
| T updated = patchEntity(created.getId().toString(), created); | ||
|
|
||
| OpenMetadataClient client = SdkClients.adminClient(); | ||
| String fqn = updated.getFullyQualifiedName(); | ||
| String response = | ||
| client | ||
| .getHttpClient() | ||
| .executeForString( | ||
| HttpMethod.GET, "/v1/changeSummary/" + getEntityType() + "/name/" + fqn, null); | ||
| assertNotNull(response, "ChangeSummary response should not be null"); | ||
| JsonNode result = MAPPER.readTree(response); | ||
| assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); | ||
| assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); | ||
| } | ||
|
|
||
| /** | ||
| * Test: Retrieve changeSummary with fieldPrefix filter. | ||
| * Verifies that the filtering parameter works correctly. | ||
| */ | ||
| @Test | ||
| void get_changeSummaryWithFieldPrefix_200(TestNamespace ns) throws Exception { | ||
| K createRequest = createMinimalRequest(ns); | ||
| T created = createEntity(createRequest); | ||
|
|
||
| created.setDescription("Updated for fieldPrefix test"); | ||
| T updated = patchEntity(created.getId().toString(), created); | ||
|
|
||
| OpenMetadataClient client = SdkClients.adminClient(); | ||
| String response = | ||
| client | ||
| .getHttpClient() | ||
| .executeForString( | ||
| HttpMethod.GET, | ||
| "/v1/changeSummary/" | ||
| + getEntityType() | ||
| + "/" | ||
| + updated.getId() | ||
| + "?fieldPrefix=description", | ||
| null); | ||
| assertNotNull(response, "ChangeSummary filtered response should not be null"); | ||
| JsonNode result = MAPPER.readTree(response); | ||
| assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); | ||
| assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); | ||
|
|
||
| JsonNode changeSummary = result.get("changeSummary"); | ||
| if (changeSummary.isObject()) { | ||
| changeSummary | ||
| .fieldNames() | ||
| .forEachRemaining( | ||
|
||
| key -> | ||
| assertTrue( | ||
| key.startsWith("description"), | ||
| "All keys should start with 'description', but found: " + key)); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Test: Retrieve changeSummary with pagination parameters. | ||
| */ | ||
| @Test | ||
| void get_changeSummaryWithPagination_200(TestNamespace ns) throws Exception { | ||
| K createRequest = createMinimalRequest(ns); | ||
| T created = createEntity(createRequest); | ||
|
|
||
| created.setDescription("Updated for pagination test"); | ||
| patchEntity(created.getId().toString(), created); | ||
|
|
||
| OpenMetadataClient client = SdkClients.adminClient(); | ||
| String response = | ||
| client | ||
| .getHttpClient() | ||
| .executeForString( | ||
| HttpMethod.GET, | ||
| "/v1/changeSummary/" | ||
| + getEntityType() | ||
| + "/" | ||
| + created.getId() | ||
| + "?limit=1&offset=0", | ||
| null); | ||
| assertNotNull(response, "ChangeSummary paginated response should not be null"); | ||
| JsonNode result = MAPPER.readTree(response); | ||
| assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); | ||
| assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); | ||
| assertTrue(result.has("offset"), "Paginated response must contain offset field"); | ||
| assertTrue(result.has("limit"), "Paginated response must contain limit field"); | ||
|
pmbrull marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Test: changeSummary returns 404 for non-existent entity. | ||
| */ | ||
| @Test | ||
| void get_changeSummaryNotFound_404(TestNamespace ns) { | ||
| OpenMetadataClient client = SdkClients.adminClient(); | ||
| UUID randomId = UUID.randomUUID(); | ||
| Exception thrown = | ||
| assertThrows( | ||
| Exception.class, | ||
| () -> | ||
| client | ||
| .getHttpClient() | ||
| .executeForString( | ||
| HttpMethod.GET, | ||
| "/v1/changeSummary/" + getEntityType() + "/" + randomId, | ||
| null)); | ||
| assertTrue( | ||
| thrown.getMessage().contains("404") || thrown.getMessage().contains("not found"), | ||
| "Should get 404 for non-existent entity, got: " + thrown.getMessage()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,218 @@ | ||||||||||||||||||
| /* | ||||||||||||||||||
| * 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; | ||||||||||||||||||
|
|
||||||||||||||||||
| import io.swagger.v3.oas.annotations.Operation; | ||||||||||||||||||
| import io.swagger.v3.oas.annotations.Parameter; | ||||||||||||||||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||||||||||||||||
| import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||||||||||||||||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||||||||||||||||
| import jakarta.ws.rs.DefaultValue; | ||||||||||||||||||
| import jakarta.ws.rs.GET; | ||||||||||||||||||
| 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.LinkedHashMap; | ||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||
| import java.util.UUID; | ||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||
| import org.openmetadata.schema.EntityInterface; | ||||||||||||||||||
| import org.openmetadata.schema.type.ChangeDescription; | ||||||||||||||||||
| import org.openmetadata.schema.type.ChangeSummaryMap; | ||||||||||||||||||
| import org.openmetadata.schema.type.MetadataOperation; | ||||||||||||||||||
| import org.openmetadata.schema.type.change.ChangeSummary; | ||||||||||||||||||
| import org.openmetadata.service.Entity; | ||||||||||||||||||
| import org.openmetadata.service.jdbi3.EntityRepository; | ||||||||||||||||||
| import org.openmetadata.service.security.Authorizer; | ||||||||||||||||||
| import org.openmetadata.service.security.policyevaluator.OperationContext; | ||||||||||||||||||
| import org.openmetadata.service.security.policyevaluator.ResourceContext; | ||||||||||||||||||
|
|
||||||||||||||||||
| @Slf4j | ||||||||||||||||||
| @Path("/v1/changeSummary") | ||||||||||||||||||
| @Tag( | ||||||||||||||||||
| name = "ChangeSummary", | ||||||||||||||||||
| description = | ||||||||||||||||||
| "APIs to retrieve change summary metadata for entities. " | ||||||||||||||||||
| + "Change summary tracks who changed each field, the source of the change " | ||||||||||||||||||
| + "(e.g., Suggested for AI-generated, Manual for user edits), and when the change occurred.") | ||||||||||||||||||
| @Produces(MediaType.APPLICATION_JSON) | ||||||||||||||||||
| @Collection(name = "changeSummary") | ||||||||||||||||||
| public class ChangeSummaryResource { | ||||||||||||||||||
|
|
||||||||||||||||||
| private final Authorizer authorizer; | ||||||||||||||||||
|
|
||||||||||||||||||
| public ChangeSummaryResource(Authorizer authorizer) { | ||||||||||||||||||
| this.authorizer = authorizer; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| @GET | ||||||||||||||||||
| @Path("/{entityType}/{id}") | ||||||||||||||||||
| @Operation( | ||||||||||||||||||
| operationId = "getChangeSummaryById", | ||||||||||||||||||
| summary = "Get change summary for an entity by ID", | ||||||||||||||||||
| description = | ||||||||||||||||||
| "Returns the change summary map for the specified entity, showing who changed each field, " | ||||||||||||||||||
| + "the source of the change (Manual, Suggested, Automated, etc.), and when it was changed. " | ||||||||||||||||||
| + "Use fieldPrefix to filter entries (e.g., 'columns.' for column-level changes only).", | ||||||||||||||||||
| responses = { | ||||||||||||||||||
| @ApiResponse(responseCode = "200", description = "Change summary map"), | ||||||||||||||||||
| @ApiResponse(responseCode = "404", description = "Entity not found") | ||||||||||||||||||
| }) | ||||||||||||||||||
| public Response getChangeSummaryById( | ||||||||||||||||||
|
gitar-bot[bot] marked this conversation as resolved.
|
||||||||||||||||||
| @Context UriInfo uriInfo, | ||||||||||||||||||
| @Context SecurityContext securityContext, | ||||||||||||||||||
| @Parameter( | ||||||||||||||||||
| description = "Entity type (e.g., table, topic, dashboard)", | ||||||||||||||||||
| schema = @Schema(type = "string")) | ||||||||||||||||||
| @PathParam("entityType") | ||||||||||||||||||
| String entityType, | ||||||||||||||||||
| @Parameter(description = "Entity ID", schema = @Schema(type = "UUID")) @PathParam("id") | ||||||||||||||||||
| UUID id, | ||||||||||||||||||
| @Parameter( | ||||||||||||||||||
| description = | ||||||||||||||||||
| "Filter entries by field name prefix (e.g., 'columns.' to get only column-level changes)", | ||||||||||||||||||
| schema = @Schema(type = "string")) | ||||||||||||||||||
| @QueryParam("fieldPrefix") | ||||||||||||||||||
| String fieldPrefix, | ||||||||||||||||||
| @Parameter( | ||||||||||||||||||
| description = "Limit the number of entries returned", | ||||||||||||||||||
| schema = @Schema(type = "integer")) | ||||||||||||||||||
| @QueryParam("limit") | ||||||||||||||||||
| @DefaultValue("10") | ||||||||||||||||||
| int limit, | ||||||||||||||||||
|
Comment on lines
+96
to
+103
|
||||||||||||||||||
| @Parameter(description = "Offset for pagination", schema = @Schema(type = "integer")) | ||||||||||||||||||
| @QueryParam("offset") | ||||||||||||||||||
| @DefaultValue("0") | ||||||||||||||||||
| int offset) { | ||||||||||||||||||
|
Comment on lines
+104
to
+108
|
||||||||||||||||||
|
|
||||||||||||||||||
| OperationContext operationContext = | ||||||||||||||||||
| new OperationContext(entityType, MetadataOperation.VIEW_BASIC); | ||||||||||||||||||
| ResourceContext<?> resourceContext = new ResourceContext<>(entityType, id, null); | ||||||||||||||||||
| authorizer.authorize(securityContext, operationContext, resourceContext); | ||||||||||||||||||
|
|
||||||||||||||||||
| EntityRepository<?> repository = Entity.getEntityRepository(entityType); | ||||||||||||||||||
| EntityInterface entity = (EntityInterface) repository.get(null, id, repository.getFields("*")); | ||||||||||||||||||
|
||||||||||||||||||
| EntityInterface entity = (EntityInterface) repository.get(null, id, repository.getFields("*")); | |
| EntityInterface entity = | |
| (EntityInterface) repository.get(null, id, repository.getFields("changeDescription")); |
Copilot
AI
Mar 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
uriInfo is available but the repository call passes null instead. Either remove the unused @Context UriInfo parameter or pass uriInfo into repository.get(...) for consistency with other resources and to avoid missing/incorrect href values on the loaded entity.
| EntityInterface entity = (EntityInterface) repository.get(null, id, repository.getFields("*")); | |
| EntityInterface entity = | |
| (EntityInterface) repository.get(uriInfo, id, repository.getFields("*")); |
Copilot
AI
Apr 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty changeSummary response omits offset and limit, while the non-empty response always includes them. For a paginated endpoint this inconsistency makes client handling more complex. Consider always returning offset/limit (even when totalEntries=0) to keep the response shape stable.
| return Response.ok(Map.of("changeSummary", Map.of(), "totalEntries", 0)).build(); | |
| return Response.ok( | |
| Map.of( | |
| "changeSummary", Map.of(), | |
| "totalEntries", 0, | |
| "offset", offset, | |
| "limit", limit)) | |
| .build(); |
Copilot
AI
Mar 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pagination is applied by iterating over filtered.entrySet(), but the underlying changeSummary map order is not guaranteed to be stable (depends on JSON deserialization / map implementation). This can make limit/offset pagination non-deterministic across requests. Consider applying a deterministic ordering (e.g., sort by key, or by changedAt desc then key) before slicing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The assertions only check for presence of
changeSummary/totalEntries, which will still pass when the API returns an empty map. To validate behavior, assert thatchangeSummarycontains the updated field (e.g.,description) and thattotalEntriesis > 0 after the PATCH.