Reuse Liferay's RESTBuilder Engine's (Vulcan) internal dispatch pattern to invoke Headless endpoints in-process: no HTTP client, no network hop.
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:
GETDELETEPOSTPUTPATCH- 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
HttpServletRequestfromServiceContext - Call Headless endpoints directly
- Avoid unnecessary complexity
Sometimes the easiest REST call is the one that never leaves the JVM.

