Headless GraphQL
Yêu Cầu Cài Đặt
GraphQL API có từ AEM 6.5 SP10. Phải cài thêm index package riêng:
Không cài index package = query chạy trên Lucene generic index, rất chậm khi data lớn.
Cách Hoạt Động
AEM tự sinh GraphQL schema từ Content Fragment Models. Mỗi model = 1 GraphQL type, mỗi field = 1 queryable property, fragment reference = nested type. Không cần viết schema thủ công.
Mapping CF Model Field → GraphQL Type
Tạo GraphQL Endpoint
Qua UI
- Tools → General → GraphQL
- Click Create:
- Name:
myproject
- Configuration: chọn config chứa CF Models (vd:
/conf/myproject)
- Endpoint available tại:
/content/cq:graphql/myproject/endpoint.json
# hoặc JCR-safe encoding:
/content/_cq_graphql/myproject/endpoint.json
Global vs Site-specific
Dùng site-specific trên production. Global endpoint expose toàn bộ model của mọi site trên cùng instance — security risk.
Tạo endpoint bằng curl (automation)
curl -u admin:admin \
-X POST \
-H "Content-Type: application/json" \
-d '{
"jcr:primaryType": "cq:Page",
"jcr:content": {
"jcr:primaryType": "cq:PageContent",
"jcr:title": "myproject",
"sling:resourceType": "cq/graphql/components/endpoint",
"configurationName": "/conf/myproject"
}
}' \
"http://localhost:4502/content/cq:graphql/myproject"
GraphiQL IDE
AEM 6.5 ship sẵn GraphiQL IDE:
http://localhost:4502/content/graphiql.html
Tính năng: schema explorer, auto-complete, query history, variables panel.
Chọn endpoint ở dropdown phía trên trước khi query. Nếu không thấy endpoint → kiểm tra đã tạo và publish CF Model chưa.
Query Cơ Bản
List query — lấy nhiều fragments
{
articleList {
items {
_path
title
body {
html
}
publishDate
featured
category
}
}
}
AEM sinh ra 2 query types cho mỗi model Article:
articleList — lấy nhiều, hỗ trợ filter/sort/pagination
articleByPath — lấy 1 fragment theo path
Single fragment by path
{
articleByPath(
_path: "/content/dam/myproject/articles/getting-started"
) {
item {
title
body {
html
plaintext
}
publishDate
author {
name
bio { plaintext }
}
}
}
}
Multi-line text sub-fields
Multi-line text field expose nội dung theo nhiều format:
{
articleList {
items {
body {
html # HTML formatted
plaintext # Plain text, tags stripped
markdown # Markdown (nếu authored bằng Markdown)
}
}
}
}
Chỉ chọn format cần dùng — html + plaintext + markdown cùng lúc = over-fetch.
Fragment references (nested queries)
Fragment reference tự động resolve thành nested type:
{
articleList {
items {
title
# Single fragment reference
author {
name
bio { html }
}
# Multi-valued fragment reference
relatedArticles {
title
_path
}
}
}
}
Content reference / Image reference
{
articleList {
items {
title
featuredImage {
... on ImageRef {
_path
_authorUrl
_publishUrl
mimeType
width
height
}
}
}
}
}
_authorUrl / _publishUrl trả về URL đầy đủ — dùng trong SPA để render image không cần hardcode hostname.
Filter cơ bản
{
articleList(
filter: {
featured: { _expressions: [{ value: true }] }
category: { _expressions: [{ value: "technology" }] }
}
) {
items {
title
category
}
}
}
Nhiều field trong cùng filter = AND logic.
Filter operators
Filter theo string
{
articleList(
filter: {
title: {
_expressions: [{
value: "AEM"
_operator: CONTAINS
}]
}
}
) {
items { title }
}
}
Filter theo date range
{
articleList(
filter: {
publishDate: {
_expressions: [
{ value: "2025-01-01T00:00:00Z", _operator: AFTER },
{ value: "2025-12-31T23:59:59Z", _operator: BEFORE }
]
}
}
) {
items { title publishDate }
}
}
Sorting
{
articleList(
sort: "publishDate DESC"
) {
items { title publishDate }
}
}
# Multi-field sort
{
articleList(
sort: "category ASC, publishDate DESC"
) {
items { title category publishDate }
}
}
AEM 6.5 chỉ hỗ trợ offset-based pagination. Cursor-based pagination (articlePaginated) chỉ có trên AEMaaCS.
# Trang 1
{
articleList(offset: 0, limit: 10) {
items { title }
}
}
# Trang 2
{
articleList(offset: 10, limit: 10) {
items { title }
}
}
Luôn set limit. Không set = AEM trả về default (thường 10, configurable). Không bao giờ query không có limit trên production.
Variations
Query variation khác ngoài master:
{
articleList(variation: "summary") {
items {
title
body { html }
}
}
}
Nếu field không có value trong variation → fallback về master.
Persisted Queries
Persisted queries là cách khuyến nghị cho production — query được lưu trên server, gọi qua GET request.
Tại sao cần dùng:
- Cacheable bởi Dispatcher và CDN (GET request với URL ổn định)
- Bảo mật — client không gửi arbitrary query
- Nhanh hơn — server không parse query mỗi lần
Tạo persisted query
curl -u admin:admin \
-X PUT \
-H "Content-Type: application/json" \
-d '{
"query": "{ articleList(sort: \"publishDate DESC\", limit: 10) { items { _path title body { html } publishDate category } } }"
}' \
"http://localhost:4502/graphql/persist.json/myproject/latest-articles"
Gọi persisted query
# GET request — cacheable
curl "http://localhost:4502/graphql/execute.json/myproject/latest-articles"
Persisted query với variables
# Tạo
curl -u admin:admin \
-X PUT \
-H "Content-Type: application/json" \
-d '{
"query": "query ArticlesByCategory($category: String!, $limit: Int = 10) { articleList(filter: { category: { _expressions: [{ value: $category }] } }, limit: $limit, sort: \"publishDate DESC\") { items { _path title publishDate category } } }"
}' \
"http://localhost:4502/graphql/persist.json/myproject/articles-by-category"
# Gọi với variables trong URL path
curl "http://localhost:4502/graphql/execute.json/myproject/articles-by-category;category=technology;limit=5"
URL patterns
Persisted queries phải được replicate sang Publish. Nếu tạo trên Author mà quên activate → 404 trên Publish.
Frontend Integration
Fetch helper (JavaScript / TypeScript)
const AEM_HOST = process.env.AEM_PUBLISH_HOST || 'https://publish.myproject.com';
async function fetchPersistedQuery<T>(
queryPath: string,
variables?: Record<string, string | number>
): Promise<T> {
let url = `${AEM_HOST}/graphql/execute.json/${queryPath}`;
if (variables) {
const params = Object.entries(variables)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join(';');
url += `;${params}`;
}
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`GraphQL error: ${response.status} ${response.statusText}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(
json.errors.map((e: { message: string }) => e.message).join(', ')
);
}
return json.data;
}
Sử dụng
interface Article {
_path: string;
title: string;
body: { html: string };
publishDate: string;
}
interface ArticleListResponse {
articleList: { items: Article[] };
}
// Fetch latest articles
const data = await fetchPersistedQuery<ArticleListResponse>(
'myproject/latest-articles'
);
const articles = data.articleList.items;
// Fetch by category
const tech = await fetchPersistedQuery<ArticleListResponse>(
'myproject/articles-by-category',
{ category: 'technology', limit: 5 }
);
React hook
import { useState, useEffect } from 'react';
function useArticles(category?: string) {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
const path = category
? 'myproject/articles-by-category'
: 'myproject/latest-articles';
const vars = category ? { category } : undefined;
fetchPersistedQuery<ArticleListResponse>(path, vars)
.then(data => setArticles(data.articleList.items))
.catch(setError)
.finally(() => setLoading(false));
}, [category]);
return { articles, loading, error };
}
Dispatcher Configuration (AEM 6.5)
Filter rules
# Allow persisted queries (GET)
/0100 {
/type "allow"
/method "GET"
/url "/graphql/execute.json/*"
}
# Allow GraphQL endpoint (GET + POST + OPTIONS)
/0101 {
/type "allow"
/method "(GET|POST|OPTIONS)"
/url "/content/_cq_graphql/*/endpoint.json"
}
# dispatcher/clientheaders.any
$include "./default_clientheaders.any"
"Origin"
"Access-Control-Request-Method"
"Access-Control-Request-Headers"
"Authorization"
Cache rules
# Cache persisted query responses
/0100 {
/glob "*.json"
/type "allow"
}
TTL cho GraphQL responses
# vhost config
<LocationMatch "^/graphql/execute\.json/.*$">
Header set Cache-Control "public, max-age=300"
Header set Surrogate-Control "max-age=600"
</LocationMatch>
Cache invalidation
Khi Content Fragment được publish, AEM gửi flush request tới Dispatcher. Cấu hình invalidation cho .json:
# dispatcher/cache/invalidate.any
/0001 {
/glob "*.json"
/type "allow"
}
CORS Configuration
Bắt buộc khi SPA ở domain khác gọi GraphQL API:
// ui.config/.../com.adobe.granite.cors.impl.CORSPolicyImpl~graphql.cfg.json
{
"supportscredentials": false,
"supportedmethods": ["GET", "HEAD", "POST", "OPTIONS"],
"alloworigin": [
"https://www.myproject.com",
"https://app.myproject.com"
],
"maxage:Integer": 1800,
"alloworiginregexp": [
"http://localhost:.*"
],
"allowedpaths": [
"/content/_cq_graphql/myproject/endpoint.json",
"/graphql/execute.json/.*"
],
"supportedheaders": [
"Origin",
"Accept",
"X-Requested-With",
"Content-Type",
"Access-Control-Request-Method",
"Access-Control-Request-Headers",
"Authorization"
]
}
Không dùng wildcard * cho alloworigin trên production. Liệt kê từng domain.
Referrer filter
// ui.config/.../org.apache.sling.security.impl.ReferrerFilter~graphql.cfg.json
{
"allow.empty": false,
"allow.hosts": [
"www.myproject.com",
"app.myproject.com"
],
"allow.hosts.regexp": [
"http://localhost:.*"
],
"filter.methods": ["POST", "PUT", "DELETE"]
}
Authentication (AEM 6.5)
Publish — Anonymous access
Publish instance thường cho phép anonymous GET requests tới persisted queries. Không cần authentication nếu content đã được publish.
Author — Basic auth (dev only)
curl -u admin:admin \
"http://localhost:4502/graphql/execute.json/myproject/latest-articles"
Author — Token-based (production)
Với AEM 6.5 on-premise, dùng token authentication cho server-to-server:
// Java backend gọi Author GraphQL
URL url = new URL(
"http://author.myproject.com/graphql/execute.json/myproject/latest-articles"
);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Basic "
+ Base64.getEncoder().encodeToString("service-user:password".getBytes()));
// Hoặc dùng login token từ AEM:
// conn.setRequestProperty("Cookie", "login-token=" + token);
Không embed Author credentials trong client-side JavaScript. Author queries chỉ chạy từ server-side (API routes, build-time fetch, serverless functions).
Caching Strategy
Optimized Filtering (AEM 6.5.17+)
Từ SP17, AEM hỗ trợ optimized filtering cho GraphQL. Cần chạy migration 1 lần:
- Vào OSGi Console → Configuration
- Tìm Content Fragment Migration Job Configuration
- Set:
ContentFragmentMigration:Enabled = 1
ContentFragmentMigration:Enforce = 1
- Save → migration chạy tự động
Sau migration, property cfGlobalVersion xuất hiện tại /content/dam (kiểm tra trong CRXDE). Nếu import CF bằng content package sau migration → cần chạy lại.
AEM 6.5 vs AEMaaCS — Khác Biệt
Luôn dùng persisted queries
Ad-hoc POST queries bypass Dispatcher/CDN cache. Trên Publish, chỉ dùng persisted queries.
Giới hạn query depth
# Tệ — 4 levels nested, gây expensive JCR traversals
{
articleList {
items {
author {
articles {
relatedArticles {
author { name }
}
}
}
}
}
}
# Tốt — flat, chỉ lấy cần thiết
{
articleList(limit: 10) {
items {
title
body { html }
author { name }
}
}
}
Chỉ lấy field cần dùng
# Tệ — over-fetch
{
articleList {
items {
title
body { html plaintext markdown }
author { name bio { html plaintext } }
relatedArticles { title body { html } }
}
}
}
# Tốt — chỉ lấy cho UI card
{
articleList(limit: 20) {
items {
title
publishDate
category
}
}
}
Tạo Oak index cho filter fields
Nếu filter trên field thường xuyên (vd: category, publishDate), tạo Oak property index tương ứng. Xem note Query Builder phần Oak indexing.
Chạy optimized filtering migration
Trên AEM 6.5.17+, migration giúp filter nhanh hơn bằng cách flatten CF data vào index-friendly properties.
Pitfalls Thường Gặp
Tham Khảo