AEM Content Fragments as an API

One of the many features of Adobe Experience Manager are content fragments. With these you can create schemas, author headless content, and distribute that content to various channels. In the previous blog post we looked into setting up content fragments for use as AEM page level components. This time we will look into enabling content fragments for use as an API. This will make our content available for use in other applications, which will be demonstrated in a third and final blog post.

By way of review, let’s look at the three broad ways of utilizing content fragments:

  1. Content fragment -> Core component -> Add style options
  2. Content fragment -> Access schema from sling model -> Implement requirements as needed in custom component
  3. Content fragment -> Create API by accessing content fragment from a servlet -> Use custom API in another channel

In the previous blog post we already covered the first two methods. Now in this blog post we will look into the API aspects of the third method. Then, in the final blog post, we will utilize that API in a React app.

To get started you will want to first go through the “AEM Content Fragments in the Wild” blog post as we will pick up where it ended. However if you would like to skip that blog post you can also clone and install the fragmentexamples project with “mvn clean install -PautoInstallPackage”.

Setting up content fragments

If you already have gone through the first blog post you can skip this section. Otherwise here are the needed AEM configurations that you will need to perform:

Create the fragmentexamples configuration

The first step is to create the configuration for our sample project, fragmentexamples. AEM configurations allow you to do many things such as editable templates, contextual site configurations, and content fragment configurations.

  1. Go to AEM Start > Tools > General > Configuration Browser > Create
  2. Enter “fragmentexamples” for the title
  3. Check the “Content Fragment Models” checkbox
  4. Click create
Create the fragmentexamples DAM Folder

Content fragments are stored in the AEM DAM and are simply treated as an asset like any image or pdf. We will want to create a DAM folder for storing all of our content fragments.

  1. Go to AEM Start > Assets > Files > Create > Folder
  2. Enter the title “fragmentexamples”
  3. Click create
Apply the fragmentexamples configuration to the fragmentexamples DAM folder

Next we need to apply our fragmentexamples configuration to our fragmentexamples DAM folder so that we can create content fragments with custom schemas within this folder.

  1. Go to AEM Start > Assets > Files
  2. Edit the properties of the fragmentexamples folder
  3. Go to the “Cloud Services” tab
  4. In the “Cloud Configuration” field select “fragmentexamples”
Create a “Movie” content fragment model

Now we are ready to create out model. In order to fulfill the requirements of the user story we need to display a list of movies.

  1. Go to AEM Start > Tools > Assets > Content Fragment Models > fragmentexamples > Create
  2. Set the title to “Movie” and then click “Open”.
  3. Add a single line text field with a field label of “Title” and a field name of “title”
  4. Add a single line text field with a field label of “Description and a field name of “description”
  5. Add a date and time field with a label of “Release Date” and a field name of “releaseDate”
  6. Add a content reference field with the label “Hero Image” and a field name of “heroImage”

Some Other APIs

Before we create our custom API I want to speak briefly to some of the out of the box API’s that are available in AEM and why we will not use them here. Rather than creating a custom API through an AEM servlet we could also utilize either the default GET API, the Assets HTTP API, or the Sling Models API. Each of these provide various levels of access to various types of content. However they each have a unique reason for why we are instead going to opt for a custom API.

Assets HTTP API

Firstly, the Assets HTTP API provides CRUD operations and pagination. However this will not allow us to do field based search, full text search, or other API requirements we might want to provide. For the sake of this blog post we will not use the Assets HTTP API

Sling Model Exporter API

The Sling Model Exporter API allows you to associate a Sling Model to a resource type. Then the public methods of that Sling Model are used to generate a JSON response for HTTP requests to resources of that resource type when they are accessed with the “model” selector. However as this is specific to a resource type we cannot use it for content fragments, as all content fragments have the same resource type whereas different types of components on a page each have a different resource type.

The Default GET API

Finally there is also the Default GET API which is the JSON that AEM provides when you access a specific path with the “json” extension. While this API gives us access to the JSON data of the content fragments it is limited in many ways. For example it cannot give you the JSON data of referenced content. Also it is by no means minimal in that it provides a lot of unnecessary information. Lastly listing out multiple content fragments while technically possible is not practical while using the default GET API. For all of these reasons we will proceed to creating a servlet which will allow us to fine tune our API logic.

Updating the schema and creating data

Content fragment -> Create API by accessing content fragment from a servlet -> Use custom API in another channel

We will create some content and then look at how to interact with this API. To do this go ahead and update the movie model from the previous blog post, create an actor model, and then add some content fragments as explained below.

Update the movie content fragment model
  1. Go to AEM Start > Tools > Assets > Content Fragment Models > fragmentexamples > Movie > Edit
  2. Add a content reference field with a field label of “Actors” and a field name of “actors”. Update the “render as” option to be “multifield”.
Create an “Actor” content fragment model
  1. Go to AEM Start > Tools > Assets > Content Fragment Models > fragmentexamples > Create
  2. Set the title to “Actor” and then click “Open”.
  3. Add a single line text field with a field label of “Name” and a field name of “name”
Creating content fragments
  1. Go to AEM Start > Assets > Files > fragmentexamples > Create > Content Fragment
  2. Select “Movie” or “Actor”
  3. Enter a title for the content fragment
  4. Click create
  5. Fill in the rest of the fields as desired

Start creating sample data by creating a few actor content models following the above steps. Then create at least three movies in this way, each containing at least a title, an imdbProfile, and a few actor references.

Creating a content fragment API

The first code change will be to create a sling model for the actor content fragment model. This will allow us to easily serialize the actors associated to the movies into JSON for our API. This looks very similar to the sling model for the Movie from the previous blog post but with less fields:

  • core/src/main/java/fragmentexamples/core/models/ContentFragmentActor.java
package fragmentexamples.core.models;

import com.adobe.cq.dam.cfm.ContentFragment;
import com.adobe.cq.dam.cfm.ContentElement;
import com.adobe.cq.dam.cfm.FragmentTemplate;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.commons.lang.StringUtils;
import java.util.Optional;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.Self;
import javax.annotation.PostConstruct;
import javax.inject.Inject;

@Model(adaptables = Resource.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class ContentFragmentActor {
    public static final String MODEL_TITLE = "Actor";

    @Inject @Self
    private Resource resource;

    private Optional<ContentFragment> contentFragment;

    @PostConstruct
    public void init() {
        contentFragment = Optional.ofNullable(resource.adaptTo(ContentFragment.class));
    }

    public String getModelTitle() {
        return contentFragment
            .map(ContentFragment::getTemplate)
            .map(FragmentTemplate::getTitle)
            .orElse(StringUtils.EMPTY);
    }

    public String getName() {
        return contentFragment
            .map(cf -> cf.getElement("name"))
            .map(ContentElement::getContent)
            .orElse(StringUtils.EMPTY);
    }
}

Now we are going to update the movie sling model with a method for retrieving a list of the associated actors. Add the following method to the ContentFragmentMovie class. This method uses the content fragment Java API in order to retrieve the value of the actors field from the content fragment. It then converts this object into an array of strings. Then it resolves each string to a resource which are finally adapted to our ContentFragmentActor class that we created above.

  • core/src/main/java/fragmentexamples/core/models/ContentFragmentMovie.java
...

public class ContentFragmentMovie {	
    ...

    public List<ContentFragmentActor> getActors() {
        return Arrays.asList((String[]) contentFragment
            .map(cf -> cf.getElement("actors"))
            .map(ContentElement::getValue)
            .map(FragmentData::getValue)
            .orElse(new String[0]))
            .stream()
            .map(actorPath -> resource.getResourceResolver().resolve(actorPath))
            .filter(Objects::nonNull)
            .map(actorResource -> actorResource.adaptTo(ContentFragmentActor.class))
            .collect(Collectors.toList());
    }
}

The final step will be to create the actual servlet. I will show you the whole servlet here and then explain each part individually.

  • core/src/main/java/fragmentexamples/core/servlets/MovieServlet.java
package fragmentexamples.core.servlets;

import com.day.cq.search.PredicateGroup;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import fragmentexamples.core.models.ContentFragmentMovie;
import org.apache.commons.lang.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.HttpConstants;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.osgi.framework.Constants;
import javax.jcr.Session;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import java.io.IOException;
import org.osgi.service.component.annotations.Component;
import com.day.cq.search.QueryBuilder;
import com.day.cq.search.Query;

import java.util.*;
import java.util.stream.Collectors;
import javax.jcr.RepositoryException;
import com.day.cq.search.result.SearchResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component(service=Servlet.class,
           property={
                   Constants.SERVICE_DESCRIPTION + "=Movie Servlet",
                   "sling.servlet.methods=" + HttpConstants.METHOD_GET,
                   "sling.servlet.paths="+ "/bin/fragmentexamples/movies",
                   "sling.servlet.extensions=" + "json"
           })
public class MovieServlet extends SlingSafeMethodsServlet {
    private static final Logger log = LoggerFactory.getLogger(MovieServlet.class);

    private static final long serialVersionUID = 1L;
    private static final List<String> reservedParams = Arrays.asList( "search" );

    @Override
    protected void doGet(final SlingHttpServletRequest req,
                         final SlingHttpServletResponse resp)
                         throws ServletException, IOException {
        final QueryBuilder queryBuilder = req.getResourceResolver().adaptTo(QueryBuilder.class);

        final Map<String, String> map = new HashMap<String, String>();
        map.put("type", "dam:Asset");
        map.put("path", "/content/dam");

        map.put("boolproperty", "jcr:content/contentFragment");
        map.put("boolproperty.value", "true");

        map.put("property", "jcr:content/data/cq:model");
        map.put("property.value", "/conf/fragmentexamples/settings/dam/cfm/models/movie");

        final String search = req.getParameter("search");
        if (StringUtils.isNotEmpty(search)) {
            map.put("fulltext", search);
            map.put("fulltext.relPath", "jcr:content/data/master");
        }

        int paramCount = 1;
        for (final String key : req.getParameterMap().keySet()) {
            paramCount++;
            if (! reservedParams.contains(key)) {
                map.put(paramCount + "_property", "jcr:content/data/master/" + key);
                map.put(paramCount + "_property.value", req.getParameter(key));
            }
        }

        final Query query = queryBuilder.createQuery(PredicateGroup.create(map), req.getResourceResolver().adaptTo(Session.class));
        final SearchResult result = query.getResult();
        final ObjectMapper objectMapper = new ObjectMapper();
        final List<ContentFragmentMovie> test = result.getHits().stream()
        .map(hit -> {
            try {
                return req.getResourceResolver().resolve(hit.getPath()).adaptTo(ContentFragmentMovie.class);
            } catch (RepositoryException e) {
                log.error("Error collecting search results", e);
                return null;
            }
        })
        .filter(Objects::nonNull)
        .collect(Collectors.toList());

        resp.setContentType("application/json");
        try {
            resp.getWriter().write(objectMapper.writeValueAsString(test));
        } catch (JsonProcessingException e) {
            resp.getWriter().write("{ \"error\": \"Could not write movies as JSON\" }");
        }
    }
}

The first thing we do is limit the scope to only dam assets (line 1), only to the dam path (line 2), only to content fragments (lines 4 and 5) and only to content fragments of the movie model (lines 7 and 8).

map.put("type", "dam:Asset");
map.put("path", "/content/dam");

map.put("boolproperty", "jcr:content/contentFragment");
map.put("boolproperty.value", "true");

map.put("property", "jcr:content/data/cq:model");
map.put("property.value", "/conf/fragmentexamples/settings/dam/cfm/models/movie");

Next we do a full text search using the “search” GET parameter if it is available, searching on the master variation data. You could also make the variation that is searched on a GET parameter where “master” is used if no variation is supplied. However for now we will limit the scope to only searching on the master variation.

final String search = req.getParameter("search");
if (StringUtils.isNotEmpty(search)) {
    map.put("fulltext", search);
    map.put("fulltext.relPath", "jcr:content/data/master");
}

The final step in our query is to filter on exact equality for each other GET parameter that is supplied so that the user can fine tune exactly what movies they want to retrieve based upon any of the fields from the movie schema.

int paramCount = 1;
for (final String key : req.getParameterMap().keySet()) {
    paramCount++;
    if (! reservedParams.contains(key)) {
        map.put(paramCount + "_property", "jcr:content/data/master/" + key);
        map.put(paramCount + "_property.value", req.getParameter(key));
    }
}

Then we get each hit from the query and adapt them to the ContentFragmentMovie class. This will be serialize-able into JSON and will include the list of actors.

req.getResourceResolver().resolve(hit.getPath()).adaptTo(ContentFragmentMovie.class);

Finally we serialize the array of movies into a JSON string containing all of the matched movies each with a subarray of linked actors.

resp.getWriter().write(objectMapper.writeValueAsString(test));

This finishes the creation of our API. Go ahead and build the changes with “mvn clean install -PautoInstallPackage”. Now let’s go ahead and take a look at how to utilize our movie API. Firstly we can supply no parameter in order to get all of our movies:

[
   {
      "modelTitle":"Movie",
      "actors":[
         {
            "name":"Cate Blanchett",
            "modelTitle":"Actor"
         }
      ],
      "title":"Another movie",
      "image":"/content/dam/we-retail/en/experiences/arctic-surfing-in-lofoten/camp-fire.jpg",
      "description":"Stuff",
      "releaseDate":1563508800000,
      "imdbProfile":"https://www.imdb.com/title/tt0096895"
   },
   {
      "modelTitle":"Movie",
      "actors":[
         {
            "name":"Sean Astin",
            "modelTitle":"Actor"
         },
         {
            "name":"Cate Blanchett",
            "modelTitle":"Actor"
         }
      ],
      "title":"Fellowship of the Ring",
      "image":"/content/dam/fragmentexamples/fellowship-of-the-ring.jpg",
      "description":"The best movie ever CHANGED",
      "releaseDate":1008738000000,
      "imdbProfile":"https://www.imdb.com/title/tt0120737"
   }
]

We can also search using full text search:

[
   {
      "modelTitle":"Movie",
      "actors":[
         {
            "name":"Sean Astin",
            "modelTitle":"Actor"
         },
         {
            "name":"Cate Blanchett",
            "modelTitle":"Actor"
         }
      ],
      "title":"Fellowship of the Ring",
      "image":"/content/dam/fragmentexamples/fellowship-of-the-ring.jpg",
      "description":"The best movie ever CHANGED",
      "releaseDate":1008738000000,
      "imdbProfile":"https://www.imdb.com/title/tt0120737"
   }
]

Lastly we can search by specifying an exact match on any of the fields of our movie model:

[
   {
      "modelTitle":"Movie",
      "actors":[
         {
            "name":"Sean Astin",
            "modelTitle":"Actor"
         },
         {
            "name":"Cate Blanchett",
            "modelTitle":"Actor"
         }
      ],
      "title":"Fellowship of the Ring",
      "image":"/content/dam/fragmentexamples/fellowship-of-the-ring.jpg",
      "description":"The best movie ever CHANGED",
      "releaseDate":1008738000000,
      "imdbProfile":"https://www.imdb.com/title/tt0120737"
   }
]

You could also mix and match the search parameter and multiple field parameters restricting the result set. This API can then be used as a headless CMS and integrated into various channels such as an AEM hosted website, other web applications, mobile applications, and more. In the third and final blog post in this series we will look an an example of utilizing this API in a React application.

Pros

  1. Any API requirements can be created
  2. Referenced content can be returned to a single HTTP request
  3. Content fragments can be returned in a list based upon provided input parameters

Cons

  1. Requires creating and maintaining a custom API through an AEM servlet
Next steps
  1. Add pagination
  2. Add a path parameter for restricting the search to a given path
  3. Add a variation parameter for specifying what variation of content fragment you want to search for
  4. Move the logic into a service that can be reused in a servlet or in other Java classes

Conclusion

While we only scratched the surface of what is possible, we demonstrated that Adobe Experience Manager content fragments provide a lot of functionality with minimal customization, but can also be easily expanded and customized to fit a wide variety of use cases.

Leave a Reply

avatar
  Subscribe  
Notify of