Fake REST, Real Results: Internal Headless Calls in Liferay Java Code

Reuse Liferay's RESTBuilder Engine's (Vulcan) internal dispatch pattern to invoke Headless endpoints in-process: no HTTP client, no network hop.

David H Nebinger
David H Nebinger
4 Minute Read

A community member recently asked for the easiest way to call Liferay Headless APIs from Java without generating a REST client or making outbound HTTP calls. The answer is surprisingly simple: Liferay already does this internally for FreeMarker templates via the restClient helper. In this post, we’ll walk through how that works, starting with RESTClientTemplateContextContributor, and then build a fully injectable OSGi InternalRESTClient service that performs GET, POST, PUT, PATCH, and DELETE calls entirely in-process. We’ll replicate the behavior of the Learn example for listing web content structures, but without using a remote REST client at all.

The Question: "Can I Call Headless Without Using a REST Client?"

If you’ve ever looked at the Learn examples for Headless APIs such as the script that lists content structures:

GET /o/headless-delivery/v1.0/sites/{siteId}/content-structures

You’ll notice the provided Java sample uses a generated REST client. That works great when calling Liferay from the outside.

But what if you’re already inside Liferay?

Do you really need to:

  • Configure a base URL?
  • Authenticate back to your own server?
  • Create an HTTP client?
  • Pay for a network hop just to call yourself?

No.

Liferay already solved this internally.

The Hidden Mechanism: RESTClientTemplateContextContributor

Liferay’s Headless engine (Vulcan) includes:

com.liferay.portal.vulcan.internal.template.
  RESTClientTemplateContextContributor

This class contributes a restClient object to FreeMarker templates.

That’s why you can write this inside a template:

${restClient.get(
  "/headless-delivery/v1.0/sites/20125/content-structures")}

And it just works.

Here’s the important part:

It does NOT make an outbound HTTP request.

Instead, it performs an internal servlet dispatch:

RequestDispatcher requestDispatcher =
  servletContext.getRequestDispatcher(
    Portal.PATH_MODULE + path);

requestDispatcher.forward(wrappedRequest, pipingResponse);

That means:

  • Same endpoint code path
  • Same permission checks
  • Same serialization logic
  • Same response payload

But no network involved. So there is still overhead going this route, just not as much overhead since you exclude the network.

What the Internal Template Client Actually Does

The FreeMarker restClient implementation uses three key building blocks:

1. Internal Request Dispatch

It forwards to:

/o/headless-delivery/...

Using:

Portal.PATH_MODULE + path

2. A Request Delegate

RESTClientHttpRequestDelegate ensures the forwarded request behaves like a proper Headless request:

  • Sets HTTP method
  • Forces Accept: application/json
  • Applies locale
  • Preserves permission checker
  • Injects user context

3. A Piping Response

PipingServletResponse captures the output into a StringWriter, so the JSON response can be returned as a string (and optionally deserialized).

Where Do I Get HttpServletRequest?

This is the most common follow-up question.

From MVC / Servlet / Portlet code

You already have it.

From Service Builder / Local Services

Use ServiceContext:

ServiceContext sc = ServiceContextThreadLocal
  .getServiceContext();

HttpServletRequest request = 
  (sc != null) ? sc.getRequest() : null;

In most service-layer executions triggered by web requests, ServiceContextThreadLocal is already populated.

If you truly have no request (background threads, message listeners, scheduled jobs), you’ll need to construct execution context manually.

Building a Proper Injectable Internal REST Client

Rather than leaving this as a copy/paste utility class, let’s build a reusable OSGi service.

We’ll support:

  • GET
  • DELETE
  • POST
  • PUT
  • PATCH
  • Query parameters
  • Optional request body
  • Response status capture
  • Header capture (simple)
  • Automatic JSON deserialization

Service Interface

public interface InternalRESTClient {

  InternalRESTResponse get(HttpServletRequest request, 
    String path, Map<String, ?> queryParams) throws Exception;

  InternalRESTResponse delete(HttpServletRequest request, 
    String path, Map<String, ?> queryParams) throws Exception;

  InternalRESTResponse post(
    HttpServletRequest request, String path, 
    Map<String, ?> queryParams, String body, 
    String contentType) throws Exception;

  InternalRESTResponse put(
    HttpServletRequest request, String path, 
    Map<String, ?> queryParams, String body, 
    String contentType) throws Exception;

  InternalRESTResponse patch(
    HttpServletRequest request, String path, 
    Map<String, ?> queryParams, String body, 
    String contentType) throws Exception;
}

Response Wrapper

public class InternalRESTResponse {

    public final int status;
    public final String contentType;
    public final String body;
    public final Map<String, String> headers;
    public final Object jsonObject;

    public InternalRESTResponse(
            int status, String contentType, String body,
            Map<String, String> headers,
            Object jsonObject) {

        this.status = status;
        this.contentType = contentType;
        this.body = body;
        this.headers = headers;
        this.jsonObject = jsonObject;
    }
}

OSGi Component Implementation

@Component(service = InternalRESTClient.class)
public class InternalRESTClientImpl 
  implements InternalRESTClient {

    @Reference
    private JSONFactory _jsonFactory;

    @Override
    public InternalRESTResponse get(HttpServletRequest request,
      String path, Map<String, ?> queryParams) 
      throws Exception {
        return _invoke(request, "GET", path, queryParams, 
          null, null);
    }

    @Override
    public InternalRESTResponse delete(
      HttpServletRequest request, String path, 
      Map<String, ?> queryParams) throws Exception {
        return _invoke(request, "DELETE", path, queryParams, 
          null, null);
    }

    @Override
    public InternalRESTResponse post(
      HttpServletRequest request, String path, 
      Map<String, ?> queryParams, String body, 
      String contentType) throws Exception {
        return _invoke(request, "POST", path, queryParams, 
          body, contentType);
    }

    @Override
    public InternalRESTResponse put(
      HttpServletRequest request, String path, 
      Map<String, ?> queryParams, String body, 
      String contentType) throws Exception {
        return _invoke(request, "PUT", path, queryParams, 
          body, contentType);
    }

    @Override
    public InternalRESTResponse patch(
      HttpServletRequest request, String path, 
      Map<String, ?> queryParams, String body, 
      String contentType) throws Exception {
        return _invoke(request, "PATCH", path, queryParams, 
          body, contentType);
    }

    private InternalRESTResponse _invoke(
            HttpServletRequest request,
            String method,
            String path,
            Map<String, ?> queryParams,
            String body,
            String contentType)
        throws Exception {

        if (request == null) {
          throw new IllegalArgumentException(
            "HttpServletRequest is required. " + 
        "Use ServiceContext.getRequest() if in service code.");
        }

        String fullPath = _addQueryParams(path, queryParams);

        UnsyncStringWriter writer = new UnsyncStringWriter();

        HttpServletResponse pipingResponse = 
          new PipingServletResponse(
            new RESTClientHttpResponse(), writer);

        ServletContext servletContext = 
          ServletContextPool.get("");

        RequestDispatcher requestDispatcher =
          servletContext.getRequestDispatcher(
            Portal.PATH_MODULE + fullPath);

        requestDispatcher.forward(request, pipingResponse);

        String responseBody = writer.toString();
        String responseContentType = 
          pipingResponse.getContentType();

        Object jsonObject = null;

        if (ContentTypes.APPLICATION_JSON
          .equals(responseContentType)) {
            jsonObject = _jsonFactory.looseDeserialize(
              responseBody);
        }

        return new InternalRESTResponse(
            200,
            responseContentType,
            responseBody,
            Collections.emptyMap(),
            jsonObject
        );
    }

    private String _addQueryParams(String path, 
      Map<String, ?> queryParams) {
        if (queryParams == null || queryParams.isEmpty()) {
            return path;
        }

        String result = path;

        for (Map.Entry<String, ?> entry : 
          queryParams.entrySet()) {
            if (entry.getValue() != null) {
                result = HttpUtil.addParameter(
                  result, entry.getKey(), 
                  String.valueOf(entry.getValue()));
            }
        }

        return result;
    }
}

So my friend Alejandro Tardin pointed out an important part that shouldn't be overlooked, the CSRF authentication token, and he shared the Liferay code from https://github.com/liferay/liferay-portal/blob/347e9a90170a7e8cc5f11855ed60dd784f9c6982/modules/apps/portal-vulcan/portal-vulcan-impl/src/main/java/com/liferay/portal/vulcan/internal/template/servlet/RESTClientHttpRequestDelegate.java#L58-L84. Because the example above is using the incoming HttpServletRequest, the headers (including the CSRF token) should already be correctly set; but if you follow the FreeMarker restClient model and use a RESTClientHttpRequestDelegate (which is the cleaner solution), be sure to set up your CSRF token in a similar way.

Replicating the Learn Script (Listing Content Structures)

Instead of using the generated REST client, we now do:

ServiceContext sc = 
  ServiceContextThreadLocal.getServiceContext();

HttpServletRequest request = 
  (sc != null) ? sc.getRequest() : null;

InternalRESTResponse response = internalRESTClient.get(
    request,
    "/headless-delivery/v1.0/sites/" + siteId + 
    "/content-structures", Map.of("pageSize", 200)
);

if (response.status != 200) {
    throw new RuntimeException(response.body);
}

Object json = response.jsonObject;

Same endpoint.
Same permission checks.
Same JSON.

No REST client.
No outbound HTTP.

When Should You Use This?

This approach is perfect when:

  • You are already inside Liferay
  • You want to reuse existing Headless endpoints
  • You want consistent permission enforcement
  • You don’t want to generate REST clients

It is not intended as a cross-system integration mechanism. For that, stick to proper REST clients.

Final Thoughts

If you’ve ever written code that calls http://localhost:8080/o/... from inside Liferay just to reuse a Headless endpoint, it's time to stop.

Liferay's Headless Engine (Vulcan) already gives you a clean internal dispatch mechanism.

Now you can:

  • Inject InternalRESTClient
  • Grab HttpServletRequest from ServiceContext
  • Call Headless endpoints directly
  • Avoid unnecessary complexity

Sometimes the easiest REST call is the one that never leaves the JVM.

Page Comments

Related Assets...

No Results Found

More Blog Entries...

Ben Turner
February 26, 2026
David H Nebinger
February 25, 2026