4.4. Example

4.4.1. HTML page

The code for the HTML page with our Tweet button can be found at https://github.com/apache/incubator-unomi/blob/master/wab/src/main/webapp/index.html.

This HTML page is fairly straightforward: we create a tweet button using the Twitter API while a Javascript script performs the actual logic.

4.4.2. Javascript

Globally, the script loads both the twitter widget and the initial context asynchronously (as shown previously). This is accomplished using fairly standard javascript code and we won’t look at it here. Using the Twitter API, we react to the tweet event and call the Unomi server to update the user’s profile with the required information, triggering a custom tweetEvent event. This is accomplished using a contextRequest function which is an extended version of a classic AJAX request:

  1. function contextRequest(successCallback, errorCallback, payload) {
  2. var data = JSON.stringify(payload);
  3. // if we don't already have a session id, generate one
  4. var sessionId = cxs.sessionId || generateUUID();
  5. var url = 'http://localhost:8181/context.json?sessionId=' + sessionId;
  6. var xhr = new XMLHttpRequest();
  7. var isGet = data.length < 100;
  8. if (isGet) {
  9. xhr.withCredentials = true;
  10. xhr.open("GET", url + "&payload=" + encodeURIComponent(data), true);
  11. } else if ("withCredentials" in xhr) {
  12. xhr.open("POST", url, true);
  13. xhr.withCredentials = true;
  14. } else if (typeof XDomainRequest != "undefined") {
  15. xhr = new XDomainRequest();
  16. xhr.open("POST", url);
  17. }
  18. xhr.onreadystatechange = function () {
  19. if (xhr.readyState != 4) {
  20. return;
  21. }
  22. if (xhr.status ==== 200) {
  23. var response = xhr.responseText ? JSON.parse(xhr.responseText) : undefined;
  24. if (response) {
  25. cxs.sessionId = response.sessionId;
  26. successCallback(response);
  27. }
  28. } else {
  29. console.log("contextserver: " + xhr.status + " ERROR: " + xhr.statusText);
  30. if (errorCallback) {
  31. errorCallback(xhr);
  32. }
  33. }
  34. };
  35. xhr.setRequestHeader("Content-Type", "text/plain;charset=UTF-8"); // Use text/plain to avoid CORS preflight
  36. if (isGet) {
  37. xhr.send();
  38. } else {
  39. xhr.send(data);
  40. }
  41. }

There are a couple of things to note here:

  • If we specify a payload, it is expected to use the JSON format so we stringify it and encode it if passed as a URL parameter in a GET request.

  • We need to make a CORS request since the Unomi server is most likely not running on the same host than the one from which the request originates. The specific details are fairly standard and we will not explain them here.

  • We need to either retrieve (from the initial context we retrieved previously using cxs.sessionId) or generate a session identifier for our request since Unomi currently requires one.

  • We’re calling the ContextServlet using the default install URI, specifying the session identifier: http://localhost:8181/context.json?sessionId='; + sessionId. This URI requests context from Unomi, resulting in an updated cxs object in the javascript global scope. The context server can reply to this request either by returning a JSON-only object containing solely the context information as is the case when the requested URI is context.json. However, if the client requests context.js then useful functions to interact with Unomi are added to the cxs object in addition to the context information as depicted above.

  • We don’t need to provide any authentication at all to interact with this part of Unomi since we only have access to read-only data (as well as providing events as we shall see later on). If we had been using the REST API, we would have needed to provide authentication information as well.

Context request and response structure

The interesting part, though, is the payload. This is where we provide Unomi with contextual information as well as ask for data in return. This allows clients to specify which type of information they are interested in getting from the context server as well as specify incoming events or content filtering or property/segment overrides for personalization or impersonation. This conditions what the context server will return with its response.

Let’s look at the context request structure:

  1. {
  2. source: <Item source of the context request>,
  3. events: <optional array of triggered events>,
  4. requiredProfileProperties: <optional array of property identifiers>,
  5. requiredSessionProperties: <optional array of property identifiers>,
  6. filters: <optional array of filters to evaluate>,
  7. profileOverrides: <optional profile containing segments,scores or profile properties to override>,
  8. - segments: <optional array of segment identifiers>,
  9. - profileProperties: <optional map of property name / value pairs>,
  10. - scores: <optional map of score id / value pairs>
  11. sessionPropertiesOverrides: <optional map of property name / value pairs>,
  12. requireSegments: <boolean, whether to return the associated segments>
  13. }

We will now look at each part in greater details.

Source

A context request payload needs to at least specify some information about the source of the request in the form of an Item (meaning identifier, type and scope plus any additional properties we might have to provide), via the source property of the payload. Of course the more information can be provided about the source, the better.

Filters

A client wishing to perform content personalization might also specify filtering conditions to be evaluated by the context server so that it can tell the client whether the content associated with the filter should be activated for this profile/session. This is accomplished by providing a list of filter definitions to be evaluated by the context server via the filters field of the payload. If provided, the evaluation results will be provided in the filteringResults field of the resulting cxs object the context server will send.

Overrides

It is also possible for clients wishing to perform user impersonation to specify properties or segments to override the proper ones so as to emulate a specific profile, in which case the overridden value will temporarily replace the proper values so that all rules will be evaluated with these values instead of the proper ones. The segments (array of segment identifiers), profileProperties (maps of property name and associated object value) and scores (maps of score id and value) all wrapped in a profileOverrides object and the sessionPropertiesOverrides (maps of property name and associated object value) fields allow to provide such information. Providing such overrides will, of course, impact content filtering results and segments matching for this specific request.

Controlling the content of the response

The clients can also specify which information to include in the response by setting the requireSegments property to true if segments the current profile matches should be returned or provide an array of property identifiers for requiredProfileProperties or requiredSessionProperties fields to ask the context server to return the values for the specified profile or session properties, respectively. This information is provided by the profileProperties, sessionProperties and profileSegments fields of the context server response.

Additionally, the context server will also returns any tracked conditions associated with the source of the context request. Upon evaluating the incoming request, the context server will determine if there are any rules marked with the trackedCondition tag and which source condition matches the source of the incoming request and return these tracked conditions to the client. The client can use these tracked conditions to learn that the context server can react to events matching the tracked condition and coming from that source. This is, in particular, used to implement form mapping (a solution that allows clients to update user profiles based on values provided when a form is submitted).

Events

Finally, the client can specify any events triggered by the user actions, so that the context server can process them, via the events field of the context request.

Default response

If no payload is specified, the context server will simply return the minimal information deemed necessary for client applications to properly function: profile identifier, session identifier and any tracked conditions that might exist for the source of the request.

Context request for our example

Now that we’ve seen the structure of the request and what we can expect from the context response, let’s examine the request our component is doing.

In our case, our source item looks as follows: we specify a scope for our application (unomi-tweet-button-samples), specify that the item type (i.e. the kind of element that is the source of our event) is a page (which corresponds, as would be expected, to a web page), provide an identifier (in our case, a Base-64 encoded version of the page’s URL) and finally, specify extra properties (here, simply a url property corresponding to the page’s URL that will be used when we process our event in our Unomi extension).

  1. var scope = 'unomi-tweet-button-samples';
  2. var itemId = btoa(window.location.href);
  3. var source = {
  4. itemType: 'page',
  5. scope: scope,
  6. itemId: itemId,
  7. properties: {
  8. url: window.location.href
  9. }
  10. };

We also specify that we want the context server to return the values of the tweetNb and tweetedFrom profile properties in its response. Finally, we provide a custom event of type tweetEvent with associated scope and source information, which matches the source of our context request in this case.

  1. var contextPayload = {
  2. source: source,
  3. events: [
  4. {
  5. eventType: 'tweetEvent',
  6. scope: scope,
  7. source: source
  8. }
  9. ],
  10. requiredProfileProperties: [
  11. 'tweetNb',
  12. 'tweetedFrom'
  13. ]
  14. };

The tweetEvent event type is not defined by default in Unomi. This is where our Unomi plugin comes into play since we need to tell Unomi how to react when it encounters such events.

Unomi plugin overview

In order to react to tweetEvent events, we will define a new Unomi rule since this is exactly what Unomi rules are supposed to do. Rules are guarded by conditions and if these conditions match, the associated set of actions will be executed. In our case, we want our new incrementTweetNumber rule to only react to tweetEvent events and we want it to perform the profile update accordingly: create the property types for our custom properties if they don’t exist and update them. To do so, we will create a custom incrementTweetNumberAction action that will be triggered any time our rule matches. An action is some custom code that is deployed in the context server and can access the Unomi API to perform what it is that it needs to do.

Rule definition

Let’s look at how our custom incrementTweetNumber rule is defined:

  1. {
  2. "metadata": {
  3. "id": "smp:incrementTweetNumber",
  4. "name": "Increment tweet number",
  5. "description": "Increments the number of times a user has tweeted after they click on a tweet button"
  6. },
  7. "raiseEventOnlyOnceForSession": false,
  8. "condition": {
  9. "type": "eventTypeCondition",
  10. "parameterValues": {
  11. "eventTypeId": "tweetEvent"
  12. }
  13. },
  14. "actions": [
  15. {
  16. "type": "incrementTweetNumberAction",
  17. "parameterValues": {}
  18. }
  19. ]
  20. }

Rules define a metadata section where we specify the rule name, identifier and description.

When rules trigger, a specific event is raised so that other parts of Unomi can react to it accordingly. We can control how that event should be raised. Here we specify that the event should be raised each time the rule triggers and not only once per session by setting raiseEventOnlyOnceForSession to false, which is not strictly required since that is the default. A similar setting (raiseEventOnlyOnceForProfile) can be used to specify that the event should only be raised once per profile if needed.

We could also specify a priority for our rule in case it needs to be executed before other ones when similar conditions match. This is accomplished using the priority property. We’re using the default priority here since we don’t have other rules triggering on tweetEvents and don’t need any special ordering.

We then tell Unomi which condition should trigger the rule via the condition property. Here, we specify that we want our rule to trigger on an eventTypeCondition condition. Unomi can be extended by adding new condition types that can enrich how matching or querying is performed. The condition type definition file specifies which parameters are expected for our condition to be complete. In our case, we use the built-in event type condition that will match if Unomi receives an event of the type specified in the condition’s eventTypeId parameter value: tweetEvent here.

Finally, we specify a list of actions that should be performed as consequences of the rule matching. We only need one action of type incrementTweetNumberAction that doesn’t require any parameters.

Action definition

Let’s now look at our custom incrementTweetNumberAction action type definition:

  1. {
  2. "id": "incrementTweetNumberAction",
  3. "actionExecutor": "incrementTweetNumber",
  4. "systemTags": [
  5. "event"
  6. ],
  7. "parameters": []
  8. }

We specify the identifier for the action type, a list of systemTags if needed: here we say that our action is a consequence of events using the event tag. Our actions does not require any parameters so we don’t define any.

Finally, we provide a mysterious actionExecutor identifier: incrementTweetNumber.

Action executor definition

The action executor references the actual implementation of the action as defined in our blueprint definition:

  1. <blueprint xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  2. xmlns="http://www.osgi.org/xmlns/blueprint/v1.0.0"
  3. xsi:schemaLocation="http://www.osgi.org/xmlns/blueprint/v1.0.0 http://www.osgi.org/xmlns/blueprint/v1.0.0/blueprint.xsd">
  4. <reference id="profileService" interface="org.apache.unomi.api.services.ProfileService"/>
  5. <!-- Action executor -->
  6. <service id="incrementTweetNumberAction" interface="org.apache.unomi.api.actions.ActionExecutor">
  7. <service-properties>
  8. <entry key="actionExecutorId" value="incrementTweetNumber"/>
  9. </service-properties>
  10. <bean class="org.apache.unomi.examples.unomi_tweet_button_plugin.actions.IncrementTweetNumberAction">
  11. <property name="profileService" ref="profileService"/>
  12. </bean>
  13. </service>
  14. </blueprint>

In standard Blueprint fashion, we specify that we will need the profileService defined by Unomi and then define a service of our own to be exported for Unomi to use. Our service specifies one property: actionExecutorId which matches the identifier we specified in our action definition. We then inject the profile service in our executor and we’re done for the configuration side of things!

Action executor implementation

Our action executor definition specifies that the bean providing the service is implemented in the org.apache.unomi.samples.tweet_button_plugin.actions
.IncrementTweetNumberAction
class. This class implements the Unomi ActionExecutor interface which provides a single int execute(Action action, Event event) method: the executor gets the action instance to execute along with the event that triggered it, performs its work and returns an integer status corresponding to what happened as defined by public constants of the EventService interface of Unomi: NO_CHANGE, SESSION_UPDATED or PROFILE_UPDATED.

Let’s now look at the implementation of the method:

  1. final Profile profile = event.getProfile();
  2. Integer tweetNb = (Integer) profile.getProperty(TWEET_NB_PROPERTY);
  3. List<String> tweetedFrom = (List<String>) profile.getProperty(TWEETED_FROM_PROPERTY);
  4. if (tweetNb ==== null || tweetedFrom ==== null) {
  5. // create tweet number property type
  6. PropertyType propertyType = new PropertyType(new Metadata(event.getScope(), TWEET_NB_PROPERTY, TWEET_NB_PROPERTY, "Number of times a user tweeted"));
  7. propertyType.setValueTypeId("integer");
  8. service.createPropertyType(propertyType);
  9. // create tweeted from property type
  10. propertyType = new PropertyType(new Metadata(event.getScope(), TWEETED_FROM_PROPERTY, TWEETED_FROM_PROPERTY, "The list of pages a user tweeted from"));
  11. propertyType.setValueTypeId("string");
  12. propertyType.setMultivalued(true);
  13. service.createPropertyType(propertyType);
  14. tweetNb = 0;
  15. tweetedFrom = new ArrayList<>();
  16. }
  17. profile.setProperty(TWEET_NB_PROPERTY, tweetNb + 1);
  18. final String sourceURL = extractSourceURL(event);
  19. if (sourceURL != null) {
  20. tweetedFrom.add(sourceURL);
  21. }
  22. profile.setProperty(TWEETED_FROM_PROPERTY, tweetedFrom);
  23. return EventService.PROFILE_UPDATED;

It is fairly straightforward: we retrieve the profile associated with the event that triggered the rule and check whether it already has the properties we are interested in. If not, we create the associated property types and initialize the property values.

Note that it is not an issue to attempt to create the same property type multiple times as Unomi will not add a new property type if an identical type already exists.

Once this is done, we update our profile with the new property values based on the previous values and the metadata extracted from the event using the extractSourceURL method which uses our url property that we’ve specified for our event source. We then return that the profile was updated as a result of our action and Unomi will properly save it for us when appropriate. That’s it!

For reference, here’s the extractSourceURL method implementation:

  1. private String extractSourceURL(Event event) {
  2. final Item sourceAsItem = event.getSource();
  3. if (sourceAsItem instanceof CustomItem) {
  4. CustomItem source = (CustomItem) sourceAsItem;
  5. final String url = (String) source.getProperties().get("url");
  6. if (url != null) {
  7. return url;
  8. }
  9. }
  10. return null;
  11. }