diff --git a/itests/pom.xml b/itests/pom.xml index 8f6f55b65..d28e1d2a6 100644 --- a/itests/pom.xml +++ b/itests/pom.xml @@ -178,6 +178,11 @@ unomi-rest test + + org.apache.unomi + unomi-tracing-api + test + org.apache.unomi unomi-api diff --git a/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java b/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java index e853b987b..9fd894912 100644 --- a/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java @@ -16,6 +16,7 @@ */ package org.apache.unomi.itests; +import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.unomi.api.Event; import org.apache.unomi.api.PartialList; import org.apache.unomi.api.Profile; @@ -184,4 +185,27 @@ public void test_EventGetNestedProperty() { Assert.assertEquals(testValue, value); } + @Test + public void test_DeleteEventViaRest() throws InterruptedException { + String eventId = "test-delete-event-id-" + System.currentTimeMillis(); + String profileId = "test-delete-event-profile-id-" + System.currentTimeMillis(); + String eventType = "test-delete-event-type"; + Profile profile = new Profile(profileId); + Event event = new Event(eventId, eventType, null, profile, null, null, null, new Date()); + + profileService.save(profile); + keepTrying("Profile with id " + profileId + " not found in the required time", () -> profileService.load(profileId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + eventService.send(event); + keepTrying("Event has not been raised", () -> eventService.getEvent(eventId), Objects::nonNull, DEFAULT_TRYING_TIMEOUT, + DEFAULT_TRYING_TRIES); + + CloseableHttpResponse response = delete("/cxs/events/" + eventId); + Assert.assertEquals("Delete event should return 204 No Content", 204, response.getStatusLine().getStatusCode()); + + waitForNullValue("Event still present after deletion via REST", () -> eventService.getEvent(eventId), DEFAULT_TRYING_TIMEOUT, + DEFAULT_TRYING_TRIES); + } + } diff --git a/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java index 2553c7c59..7c7ed28bf 100644 --- a/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java @@ -127,4 +127,56 @@ public void testEventsCollectorWithPublicApiKey() throws Exception { } } + @Test + public void testEventsCollectorWithExplain() throws Exception { + Event event = new Event(); + event.setEventType(TEST_EVENT_TYPE); + event.setScope(TEST_SCOPE); + event.setSessionId(TEST_SESSION_ID); + event.setProfileId(TEST_PROFILE_ID); + + EventsCollectorRequest eventsCollectorRequest = new EventsCollectorRequest(); + eventsCollectorRequest.setSessionId(TEST_SESSION_ID); + eventsCollectorRequest.setEvents(Collections.singletonList(event)); + + HttpPost request = new HttpPost(getFullUrl(EVENTS_URL + "?explain=true")); + request.addHeader("Content-Type", "application/json"); + String requestBody = getObjectMapper().writeValueAsString(eventsCollectorRequest); + request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(request, 200, true, true)) { + String responseContent = EntityUtils.toString(response.getEntity()); + EventCollectorResponse eventResponse = getObjectMapper().readValue(responseContent, EventCollectorResponse.class); + Assert.assertNotNull("Event collector response should not be null", eventResponse); + + int expectedFlags = EventService.SESSION_UPDATED | EventService.PROFILE_UPDATED; + Assert.assertEquals("Response should indicate both session and profile were updated", + expectedFlags, eventResponse.getUpdated()); + Assert.assertNotNull("Tracing information should be present", eventResponse.getRequestTracing()); + } + } + + @Test + public void testEventsCollectorWithExplainUnauthorized() throws Exception { + Event event = new Event(); + event.setEventType(TEST_EVENT_TYPE); + event.setScope(TEST_SCOPE); + event.setSessionId(TEST_SESSION_ID); + event.setProfileId(TEST_PROFILE_ID); + + EventsCollectorRequest eventsCollectorRequest = new EventsCollectorRequest(); + eventsCollectorRequest.setSessionId(TEST_SESSION_ID); + eventsCollectorRequest.setEvents(Collections.singletonList(event)); + + HttpPost request = new HttpPost(getFullUrl(EVENTS_URL + "?explain=true")); + request.addHeader("Content-Type", "application/json"); + String requestBody = getObjectMapper().writeValueAsString(eventsCollectorRequest); + request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(request, 403)) { + Assert.assertEquals("Request with explain parameter but without admin privileges should return 403", + 403, response.getStatusLine().getStatusCode()); + } + } + } diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java index 353bc49a2..dff72a1dc 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java @@ -16,6 +16,7 @@ */ package org.apache.unomi.itests; +import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.unomi.api.*; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.query.Query; @@ -535,4 +536,25 @@ public void testPurgeSessions() throws Exception { } } + + @Test + public void testDeleteSessionViaRest() throws Exception { + String profileId = "test-delete-session-profile-id-" + System.currentTimeMillis(); + String sessionId = "test-delete-session-id-" + System.currentTimeMillis(); + Profile profile = new Profile(profileId); + profileService.save(profile); + keepTrying("Profile with id " + profileId + " not found in the required time", () -> profileService.load(profileId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Session session = new Session(sessionId, profile, new Date(), "test-delete-session-scope"); + profileService.saveSession(session); + keepTrying("Session with id " + sessionId + " not found in the required time", () -> profileService.loadSession(sessionId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + CloseableHttpResponse response = delete("/cxs/profiles/sessions/" + sessionId); + assertEquals("Delete session should return 204 No Content", 204, response.getStatusLine().getStatusCode()); + + waitForNullValue("Session still present after deletion via REST", () -> profileService.loadSession(sessionId), + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + } } diff --git a/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java b/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java index 040f02085..eba517fc9 100644 --- a/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java @@ -16,6 +16,7 @@ */ package org.apache.unomi.itests.shell; +import org.apache.unomi.api.services.TypeResolutionService; import org.junit.Assert; import org.junit.Test; @@ -30,10 +31,38 @@ public class OtherCommandsIT extends ShellCommandsBaseIT { public void testRuleResetStats() throws Exception { String output = executeCommandAndGetOutput("unomi:rule-reset-stats"); // Should confirm statistics were reset - Assert.assertTrue("Should confirm rule statistics reset", + Assert.assertTrue("Should confirm rule statistics reset", output.contains("Rule statistics successfully reset")); } + @Test + public void testListInvalidObjects() throws Exception { + // Seed a genuinely invalid object directly in the tracking service so the command has + // something to report - otherwise this test only ever exercises the "no invalid objects + // found" branch and never validates the summary/table rendering. Going through + // rulesService.setRule() instead is unreliable here: RulesServiceImpl.ensureRuleResolved() + // skips resolution entirely for a rule that's never been seen before (it only resolves + // rules that were already tracked invalid or already had missingPlugins set), so a + // brand-new invalid rule causes setRule() to throw IllegalArgumentException rather than + // being marked invalid. + String objectType = "rules"; + String objectId = "test-invalid-rule-" + System.currentTimeMillis(); + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + + try { + typeResolutionService.markInvalid(objectType, objectId, "Unresolved condition type: nonExistentConditionType"); + + String output = executeCommandAndGetOutput("unomi:list-invalid-objects"); + Assert.assertTrue("Should show invalid objects summary", output.contains("Invalid Objects Summary")); + Assert.assertTrue("Should report at least one invalid object", output.contains("Total invalid objects:")); + validateNumericValuesInOutput(output, new String[]{"Total invalid objects:"}, false); + validateTableHeaders(output, new String[]{"Object Type", "Object ID"}); + Assert.assertTrue("Should list the invalid object's id", output.contains(objectId)); + } finally { + typeResolutionService.markValid(objectType, objectId); + } + } + @Test public void testDeployDefinition() throws Exception { validateCommandExists("unomi:deploy-definition", "deploy", "definition"); diff --git a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java index 411273647..d900c6975 100644 --- a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java +++ b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java @@ -1381,7 +1381,10 @@ protected Boolean execute(Object... args) throws Exception { itemType = customItemType; } String documentId = getDocumentIDForItemType(itemId, itemType); - String index = getIndexNameForQuery(itemType); + // Single-document delete needs a concrete index/alias, not the wildcard + // pattern getIndexNameForQuery() returns for rollover types (e.g. "context-session-*"), + // which ES's Delete API rejects with index_not_found_exception. + String index = getIndex(itemType); esClient.delete(DeleteRequest.of(builder -> builder.index(index).id(documentId))); if (MetadataItem.class.isAssignableFrom(clazz)) { diff --git a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java index c8c3969b9..0888dbc36 100644 --- a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java +++ b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.json.stream.JsonParser; @@ -1398,6 +1399,23 @@ protected Boolean execute(Object... args) throws IOException { rolloverActionBuilder.add("min_index_age", rolloverMaxAge); } + // Index patterns for ism_template auto-attachment, using the actual rollover index glob + // (e.g. "context-event-*") rather than raw item type names (e.g. "event") - the latter + // never match real index names, which is why an earlier attempt at ism_template here was + // abandoned in favor of the explicit attachRolloverPolicy() call alone. That explicit call + // only fires when Unomi itself creates the first rollover index (-000001); subsequent + // indices created by ISM's own rollover action never go through Unomi's code path, so they + // only get the inert policy_id setting from the index template and are never actually + // managed. ism_template auto-attaches to any index matching the pattern - including those + // rollover-created indices - so both mechanisms are kept: explicit attach for immediate + // management of the first index, ism_template as the durable fallback for every index after. + JsonArrayBuilder indexPatternsBuilder = Json.createArrayBuilder(); + if (rolloverIndices != null) { + for (String rolloverItemType : rolloverIndices) { + indexPatternsBuilder.add(getRolloverIndexForQuery(rolloverItemType)); + } + } + // Build the policy from the inside out JsonObjectBuilder policyBuilder = Json.createObjectBuilder() .add("states", Json.createArrayBuilder() @@ -1408,13 +1426,10 @@ protected Boolean execute(Object... args) throws IOException { .add("rollover", rolloverActionBuilder))) .add("transitions", Json.createArrayBuilder()))) .add("default_state", "ingest") - .add("description", "Rollover lifecycle policy"); - - // Policy attachment is handled deterministically via the plugins.index_state_management.policy_id - // custom setting on each per-item rollover index template (internalCreateRolloverTemplate), - // not via ism_template pattern-matching: rolloverIndices holds item type names (e.g. "event"), - // which never match real index names (e.g. "context-event-000001"), so an ism_template block - // here would never auto-attach the policy. + .add("description", "Rollover lifecycle policy") + .add("ism_template", Json.createObjectBuilder() + .add("index_patterns", indexPatternsBuilder) + .add("priority", 100)); // Wrap the policy and create JsonObject policyWrapper = Json.createObjectBuilder() diff --git a/rest/postman-readme.md b/rest/postman-readme.md index 35391d932..c68fc7743 100644 --- a/rest/postman-readme.md +++ b/rest/postman-readme.md @@ -157,6 +157,7 @@ The collection is organized in a logical demo flow order with 12 main folders: ### 11. Profile Management - Get, create, update, delete profiles +- Delete sessions - Search profiles - Get profile segments and sessions - **Authentication**: `tenantId:privateApiKey` (Basic Auth) @@ -168,7 +169,7 @@ The collection is organized in a logical demo flow order with 12 main folders: ### 12. Additional Useful Requests - Event type management -- Event search +- Event search and delete - Condition and action definitions - Property types - **Authentication**: `tenantId:privateApiKey` (Basic Auth) diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java index 46c0c2c39..0dc3df331 100644 --- a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java +++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java @@ -72,6 +72,17 @@ public Event getEvents(@PathParam("id") final String id) { return eventService.getEvent(id); } + /** + * Deletes an event by id. + * + * @param id the identifier for the event to delete + */ + @DELETE + @Path("/{id}") + public void deleteEvent(@PathParam("id") final String id) { + eventService.deleteEvent(id); + } + /** * Retrieves the list of event types identifiers that the server has processed. * @return a Set of strings that contain event type identifiers. diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java index 1ac88b474..26fe38b6c 100644 --- a/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java +++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java @@ -405,6 +405,17 @@ public Session saveSession(Session session) { return profileService.saveSession(session); } + /** + * Delete the session by specifying its unique identifier. + * + * @param sessionId the session identifier for the session to delete + */ + @DELETE + @Path("/sessions/{sessionId}") + public void deleteSession(@PathParam("sessionId") String sessionId) { + profileService.deleteSession(sessionId); + } + /** * Retrieves {@link Event}s for the {@link Session} identified by the provided session identifier, matching any of the provided event types, * ordered according to the specified {@code sortBy} String and paged: only {@code size} of them are retrieved, starting with the {@code offset}-th one. diff --git a/rest/unomi-postman-collection.json b/rest/unomi-postman-collection.json index 979e6bdcd..3cfe2a25e 100644 --- a/rest/unomi-postman-collection.json +++ b/rest/unomi-postman-collection.json @@ -2191,6 +2191,42 @@ }, "response": [] }, + { + "name": "Delete Session", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/sessions/{{sessionId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "sessions", + "{{sessionId}}" + ] + }, + "description": "Delete a session by its identifier", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Profile", "request": { @@ -2372,6 +2408,41 @@ }, "response": [] }, + { + "name": "Delete Event", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/events/{{eventId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "events", + "{{eventId}}" + ] + }, + "description": "Delete an event by its identifier", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Get Session Events", "request": { @@ -2577,6 +2648,11 @@ "value": "profile-123", "type": "string" }, + { + "key": "eventId", + "value": "event-123", + "type": "string" + }, { "key": "tenantId", "value": "mytenant", @@ -2635,4 +2711,4 @@ "description": "Health check password" } ] -} \ No newline at end of file +} diff --git a/tracing/tracing-api/pom.xml b/tracing/tracing-api/pom.xml index 379a3b6c7..f52ea6040 100644 --- a/tracing/tracing-api/pom.xml +++ b/tracing/tracing-api/pom.xml @@ -47,6 +47,11 @@ org.osgi.service.component.annotations provided + + com.fasterxml.jackson.core + jackson-annotations + provided + diff --git a/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java b/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java index 3224ca7dd..42eaf755a 100644 --- a/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java +++ b/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java @@ -16,6 +16,8 @@ */ package org.apache.unomi.tracing.api; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; @@ -25,6 +27,7 @@ * Represents a node in the request tracing tree structure. * Each node contains information about an operation, its timing, and any child operations. */ +@JsonIgnoreProperties(ignoreUnknown = true) public class TraceNode implements Serializable { private String operationType; private String description;