Liferay ThemeDisplay in Angular apps

For a recent project, we developed an Angular application that uses Liferay DXP as the back end. In this Angular application we regularly make requests into the Portal back end for User information, Journal Articles, ... Mostly this works great as we have defined several custom REST end points in DXP from which we can make use of the Liferay services and other nifty Liferay stuff.

However at one point we encountered an issue where one of our end points did not return the expected Journal Articles. In fact it even threw an exception underneath.

After some short investigation we determined that the Audience Targeting rules were not being applied properly to the Journal Articles. The actual cause is that several Audience Targeting rules make use of the Liferay ThemeDisplay object. You probably know that this is one of the standard Liferay objects available in each portlet request as an attribute under the logical name themeDisplay. And that it can be retrieved by using following code:

request.getAttribute(WebKeys.THEME_DISPLAY);

What seems to be the officer, problem?

As I have mentioned we make use of  REST end points and thus not of portlet requests. These REST requests are made from our Angular front end to custom end points in a Liferay environment, so called Controllers. Below is an example to retrieve some news articles. The first block of code is taken from our Angular service that makes the request. The second block of code is from the Java Controller which will respond to the request and returns news articles.

public findNewsArticles(limit?: number): Observable<Response<Array<NewsArticle>>> {
    const params = this.createHttpParams(limit);
    const url = this.articlesUrl();
 
    return this.http.get<Response<Array<NewsArticle>>>(url, {params: params});
}
@GET
@Path("/group/{groupId}/articles")
@Produces(MediaType.APPLICATION_JSON)
public Response getNewsArticles(final @Context HttpServletRequest request, final @Context HttpServletResponse response, @PathParam("groupId") final long groupId, @QueryParam("limit") final long limit) throws Exception {
   long aLimit = limit == 0 ? Long.MAX_VALUE : limit;
   User user = portal.getUser(request);
 
   List<UserSegment> userSegments = userSegmentService.getUserSegments(groupId, request, response);
 
   return newsService.findByUser(user, groupId, aLimit, userSegments)
         .map(this::successResponse)
         .orElseGet(this::notFoundResponse);
}

The problem which we encountered is that these REST requests don't carry a ThemeDisplay object. Because of this the list of userSegments in above example is not correctly constructed as the rules can't properly determine whether or not a user belongs to a certain segment.

So some way or another we needed to be able to pass the ThemeDisplay object from the front end to the back end through the custom REST requests. We contacted Liferay support using a LESA ticket to get this straightened out. With their help it became clear we needed to add the ThemeDisplay object manually to the REST request. Following the standard Liferay JSON WS documentation, it should be possible as follows:

Liferay.Service(
    '/api/jsonws/journal.journalarticle/get-article-content',
    {
        groupId: 20126,
        articleId: '40809',
        languageId: 'en_US',
        themeDisplay: {
            locale: "en_US"
        }
    },
    function(obj) {
        console.log(obj);
    }
);

In code we trust

As is mostly the case when in doubt with how to achieve something in Liferay: turn yourself towards the source code!
In the default Liferay JSON WS the ThemeDisplay object is actually passed as a form parameter from the front end to the back end. This also requisites that the request is actually a POST request. In the back end there are subsequent servlet filters that operate on these requests. One of these filters transforms the themeDisplay JSON string into a Java object and adds it as a request attribute.

So it became clear to us that we also needed to make adjustment in both ends.

Front end

Of course there are several ways to pass the ThemeDisplay object from the front end. To prevent much changes to our existing code base and because it is a fast & easy manner, we chose to pass the ThemeDisplay object as a header in the REST requests. This is very convenient because we can now add a plain JavaScript object, just as in the Liferay JSON WS examples. So we only need to construct a JSON string with all the necessary fields and their values.

Here be dragons
You can make use of the Liferay.ThemeDisplay object which Liferay provides, if you are still in a Liferay environment. However you cannot use this object itself als the value. It consists of functions and if those are parsed to a string, they just become null. But you can make use of the Liferay.ThemeDisplay object to populate your own object. You will see this in the following examples.

To limit to duplication of code we also made use of an Interceptor. This is an Angular component that will inspect every HTTP request and potentially transform it. As the ThemeDisplay object isn't required for each and every request but solely to those where it's actually necessary for our implementation, we added a small url validation.

So in our Angular app we added below themedisplay.interceptor.ts component:

@Injectable()
export class ThemeDisplayInterceptor implements HttpInterceptor {
 
   intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
      if (this.matchingUrl(req)) {
         const modified = req.clone({setHeaders: {
               'themeDisplay': `{"languageId": "` + Liferay.ThemeDisplay.getLanguageId() + `"}`
            }
         });
 
         return next.handle(modified);
      }
 
      return next.handle(req);
   }
 
   private matchingUrl(req: HttpRequest<any>) {
      return req.method == 'GET' && req.url.match('\/o\/acanews' +
         '|\/o\/acatasks' +
         '|\/o\/acafaqs' +
         '|\/o\/acabanners'
      );
   }
}

Back end

When the ThemeDisplay is passed as a string as in above example, it enters the back end as a header on the HttpServletRequest. So here are two issues to solve:

  1. the ThemeDisplay object is not yet in the Liferay essential location
  2. the object is of type String

Thankfully this can be resolved without much effort. Get the String header from the request, convert it to a ThemeDisplay object and add it back to the request under the correct attribute. The conversion seems to be the most complex issue. But thankfully Liferay as a lot of useful utilities and in this case it would be the JSONFactoryUtil. Using this utility you can easily transform a JSON String to a certain typed object.
Below method executes all these actions at once:

private void convertThemeDisplay(HttpServletRequest request) {
   String themeDisplay = request.getHeader(THEME_DISPLAY_PARAM_NAME);
 
   if (!isEmpty(themeDisplay)) {
      ThemeDisplay td = JSONFactoryUtil.looseDeserialize(themeDisplay, ThemeDisplay.class);
 
      request.setAttribute(WebKeys.THEME_DISPLAY, td);
   }
}

The isEmpty check in above code is necessary to prevent any NullPointerExceptions in case there wouldn't be a themeDisplay header present.

So we used this method in a custom ServletFilter so it can be executed on all requests without changing any of the existing controllers. By providing url-patterns in the @Component definition, it is also possible to just change those requests deemed necessary:

@Component(
      immediate = true,
      property = {
            "servlet-context-name=",
            "servlet-filter-name=ThemeDisplay Filter",
            "url-pattern=/o/acanews/*",
            "url-pattern=/o/acatasks/*",
            "url-pattern=/o/acafaqs/*",
            "url-pattern=/o/acabanners/*"
      },
      service = Filter.class
)
public class ThemeDisplayFilter extends BaseFilter {
 
   private static final Log LOGGER = LogFactoryUtil.getLog(ThemeDisplayFilter.class);
 
   private static final String THEME_DISPLAY_PARAM_NAME = "themeDisplay";
 
   @Override
   protected void processFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
      convertThemeDisplay(request);
 
      super.processFilter(request, response, filterChain);
   }
 
   private void convertThemeDisplay(HttpServletRequest request) {
      String themeDisplay = request.getHeader(THEME_DISPLAY_PARAM_NAME);
 
      if (!isEmpty(themeDisplay)) {
         ThemeDisplay td = JSONFactoryUtil.looseDeserialize(themeDisplay, ThemeDisplay.class);
 
         request.setAttribute(WebKeys.THEME_DISPLAY, td);
      }
   }
 
   @Override
   protected Log getLog() {
      return LOGGER;
   }
}

Well... There it is

By performing the necessary front and back end changes, we can now make proper use of Audience Targeting rules in the Controllers for our Angular application.

In the front end we intercept the desired REST requests and add a header with the ThemeDisplay (JSON) String. It is sufficient to only add to this object the necessary fields for the Audience Targeting rules.
In the back end we retrieve this String from the headers, deserialize it to an actual ThemeDisplay object and add it to the request attributes under the WebKeys.THEME_DISPLAY name. All this occurs in a ServletFilter that will only respond to the desired url patterns.

At the moment it is not possible to just pass the entire Liferay.ThemeDisplay object that Liferay provides OOTB. This object does not contain any field with values but is constructed out of functions. So we need to construct the ThemeDisplay object ourselves.

By using an Angular Interceptor and a Liferay Servlet Filter, any request can be updated transparently without touching too much code.

 

Blogs

Hi There Angular app is independent to the liferay portal then how are you able to using the Liferay Global object??

Hello Mayursinh,

 

Thank you for your question.

We actually have two approaches: one where the Angular app is a separate app, the other where the Angular app is part of a Liferay theme.

In the first case, we cannot use the Liferay Global JS object. But in the second case we can, as this is provided by the Liferay theme.

 

Regards,

 

Koen

Useful !  what i understood is to use these objects we need to create  angular portal insted of independent angular environment . Same goes  with vue.js portlet right ??