Embedded SPA2 with REST Level 2

Consume the RESTful API in the embedded single-page application without the benefit of hypermedia.

This tutorial explains how to build an embedded Single Page Application that makes the direct use of the REST resources of the host application. The new app will look just like its cousin, the Embedded SPA1 with RESTful Hypermedia. It blends in the host user interface, but does not make use of the hypermedia when working with the data.

Embedded SPA2 with REST Level 2
Embedded SPA2 with REST Level 2

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

The HTML markup of the page ~/app/pages/spa2.html will be exactly the same, so you can borrow it from SPA1. The script in the body of the page is different. Here it is.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102<script>
    (function () {
        var products,
            pageIndex = 0,
            pageSize = 10;

        $(document)
            .one('pagereadycomplete.app', e => {
                start();
            }).on('vclick', '[data-action]', handleActions);


        function start() {
            // show the first page of products by default
            refreshProductList(0);
        }

        /* product list rendering and paging */

        function refreshProductList() {
            $app.restful({
                "method": 'GET',        // 1) HTTP method of the request. GET is optional.
                "url": '~/v2/products', // 2) example: /v2/products?limit=10&page=0
                "query": {              // 3) Properties of the object are added to the URL
                    'limit': pageSize,
                    'page': pageIndex
                },
                headers: null,       // 4) Object represents the custom headers
                body: null,          // 5) Object represents the payload of the request
            })
                .then(result => {
                    products = result;
                    renderProductData();
                })
                .catch(ex => {
                    document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
                    restfulException(ex);
                });
        }

        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 pageCount = Math.ceil(products.count / pageSize);
            sb.push('<button data-action="self">Refresh</button>');
            sb.push('<button data-action="first"', pageIndex > 0 ? '' : ' disabled="disabled"', '>First</button>');
            sb.push('<button data-action="prev"', pageIndex >= 1 ? '' : ' disabled="disabled"', '>Prev</button>');
            sb.push('<button data-action="next"', pageIndex <= pageCount - 2 ? '' : ' disabled="disabled"', '>Next</button>');
            sb.push('<button data-action="last"', pageIndex < pageCount - 1 ? '' : ' disabled="disabled"', '>Last</button>');
            sb.push('Page: ', pageIndex + 1, ' of ', pageCount,
                ' (', + 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 handleActions(e) {
            var btn = e.target.closest('[data-action]')
            if (btn && products) {
                var action = btn.getAttribute('data-action');
                switch (action) {
                    case 'self':
                        // the page index is not changed
                        break;
                    case 'first':
                        pageIndex = 0;
                        break;
                    case 'prev':
                        pageIndex--;
                        break;
                    case 'next':
                        pageIndex++;
                        break;
                    case 'last':
                        pageIndex = Math.ceil(products.count / pageSize) - 1;
                        break;
                }
                refreshProductList();
            }
        }

        /* miscellaneous utilities */

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

Code Analysis and Comparison

The app does not require the hypermedia and therefore the start of the app commences immediately in the pagereadycomplete.app event handler triggered by Touch UI framework.

JavaScript
1234$(document)
    .one('pagereadycomplete.app', e => {
        start();
    }).on('vclick', '[data-action]', handleActions);

This is the code from the SPA1 tutorial. The “hypermedia” app retrieves the API definition and calls the start function.

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

Function start has no means to ascertain if the user has access to the REST resources of the products data controller. It simply proceeds to call the refreshProductList function.

JavaScript
1234function start() {
    // show the first page of products by default
    refreshProductList();
}

The hypermedia-aware client SPA1 can alter its UI to match the API hypermedia controls infused in the API definition by the RESTful API Engine.

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 uses the $app.restful method to fetch the page of the product list identified by the global variable pageIndex. The url of the request is explicitly defined along with the HTTP method. The query parameters limit and page are also specified. The global variable products stores the product data fetched from the backend.

JavaScript
1234567891011121314151617181920function refreshProductList() {
    $app.restful({
        "method": 'GET',       // 1) HTTP method of the request. GET is optional.
        "url": '/v2/products', // 2) example: /v2/products?limit=10&page=0
        "query": {             // 3) Properties of the object are added to the URL
            'limit': pageSize,
            'page': pageIndex
        },
        headers: null,       // 4) Object represents the custom headers
        body: null,          // 5) Object represents the payload of the request
    })
        .then(result => {
            products = result;
            renderProductData();
        })
        .catch(ex => {
            document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
            restfulException(ex);
        });
}

The “hypermedia” version of the refreshProductList method is simpler in SPA1. It passes the hypermedia control as the url parameter.

JavaScript
12345678function refreshProductList(hypermedia) {
    $app.restful({ "url": hypermedia })
        .then(result => {
            products = result;
            renderProductData();
        })
        .catch(restfulException);
}
Method $app.restful provides a thin wrapper on top of the Fetch API. It handles both hypermedia and explicit REST resource URIs. It uses the access token of the host application and seamlessly refreshes the token when needed.

The event handler handleActions must explicitly assign the pageIndex variables and perform calculations when the buttons in the pager toolbar are clicked.

JavaScript
123456789101112131415161718192021222324function handleActions(e) {
    var btn = e.target.closest('[data-action]')
    if (btn && products) {
        var action = btn.getAttribute('data-action');
        switch (action) {
            case 'self':
                // the page index is not changed
                break;
            case 'first':
                pageIndex = 0;
                break;
            case 'prev':
                pageIndex--;
                break;
            case 'next':
                pageIndex++;
                break;
            case 'last':
                pageIndex = Math.ceil(products.count / pageSize) - 1;
                break;
        }
        refreshProductList();
    }
}

The “hypermedia” version of this method is called handleHypermedia in SPA1. It appears to be simpler and lacks any calculations.

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 physical rendering of the product list in renderProductData function is similar to its counterpart in SPA1. The paging toolbar construction takes more work. The code performs a few arithmetic and logical operations on the pageIndex and pageCount to determine if a particular button in the pager is enabled.

JavaScript
12345678910111213141516171819202122232425262728function 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 pageCount = Math.ceil(products.count / pageSize);
    sb.push('<button data-action="self">Refresh</button>');
    sb.push('<button data-action="first"', pageIndex > 0 ? '' : ' disabled="disabled"', '>First</button>');
    sb.push('<button data-action="prev"', pageIndex >= 1 ? '' : ' disabled="disabled"', '>Prev</button>');
    sb.push('<button data-action="next"', pageIndex <= pageCount - 2 ? '' : ' disabled="disabled"', '>Next</button>');
    sb.push('<button data-action="last"', pageIndex < pageCount - 1 ? '' : ' disabled="disabled"', '>Last</button>');
    sb.push('Page: ', pageIndex + 1, ' of ', pageCount,
        ' (', + 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 configuration of the buttons in the pager toolbar is more straightforward in SPA1. A button is disabled if there is no corresponding hypermedia control in the products data as shown in the next fragment.

JavaScript
1234567891011sb.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>');

This is the sample data that will be typically stored in the products global variable. The hypermedia _links are ignored by REST-only apps and actively used by the RESTful client code.

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 hypermedia can be disabled if the server.rest.hypermedia.enabled option is set to false in ~/app/touch-settings.json configuration file. Then the products data will look like this:

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748{
    "count": 77,
    "page": 0,
    "collection": [
        {
            "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
        },
        {
            "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
        },
        {
            "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
        }
    ]
}

We recommend keeping the hypermedia in the “enabled” state even if you are not planning to build the hypermedia-aware apps. The hypermedia links will make it easier to discover the API resources, simplify debugging, and allow taking advantage of the _embed parameter to fetch complex data structures. Add the X-Restful-Hypermedia header with the false value when making HTTP requests or specify the hypermedia parameter with the false value when calling the $app.restful method to exclude the links from the final output.

RESTful vs REST

RESTful Hypermedia reduces the complexity of the client apps and allows evolving the backend application without impact on the existing clients. Hypermedia significantly simplifies the configuration of the user interface options available to the end user. Developers can enable and disabled the UI features by inspecting the hypermedia controls infused in the API definition and data.

REST-based applications include the explicit resource URIs and parameters, which may lead to the tight coupling between the client and the backend application. Client app developers will need to re-create some portion of the server-side business logic when building the resource URIs and configuring the user interface. Precise configuration of the UI features is complicated and requires obtaining the user roles, profile, or scopes.

Final Source Code

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

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA2 with REST (Level 2)</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-action] {
            margin-right: 1em !important;
        }
    </style>
</head>
<body>
    <div data-app-role="page">
        <h1 style="text-align:center">Embedded SPA2 with REST (Level 2)</h1>
        <div id="authenticated">
            <p>This is the list of products:</p>
            <div id="product-list"></div>
        </div>
    </div>
    <script>
        (function () {
            var products,
                pageIndex = 0,
                pageSize = 10;

            $(document)
                .one('pagereadycomplete.app', e => {
                    start();
                }).on('vclick', '[data-action]', handleActions);


            function start() {
                // show the first page of products by default
                refreshProductList();
            }

            /* product list rendering and paging */

            function refreshProductList() {
                $app.restful({
                    "method": 'GET',       // 1) HTTP method of the request. GET is optional.
                    "url": '/v2/products', // 2) example: /v2/products?limit=10&page=0
                    "query": {             // 3) Properties of the object are added to the URL
                        'limit': pageSize,
                        'page': pageIndex
                    },
                    headers: null,       // 4) Object represents the custom headers
                    body: null,          // 5) Object represents the payload of the request
                })
                    .then(result => {
                        products = result;
                        renderProductData();
                    })
                    .catch(ex => {
                        document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
                        restfulException(ex);
                    });
            }

            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 pageCount = Math.ceil(products.count / pageSize);
                sb.push('<button data-action="self">Refresh</button>');
                sb.push('<button data-action="first"', pageIndex > 0 ? '' : ' disabled="disabled"', '>First</button>');
                sb.push('<button data-action="prev"', pageIndex >= 1 ? '' : ' disabled="disabled"', '>Prev</button>');
                sb.push('<button data-action="next"', pageIndex <= pageCount - 2 ? '' : ' disabled="disabled"', '>Next</button>');
                sb.push('<button data-action="last"', pageIndex < pageCount - 1 ? '' : ' disabled="disabled"', '>Last</button>');
                sb.push('Page: ', pageIndex + 1, ' of ', pageCount,
                    ' (', + 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 handleActions(e) {
                var btn = e.target.closest('[data-action]')
                if (btn && products) {
                    var action = btn.getAttribute('data-action');
                    switch (action) {
                        case 'self':
                            // the page index is not changed
                            break;
                        case 'first':
                            pageIndex = 0;
                            break;
                        case 'prev':
                            pageIndex--;
                            break;
                        case 'next':
                            pageIndex++;
                            break;
                        case 'last':
                            pageIndex = Math.ceil(products.count / pageSize) - 1;
                            break;
                    }
                    refreshProductList();
                }
            }

            /* 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 SPA3 (Custom UI) with RESTful Hypermedia segment to learn how to build a single page app embedded into the host with a completely custom UI. You will create a custom page without Touch UI that still takes advantage of the host security features.


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.