Embedded SPA1 with RESTful Hypermedia

Build a single-page application that uses the RESTful hypermedia of its own host to access data.

Let’s build the first Single Page Application (SPA) in the RESTful Workshop series. The app will display a list of products when loaded in a browser.

Embedded SPA1 with RESTful Hypermedia.
Embedded SPA1 with RESTful Hypermedia.

See the live page at https://demo.codeontime.com/pages/spa1.

Create the empty spa1.html file in the ~/app/pages folder of the backend application. You will gradually add the content to the file as you progress through the tutorial.

If you want the app page to have a dedicated option in the navigation menu of the host, then start Code On Time, select your project, choose Design, click on the New Page button on the Project Explorer toolbar, enter spa1 in the page name, set Template to (blank), set Generate to First Time Only, press OK button. Drag the new page node to the desired parent if needed. Generate the application to create the placeholder file.

Single Page App Layout

Enter the following as the content of ~/app/pages/spa1.html page. Use your favorite text editor to make changes.

HTML
1234567891011121314151617181920212223242526272829303132333435<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA1 with RESTful Hypermedia</title>
    <style>
        #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 0 auto 0 auto;
            line-height: 34px;
        }

        .toolbar {
            height: auto;
            display: flex;
            border: solid 1px #ddd;
            padding: .25em;
            margin: .5em 0;
        }

        button[data-hypermedia] {
            margin-right: 1em !important;
        }
    </style>
</head>
<body>
    <div data-app-role="page">
        <h1 style="text-align:center">Embedded SPA1 with RESTful Hypermedia</h1>
        <div id="authenticated">
            <p>This is the list of products:</p>
            <div id="product-list"></div>
        </div>
    </div>
</body>
</html>

This SPA will blend in the user interface of the host. Touch UI will recognize the div[data-app-role=”page”] element as the virtual “start” page. The framework will also create the standard UI features such as navigation menu and sidebar. Element #product-list will serve as a container for the list of products.

The markup of SPA1 blends into Touch UI.
The markup of SPA1 blends into Touch UI.

Our single page app is physically embedded in the host and can take advantage of many of its features. Users will need to sign in to access the page.

Embedded Single Page App Lifecycle

Touch UI will route the visual presentation to the first virtual page when the physical HTML page ~/pages/spa1.html is loaded in a browser. The event pagereadycomplete.app is triggered on the document by the framework whenever a virtual page becomes ready for interaction. The embedded SPA will retrieve and display the first ten rows of the products when it happens.

Enter the following script block directly before the closing body tag in the page markup.

HTML
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980<script>
    (function () {
        var apiHypermedia,
            products;

        $(document)
            .one('pagereadycomplete.app', e => {
                $app.restful()
                    .then(result => {
                        apiHypermedia = result;
                        start();
                    })
                    .catch(restfulException);
            }).on('vclick', '[data-hypermedia]', handleHypermedia);

        function start() {
            // show the data if the API allows it
            if (apiHypermedia && apiHypermedia.products)
                refreshProductList(apiHypermedia.products._links.first);
            else
                document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
        }

        /* product list rendering and paging */

        function refreshProductList(hypermedia) {
            $app.restful({ "url": hypermedia })
                .then(result => {
                    products = result;
                    renderProductData();
                })
                .catch(restfulException);
        }

        function renderProductData() {
            var sb = [
                '<table border="1" cellpadding="5" cellspacing="0">',
                '<tr><th>Product</th><th>Category</th><th>Supplier</th><th>Unit Price</th><th>Units In Stock</th></tr>'];
            for (var i = 0; i < products.collection.length; i++) {
                var p = products.collection[i];
                sb.push('<tr>');
                sb.push('<td>', $app.htmlEncode(p.productName), '</td>');
                sb.push('<td>', $app.htmlEncode(p.categoryName), '</td>');
                sb.push('<td>', $app.htmlEncode(p.supplierCompanyName), '</td>');
                sb.push('<td>', $app.htmlEncode(p.unitPrice), '</td>');
                sb.push('<td>', $app.htmlEncode(p.unitsInStock), '</td>');
                sb.push('</tr>')
            }
            sb.push('</table>');
            sb.push('<div class="toolbar">')
            var hypermedia = products._links;
            sb.push('<button data-hypermedia="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
            sb.push('<button data-hypermedia="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
            sb.push('<button data-hypermedia="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
            sb.push('<button data-hypermedia="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
            sb.push('<button data-hypermedia="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
            var hypermediaArgs = $app.urlArgs(products._links.self);
            sb.push('Page: ', parseInt(hypermediaArgs.page) + 1, ' of ', Math.ceil(products.count / hypermediaArgs.limit),
                ' (', + products.count + ' items)');
            sb.push('</div>');
            document.querySelector('#product-list').innerHTML = sb.join('');
            $app.touch.scrollable('refresh'); // let the Touch UI know that the contents of the page have changed
        }

        function handleHypermedia(e) {
            var btn = e.target.closest('[data-hypermedia]')
            if (btn && products) {
                var hypermedia = btn.getAttribute('data-hypermedia');
                refreshProductList(products._links[hypermedia]);
            }
        }

        /* miscellaneous utilities */

        function restfulException(ex) {
            if (ex && ex.errors)
                $app.alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
        }
    })();
</script>

Let’s discuss the individual components of this script.

The handler of the pagereadycomplete.app event will trigger one time only when the physical page is loaded and the user interface is fully constructed. The handler will invoke the $app.restful method to get the API definition available to the current user identity. The definition will be stored in the global apiHypermedia variable.

JavaScript
123456789$(document)
    .one('pagereadycomplete.app', e => {
        $app.restful()
            .then(result => {
                apiHypermedia = result;
                start();
            })
            .catch(restfulException);
    }).on('vclick', '[data-hypermedia]', handleHypermedia);
Method $app.restful provides a thin wrapper on top of the Fetch API. It fetches the /v2 endpoint resources of the backend application with the user access token passed in the Authorization header.

Next the pagereadycomplete.app handler will invoke the start function. If the fetched hypermedia contains the products key, then the function will refresh the product list with the first page of data. Otherwise the error message will let the user know that they are not authorized to access products.

JavaScript
1234567function start() {
    // show the data if the API allows it
    if (apiHypermedia && apiHypermedia.products)
        refreshProductList(apiHypermedia.products._links.first);
    else
        document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
}

Function refreshProductList invokes the $app.restful() method with the url set to the hypermedia argument. The result is cached in the global variable products. This is followed by the renderProductData call that will be discussed later.

JavaScript
12345678function refreshProductList(hypermedia) {
    $app.restful({ "url": hypermedia })
        .then(result => {
            products = result;
            renderProductData();
        })
        .catch(restfulException);
}

The event handler handleHypermedia is triggered when the HTML elements with the data-hypermedia attribute are clicked. It calls the refreshProductList function with the corresponding hypermedia control specified in the argument. Please note that the event handler is processing the synthetic vclick event triggered by Touch UI in response to the mouse, pointer, or touch interactions.

JavaScript
1234567function handleHypermedia(e) {
    var btn = e.target.closest('[data-hypermedia]')
    if (btn && products) {
        var hypermedia = btn.getAttribute('data-hypermedia');
        refreshProductList(products._links[hypermedia]);
    }
}

The global variable apiHypermedia contains the payload of the /v2 endpoint returned when the $app.restful method is invoked without arguments in the start function. The top-level keys represent the data controllers that are available to the current user. The embedded hypermedia links “explain” how to access the controller resources.

JSON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849{
    "_links": {
        "oauth2": {
            "href": "/oauth2/v2"
        },
        "restful.js": {
            "href": "/v2/js/restful-2.0.1.js"
        }
    },
    "categories": {
        "_links": {
            "self": {
                "href": "/v2/categories?count=true"
            },
            "first": {
                "href": "/v2/categories?page=0&limit=10"
            },
            "schema": {
                "href": "/v2/categories?count=true&_schema=only"
            }
        }
    },
    "products": {
        "_links": {
            "self": {
                "href": "/v2/products?count=true"
            },
            "first": {
                "href": "/v2/products?page=0&limit=5"
            },
            "schema": {
                "href": "/v2/products?count=true&_schema=only"
            }
        }
    },
    "suppliers": {
        "_links": {
            "self": {
                "href": "/v2/suppliers?count=true"
            },
            "first": {
                "href": "/v2/suppliers?page=0&limit=10"
            },
            "schema": {
                "href": "/v2/suppliers?count=true&_schema=only"
            }
        }
    }
}

The global variable products stores the product data with the hypermedia links. It is refreshed by the refreshProductList function after each successful call to the $app.restful method with the hypermedia control specified in the url argument.

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113{
    "_links": {
        "self": {
            "href": "/v2/products?page=0&limit=3"
        },
        "next": {
            "href": "/v2/products?page=1&limit=3"
        },
        "last": {
            "href": "/v2/products?page=25&limit=3"
        },
        "lookup-supplierId": {
            "href": "/v2/suppliers?count=true&fields=supplierId,companyName",
            "embeddable": true
        },
        "lookup-categoryId": {
            "href": "/v2/categories?count=true&fields=categoryId,categoryName",
            "embeddable": true
        },
        "create": {
            "href": "/v2/products",
            "method": "POST"
        },
        "schema": {
            "href": "/v2/products?_schema=true&page=0&limit=3"
        }
    },
    "count": 77,
    "page": 0,
    "collection": [
        {
            "_links": {
                "self": {
                    "href": "/v2/products/17"
                },
                "supplierId": {
                    "href": "/v2/products/17/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/17/category-id",
                    "embeddable": true
                }
            },
            "productId": 17,
            "productName": "Alice Mutton",
            "supplierId": 7,
            "supplierCompanyName": "Pavlova, Ltd.",
            "categoryId": 6,
            "categoryName": "Meat/Poultry",
            "quantityPerUnit": "20 - 1 kg tins",
            "unitPrice": 13.3000,
            "unitsInStock": 0,
            "unitsOnOrder": 0,
            "reorderLevel": 0,
            "discontinued": false
        },
        {
            "_links": {
                "self": {
                    "href": "/v2/products/3"
                },
                "supplierId": {
                    "href": "/v2/products/3/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/3/category-id",
                    "embeddable": true
                }
            },
            "productId": 3,
            "productName": "Aniseed Syrup",
            "supplierId": 1,
            "supplierCompanyName": "Exotic Liquids",
            "categoryId": 2,
            "categoryName": "Condiments",
            "quantityPerUnit": "12 - 550 ml bottles",
            "unitPrice": 10.0000,
            "unitsInStock": 13,
            "unitsOnOrder": 70,
            "reorderLevel": 25,
            "discontinued": false
        },
        {
            "_links": {
                "self": {
                    "href": "/v2/products/40"
                },
                "supplierId": {
                    "href": "/v2/products/40/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/40/category-id",
                    "embeddable": true
                }
            },
            "productId": 40,
            "productName": "Boston Crab Meat",
            "supplierId": 19,
            "supplierCompanyName": "New England Seafood Cannery",
            "categoryId": 8,
            "categoryName": "Seafood",
            "quantityPerUnit": "24 - 4 oz tins",
            "unitPrice": 18.4000,
            "unitsInStock": 123,
            "unitsOnOrder": 0,
            "reorderLevel": 30,
            "discontinued": false
        }
    ]
}

The inclusion of the hypermedia controls (links) makes it very easy to determine the availability of a particular API resource (first, next, prev, last, self, create, etc.) and to pass its hypermedia as the url argument to the $spp.restful method. Each hypermedia control defines the mandatory href property and optional method property. The latter specifies the HTTP method that must be used to access the resource specified in href. If the method property is not specified, then GET is assumed.

If a particular hypermedia control is not available, then the state of data does not allow it. Client app developers only need to know the names of the hypermedia controls to present the corresponding user interface elements and to invoke the resources in response to user actions.

Product List Rendering

The page defines the #authenticated container with the embedded #product-list element.

Function renderProductData will produce the markup of a table element representing the rows of data stored in the global products variable.

JavaScript
1234567891011121314151617181920212223242526272829function renderProductData() {
    var sb = [
        '<table border="1" cellpadding="5" cellspacing="0">',
        '<tr><th>Product</th><th>Category</th><th>Supplier</th><th>Unit Price</th><th>Units In Stock</th></tr>'];
    for (var i = 0; i < products.collection.length; i++) {
        var p = products.collection[i];
        sb.push('<tr>');
        sb.push('<td>', $app.htmlEncode(p.productName), '</td>');
        sb.push('<td>', $app.htmlEncode(p.categoryName), '</td>');
        sb.push('<td>', $app.htmlEncode(p.supplierCompanyName), '</td>');
        sb.push('<td>', $app.htmlEncode(p.unitPrice), '</td>');
        sb.push('<td>', $app.htmlEncode(p.unitsInStock), '</td>');
        sb.push('</tr>')
    }
    sb.push('</table>');
    sb.push('<div class="toolbar">')
    var hypermedia = products._links;
    sb.push('<button data-hypermedia="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
    sb.push('<button data-hypermedia="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
    sb.push('<button data-hypermedia="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
    sb.push('<button data-hypermedia="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
    sb.push('<button data-hypermedia="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
    var hypermediaArgs = $app.urlArgs(products._links.self);
    sb.push('Page: ', parseInt(hypermediaArgs.page) + 1, ' of ', Math.ceil(products.count / hypermediaArgs.limit),
        ' (', + products.count + ' items)');
    sb.push('</div>');
    document.querySelector('#product-list').innerHTML = sb.join('');
    $app.touch.scrollable('refresh'); // let the Touch UI know that the contents of the page have changed
}

The table element is followed with the markup of the div.toolbar containing five buttons: Refresh, First, Prev, Next, and Last. The buttons are marked with the data-hypermedia attributes with the values that match the names of the hypermedia controls embedded in the products data.

The combined HTML is assigned to the #product-list element. Here is how the first page of data may look when the SPA is loaded in a browser.

The first page of data rendered by the hypermedia-enabled client app.
The first page of data rendered by the hypermedia-enabled client app.

The buttons are marked as disabled if their hypermedia control is not available. For example, the screenshot above shows the First button in the disabled state since the first page of data does not include the first hypermedia link.

The programming of five buttons in the toolbar takes exactly five lines of code. Developers are not required to calculate the range of rows that will need to be requested from the backend or to figure if a particular paging operation is possible. There are no physical resource URLs embedded in the client app code.

RESTful Hypermedia reduces the complexity of the client apps and allows evolving the backend application without impact on the existing clients.

Note that the renderProductList function is using $app.htmlEncode and $app.urlArgs methods to produce a “safe” HTML from data and to parse the URL parameters of the hypermedia href property to display the range of visible rows in the pager toolbar. Method $app.touch.scrollable notifies the host framework that the scrollable portion of the virtual page has changed.

Final Source Code

This single page app is composed of a single physical file. The complete source of the spa1.html file is shown next. It includes the HTML and embedded script block with the variables and functions discussed in the tutorial.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA1 with RESTful Hypermedia</title>
    <style>
        #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 0 auto 0 auto;
            line-height: 34px;
        }

        .toolbar {
            height: auto;
            display: flex;
            border: solid 1px #ddd;
            padding: .25em;
            margin: .5em 0;
        }

        button[data-hypermedia] {
            margin-right: 1em !important;
        }
    </style>
</head>
<body>
    <div data-app-role="page">
        <h1 style="text-align:center">Embedded SPA1 with RESTful Hypermedia</h1>
        <div id="authenticated">
            <p>This is the list of products:</p>
            <div id="product-list"></div>
        </div>
    </div>
    <script>
        (function () {
            var apiHypermedia,
                products;

            $(document)
                .one('pagereadycomplete.app', e => {
                    $app.restful()
                        .then(result => {
                            apiHypermedia = result;
                            start();
                        })
                        .catch(restfulException);
                }).on('vclick', '[data-hypermedia]', handleHypermedia);

            function start() {
                // show the data if the API allows it
                if (apiHypermedia && apiHypermedia.products)
                    refreshProductList(apiHypermedia.products._links.first);
                else
                    document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
            }

            /* product list rendering and paging */

            function refreshProductList(hypermedia) {
                $app.restful({ "url": hypermedia })
                    .then(result => {
                        products = result;
                        renderProductData();
                    })
                    .catch(restfulException);
            }

            function renderProductData() {
                var sb = [
                    '<table border="1" cellpadding="5" cellspacing="0">',
                    '<tr><th>Product</th><th>Category</th><th>Supplier</th><th>Unit Price</th><th>Units In Stock</th></tr>'];
                for (var i = 0; i < products.collection.length; i++) {
                    var p = products.collection[i];
                    sb.push('<tr>');
                    sb.push('<td>', $app.htmlEncode(p.productName), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.categoryName), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.supplierCompanyName), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.unitPrice), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.unitsInStock), '</td>');
                    sb.push('</tr>')
                }
                sb.push('</table>');
                sb.push('<div class="toolbar">')
                var hypermedia = products._links;
                sb.push('<button data-hypermedia="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
                sb.push('<button data-hypermedia="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
                sb.push('<button data-hypermedia="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
                sb.push('<button data-hypermedia="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
                sb.push('<button data-hypermedia="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
                var hypermediaArgs = $app.urlArgs(products._links.self);
                sb.push('Page: ', parseInt(hypermediaArgs.page) + 1, ' of ', Math.ceil(products.count / hypermediaArgs.limit),
                    ' (', + products.count + ' items)');
                sb.push('</div>');
                document.querySelector('#product-list').innerHTML = sb.join('');
                $app.touch.scrollable('refresh'); // let the Touch UI know that the contents of the page have changed
            }

            function handleHypermedia(e) {
                var btn = e.target.closest('[data-hypermedia]')
                if (btn && products) {
                    var hypermedia = btn.getAttribute('data-hypermedia');
                    refreshProductList(products._links[hypermedia]);
                }
            }

            /* miscellaneous utilities */

            function restfulException(ex) {
                if (ex && ex.errors)
                    $app.alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
            }
        })();
    </script>
</body>
</html>

Next

Continue to the Embedded SPA2 with REST Level 2 segment to learn how to work with the RESTful resources without hypermedia. You will modify the app created in this segment to use the direct resource URls with the explicitly specified HTTP methods.


This tutorial is part of the RESTful Workshop designed to empower individual developers and teams to create amazing enterprise-quality backends for mobile and web applications. Zero experience and no code is required to provide your data with the REST API conforming to the Level 3 of Richardson Maturity Model with the built-in OAuth 2.0 and Open ID Connect.