Magnolia 5.7 reached extended end of life on May 31, 2022. Support for this branch is limited, see End-of-life policy. Please note that to cover the extra maintenance effort, this EEoL period is a paid extension in the life of the branch. Customers who opt for the extended maintenance will need a new license key to run future versions of Magnolia 5.7. If you have any questions or to subscribe to the extended maintenance, please get in touch with your local contact at Magnolia.
This tutorial explains how to access Magnolia content from a client-side application. We use Magnolia's built-in REST API and a single-page AngularJS application.
We use the classic cars from the My first content app tutorial as sample content. The cars are typical structured content: each car has the same content type and same fields. You can publish it to a website or consume it from a client-side application like we do in this tutorial. With the Magnolia Delivery endpoint API you can access any Magnolia workspace.
If you want to know how to fetch content app data on the server side, for instance from a FreeMarker template script, see Accessing content on the server side.
Overview
Your tasks:
- Install a Magnolia bundle with a custom content app.
- Configure the delivery endpoint in a light module.
- Test REST access and think about REST security.
- Write an Angular app which consumes JSON from the configured REST endpoint.
Architecture overview:
Install a Magnolia bundle containing the app-tutorial
content app
For this tutorial you need a Magnolia bundle with:
- Magnolia REST modules version 2.1 or higher: install Magnolia version 5.6.4 or higher to ensure it contains Magnolia REST modules version 2.1+.
You may install a Community Edition (CE) or Enterprise Edition (EE) Magnolia bundle, with or without the demo for the purpose of this tutorial. We have used the
magnolia-community-demo-webapp
. - The latest version of the
app-tutorial
content app ::
To install the bundle together with the app, we recommend using Magnolia CLI jumpstart.
Once installed, the directory where you exectued the jumpstart
command looks similar to this:
erics-cars-tutorial/ ├── apache-tomcat └── light-modules
Remember the light-modules
folder. You will add files there later on.
Now start the bundle:
cd erics-cars-tutorial/ mgnl start
Give Magnolia some time to finish the installation during the first start.
Then open a browser, open the URL http://localhost:8080/magnoliaAuthor and login with user superuser
/ password superuser
.
About the custom content app
Click the Products tile open the app.
This content app has two subapps:
- The Browser subapp
- The Detail subapp
Content in the app-tutorial
You need the following information to properly configure REST access to the content:
- The items of this app are cars. They are stored in the JCR workspace
products
. - The node type of a car is
mgnl:product
. - The items are organized in folders whose content type is
mgnl:folder
. - The images of the cars are assets stored in the
dam
workspace. Car nodes store a reference to a dam asset.
If you aim to fetch JCR content as JSON, analyze the JCR content using the JCR Browser app to understand all its details.
This is the products
workspace displayed in the Tools>JCR browser app:
Look at the image
property. It stores an asset ID, a reference to an asset stored on the dam
workspace.
Configure a delivery endpoint in a light module
Since Magnolia REST 2.1 you can define multiple delivery endpoints. You must configure at least one delivery endpoint to consume content as JSON from the delivery API. You can configure endpoints using both YAML and JCR.
To get the content from the app-tutorial
as JSON, we will configure a delivery endpoint in a light module. In this example, we name the light module content-app-clients-v2
.
Within your light-modules
folder, create this structure:
content-app-clients-v2/ └── restEndpoints/ └── delivery/ └── carFinder.yaml
Note:
- Endpoint definitions must reside within the folder
restEndpoints
. You can also use subfolders; here we created a subfolder calleddelivery
. - The location of the endpoint definition within the folder restEndpoints partially defines the URL to access the configured endpoint.
Read Delivery endpoint API v2 - endpointPath property to understand theendpointPath
and REST access URLs. - The above configuration leads to the following REST access URL:
<magnolia-base-path>/.rest/delivery/carFinder
The definition file should look like this:
class: info.magnolia.rest.delivery.jcr.v2.JcrDeliveryEndpointDefinition workspace: products rootPath: / nodeTypes: - mgnl:folder - mgnl:product childNodeTypes: - mgnl:folder - mgnl:product depth: 2 includeSystemProperties: false bypassWorkspaceAcls: true references: - name: carAssets propertyName: image referenceResolver: class: info.magnolia.rest.reference.dam.AssetReferenceResolverDefinition includeAssetMetadata: false
- To understand all details of the configuration, read Delivery endpoint API v2 - Configuration.
Line 12: Do not use the property
bypassWorkspaceAcls
in a production environment. See REST security below.- Lines 13-19: Define the reference resolver to resolve the reference to the asset.
Test REST access and understand REST security
Before developping the Angular app, it is good practice to check if the API returns the JSON you expect and will be required by the Angular app. Check this using a browser.
REST URLs
We want to test the requests:
- Access JSON for one car to display one car in detail.
- Access JSON for all the cars and the folders they are wrapped with to display a list of cars to select from.
The requests should look like this:
<magnolia-base-path>/.rest/delivery/carFinder/cars/007/Aston-Martin-DB5
(for one car)<magnolia-base-path>/.rest/delivery/carFinder/cars
(for all cars)
delivery/carFinder
is the endpointPath defined by the endpoint configuration.
On magnoliaAuthor
You must be logged in to test the requests on the magnoliaAuthor
webapp. Open a browser, request just http://localhost:8080/magnoliaAuthor/ and log in with superuser
as the user name and password.
Now test the REST requests:
- http://localhost:8080/magnoliaAuthor/.rest/delivery/carFinder/cars/007/Aston-Martin-DB5
- http://localhost:8080/magnoliaAuthor/.rest/delivery/carFinder/cars
On magnoliaPublic
In a productive environment you request JSON from the public instance. In this tutorial we request the JSON from the magnoliaPublic
webapp. Do not log in so that you test as the anonymous
user.
- http://localhost:8080/magnoliaPublic/.rest/delivery/carFinder/cars/007/Aston-Martin-DB5
- http://localhost:8080/magnoliaPublic/.rest/delivery/carFinder/cars
JSON responses
This is what the JSON responses look like:
REST security
REST APIs are a powerful feature but they may also turn into a security risk. This is why it is important you understand how Magnolia handles security for REST.
We strongly recommend you read and understand the page REST security.
For this tutorial, note the following points.
There are two levels of control when REST requests are issued on the delivery API:
- Web access (checking URLs)
- JCR access (checking access to JCR workspaces)
Both web and JCR access must be granted. In Magnolia it is granted using Roles with access control lists that are assigned to users and groups.
When you tested the requests above, you used:
superuser
onmagnoliaAuthor
anonymous
user onmagnoliaPublic
These users have very different security settings. While superuser
typically has a lot of permissions, anonymous has only limited access for both web access and JCR security. Requests made using the anonymous user on magnoliaPublic only worked because:
- Web access is granted by the role
rest-anonymous
that comes by default with the REST modules and is assigned to anonymous user. - JCR access was bypassed with the delivery endpoint configuration by setting the property
bypassWorkspaceAcls
.
In a production environment:
- Do not use the property
bypassWorkspaceAcls
in endpoint definitions. - Create custom REST roles granting specific access for specific use cases.
Create an AngularJS app
AngularJS is a Web application framework popular among front-end developers. In this tutorial, we use an AngularJS app to access and render content from the Products app. We assume you know enough JavaScript to follow along.
The content items we render are cars. They are organized in the app like this:
cars/ ├── 007/ │ ├── Aston-Martin-DB5 │ ├── Aston-Martin-V8 │ ├── Lotus-Esprit-S1 │ └── Sunbeam-Alpine └── classics/ ├── 1927-Hudson ├── Continental-Mark-II ├── Fiat-Cinquecento ├── Pontiac-Chieftain-1952 └── Riley-Brooklands-1930
Where:
cars
,007
andclassics
are folders.- The cars are of type
mgnl:product
.
We will create an Angular app that provides:
- A list of cars to choose from.
- Details of a selected car with a description and image.
- Checkboxes to filter the list into
classics
or007
cars. We use the parent folders as pseudo-categories.
App structure
We will create an
ngApp
with two controllers:
carsList
renders a list of all cars. Each car has a clickable label. Clicking on the label shows the car in thecarDetail
controller. The list controller also has two checkboxes to filter the list; one for each parent folder.carDetail
renders the details of the selected car: title, description and image.
App files
Now create a folder called angular
with the files app.js
, erics-cars.html
and style.css
in the light module, to create this structure:
content-app-clients-v2/ ├── angular │ ├── app.js │ ├── erics-cars.html │ └── style.css └── restEndpoints └── delivery └── carFinder.yaml
To avoid running into trouble with the same-origin policy, we run the Angular app on the same server as Magnolia. See how to overcome same-origin policy problem.
In a real use case you would serve the Angular app not from Magnolia, but rather from a static webserver like Apache and most probably on a different (sub)domain.
CarsContentClient
app
Start creating the Angular app in erics-cars.html
. Add an ng-app
directive to the body
element which wraps the two controllers:
<!doctype html> <html> <head> <title>Angular loves Magnolia :-)</title> <link rel="stylesheet" href="style.css?u=12"> <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.0-rc.0/angular.min.js"></script> <script src="app.js?v=w"></script> </head> <body ng-app="CarsContentClient"> <h1>Eric's cars - shown with a lightweight angular client</h1> </body> </html>
Note:
- Lines 5-7: References to JS and CSS files. We use dummy request parameters such as
?u=12
to bypass cache. We recommend that you use such dummy request parameters while developing. Alternatively, you could exclude content from the cache on the Magnolia instance but your browser may also cache the resources, particularly JS and CSS files. Once you're done with development, remove the request parameters. - Line 10:
ng-app
directive in thebody
element.
Request the file in the browser to make sure that Magnolia serves a static file. Use a dummy parameter ?foo=bar
also in the request URL. It ensures we bypass cache on both server and client side.
http://localhost:8080/magnoliaPublic/.resources/content-app-clients-v2/angular/erics-cars.html?foo=bar
Next, configure the Angular app in app.js
:
var app = angular.module('CarsContentClient', []); /** * Configuration of some constants */ app.constant("APP_CONFIG", { webAppBaseUrl: "http://localhost:8080/magnoliaPublic", restBaseUrl: "/.rest", requestHeaders: {headers: {'Accept': 'application/json;odata=verbose'}}, deliveryEndpointCarfinder: "/delivery/carFinder", workspace: "products", defaultCarPath: "cars/007/Aston-Martin-DB5" });
CSS style sheet
To style Eric's car page, add some CSS code to style.css
.
carDetail
controller and AngularJS utilities
To render a car's details we want a
$scope
object of the carDetail
controller with the following properties:
carTitle
carDescription
imgUrl
Add the following HTML snippet to erics-car.html
:
<div ng-controller="carDetail" class="car-detail"> <table width="100%"> <tr valign="top"> <td width="60%" class="image"> <img ng-src="{{imgUrl}}" width="640" class="fancy"> </td> <td width="40%"> <h2>{{carTitle}}</h2><p ng-bind-html="carDescription | sanitize" class="description"></p></td> </tr> </table> </div> <!-- eof 'carDetail' ctrl. -->
When you reload the file in the browser you may notice JavaScript errors. To see the errors, open the JavaScript console of your browser. The name of this console depends on the browser you are using. For instance, on Google Chrome it is named DevTools.
globalData
component
Add a component named globalData
. Both controllers use it. It has a property productPath
that is set by one controller and read by the other controller.
app.service('globalData', ['APP_CONFIG', function (APP_CONFIG) { var productPath = APP_CONFIG.defaultCarPath; var setProductPath = function (newPath) { productPath = newPath; }; var getProductPath = function () { return productPath; }; return { setProductPath: setProductPath, getProductPath: getProductPath }; }]);
utils
component
Add a component named utils
. It provides some useful methods that both controllers can use. We use the function replaceAll
in the carsList
controller.
app.service('utils', function () { var escapeRegExp = function (str) { return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } var replaceAll = function (str, find, replace) { return str.replace(new RegExp(this.escapeRegExp(find), 'g'), replace); }; return { replaceAll: replaceAll, escapeRegExp: escapeRegExp } });
sanitize
filter
The third helper to add is the sanitize
filter. Remember that the description
property of the content item contains encoded HTML. By default, Angular refuses to render JSON content that contains (HTML) markup. The sanitize
filter makes sure that the (encoded) HTML is rendered properly as HTML.
app.filter("sanitize", ['$sce', function ($sce) { return function (htmlCode) { return $sce.trustAsHtml(htmlCode); } }]);
$sce
.carDetail
controller
Finally, add the carDetail
controller:
app.controller('carDetail', ['$scope', '$http', 'globalData', 'utils', 'APP_CONFIG', function ($scope, $http, globalData, utils, APP_CONFIG) { $scope.$watch(function () { return globalData.getProductPath(); }, function (productPath) { var detailNodeRequestPath = APP_CONFIG.webAppBaseUrl + APP_CONFIG.restBaseUrl + APP_CONFIG.deliveryEndpointCarfinder + "/" + productPath; $http.get(detailNodeRequestPath, APP_CONFIG.requestHeaders) .then(function (response) { $scope.carTitle = response.data.title; $scope.carDescription = response.data.description; $scope.imgUrl = response.data.image["@link"]; }); }, true); }]);
- Line 1: Custom components
APP_CONFIG
,globalData
andutils
are injected. - Lines 6, 7: Computes a request URL for the REST call using configuration data from the
globalData
component. - Line 7: The controller executes an
HTTP#get
method on thedelivery
REST endpoint on the prefixcarFinder
. The content item path is the selected item. It requests data for one content item as we did in testing REST access. - Line 2 and onward: The
HTTP#get
call is defined as a callback function of$watch
which is a value change listener forglobalData.productPath
. The REST call is executed initially and whenglobalData.productPath
changes.
Reload the file in your browser. Now you can see the detail of the Aston Martin DB5 in your browser. Cool, isn't it!?
carsList
controller
The carsList
controller renders a list of clickable span
elements that contain car titles. The list can be filtered with checkboxes using the parent folders classics
and 007
as pseudo-categories.
Add this HTML in erics-cars.html
:
<div ng-controller="carsList"> <!-- car folders filters --> <form class="filters"> <div ng-repeat="cat in carCategories" class="cat"> <label>{{cat.name}} cars</label><input type="checkbox" name="category" ng-click="clickCategory(cat.name)" ng-model="cat.checked" /> <label> </label> </div> </form> <div class="clear"></div> <!-- car titles to click to change the detail --> <div class="cars"> <div data-ng-repeat="car in cars | filter:isInCategory" class="car"> <span ng-click="selectCar(car.path)" class="click">{{car.title}}</span> </div> </div> </div><!-- eof 'carsList' ctrl. -->
cars
: An array of car items.
Each car item has these properties:path
category
: actually a pseudo-category, the name of the parent folder such asclassics
or007
.title
: Title of the carname
: Node name of the item.
carCategories
: An associative array (map) for the pseudo-categories.
Each item has one property:checked
: Whether the pseudo-category is currently selected.
Here is the JavaScript code:
app.controller('carsList', ['$scope', '$http', 'globalData', 'utils', 'APP_CONFIG', function ($scope, $http, globalData, utils, APP_CONFIG) { $scope.carCategories = {}; $scope.selectCar = function (arg) { // we need the path relative to the "rootPath" as defined in the endpointPrefix if (arg.length > 1 && arg.startsWith("")) { globalData.setProductPath(arg.substr(1)); } }; $scope.clickCategory = function (catName) { $scope.carCategories[catName].selected = !($scope.carCategories[catName].checked); }; // a per scope filter $scope.isInCategory = function (car) { return $scope.carCategories[car.category].checked; }; var carsNodesRequestUrl = APP_CONFIG.webAppBaseUrl + APP_CONFIG.restBaseUrl + APP_CONFIG.deliveryEndpointCarfinder + "/" + "cars"; $http.get(carsNodesRequestUrl, APP_CONFIG.requestHeaders) .then(function successCallback(response) { $scope.cars = []; for (var item in response.data) { // grab the top level folders with the car categories ("classic-cars", "007-cars") if ("mgnl:folder" == response.data[item]["@nodeType"]) { var folderName = response.data[item]["@name"]; var folderNode = response.data[item]; $scope.carCategories[folderName] = {checked: true, name: folderName}; // grab some params for each car for (var subItem in folderNode) { if ("mgnl:product" == folderNode[subItem]["@nodeType"]) { var carNode = folderNode[subItem]; var carPath = folderNode[subItem]["@path"] // replacing space (" ") with non-breaking to produce button-like labels var carTitle = utils.replaceAll(carNode.title, " ", "\u00A0"); $scope.cars.push({path: carPath, category: folderName, title: carTitle, name: carNode["@name"]}); } } } } }); }]);
- Line 1: Again custom components
APP_CONFIG
,globalData
andutils
are injected. - Lines 5-10:
selectCar
is executed when you click a car in the list. The function setsglobalData.productPath
so it indirectly triggers the execution of thecarDetail
controller.
We remove the slash at the very begining, since we want a path relative to the rootPath as defined in the configuration for thecarFinder
prefix of the delivery endpoint. - Lines 12-14:
clickCategory
maintains the $scope variablecarCategories
. Since this changes the state of a controller $scope variable, the UI gets repainted - the list items get updated. - Line 21, 22: The controller executes an
HTTP#get
method. Here the URI for the JSON call requests the cars root folder and contains the argumentdepth
.
Final result:
The angular app is now fully functional. Congratulations!
Get the completed files
The files in their final form are available in the Magnolia Git repository.
Clone the repository to get the complete file structure. Use a terminal, go to your light modules folder and clone:
git clone https://git.magnolia-cms.com/scm/documentation/content-app-clients-v2.git
What next?
Where to add the AngularJS app
You can use or host the AngularJS app anywhere:
- As a static web resource served by Magnolia as seen in this tutorial.
- As part of a page or component template, in a template script.
- As a static or dynamic resource in any other server.
Same-origin policy
If you want to run the AngularJS app on a different host – a host which has a different address than the Magnolia server which provides the REST API – you run into the same-origin policy problem. To overcome this problem, some use specific Apache settings, see StackOverflow.
An elegant solution to solve the same-origin policy without changing Apache configuration is the Magnolia Add HTTP Headers filter.
Photo credits:
- Riley Brooklands 1930 and Fiat Cinquecento, Pedro Ribeiro Simões. Creative Commons Attribution 2.0 Generic (CC BY 2.0)
- Pontiac 1952, Continental Mark II and Hudson 1927, John Lloyd. Creative Commons Attribution 2.0 Generic (CC BY 2.0)
Aston Martin DB5, Aston Martin Works
Aston Martin V8, Beltane43, Creative Commons Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0)
- Lotus Esprit S1, Don Griffin