You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2020/04/24 07:08:42 UTC

[unomi] branch master updated: feat(Event): Add the ability to update event by item id (#131)

This is an automated email from the ASF dual-hosted git repository.

shuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new bacb7bc  feat(Event): Add the ability to update event by item id (#131)
bacb7bc is described below

commit bacb7bc3fe1abac08739d249715f844dc0829fe6
Author: Ron <rb...@yotpo.com>
AuthorDate: Fri Apr 24 10:08:30 2020 +0300

    feat(Event): Add the ability to update event by item id (#131)
---
 api/src/main/java/org/apache/unomi/api/Event.java  |  75 +++++++---
 .../main/java/org/apache/unomi/api/rules/Rule.java |  20 +++
 .../apache/unomi/api/services/EventService.java    |   7 +
 .../unomi/api/services/RuleListenerService.java    |   3 +-
 .../test/java/org/apache/unomi/itests/AllITs.java  |  29 ++--
 .../test/java/org/apache/unomi/itests/BasicIT.java |  55 ++-----
 .../java/org/apache/unomi/itests/TestUtils.java    |  82 +++++++---
 .../itests/UpdateEventFromContextServletIT.java    | 165 +++++++++++++++++++++
 manual/src/main/asciidoc/datamodel.adoc            |   2 +
 manual/src/main/asciidoc/index.adoc                |   2 +
 manual/src/main/asciidoc/updating-events.adoc      |  85 +++++++++++
 .../main/resources/META-INF/cxs/mappings/rule.json |   3 +
 .../services/impl/events/EventServiceImpl.java     |   5 +
 .../services/impl/rules/RulesServiceImpl.java      |  11 +-
 .../java/org/apache/unomi/web/ServletCommon.java   |   3 +
 15 files changed, 448 insertions(+), 99 deletions(-)

diff --git a/api/src/main/java/org/apache/unomi/api/Event.java b/api/src/main/java/org/apache/unomi/api/Event.java
index 570c429..99eb51d 100644
--- a/api/src/main/java/org/apache/unomi/api/Event.java
+++ b/api/src/main/java/org/apache/unomi/api/Event.java
@@ -74,6 +74,7 @@ public class Event extends Item implements TimestampedItem {
     /**
      * Instantiates a new Event.
      *
+     * @param itemId    the event item id identifier
      * @param eventType the event type identifier
      * @param session   the session associated with the event
      * @param profile   the profile associated with the event
@@ -82,47 +83,83 @@ public class Event extends Item implements TimestampedItem {
      * @param target    the target of the event if any
      * @param timestamp the timestamp associated with the event if provided
      */
-    public Event(String eventType, Session session, Profile profile, String scope, Item source, Item target, Date timestamp) {
-        super(UUID.randomUUID().toString());
-        this.eventType = eventType;
-        this.profile = profile;
-        this.session = session;
-        this.profileId = profile.getItemId();
-        this.scope = scope;
-        this.source = source;
-        this.target = target;
-
-        if (session != null) {
-            this.sessionId = session.getItemId();
-        }
-        this.timeStamp = timestamp;
+    public Event(String itemId, String eventType, Session session, Profile profile, String scope, Item source, Item target, Date timestamp) {
+        super(itemId);
+        initEvent(eventType, session, profile, scope, source, target, timestamp);
+    }
 
-        this.properties = new HashMap<String, Object>();
+    /**
+     * Instantiates a new Event.
+     *
+     * @param eventType the event type identifier
+     * @param session   the session associated with the event
+     * @param profile   the profile associated with the event
+     * @param scope     the scope from which the event is issued
+     * @param source    the source of the event
+     * @param target    the target of the event if any
+     * @param timestamp the timestamp associated with the event if provided
+     */
+    public Event(String eventType, Session session, Profile profile, String scope, Item source, Item target, Date timestamp) {
+        this(eventType, session, profile, scope, source, target, null, timestamp, false);
+    }
 
-        actionPostExecutors = new ArrayList<>();
+    /**
+     * Instantiates a new Event.
+     *
+     * @param eventType the event type identifier
+     * @param session   the session associated with the event
+     * @param profile   the profile associated with the event
+     * @param scope     the scope from which the event is issued
+     * @param source    the source of the event
+     * @param target    the target of the event if any
+     * @param timestamp the timestamp associated with the event if provided
+     * @param persistent specifies if the event needs to be persisted
+     */
+    public Event(String eventType, Session session, Profile profile, String scope, Item source, Item target, Map<String, Object> properties, Date timestamp, boolean persistent) {
+        this(UUID.randomUUID().toString(), eventType, session, profile, scope, source, target, properties, timestamp, persistent);
     }
 
     /**
      * Instantiates a new Event.
      *
+     * @param itemId     the event item id identifier
      * @param eventType  the event type identifier
      * @param session    the session associated with the event
      * @param profile    the profile associated with the event
      * @param scope      the scope from which the event is issued
      * @param source     the source of the event
      * @param target     the target of the event if any
-     * @param timestamp  the timestamp associated with the event if provided
      * @param properties the properties for this event if any
+     * @param timestamp  the timestamp associated with the event if provided
      * @param persistent specifies if the event needs to be persisted
      */
-    public Event(String eventType, Session session, Profile profile, String scope, Item source, Item target, Map<String, Object> properties, Date timestamp, boolean persistent) {
-        this(eventType, session, profile, scope, source, target, timestamp);
+    public Event(String itemId, String eventType, Session session, Profile profile, String scope, Item source, Item target, Map<String, Object> properties, Date timestamp, boolean persistent) {
+        this(itemId, eventType, session, profile, scope, source, target, timestamp);
         this.persistent = persistent;
         if (properties != null) {
             this.properties = properties;
         }
     }
 
+    private void initEvent(String eventType, Session session, Profile profile, String scope, Item source, Item target, Date timestamp) {
+        this.eventType = eventType;
+        this.profile = profile;
+        this.session = session;
+        this.profileId = profile.getItemId();
+        this.scope = scope;
+        this.source = source;
+        this.target = target;
+
+        if (session != null) {
+            this.sessionId = session.getItemId();
+        }
+        this.timeStamp = timestamp;
+
+        this.properties = new HashMap<String, Object>();
+
+        actionPostExecutors = new ArrayList<>();
+    }
+
     /**
      * Retrieves the session identifier if available.
      *
diff --git a/api/src/main/java/org/apache/unomi/api/rules/Rule.java b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
index ec04d26..d3a44cb 100644
--- a/api/src/main/java/org/apache/unomi/api/rules/Rule.java
+++ b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
@@ -52,6 +52,8 @@ public class Rule extends MetadataItem {
 
     private boolean raiseEventOnlyOnceForSession = false;
 
+    private boolean raiseEventOnlyOnce = false;
+
     private int priority;
 
     /**
@@ -133,6 +135,15 @@ public class Rule extends MetadataItem {
     }
 
     /**
+     * Determines whether the event raised when the rule is triggered should only be raised once
+     *
+     * @return {@code true} if the rule-triggered event should only be raised once per profile
+     */
+    public boolean isRaiseEventOnlyOnce() {
+        return raiseEventOnlyOnce;
+    }
+
+    /**
      * Specifies whether the event raised when the rule is triggered should only be raised once per {@link Profile}.
      *
      * @param raiseEventOnlyOnceForProfile {@code true} if the rule-triggered event should only be raised once per profile, {@code false} otherwise
@@ -160,6 +171,15 @@ public class Rule extends MetadataItem {
     }
 
     /**
+     * Specifies whether the event raised when the rule is triggered should only be raised once per {@link Event}.
+     *
+     * @param raiseEventOnlyOnce {@code true} if the rule-triggered event should only be raised once per event, {@code false} otherwise
+     */
+    public void setRaiseEventOnlyOnce(boolean raiseEventOnlyOnce) {
+        this.raiseEventOnlyOnce = raiseEventOnlyOnce;
+    }
+
+    /**
      * Retrieves the priority in case this Rule needs to be executed before other ones when similar conditions match.
      *
      * @return the priority
diff --git a/api/src/main/java/org/apache/unomi/api/services/EventService.java b/api/src/main/java/org/apache/unomi/api/services/EventService.java
index 6060481..4dc53f7 100644
--- a/api/src/main/java/org/apache/unomi/api/services/EventService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/EventService.java
@@ -142,6 +142,13 @@ public interface EventService {
      * @return {@code true} if the event has already been raised, {@code false} otherwise
      */
     boolean hasEventAlreadyBeenRaised(Event event, boolean session);
+    /**
+     * Checks whether the specified event has already been raised with the same itemId.
+     *
+     * @param event   the event we want to check
+     * @return {@code true} if the event has already been raised, {@code false} otherwise
+     */
+    boolean hasEventAlreadyBeenRaised(Event event);
 
     /**
      * Removes all events of the specified profile
diff --git a/api/src/main/java/org/apache/unomi/api/services/RuleListenerService.java b/api/src/main/java/org/apache/unomi/api/services/RuleListenerService.java
index be67f7f..f659904 100644
--- a/api/src/main/java/org/apache/unomi/api/services/RuleListenerService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/RuleListenerService.java
@@ -30,7 +30,8 @@ public interface RuleListenerService {
      */
     enum AlreadyRaisedFor {
         SESSION,
-        PROFILE
+        PROFILE,
+        EVENT
     }
 
     /**
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 5456a30..938fcf8 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -23,24 +23,25 @@ import org.junit.runners.Suite.SuiteClasses;
 
 /**
  * Defines suite of test classes to run.
- * 
+ *
  * @author Sergiy Shyrkov
  */
 @RunWith(Suite.class)
 @SuiteClasses({
-        BasicIT.class,
-        ConditionEvaluatorIT.class,
-        ConditionESQueryBuilderIT.class,
-        SegmentIT.class,
-        ProfileServiceIT.class,
-        ProfileImportBasicIT.class,
-        ProfileImportSurfersIT.class,
-        ProfileImportRankingIT.class,
-        ProfileImportActorsIT.class,
-        ProfileExportIT.class,
-        PropertiesUpdateActionIT.class,
-        ModifyConsentIT.class,
-        PatchIT.class
+	BasicIT.class,
+	ConditionEvaluatorIT.class,
+	ConditionESQueryBuilderIT.class,
+	SegmentIT.class,
+	ProfileServiceIT.class,
+	ProfileImportBasicIT.class,
+	ProfileImportSurfersIT.class,
+	ProfileImportRankingIT.class,
+	ProfileImportActorsIT.class,
+	ProfileExportIT.class,
+	PropertiesUpdateActionIT.class,
+	ModifyConsentIT.class,
+	PatchIT.class,
+	UpdateEventFromContextServletIT.class
 })
 public class AllITs {
 }
diff --git a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
index fa46b75..1a8e7e7 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
@@ -51,14 +51,14 @@ import java.io.File;
 import java.io.IOException;
 import java.util.*;
 
+
 @RunWith(PaxExam.class)
 @ExamReactorStrategy(PerSuite.class)
 public class BasicIT extends BaseIT {
     private final static Logger LOGGER = LoggerFactory.getLogger(BasicIT.class);
 
-    private static final String JSON_MYME_TYPE = "application/json";
-
     private ObjectMapper objectMapper = new ObjectMapper();
+    private  TestUtils testUtils = new TestUtils();
 
     private static final String SESSION_ID_0 = "aa3b04bd-8f4d-4a07-8e96-d33ffa04d3d0";
     private static final String SESSION_ID_1 = "aa3b04bd-8f4d-4a07-8e96-d33ffa04d3d1";
@@ -173,7 +173,7 @@ public class BasicIT extends BaseIT {
         HttpPost requestPageView1 = new HttpPost(URL + "/context.json");
         requestPageView1.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequestPageViewSession1),
                 ContentType.create("application/json")));
-        RequestResponse requestResponsePageView1 = executeContextJSONRequest(requestPageView1, SESSION_ID_3);
+        TestUtils.RequestResponse requestResponsePageView1 = executeContextJSONRequest(requestPageView1, SESSION_ID_3);
         String profileIdVisitor1 = requestResponsePageView1.getContextResponse().getProfileId();
         Thread.sleep(1000);
 
@@ -191,7 +191,7 @@ public class BasicIT extends BaseIT {
         requestLoginVisitor1.addHeader("X-Unomi-Peer", UNOMI_KEY);
         requestLoginVisitor1.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequestLoginVisitor1),
                 ContentType.create("application/json")));
-        RequestResponse requestResponseLoginVisitor1 = executeContextJSONRequest(requestLoginVisitor1, SESSION_ID_3);
+        TestUtils.RequestResponse requestResponseLoginVisitor1 = executeContextJSONRequest(requestLoginVisitor1, SESSION_ID_3);
         Assert.assertEquals("Context profile id should be the same", profileIdVisitor1,
                 requestResponseLoginVisitor1.getContextResponse().getProfileId());
         checkVisitor1ResponseProperties(requestResponseLoginVisitor1.getContextResponse().getProfileProperties());
@@ -202,7 +202,7 @@ public class BasicIT extends BaseIT {
         requestPageView2.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue());
         requestPageView2.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequestPageViewSession1),
                 ContentType.create("application/json")));
-        RequestResponse requestResponsePageView2 = executeContextJSONRequest(requestPageView2, SESSION_ID_3);
+        TestUtils.RequestResponse requestResponsePageView2 = executeContextJSONRequest(requestPageView2, SESSION_ID_3);
         Assert.assertEquals("Context profile id should be the same", profileIdVisitor1,
                 requestResponsePageView2.getContextResponse().getProfileId());
         checkVisitor1ResponseProperties(requestResponsePageView2.getContextResponse().getProfileProperties());
@@ -215,7 +215,7 @@ public class BasicIT extends BaseIT {
         requestPageView3.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue());
         requestPageView3.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequestPageViewSession2),
                 ContentType.create("application/json")));
-        RequestResponse requestResponsePageView3 = executeContextJSONRequest(requestPageView3, SESSION_ID_4);
+        TestUtils.RequestResponse requestResponsePageView3 = executeContextJSONRequest(requestPageView3, SESSION_ID_4);
         Assert.assertEquals("Context profile id should be the same", profileIdVisitor1,
                 requestResponsePageView3.getContextResponse().getProfileId());
         checkVisitor1ResponseProperties(requestResponsePageView3.getContextResponse().getProfileProperties());
@@ -235,7 +235,7 @@ public class BasicIT extends BaseIT {
         requestLoginVisitor2.addHeader("X-Unomi-Peer", UNOMI_KEY);
         requestLoginVisitor2.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequestLoginVisitor2),
                 ContentType.create("application/json")));
-        RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4);
+        TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4);
         // We should have a new profile id so the session should have been moved from VISITOR_1 to VISITOR_2
         String profileIdVisitor2 = requestResponseLoginVisitor2.getContextResponse().getProfileId();
         Assert.assertNotEquals("Context profile id should not be the same", profileIdVisitor1,
@@ -248,7 +248,7 @@ public class BasicIT extends BaseIT {
         requestPageView4.addHeader("Cookie", requestResponseLoginVisitor2.getCookieHeaderValue());
         requestPageView4.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequestPageViewSession2),
                 ContentType.create("application/json")));
-        RequestResponse requestResponsePageView4 = executeContextJSONRequest(requestPageView4, SESSION_ID_4);
+        TestUtils.RequestResponse requestResponsePageView4 = executeContextJSONRequest(requestPageView4, SESSION_ID_4);
         Assert.assertEquals("Context profile id should be the same", profileIdVisitor2,
                 requestResponsePageView4.getContextResponse().getProfileId());
         checkVisitor2ResponseProperties(requestResponsePageView4.getContextResponse().getProfileProperties());
@@ -304,25 +304,8 @@ public class BasicIT extends BaseIT {
         return contextRequest;
     }
 
-    private RequestResponse executeContextJSONRequest(HttpPost request, String sessionId) throws IOException, InterruptedException {
-        try (CloseableHttpResponse response = HttpClientBuilder.create().build().execute(request)) {
-            // validate mimeType
-            String mimeType = ContentType.getOrDefault(response.getEntity()).getMimeType();
-            Assert.assertEquals("Response content type should be " + JSON_MYME_TYPE, JSON_MYME_TYPE, mimeType);
-
-            // validate context
-            ContextResponse context = TestUtils.retrieveResourceFromResponse(response, ContextResponse.class);
-            Assert.assertNotNull("Context should not be null", context);
-            Assert.assertNotNull("Context profileId should not be null", context.getProfileId());
-            Assert.assertEquals("Context sessionId should be the same as the sessionId used to request the context", sessionId,
-                    context.getSessionId());
-
-            String cookieHeader = null;
-            if (response.containsHeader("Set-Cookie")) {
-                cookieHeader = response.getHeaders("Set-Cookie")[0].toString().substring(12);
-            }
-            return new RequestResponse(context, cookieHeader);
-        }
+    private TestUtils.RequestResponse executeContextJSONRequest(HttpPost request, String sessionId) throws IOException {
+        return testUtils.executeContextJSONRequest(request, sessionId);
     }
 
     private void checkVisitor1ResponseProperties(Map<String, Object> profileProperties) {
@@ -351,22 +334,4 @@ public class BasicIT extends BaseIT {
         Assert.assertEquals("Context profile properties " + EMAIL + " should be equal to " + emailVisitor,
                 profileProperties.get(EMAIL), emailVisitor);
     }
-
-    private class RequestResponse {
-        private ContextResponse contextResponse;
-        private String cookieHeaderValue;
-
-        public RequestResponse(ContextResponse contextResponse, String cookieHeaderValue) {
-            this.contextResponse = contextResponse;
-            this.cookieHeaderValue = cookieHeaderValue;
-        }
-
-        public ContextResponse getContextResponse() {
-            return contextResponse;
-        }
-
-        public String getCookieHeaderValue() {
-            return cookieHeaderValue;
-        }
-    }
 }
diff --git a/itests/src/test/java/org/apache/unomi/itests/TestUtils.java b/itests/src/test/java/org/apache/unomi/itests/TestUtils.java
index 7cc9733..066adbd 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TestUtils.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TestUtils.java
@@ -19,29 +19,75 @@ package org.apache.unomi.itests;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.impl.client.HttpClientBuilder;
 import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.ContextResponse;
 import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import org.junit.Assert;
 
 import java.io.IOException;
 
 public class TestUtils {
+	private static final String JSON_MYME_TYPE = "application/json";
 
-    public static <T> T retrieveResourceFromResponse(HttpResponse response, Class<T> clazz) throws IOException {
-        if (response == null) {
-            return null;
-        }
-        if (response.getEntity() == null) {
-            return null;
-        }
-        String jsonFromResponse = EntityUtils.toString(response.getEntity());
-        // ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
-        ObjectMapper mapper = CustomObjectMapper.getObjectMapper();
-        try {
-            T value = mapper.readValue(jsonFromResponse, clazz);
-            return value;
-        } catch (Throwable t) {
-            t.printStackTrace();
-        }
-        return null;
-    }
+	public static <T> T retrieveResourceFromResponse(HttpResponse response, Class<T> clazz) throws IOException {
+		if (response == null) {
+			return null;
+		}
+		if (response.getEntity() == null) {
+			return null;
+		}
+		String jsonFromResponse = EntityUtils.toString(response.getEntity());
+		// ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+		ObjectMapper mapper = CustomObjectMapper.getObjectMapper();
+		try {
+			T value = mapper.readValue(jsonFromResponse, clazz);
+			return value;
+		} catch (Throwable t) {
+			t.printStackTrace();
+		}
+		return null;
+	}
+
+	public RequestResponse executeContextJSONRequest(HttpPost request, String sessionId) throws IOException {
+		try (CloseableHttpResponse response = HttpClientBuilder.create().build().execute(request)) {
+			// validate mimeType
+			String mimeType = ContentType.getOrDefault(response.getEntity()).getMimeType();
+			Assert.assertEquals("Response content type should be " + JSON_MYME_TYPE, JSON_MYME_TYPE, mimeType);
+
+			// validate context
+			ContextResponse context = TestUtils.retrieveResourceFromResponse(response, ContextResponse.class);
+			Assert.assertNotNull("Context should not be null", context);
+			Assert.assertNotNull("Context profileId should not be null", context.getProfileId());
+			Assert.assertEquals("Context sessionId should be the same as the sessionId used to request the context", sessionId,
+				context.getSessionId());
+
+			String cookieHeader = null;
+			if (response.containsHeader("Set-Cookie")) {
+				cookieHeader = response.getHeaders("Set-Cookie")[0].toString().substring(12);
+			}
+			return new RequestResponse(context, cookieHeader);
+		}
+	}
+
+	public static class RequestResponse {
+		private ContextResponse contextResponse;
+		private String cookieHeaderValue;
+
+		public RequestResponse(ContextResponse contextResponse, String cookieHeaderValue) {
+			this.contextResponse = contextResponse;
+			this.cookieHeaderValue = cookieHeaderValue;
+		}
+
+		public ContextResponse getContextResponse() {
+			return contextResponse;
+		}
+
+		public String getCookieHeaderValue() {
+			return cookieHeaderValue;
+		}
+	}
 }
diff --git a/itests/src/test/java/org/apache/unomi/itests/UpdateEventFromContextServletIT.java b/itests/src/test/java/org/apache/unomi/itests/UpdateEventFromContextServletIT.java
new file mode 100644
index 0000000..20da2bc
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/UpdateEventFromContextServletIT.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.itests;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.unomi.api.ContextRequest;
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.Session;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.persistence.spi.PersistenceService;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+import org.ops4j.pax.exam.util.Filter;
+
+import javax.inject.Inject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Date;
+
+
+/**
+ * Created by Ron Barabash on 5/4/2020.
+ */
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class UpdateEventFromContextServletIT extends BaseIT {
+	private final static String TEST_SESSION_ID = "test-session-id";
+	private final static String TEST_EVENT_ID = "test-event-id";
+	private final static String TEST_SCOPE = "test-scope";
+	private final static String TEST_PROFILE_ID = "test-profile-id";
+	private final static String EVENT_TYPE = "view";
+	private final static String THIRD_PARTY_HEADER_NAME = "X-Unomi-Peer";
+	private Profile profile = new Profile(TEST_PROFILE_ID);
+	private Session session = new Session(TEST_SESSION_ID, profile, new Date(), TEST_SCOPE);
+	private ObjectMapper objectMapper = new ObjectMapper();
+	private TestUtils testUtils = new TestUtils();
+
+	@Inject
+	@Filter(timeout = 600000)
+	protected EventService eventService;
+	@Inject
+	@Filter(timeout = 600000)
+	protected PersistenceService persistenceService;
+	@Inject
+	@Filter(timeout = 600000)
+	protected ProfileService profileService;
+
+	@Before
+	public void setUp() throws InterruptedException {
+		Event pageViewEvent = new Event(TEST_EVENT_ID, EVENT_TYPE, session, profile, TEST_SCOPE, null, null, new Date());
+
+		profileService.save(profile);
+		this.eventService.send(pageViewEvent);
+
+		Thread.sleep(2000);
+	}
+
+	@After
+	public void tearDown() {
+		//Using remove index due to document version is still persistent after deletion as referenced here https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html#delete-versioning
+		this.persistenceService.removeIndex("event-date-*");
+		this.profileService.delete(TEST_PROFILE_ID, false);
+		this.persistenceService.refresh();
+	}
+
+	@Test
+	public void testUpdateEventFromContextAuthorizedThirdParty_Success() throws IOException, InterruptedException {
+		Event event = this.eventService.getEvent(TEST_EVENT_ID);
+		Assert.assertEquals(new Long(1), event.getVersion());
+		Profile profile = profileService.load(TEST_PROFILE_ID);
+		Event pageViewEvent = new Event(TEST_EVENT_ID, EVENT_TYPE, session, profile, TEST_SCOPE, null, null, new Date());
+
+		ContextRequest contextRequest = new ContextRequest();
+		contextRequest.setSessionId(session.getItemId());
+		contextRequest.setEvents(Collections.singletonList(pageViewEvent));
+
+		HttpPost request = new HttpPost(URL + "/context.json");
+		request.addHeader(THIRD_PARTY_HEADER_NAME, UNOMI_KEY);
+		request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest),
+			ContentType.create("application/json")));
+
+		//Making sure Unomi is up and running
+		Thread.sleep(5000);
+		this.testUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+
+		//Making sure event is updated in DB
+		Thread.sleep(2000);
+		event = this.eventService.getEvent(TEST_EVENT_ID);
+		Assert.assertEquals(new Long(2), event.getVersion());
+	}
+
+	@Test
+	public void testUpdateEventFromContextUnAuthorizedThirdParty_Fail() throws IOException, InterruptedException {
+		Event event = this.eventService.getEvent(TEST_EVENT_ID);
+		Assert.assertEquals(new Long(1), event.getVersion());
+		Profile profile = profileService.load(TEST_PROFILE_ID);
+		Event pageViewEvent = new Event(TEST_EVENT_ID, EVENT_TYPE, session, profile, TEST_SCOPE, null, null, new Date());
+		ContextRequest contextRequest = new ContextRequest();
+		contextRequest.setSessionId(session.getItemId());
+		contextRequest.setEvents(Collections.singletonList(pageViewEvent));
+		HttpPost request = new HttpPost(URL + "/context.json");
+		request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest),
+			ContentType.create("application/json")));
+
+		//Making sure Unomi is up and running
+		Thread.sleep(5000);
+		this.testUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+
+		//Making sure event is updated in DB
+		Thread.sleep(2000);
+		event = this.eventService.getEvent(TEST_EVENT_ID);
+		Assert.assertEquals(new Long(1), event.getVersion());
+	}
+
+
+	@Test
+	public void testUpdateEventFromContextAuthorizedThirdPartyNoItemID_Fail() throws IOException, InterruptedException {
+		Event event = this.eventService.getEvent(TEST_EVENT_ID);
+		Assert.assertEquals(new Long(1), event.getVersion());
+		Profile profile = profileService.load(TEST_PROFILE_ID);
+		Event pageViewEvent = new Event(EVENT_TYPE, session, profile, TEST_SCOPE, null, null, new Date());
+		ContextRequest contextRequest = new ContextRequest();
+		contextRequest.setSessionId(session.getItemId());
+		contextRequest.setEvents(Collections.singletonList(pageViewEvent));
+		HttpPost request = new HttpPost(URL + "/context.json");
+		request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest),
+			ContentType.create("application/json")));
+
+		//Making sure Unomi is up and running
+		Thread.sleep(5000);
+		this.testUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+
+		//Making sure event is updated in DB
+		Thread.sleep(2000);
+		event = this.eventService.getEvent(TEST_EVENT_ID);
+		Assert.assertEquals(new Long(1), event.getVersion());
+	}
+}
diff --git a/manual/src/main/asciidoc/datamodel.adoc b/manual/src/main/asciidoc/datamodel.adoc
index a8f8382..0d97d43 100755
--- a/manual/src/main/asciidoc/datamodel.adoc
+++ b/manual/src/main/asciidoc/datamodel.adoc
@@ -774,6 +774,8 @@ Inherits all the fields from: <<MetadataItem>>
 
 | linkedItems | String array | A list of references to objects that may have generated this rule. Goals and segments dynamically generate rules to react to incoming events. It is not recommend to manipulate rules that have linkedItems as it may break functionality.
 
+| raiseEventOnlyOnce | Boolean | If true, the rule will only be executed once for a given event.
+
 | raiseEventOnlyOnceForProfile | Boolean | If true, the rule will only be executed once for a given profile and a matching event. Warning: this functionality has a performance impact since it looks up past events.
 
 | raiseEventOnlyOnceForSession | Boolean | If true, the rule will only be executed once for a given session and a matching event. Warning: this functionality has a performance impact since it looks up past events.
diff --git a/manual/src/main/asciidoc/index.adoc b/manual/src/main/asciidoc/index.adoc
index 49fb4fb..acb2e63 100644
--- a/manual/src/main/asciidoc/index.adoc
+++ b/manual/src/main/asciidoc/index.adoc
@@ -78,6 +78,8 @@ include::builtin-condition-types.adoc[]
 
 include::builtin-action-types.adoc[]
 
+include::updating-events.adoc[]
+
 == Integration samples
 
 include::samples/samples.adoc[]
diff --git a/manual/src/main/asciidoc/updating-events.adoc b/manual/src/main/asciidoc/updating-events.adoc
new file mode 100644
index 0000000..84a3909
--- /dev/null
+++ b/manual/src/main/asciidoc/updating-events.adoc
@@ -0,0 +1,85 @@
+//
+// 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.
+//
+=== Updating Events Using the Context Servlet
+One of the use cases that needed to be supported by Unomi is the ability to build a user profile based on Internal System events or https://en.wikipedia.org/wiki/Change_data_capture[Change Data Capture] which usally transported through internal messaging queues such as Kafka.
+
+This can easily achieved using the `KafkaInjector` module built in within Unomi.
+
+But, as streaming system usually operates in https://dzone.com/articles/kafka-clients-at-most-once-at-least-once-exactly-o[at-least-once] semantics,
+we need to have a way to guarantee we wont have duplicate events in the system.
+
+==== Solution
+
+One of the solutions to this scenario is to have the ability to control and pass in the `eventId` property from outside of Unomi,
+Using an authorized 3rd party. This way whenever an event with the same `itemId` will be processed once again he wont be appended to list of events, but will be updated.
+
+Here is an example of a request contains the `itemdId`
+
+[source]
+----
+curl -X POST http://localhost:8181/context.json \
+-H "Content-Type: application/json" \
+-d @- <<'EOF'
+{
+    "events":[
+        {
+            "itemId": "exampleEventId",
+            "eventType":"view",
+            "scope": "example",
+            "properties" : {
+              "firstName" : "example"
+            }
+        }
+    ]
+}
+EOF
+----
+Make sure to use an authorized third party using `X-Unomi-Peer` requests headers and that the `eventType` is in the list of allowed events
+
+==== Defining Rules
+Another use case we support is the ability to define a rule on the above mentioned events.
+If we have a rule that increment a property on profile level, we would want the action to be executed only once per event id.
+this can be achieved by adding `"raiseEventOnlyOnce": false` to the rule definition.
+
+[source]
+----
+curl -X POST http://localhost:8181/context.json \
+-H "Content-Type: application/json" \
+-d @- <<'EOF'
+{
+  "metadata": {
+    "id": "updateNumberOfOrders",
+    "name": "update number of orders on orderCreated eventType",
+    "description": "update number of orders on orderCreated eventType"
+  },
+  "raiseEventOnlyOnce": false,
+  "condition": {
+    "type": "eventTypeCondition",
+    "parameterValues": {
+      "eventTypeId": "orderCreated"
+    }
+  },
+  "actions": [
+    {
+      "parameterValues": {
+        "setPropertyName": "properties.nbOfOrders",
+        "setPropertyValue": "script::profile.properties.?nbOfOrders != null ? (profile.properties.nbOfOrders + 1) : 1",
+        "storeInSession": false
+      },
+      "type": "setPropertyAction"
+    }
+  ]
+}
+EOF
+----
\ No newline at end of file
diff --git a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/rule.json b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/rule.json
index 2027c2f..f3fc85f 100644
--- a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/rule.json
+++ b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/rule.json
@@ -42,6 +42,9 @@
     },
     "raiseEventOnlyOnceForSession": {
       "type": "boolean"
+    },
+    "raiseEventOnlyOnce": {
+      "type": "boolean"
     }
   }
 }
\ No newline at end of file
diff --git a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
index d0bdb51..85e4aef 100644
--- a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
+++ b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
@@ -273,6 +273,11 @@ public class EventServiceImpl implements EventService {
         return persistenceService.load(id, Event.class);
     }
 
+    public boolean hasEventAlreadyBeenRaised(Event event) {
+        Event pastEvent = this.persistenceService.load(event.getItemId(), Event.class);
+        return pastEvent != null && pastEvent.getVersion() >= 1 && pastEvent.getSessionId().equals(event.getSessionId());
+    }
+
     public boolean hasEventAlreadyBeenRaised(Event event, boolean session) {
         List<Condition> conditions = new ArrayList<Condition>();
 
diff --git a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
index 4f7161a..6aeeae9 100644
--- a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
+++ b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
@@ -157,6 +157,7 @@ public class RulesServiceImpl implements RulesService, EventListenerService, Syn
     public Set<Rule> getMatchingRules(Event event) {
         Set<Rule> matchedRules = new LinkedHashSet<Rule>();
 
+        Boolean hasEventAlreadyBeenRaised = null;
         Boolean hasEventAlreadyBeenRaisedForSession = null;
         Boolean hasEventAlreadyBeenRaisedForProfile = null;
 
@@ -189,8 +190,14 @@ public class RulesServiceImpl implements RulesService, EventListenerService, Syn
                     updateRuleStatistics(ruleStatistics, ruleConditionStartTime);
                     continue;
                 }
-
-                if (rule.isRaiseEventOnlyOnceForProfile()) {
+                if (rule.isRaiseEventOnlyOnce()) {
+                    hasEventAlreadyBeenRaised = hasEventAlreadyBeenRaised != null ? hasEventAlreadyBeenRaised : eventService.hasEventAlreadyBeenRaised(event);
+                    if (hasEventAlreadyBeenRaised) {
+                        updateRuleStatistics(ruleStatistics, ruleConditionStartTime);
+                        fireAlreadyRaised(RuleListenerService.AlreadyRaisedFor.EVENT, rule, event);
+                        continue;
+                    }
+                } else if (rule.isRaiseEventOnlyOnceForProfile()) {
                     hasEventAlreadyBeenRaisedForProfile = hasEventAlreadyBeenRaisedForProfile != null ? hasEventAlreadyBeenRaisedForProfile : eventService.hasEventAlreadyBeenRaised(event, false);
                     if (hasEventAlreadyBeenRaisedForProfile) {
                         updateRuleStatistics(ruleStatistics, ruleConditionStartTime);
diff --git a/wab/src/main/java/org/apache/unomi/web/ServletCommon.java b/wab/src/main/java/org/apache/unomi/web/ServletCommon.java
index 44785aa..87917e1 100644
--- a/wab/src/main/java/org/apache/unomi/web/ServletCommon.java
+++ b/wab/src/main/java/org/apache/unomi/web/ServletCommon.java
@@ -73,6 +73,9 @@ public class ServletCommon {
                         logger.warn("Event is not allowed : {}", event.getEventType());
                         continue;
                     }
+                    if (thirdPartyId != null && event.getItemId() != null) {
+                        eventToSend = new Event(event.getItemId(), event.getEventType(), session, profile, event.getScope(), event.getSource(), event.getTarget(), event.getProperties(), timestamp, event.isPersistent());
+                    }
                     if (filteredEventTypes != null && filteredEventTypes.contains(event.getEventType())) {
                         logger.debug("Profile is filtering event type {}", event.getEventType());
                         continue;