Si trabajas con Liferay DXP, en algún momento te habrás encontrado con una situación confusa: para obtener los mismos datos -- digamos, una lista de artículos web -- tienes al menos dos formas completamente diferentes de hacerlo. Una usa /api/jsonws y parece llamar directamente a métodos Java. La otra usa /o/headless-delivery/v1.0 y se comporta como una API REST moderna. Ambas funcionan, ambas devuelven datos, pero tienen filosofías radicalmente distintas.
Entender las diferencias entre JSONWS y Headless Delivery no es un ejercicio académico. La elección impacta la seguridad, mantenibilidad y futuro de tu proyecto. Después de trabajar con ambas APIs desde Liferay 7.1 hasta 7.4, puedo decir que la decisión no siempre es obvia, especialmente cuando migras proyectos legacy.
JSONWS (JSON Web Services) fue el primer intento de Liferay de exponer sus servicios internos como APIs HTTP. Cada servicio Java registrado en Liferay se convierte automáticamente en un endpoint invocable vía HTTP.
Accede a /api/jsonws en cualquier instancia de Liferay y verás un explorador interactivo con cientos de servicios disponibles. Cada uno corresponde a un método Java real. Por ejemplo:
# Obtener un articulo web por groupId y articleId
/api/jsonws/journal.journalarticle/get-article \
-d groupId=20123 \
-d articleId=MI-ARTICULO \
-d version=1.0
Desde JavaScript en el contexto de Liferay, usas el objeto global Liferay.Service():
Liferay.Service(
'/journal.journalarticle/get-article',
{
groupId: themeDisplay.getScopeGroupId(),
articleId: 'MI-ARTICULO',
version: 1.0,
},
function (article) {
console.log(article.title)
}
)
Esto ejecuta una llamada AJAX que invoca directamente el método JournalArticleService.getArticle() en el backend Java. La respuesta es una serialización directa del objeto Java, lo que significa que obtienes todos los campos internos de la entidad, incluidos muchos que probablemente no necesitas.
/api/jsonws permite probar servicios directamente, ver parámetros y respuestas.jsonws.web.service.paths.includes y jsonws.web.service.paths.excludes en portal properties, y sobre todo las Service Access Policies, que declaran una whitelist por clase y método (DLAppService#get*) y que incluso se exponen como scopes OAuth2. La diferencia con Headless no es que JSONWS carezca de control de alcance, sino que ese control vive en configuración de plataforma que hay que activar a conciencia: por defecto, una petición autenticada con usuario y contraseña cae en la política SYSTEM_USER_PASSWORD, que permite invocar cualquier método.p_auth. Desde un cliente que no es navegador puedes usar HTTP Basic y saltarte p_auth, pero entonces estás mandando credenciales en cada llamada en lugar de un token revocable.start y end para paginar, otros no. No hay un formato uniforme de respuesta paginada.Liferay introdujo su framework Headless, basado en la especificación OpenAPI, con Liferay Portal 7.2 GA1 (junio de 2019); los módulos se retroportaron después a 7.1 a partir del FixPack 12. Estas APIs están agrupadas bajo el prefijo /o/ y siguen convenciones REST estándar.
Liferay organiza sus APIs Headless en varios módulos:
/o/headless-delivery/v1.0: Contenido estructurado, documentos, blogs, páginas de sitio, Knowledge Base, foros, wiki y menús de navegación/o/headless-admin-user/v1.0: Usuarios, organizaciones, roles/o/headless-admin-taxonomy/v1.0: Vocabularios y categorías/o/headless-commerce-delivery-catalog/v1.0: Productos y catálogo (Commerce)/o/c/{objectName}: Objects personalizados (auto-generados)# Obtener contenido estructurado de un site
curl -X GET \
"http://localhost:8080/o/headless-delivery/v1.0/sites/20123/structured-contents" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiI..." \
-H "Accept: application/json"
La respuesta sigue un formato estándar con paginación:
{
"items": [
{
"id": 45231,
"title": "Bienvenida",
"dateCreated": "2025-06-15T10:30:00Z",
"dateModified": "2025-06-20T14:22:00Z",
"contentFields": [
{
"name": "contenido",
"contentFieldValue": {
"data": "<p>Texto del articulo...</p>"
}
}
]
}
],
"page": 1,
"pageSize": 20,
"totalCount": 134,
"lastPage": 7
}
Nota la diferencia: la respuesta está estructurada, los campos tienen nombres semánticos, la paginación es consistente, y los datos están normalizados. No estás viendo la serialización cruda de un objeto Java.
| Aspecto | JSONWS | Headless Delivery |
|---------|--------|-------------------|
| Base path | /api/jsonws | /o/headless-*/v1.0, /o/c/ |
| Autenticación | Cookie + p_auth (navegador) o HTTP Basic | OAuth2, Basic Auth, Cookie |
| Formato respuesta | Serialización Java ad-hoc | JSON estándar con schema OpenAPI |
| Paginación | Inconsistente (start/end) | Estándar (page/pageSize/totalCount) |
| Filtrado | Parámetros por método | OData syntax uniforme |
| Documentación | Explorador en /api/jsonws | OpenAPI spec + /o/api |
| Versionado | Sin versiones (cambia con Liferay) | Versionado en URL (v1.0, v2.0) |
| Control de alcance | Whitelist global: jsonws.web.service.paths.includes/excludes y Service Access Policies (por clase y método) | Lo anterior más scopes OAuth2 asignados por aplicación |
| GraphQL | No | Sí, en /o/graphql |
| Futuro | Soportado, pero ya no recomendado | Modelo recomendado |
Liferay ha sido gradual pero consistente en mover el ecosistema hacia Headless. Las razones principales son:
JSONWS expone internamente servicios que no estaban diseñados para acceso HTTP. La distinción entre *Service (con verificación de permisos) y *LocalService (sin verificación) es crítica en Java, y aunque JSONWS solo publica la capa *Service, nada garantiza que esa capa verifique nada: el método remoto lo escribe el desarrollador, y si delega en el LocalService sin comprobar permisos, el agujero queda expuesto por HTTP. Un desarrollador que no entiende esta distinción puede crear vulnerabilidades serias.
En Liferay DXP 7.4 JSONWS sigue habilitado por defecto (json.web.service.enabled=true) y el explorador de /api/jsonws sigue siendo visible; lo único excluido de fábrica es /user/update-password. Lo que ha cambiado no es el interruptor por defecto, sino la recomendación: Liferay documenta JSONWS como un framework antiguo, todavía soportado pero ya no recomendado, y deja en manos del administrador restringirlo con jsonws.web.service.paths.includes/excludes y Service Access Policies.
Este es un punto que merece atención especial. En la arquitectura interna de Liferay, cada entidad tiene dos capas de servicio:
*LocalService: Ejecuta operaciones directamente, sin verificar permisos. Es para uso interno entre módulos del servidor.*Service: Wrapper que primero verifica que el usuario tiene permisos, y luego delega al LocalService.En código Java dentro de Liferay, un módulo puede llamar a JournalArticleLocalService.getArticle() directamente, bypaseando la verificación de permisos. Esto es válido en ciertos contextos (un servicio del sistema que necesita acceso incondicional), pero peligroso si se expone externamente.
JSONWS nunca expuso los LocalServices: Service Builder solo anota con @JSONWebService la interfaz *Service de las entidades con remote-service="true". El riesgo real es otro, y sigue vigente: nada impide que un desarrollador anote a mano un *ServiceImpl que no verifica permisos, o que escriba un método remoto que delegue en el LocalService sin comprobar nada. Esa confusión persiste en proyectos que arrastran ese patrón.
Headless Delivery reduce mucho el riesgo: los endpoints que vienen de fábrica se apoyan en la capa *Service y respetan permisos, e incluso exponen subrecursos /permissions para gestionarlos. Lo que no desaparece es la responsabilidad del desarrollador: una API propia hecha con REST Builder no verifica permisos por sí sola, hay que delegar en los servicios remotos o comprobarlos explícitamente en el *ResourceImpl.
JSONWS no sigue ningún estándar HTTP o API reconocido. Esto significa que herramientas estándar (Postman collections generadas automáticamente, generadores de clientes, documentación interactiva) no funcionan bien con JSONWS.
En /o/api tienes el API Explorer, y cada aplicación publica su especificación legible por máquina en /o/<schema>/v1.0/openapi.json (por ejemplo /o/headless-delivery/v1.0/openapi.json); desde 7.4 U69/GA69 también puedes descargar el openapi.json agregado de todo el portal. Puedes importar esa especificación en cualquier herramienta que soporte OpenAPI y generar automáticamente clientes en cualquier lenguaje, colecciones de Postman, o documentación.
Además de REST, Liferay expone un endpoint GraphQL en /o/graphql que permite consultar los mismos datos disponibles en Headless Delivery pero solicitando solo los campos que necesitas.
query {
structuredContents(siteKey: "20123", filter: "title eq 'Inicio'") {
items {
id
title
dateModified
contentFields {
name
contentFieldValue {
data
}
}
}
totalCount
}
}
GraphQL es particularmente útil cuando:
Sin embargo, en la práctica, REST sigue siendo la opción más común en proyectos Liferay porque la documentación y ejemplos están más orientados a REST, y la mayoría de equipos enterprise ya tienen herramientas establecidas para APIs REST.
Para consumir Headless APIs desde fuera de Liferay (aplicaciones externas, Client Extensions remotas, integraciones), necesitas configurar OAuth2.
Este flujo es el indicado para integraciones servidor-a-servidor donde no hay un usuario humano involucrado:
Para obtener un token:
curl -X POST "http://localhost:8080/o/oauth2/token" \
-d "grant_type=client_credentials" \
-d "client_id=tu-client-id" \
-d "client_secret=tu-client-secret"
La respuesta incluye un access_token que usas en el header Authorization: Bearer.
Para aplicaciones donde un usuario se autentica:
Si tienes un proyecto que usa JSONWS y necesitas migrar, el enfoque gradual funciona mejor que una reescritura total.
Lista todas las llamadas JSONWS en tu código. Busca Liferay.Service(, /api/jsonws, y p_auth como indicadores. Clasifica cada llamada por entidad (journal, user, document, etc.).
Para cada llamada JSONWS, encuentra el endpoint Headless equivalente. Ejemplos comunes:
# Articulos web
JSONWS: /api/jsonws/journal.journalarticle/get-articles
Headless: /o/headless-delivery/v1.0/sites/{siteId}/structured-contents
# Documentos
JSONWS: /api/jsonws/dlapp/get-file-entries
Headless: /o/headless-delivery/v1.0/sites/{siteId}/documents
# Usuarios
JSONWS: /api/jsonws/user/get-user-by-id
Headless: /o/headless-admin-user/v1.0/user-accounts/{id}
# Categorias
JSONWS: /api/jsonws/assetcategory/get-categories
Headless: /o/headless-admin-taxonomy/v1.0/sites/{siteId}/taxonomy-categories
(o /taxonomy-vocabularies/{vocabularyId}/taxonomy-categories)
Reemplaza las llamadas una por una, empezando por las más usadas. Para cada una:
Si antes dependías de la cookie de sesión + CSRF token, evalúa si necesitas migrar a OAuth2. Para Client Extensions que corren dentro de Liferay, Liferay.Util.fetch() sigue manejando la autenticación automáticamente. Para integraciones externas, configura OAuth2.
Mi recomendación después de varios proyectos con ambas APIs: si empiezas un proyecto nuevo en Liferay 7.4, usa exclusivamente Headless Delivery. No hay razón válida para elegir JSONWS en un proyecto greenfield.
Si mantienes un proyecto legacy, migra gradualmente. JSONWS sigue funcionando y sigue habilitado por defecto, pero Liferay ya lo documenta como un framework antiguo y no recomendado, y toda la inversión en producto va a Headless. Es mejor migrar proactivamente que quedarse anclado a una API que ya no recibe funcionalidad nueva.
Y si necesitas acceso a un servicio interno que Headless no expone, la solución correcta no es volver a JSONWS: es crear un endpoint Headless personalizado a través de REST Builder o exponer la funcionalidad como un Object Action. El ecosistema de Liferay se mueve firmemente hacia Headless, y alinear tu proyecto con esa dirección reduce la deuda técnica futura.