Fetching content from a front-end
Contents
So far you have run every query from Query Playground, which quietly supplied the project, the branch and your login. A front-end has none of that. In this chapter you’ll send the same queries over HTTP, from the command line, from Node and from a browser, and handle the four things every front-end on Enonic needs: the request format, published versus draft content, URLs for pages and media, and lists that page.
| The tasks and examples in this chapter are based on work done in previous chapters (starting with the setup chapter). If you want to follow along with the examples, make sure you’re all caught up. |
Publish first
Everything you have created so far lives in the draft branch. Draft content is only readable by logged-in users with a role in the content project, which a front-end does not have. The master branch, on the other hand, is readable by anyone in a public project like yours.
So before anything else, publish your content: in Content Studio, select the "artists", "animals" and "images" folders, choose Publish, and make sure the dialog includes their children. From now on, use the master branch in your queries.
The request
The endpoint is http://localhost:8080/api/com.enonic.app.guillotine:graphql, as you saw in the Headless API chapter. It takes an HTTP POST with Content-Type: application/json and a JSON body with two properties:
| Property | Type | Description |
|---|---|---|
|
query |
String |
The GraphQL query to execute. |
|
variables |
Object |
Optional. Values for variables declared by the query. |
Outside Content Studio there is no selected project, so the query names the project and the branch as arguments on the guillotine field. Passing them as variables keeps the query reusable between environments.
Save this as request.json:
{
"query": "query Artists($project: String!, $branch: String!) { guillotine(project: $project, branch: $branch) { getChildren(key: \"/artists\", first: 10) { _id displayName } } }",
"variables": {
"project": "my-first-project",
"branch": "master"
}
}
And send it with cURL:
curl "http://localhost:8080/api/com.enonic.app.guillotine:graphql" \
-H 'Content-Type: application/json' \
--data-binary @request.json
You should get the three artists back as JSON, the same shape you saw in the playground’s response panel. If the list is empty, the content is not published, or you spelled the project name differently when you created it.
From JavaScript
The same request with fetch(). Save it as artists.mjs and run it with node artists.mjs. Node 18 or newer has fetch built in.
const apiBaseUrl = 'http://localhost:8080/api';
const query = `
query Artists($project: String!, $branch: String!) {
guillotine(project: $project, branch: $branch) {
getChildren(key: "/artists", first: 10) {
_id
displayName
... on com_example_tutorial101_Artist {
data {
name
birthday
}
}
}
}
}
`;
async function getArtists() {
const response = await fetch(apiBaseUrl + '/com.enonic.app.guillotine:graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { project: 'my-first-project', branch: 'master' }
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.errors?.length) {
throw new Error(result.errors.map((error) => error.message).join('\n'));
}
return result.data.guillotine.getChildren;
}
getArtists().then(console.log).catch(console.error);
Two details worth copying into any client you write:
-
A GraphQL response can be HTTP 200 and still carry errors, in the
errorsarray next todata. Check both. -
The API base URL is the only thing that changes between your sandbox and production. Keep it in one place, as here, so that going live is a configuration change.
From a browser
Running the same code in a web page served from another origin, say a dev server on http://localhost:3000, fails until XP is told to allow that origin. This is browser cross-origin protection, CORS, and Guillotine has a configuration file for it.
Create the file com.enonic.app.guillotine.cfg in your sandbox’s config directory, ~/.enonic/sandboxes/tutorial/home/config/:
cors.origin=http://localhost:3000
cors.methods=POST, OPTIONS
cors.allowedHeaders=Content-Type
XP picks the file up without a restart. cors.origin takes a comma-separated list of origins, including scheme and port. The Guillotine configuration docs cover the remaining options.
| CORS only controls which origins a browser lets read the response. It does not grant access to content: a public project’s published content is readable by anyone, and draft content still needs a login. |
Draft content
A front-end that previews unpublished work, for instance a staging site for editors, queries the draft branch and must send the editor’s login with every request. With cookie-based login across origins that means cors.credentials=true in the file above, an explicit origin rather than *, and credentials: 'include' in the fetch() call. For a public front-end, stay on master and skip all of this.
Resolving a route
A front-end usually goes the other way from the queries so far: the visitor arrives at a URL, and the page needs the content behind it. The content path is the natural route. get accepts a path as its key, and returns null when nothing is there, which is your 404:
query AnimalPage($project: String!, $branch: String!, $path: ID!) {
guillotine(project: $project, branch: $branch) {
get(key: $path) {
displayName
... on com_example_tutorial101_Animal {
data {
otherNames
description {
processedHtml
}
}
}
}
}
}
With path set to /animals/lion, this is everything the lion’s page needs. The route in your front-end can be the content path itself, or any mapping you prefer, as long as the front-end knows how to turn it back into a path.
Paging a list
getChildren and queryDsl take first and offset, which is enough for a short list. For a list of unknown length, use the connection variant, which returns a cursor for the next page and the total count:
query AnimalsPage($project: String!, $branch: String!, $after: String) {
guillotine(project: $project, branch: $branch) {
getChildrenConnection(key: "/animals", first: 2, after: $after, sort: "displayName ASC") {
totalCount
edges {
node {
_id
displayName
}
}
pageInfo {
endCursor
hasNext
}
}
}
}
Send it with after set to null for the first page. The items are in edges, one node each. If pageInfo.hasNext is true, send the same query again with after set to pageInfo.endCursor, and repeat until hasNext is false:
async function allAnimals() {
const animals = [];
let after = null;
do {
const page = await send(query, { project, branch, after });
const connection = page.guillotine.getChildrenConnection;
animals.push(...connection.edges.map((edge) => edge.node));
after = connection.pageInfo.hasNext ? connection.pageInfo.endCursor : null;
} while (after);
return animals;
}
Treat the cursor as an opaque value, and keep the key and sorting the same while paging. With two animals per page and three animals, you get two requests.
Building URLs
Guillotine returns the parts of every URL, never a finished address, as you saw with media and rich text. Your front-end owns the base, so building the address is one line:
const mediaBaseUrl = 'http://localhost:8080/api';
const url = (parts) => mediaBaseUrl + parts.path + parts.queryString;
// imageUrl(scale: "width(800)") { path queryString }
img.src = url(animal.data.images[0].imageUrl);
For rich text, the same function rewrites the defaults in processedHtml. Match each element on its reference attribute and set the attribute from the structured data:
for (const link of description.links) {
const a = container.querySelector(`[data-link-ref="${link.ref}"]`);
if (a && link.pageUrl) {
a.href = (link.pageUrl.baseUrl ?? frontendBaseUrl) + link.pageUrl.path + link.pageUrl.queryString + link.fragment;
}
}
for (const image of description.images) {
const img = container.querySelector(`[data-image-ref="${image.ref}"]`);
if (img && image.src) {
img.src = url(image.src);
if (image.srcset.length) {
img.srcset = image.srcset.map((source) => `${url(source.imageUrl)} ${source.width}w`).join(', ');
}
}
}
Here frontendBaseUrl is wherever your front-end serves its pages. A link whose target does not resolve has a null pageUrl, so the guard leaves its text in place without a broken link.
Going live
Everything in this chapter ran against your sandbox, but there is no difference between the sandbox and a live Enonic server except the URL. The same request format, the same API names, the same paths in every response, the same CORS configuration file. When your app and content are on a server, point apiBaseUrl at that server’s /api and the front-end is live. Keep that base in one place, as the examples do, and going live is a configuration change rather than a code change.
Summary
You have called the API from cURL, Node and a browser, published content so a front-end can read it, resolved a route to a content item, paged a list, and built page and media URLs from their parts. That is the whole contract between Enonic and a front-end; everything else is your framework’s business.
Next up, let’s look under the hood at how XP actually stores all of this.