Blog: Posts from February, 2022

Labels
AJAX(112) Apple(1) Application Builder(242) Application Factory(207) ASP.NET(95) ASP.NET 3.5(45) ASP.NET Code Generator(72) ASP.NET Membership(28) Azure(18) Barcodes(3) BLOB(18) Business Rules(1) Business Rules/Logic(140) BYOD(13) Caching(2) Calendar(5) Charts(29) Cloud(14) Cloud On Time(2) Cloud On Time for Windows 7(2) Code Generator(54) Collaboration(11) command line(1) Conflict Detection(1) Content Management System(11) COT Tools for Excel(26) CRUD(1) Custom Actions(1) Data Aquarium Framework(122) Data Sheet(9) Data Sources(22) Database Lookups(50) Deployment(22) Designer(177) DotNetNuke(12) EASE(20) Email(6) Features(99) Firebird(1) Form Builder(14) Globalization and Localization(6) Hypermedia(2) Installation(4) JavaScript(20) Kiosk(1) Low Code(3) Mac(1) Many-To-Many(4) Maps(6) Master/Detail(36) Microservices(4) Mobile(63) Mode Builder(3) Model Builder(3) MySQL(10) Native Apps(5) News(15) OAuth(5) OAuth Scopes(1) OAuth2(6) Offline(14) Oracle(10) PKCE(1) PostgreSQL(2) QR codes(2) Rapid Application Development(5) Reading Pane(2) Release Notes(163) Reports(48) REST(26) RESTful(21) RESTful Workshop(13) RFID tags(1) SaaS(7) Security(75) SharePoint(12) SPA(5) SQL Anywhere(3) SQL Server(26) Stored Procedure(4) Teamwork(15) Tips and Tricks(81) Tools for Excel(2) Touch UI(93) Transactions(5) Tutorials(183) Universal Windows Platform(3) User Interface(331) Video Tutorial(37) Web 2.0(100) Web App Generator(101) Web Application Generator(607) Web Form Builder(39) Web.Config(9) Workflow(28)
Archive
Blog
Posts from February, 2022
Tuesday, February 15, 2022PrintSubscribe
CRUD with Hypermedia

CRUD stands for Create, Read, Update, and Delete.

Typical CRUD operations are performed on REST resources with POST, GET, PATCH, PUT, and DELETE methods of HTTP protocol. RESTful API Engine embeds the uniform hypermedia controls create, edit, replace, and delete in the relevant resources to eliminate the guesswork and scrutinizing of API definitions.

This workshop segment will guide the reader through the process of full implementation of CRUD with Hypermedia. Single page application SPA4 will be modified to allow manipulation of the product list data. Developers may choose to alter the code to use the explicit resource locations as explained in the instructions of the SPA5 segment.

Single page app SPA4 with RESTful Hypermedia and CRUD has the "New" button in the toolbar. The "New Product" form is displayed when the button is pressed. A click on any row in the product grid will open the "Edit Product" form allowing users to change or delete the product.
Single page app SPA4 with RESTful Hypermedia and CRUD has the "New" button in the toolbar. The "New Product" form is displayed when the button is pressed. A click on any row in the product grid will open the "Edit Product" form allowing users to change or delete the product.

User Identity Authentication

Client app SPA4 can be deployed to any web server and operating system. Users are authenticated with OAuth 2.0 Authorization Code flow. The app automatically provides the access token to the $app.restful method. The start function configures the method to invoke the token callback for that purpose.

The samples below will demonstrate the HTTP requests executed in Postman. The user identity must be specified explicitly in the request headers. Specify the user identity either with the API Key or access token when instructed.

Use cURL or any other API development tool that you are familiar with as an alternative.

Create

Locating Hypermedia Controls

Client app SPA4 implements paging of products. The product page data is stored in the global variable products. This is the extract of the hypermedia controls found in the variable.

JSON
12345678910111213141516171819202122232425262728{
    "_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"
        }
    }
}

The hypermedia control create explains how to add a new product to the collection. Execute the POST request to the /v2/products endpoint defined in the href property of the control. Make sure to specify either the admin or user identity in the Authorization settings of the request.

RESTful API Engine includes the _schema key in the output with errors related to the request payload. Field names, types, and other metadata will help developers to troubleshoot the issue.
RESTful API Engine includes the _schema key in the output with errors related to the request payload. Field names, types, and other metadata will help developers to troubleshoot the issue.

The error with the HTTP status 403 is returned. It explains that the field productName is expected in the body of the request.

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081{
    "error": {
        "errors": [
            {
                "id": "8bcf7f89-b264-49da-bc96-0f8baa8a2584",
                "reason": "invalid_argument",
                "message": "Field 'productName' is expected in the body."
            }
        ],
        "code": 403,
        "message": "Forbidden"
    },
    "_schema": {
        "productId": {
            "type": "Int32",
            "required": true,
            "key": true,
            "readOnly": true,
            "label": "Product ID"
        },
        "productName": {
            "type": "String",
            "length": 40,
            "required": true,
            "label": "Product Name"
        },
        "supplierId": {
            "type": "Int32",
            "lookup": true,
            "label": "Supplier ID"
        },
        "supplierCompanyName": {
            "type": "String",
            "length": 40,
            "readOnly": true,
            "label": "Supplier Company Name"
        },
        "categoryId": {
            "type": "Int32",
            "lookup": true,
            "label": "Category ID"
        },
        "categoryName": {
            "type": "String",
            "length": 15,
            "readOnly": true,
            "label": "Category Name"
        },
        "quantityPerUnit": {
            "type": "String",
            "length": 20,
            "label": "Quantity Per Unit"
        },
        "unitPrice": {
            "type": "Decimal",
            "default": "((0))",
            "label": "Unit Price"
        },
        "unitsInStock": {
            "type": "Int16",
            "default": "((0))",
            "label": "Units In Stock"
        },
        "unitsOnOrder": {
            "type": "Int16",
            "default": "((0))",
            "label": "Units On Order"
        },
        "reorderLevel": {
            "type": "Int16",
            "default": "((0))",
            "label": "Reorder Level"
        },
        "discontinued": {
            "type": "Boolean",
            "default": "((0))",
            "required": true,
            "label": "Discontinued"
        }
    }
}

The error response also includes the _schema key. It describes the individual properties of the expected input data including the type, length, label, required, key, lookup, and default.

Posting to Collection

Now we know how to proceed! Post the following JSON payload to the same resource.

JSON
12345{
    "productName": "New Product",
    "unitPrice": 4.49,
    "categoryName": "Confections"
}

The HTTP response status 201 indicates that the new resource was created.

Successful POST to a collection returns the new item resource with with HTTP status code 201.
Successful POST to a collection returns the new item resource with with HTTP status code 201.

RESTful API Engine was able to lookup the omitted value of the categoryId with the help of the field categoryName in the payload.

JSON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455{
    "_links": {
        "self": {
            "href": "/v2/products/247"
        },
        "up": {
            "href": "/v2/products"
        },
        "collection": {
            "href": "/v2/products?count=true"
        },
        "first": {
            "href": "/v2/products?page=0&limit=10"
        },
        "lookup-supplierId": {
            "href": "/v2/suppliers?count=true&fields=supplierId,companyName",
            "embeddable": true
        },
        "categoryId": {
            "href": "/v2/products/247/category-id",
            "embeddable": true
        },
        "lookup-categoryId": {
            "href": "/v2/categories?count=true&fields=categoryId,categoryName",
            "embeddable": true
        },
        "edit": {
            "href": "/v2/products/247",
            "method": "PATCH"
        },
        "replace": {
            "href": "/v2/products/247",
            "method": "PUT"
        },
        "delete": {
            "href": "/v2/products/247",
            "method": "DELETE"
        },
        "schema": {
            "href": "/v2/products/247?_schema=true"
        }
    },
    "productId": 247,
    "productName": "New Product",
    "supplierId": null,
    "supplierCompanyName": null,
    "categoryId": 3,
    "categoryName": "Confections",
    "quantityPerUnit": null,
    "unitPrice": 4.4900,
    "unitsInStock": 0,
    "unitsOnOrder": 0,
    "reorderLevel": 0,
    "discontinued": false
}

The rich hypermedia of the new resource provides the links to the resource itself (self) and its parent collection (up, collection, first). Developers and apps can infer how to edit, replace, or delete this resource.

If a hypermedia control is not present, then the state of data does not allow it.

For example, there is the hypermedia control categoryId, which provides a way to explore the category resource Confections. There is no hypermedia control for the supplierId since the field value is null. Yet developers and apps can lookup the values of both fields with the lookup-categoryId and lookup-supplierId hypermedia controls.

New Product Form

Users click the New button in the product pager toolbar to display the emtpy New Product form. The Supplier Company Name and Category Name dropdowns are populated with the relevant options.

"New Product" form will post the field values to the product collection to create a new product in the inventory.
"New Product" form will post the field values to the product collection to create a new product in the inventory.

Users press the Create button to save the new product to the list. The current page of data is refreshed to reflect the changes.

The refreshed page of the product list after a successful POST to the product collection.
The refreshed page of the product list after a successful POST to the product collection.

The newProduct function is invoked when the New button is clicked in the toolbar of the product list. It executes the “magical” showForm function discussed later. The showForm function builds the user interface from the resource identified by the create hypermedia control of the products variable. The function places the Create button into the form toolbar. This button is specified in the buttons parameters. The Cancel button is the standard feature of the toolbar.

JavaScript
1234567891011121314151617181920212223/* CRUD: Create */

function newProduct() {
    showForm({
        title: 'New Product',
        from: products,
        buttons: [
            { text: 'Create', hypermedia: 'create' }
        ]
    });
}

function postProduct() {
    $app.restful({
        url: showForm.create,
        body: collectFieldValues()
    })
        .then(() => {
            hideForm();
            refreshProductList(products._links.self);
        })
        .catch(restfulException);
}

The postProduct function is invoked when the Create button is clicked in the New Product form. The function calls the $app.restful method with the parameter url set to the showForm.create property. The parameter body is an object with the properties set to the field values collected from the form inputs. The function collectFieldValues returns the object shown in the next snippet.

JSON
1234567891011{
    "productName": "Apples",
    "supplierId": "5",
    "categoryId": "7",
    "quantityPerUnit": "12 per box",
    "unitPrice": "4.99",
    "unitsInStock": "100",
    "unitsOnOrder": null,
    "reorderLevel": null,
    "discontinued": null
}

If the $app.restful method succeeds to post the product to the collection, then the form is hidden and the product list is refreshed.

RESTful API Engine will validate and convert the body properties according to their expected data types. Client apps may opt to perform additional validation.

The execution of the showForm function results in the new properties assigned to it - one for each button specified in the buttons parameter. The value of such property is the hypermedia control of the from parameter with the name defined in the button’s hypermedia option. In this instance, the showForm.create value specified in the url parameter of the $app.restful method is pointing to the products._links.create object.

JSON
1234{
    "href": "/v2/products",
    "method": "POST"
}

Read and Update

Users click on any row in the product grid to open the corresponding item in the Edit Product form. That involves reading the item data from the backend application. The product is changed when the Update button is pressed.

Users can make changes to the product or delete it from the list.
Users can make changes to the product or delete it from the list.

The readProduct function accepts the selectedIndex parameter. It provides the offset of the selected item in the product data collection. The function invokes the $app.restful method with the self hypermedia control of the item in the url parameter. Then the “magical” showForm function is invoked. It follows up by iterating through the form.fields and assigning the field property values from the product item obj to the corresponding input fields in the form.

In other words, the readProduct function fetches the selected item, creates a form, and copies item properties to inputs. This is the Read sequence of the CRUD performed in asynchronous fashion.

JavaScript
1234567891011121314151617181920212223242526272829303132/* CRUD: Read and Update */

function readProduct(selectedIndex) {
    $app.restful({
        url: products.collection[selectedIndex]._links.self
    }).then(obj => {
        showForm({
            title: 'Edit Product',
            from: obj,
            buttons: [
                { text: 'Save', hypermedia: 'edit' },
                { text: 'Delete', hypermedia: 'delete' }
            ]
        }).then(form => {
            form.fields.forEach(f => {
                form.elem.querySelector('[data-field="' + f.name + '"]').value = obj[f.name];
            });
        });
    })
}

function patchProduct() {
    $app.restful({
        url: showForm.edit,
        body: collectFieldValues()
    })
        .then(() => {
            hideForm();
            refreshProductList(products._links.self);
        })
        .catch(restfulException);
}

The patchProduct function is invoked when the Update button is clicked in the Edit Product form. The function follows in the steps of the postProduct with one exception. The url parameter of the $app.restful method is set to the showForm.edit hypermedia control. It contains the obj._links.edit reference.

JSON
1234{
    "href": "/v2/products/247",
    "method": "PATCH"
}

If the $app.restful method succeeds to patch the product item, then the form is hidden and the product list is refreshed.

Delete

Products are deleted in the Edit Product form when the Delete button is pressed.

A confirmation is displayed when users press the Delete button in "Edit Product" form.
A confirmation is displayed when users press the Delete button in "Edit Product" form.

The deleteProduct function invokes the $app.restful method with the url parameter set to showForm.delete property. Successful deletion will hide the form and refresh the product list.

JavaScript
1234567891011121314/* CRUD: Delete */

function deleteProduct() {
    if (confirm('Delete?')) {
        $app.restful({
            url: showForm.delete
        })
            .then(() => {
                hideForm();
                refreshProductList(products._links.self);
            })
            .catch(restfulException);
    }
}

The showForm.delete hypermedia control contains the obj._links.delete reference.The delete property was assigned to showForm in the readProduct function. The property value is shown in the snippet below.

JSON
1234{
    "href": "/v2/products/247",
    "method": "DELETE"
}

Handling Errors

Errors can be raised when users create, edit or delete the products. Here is an example of the field type conversion error reported by the backend.

Errors returned by the RESTful API Engine are reported by SPA4 to the user in the standard browser alert.
Errors returned by the RESTful API Engine are reported by SPA4 to the user in the standard browser alert.

Caught exceptions are passed to the restfulException function that was introduced in the verify first client app of the workshop, the Embedded SPA1 with RESTful Hypermedia. The presence of the errors key in the response body will trigger an alert. If the request is unauthorized or forbidden, then the app is reloaded.

JavaScript
123456789function restfulException(ex) {
    if (ex && ex.errors) {
        alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
        if (ex.code == 401 || ex.code == 403) {
            session('token', null);
            reloadApp();
        }
    }
}

Only the first error in the errors array is displayed to users. Multiple errors may be included when several field values contain invalid data. Developers should present all error messages in their own apps.

Hypermedia is Amazing

Here is the quick recap of the functions that will create, update, and delete the products.

JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142/* CRUD: Create */

function postProduct() {
    $app.restful({
        url: showForm.create,
        body: collectFieldValues()
    })
        .then(() => {
            hideForm();
            refreshProductList(products._links.self);
        })
        .catch(restfulException);
}

/* CRUD: Update */

function patchProduct() {
    $app.restful({
        url: showForm.edit,
        body: collectFieldValues()
    })
        .then(() => {
            hideForm();
            refreshProductList(products._links.self);
        })
        .catch(restfulException);
}

/* CRUD: Delete */

function deleteProduct() {
    if (confirm('Delete?')) {
        $app.restful({
            url: showForm.delete
        })
            .then(() => {
                hideForm();
                refreshProductList(products._links.self);
            })
            .catch(restfulException);
    }
}

The implementations are so generic that they can work for just about anything. Functions look almost like a pseudo code - that’s how brief they are.

Hypermedia does make the programming of user interfaces into a very simple affair.

A hypermedia control contains the URL with all required parameters and the optional HTTP method. If the control is not present in data, then the corresponding user interface feature must not be available.

The client application developer needs to know only the names of the hypermedia controls to alter the frontend. The backend developers are taking into account the state of data and the user identity to customize the controllers with the data controller virtualization available in apps built with Code On Time.

RESTful API Engine infuses the data with the hypermedia that matches the data controller fields and actions. The engine will not allow actions or fields that do not match the same rules that are used to produce the hypermedia. Hypermedia communicates the backend design and capabilities to the frontend.

Form Management

RESTful Workshop is designed to teach its audience to work with the RESTful API Engine. The promotion of any particular user interface framework is not its objective. Mobile app developers and JavaScript frontend gurus can choose their preferred toolkit to build the client applications.

The user interface of the single page apps presented in the workshop is built with the plain JavaScript and Document Object Model (DOM) without 3rd party dependencies.

The showForm function encapsulates the HTML form construction logic and constitutes about a quarter of the application source code. It demonstrated the dynamic discovery of the RESTful resource metadata and fetching of multiple lookup collections.

Showing the Form

The showForm function transforms the title, from, and buttons properties specified in the options argument into a modal form. It starts by creating the transparent screen element to prevent users from being able to interact with the product list while the form is active. The product list is blurred and then the Promise instance is returned.

The promise is resolved with the form object that has the fields and elem properties. The former is the array of field descriptors. The latter is the DOM element of the form. The input fields have no values.

Here is how the argument form of the resolved promise is being used in the readProduct function.

JavaScript
123456789101112131415161718function readProduct(selectedIndex) {
    $app.restful({
        url: products.collection[selectedIndex]._links.self
    }).then(obj => {
        showForm({
            title: 'Edit Product',
            from: obj,
            buttons: [
                { text: 'Save', hypermedia: 'edit' },
                { text: 'Delete', hypermedia: 'delete' }
            ]
        }).then(form => {
            form.fields.forEach(f => {
                form.elem.querySelector('[data-field="' + f.name + '"]').value = obj[f.name];
            });
        });
    })
}

The code iterates through the form fields and locates the matching input elements with the data-field attribute. The value of each element is set to the namesake property of the obj product item. The input fields in the Edit Product form are displayed with data.

This is the execution flow of the Promise instance returned by showForm:

  • First the code fetches the schema from the resource specified by the certain hypermedia control of the data in the from parameter. This control is identified by the hypermedia property of the first button in the buttons parameter.
  • The schema fetching is performed by the $app.restful method that has the schemaOnly argument set to true. The schema-only request will return the _schema key in the response body for any HTTP method. The API engine will not fulfill the request. The fetched schema is cached directly in the showForm instance. The subsequent invocation of showForm with the same first button will be working with the cached schema information.
  • The schema contents are enumerated and placed in the allFields array.
  • The array is scanned to produce the formFields array. It does not include the primary key field or the aliases of the lookups.
  • The formFields array is scanned to identify the lookups. A separate $app.restful request is initiated to fetch each lookup resource. For example, two requests to fetch the lookup-categoryId and lookup-supplierId will be queued in the fetchLookups array when showForm is invoked in readProduct. Both hypermedia controls are defined in the product item data.
  • The code waits for the lookup fetching to finish in the Promise.all method.
  • Next the building of the HTML starts:
    • The h2 header is added with the value of the title parameter.
    • The table consisting of the rows with the field labels and inputs is generated.
    • The toolbar markup is produced from the buttons parameter. Each button will have the matching property in the showForm function instance. The property name will match the hypermedia property of the button. The property value will point to the corresponding hypermedia control in the from parameter data.
    • The Cancel button markup is added to the toolbar.
  • Finally the code adds the generated HTML form to the screen element, centers the form vertically, sets the focus on the first field, and resolves the promise.

This is the full implementation of the showForm function:

JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107function showForm(options) {
    // cover the page with the "screen" 
    var screen = document.createElement('div');
    screen.className = 'screen';
    document.body.appendChild(screen);
    document.body.className = "form-active";
    // get the form metadata and resolve the promise when done
    return new Promise((resolve, reject) => {
        var hypermediaLinks = options.from._links,
            hypermediaControlOfFirstButton = options.buttons[0].hypermedia,
            resourceSchema = showForm[hypermediaControlOfFirstButton + 'Schema'];
        if (resourceSchema)
            buildForm(); // use the cached schema
        else
            $app.restful({
                url: hypermediaLinks[hypermediaControlOfFirstButton],
                schemaOnly: true
            }).then(result => {
                resourceSchema = showForm[hypermediaControlOfFirstButton + 'Schema'] = result;
                buildForm();
            });

        // generates the form from the resource schema
        function buildForm() {
            var allFields = [];
            // enumerate the schema fields
            for (var fieldName in resourceSchema._schema) {
                var f = resourceSchema._schema[fieldName];
                f.name = fieldName;
                allFields.push(f);
            }
            // exclude the primary key and lookup alias fields
            var formFields = [];
            allFields.forEach((f, index) => {
                if (!f.key && !(index && allFields[index - 1].lookup) && f.type != 'DataView')
                    formFields.push(f);
                if (f.lookup)
                    f.label = allFields[index + 1].label; // borrow the label from the alias
            });
            // enumerate the lookups and fetch their data
            var fetchLookups = [],
                lookupData = {};
            formFields.forEach(f => {
                if (f.lookup)
                    fetchLookups.push(
                        $app.restful({
                            url: hypermediaLinks['lookup-' + f.name]
                        }).then(result => {
                            lookupData[f.name] = result;
                        })
                    );
            });
            Promise.all(fetchLookups) // wait for all lookups to be fetched
                .then(() => {
                    // generate the form from the fields
                    var html = [
                        '<div class="form">',
                        '<h2>', options.title, '</h2>',
                        '<table cellpadding="8">'];
                    formFields.forEach(f => {
                        var tagName = f.lookup ? 'select' : 'input';
                        html.push(
                            '<tr>',
                            '<td>', htmlEncode(f.label), '</td>',
                            '<td>',
                            '<', tagName, ' data-field="', f.name, '">');
                        if (f.lookup) {
                            html.push('<option value="">(select)</option>'); // allow an empty selection
                            var lookup = lookupData[f.name];
                            lookup.collection.forEach(v => {
                                // convert the lookup object into an array of property values
                                var values = Object.entries(v).filter(v => v[0] != '_links').map(v => v[1]);
                                html.push(
                                    '<option value="', values[0], '">', // 1st property is the primary key
                                    htmlEncode(values[1]),              // 2nd property is the text
                                    '</option>');
                            });
                        }
                        html.push(
                            '</', tagName, '>',
                            '</td>',
                            '</tr>');
                    });
                    html.push('</table>');
                    html.push('<div class="toolbar">');
                    options.buttons.forEach(btn => {
                        var hypermediaControl = hypermediaLinks[btn.hypermedia];
                        if (hypermediaControl) {
                            html.push(
                                '<button data-hypermedia="', btn.hypermedia, '">', btn.text, '</button>');
                            showForm[btn.hypermedia] = hypermediaControl;
                        }
                    })
                    if (hypermediaLinks.self)
                        showForm.etag = hypermediaLinks.self.etag;
                    html.push('<button data-hypermedia="cancel">Cancel</button>');
                    html.push('</div>');
                    screen.innerHTML = html.join(''); // add the form to the "screen"
                    var form = document.querySelector('.form');
                    form.style.cssText = 'margin-top:' + (screen.clientHeight - form.getBoundingClientRect().height) / 2 + 'px';
                    form.querySelector('[data-field]').focus();
                    // resolve the promise with the list of 'fields' and the 'form' element
                    resolve({ fields: formFields, elem: form });
                });
        }
    });
}

The technique of the RESTful resource metadata discovery may be helpful in some scenarios, such as the one above. Specification of the schemaOnly parameter adds the X-Restful-Schema header with the only value to the HTTP request initiated by the $app.restful method.

The forms do not need to be constructed with the dynamic code and can be replaced with the static HTML or the binding of the frontend library.

Hiding the Form

Thankfully the hiding of the form is quite simple. The screen element containing the form is removed. The class form-active is cleared from the body of the document to stop the blurring of the product list.

JavaScript
1234function hideForm() {
    document.querySelector('.screen').remove();
    document.body.className = '';
}

Collecting Values

The form input values are converted into an object by iterating over the form elements that have the data-field attribute. Empty values are represented by null.

JavaScript
1234567function collectFieldValues() {
    var obj = {};
    document.querySelectorAll('.form [data-field]').forEach(fieldElem => {
        obj[fieldElem.getAttribute('data-field')] = fieldElem.value.length ? fieldElem.value : null;
    });
    return obj;
}

The non-blank properties of the object returned by the collectFieldValues function will have the string type. The RESTful API Engine will automatically perform the type conversion as needed.

Developers may implement the client-side validation of values for a better user experience.The shema-only request to the resource may come handy here since it yields a treasure trove of information about the resource fields.

Orchestrating UI Flow

The original rendering of the product list will need to be changed to assign an index to each product row in the grid. This is accomplished by adding the data-index attribute to each tr element with the product item data.

The toolbar is enhanced with the New button in the first position. The button is enabled only if the create hypermedia is present in the data of the 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 data-index="', i, '">');
        sb.push('<td>', htmlEncode(p.productName), '</td>');
        sb.push('<td>', htmlEncode(p.categoryName), '</td>');
        sb.push('<td>', htmlEncode(p.supplierCompanyName), '</td>');
        sb.push('<td>', htmlEncode(p.unitPrice), '</td>');
        sb.push('<td>', htmlEncode(p.unitsInStock), '</td>');
        sb.push('</tr>')
    }
    sb.push('</table>');
    sb.push('<div class="toolbar">')
    var hypermedia = products._links;
    sb.push('<button data-hypermedia="new"', hypermedia.create ? '' : ' disabled="disabled"', '>New</button>');
    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 = 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('');
}

The app is monitoring the click event. If the table row with the data-index attributes is clicked, then the readProduct function is invoked with the index value in the argument. Otherwise the code is responding to the clicks on the elements with the data-hypermedia attribute.

JavaScript
12345678910111213141516171819202122232425262728293031function handleHypermedia(e) {
    var row = e.target.closest('[data-index]')
    if (row && products)
        readProduct(row.getAttribute("data-index"));
    else {
        var btn = e.target.closest('[data-hypermedia]');
        if (btn && products) {
            var hypermedia = btn.getAttribute('data-hypermedia');
            switch (hypermedia) {
                case 'new':
                    newProduct();
                    break;
                case 'create':
                    postProduct();
                    break;
                case 'edit':
                    patchProduct();
                    break;
                case 'delete':
                    deleteProduct();
                    break;
                case 'cancel':
                    hideForm();
                    break;
                default:
                    refreshProductList(products._links[hypermedia]);
                    break;
            }
        }
    }
}

The original implementation of the handleHypermedia function in SPA4 without CRUD looks simpler:

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

Final Source Code

The file spa4.html has been enhanced with a few CSS classes for the form presentation.

HTML
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SPA4 with RESTful Hypermedia and CRUD</title>
    <script src="spa4.js"></script>
    <script src="https://demo.codeontime.com/v2/js/restful-2.0.1.js"></script>
    <style>
        #avatar {
            display: inline-block;
            width: 32px;
            height: 32px;
            background-size: 100% auto;
            border-radius: 17px;
            border: solid 1px #888;
            background-position: center center;
        }

        #anonymous, #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 3em auto 2em auto;
            line-height: 34px;
        }

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

        button[data-hypermedia] {
            margin-right: 1em;
        }

        /* CRUD-specific changes */

        .form-active #authenticated, .form-active h1 {
            filter: blur(8px);
        }

        .screen {
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            position: absolute;
        }

            .screen .form {
                padding: 1em;
                margin: 0 auto;
                background: #fff;
                border: solid 1px #777;
                width: 500px;
            }

        .form, .form input, .form select {
            font-family: Arial;
            min-width: 250px;
            padding: .25em;
            font-size: 14px;
        }

        [data-hypermedia]:first-of-type {
            background-color: green;
            color: #fff;
            padding: 0 1.25em;
        }
    </style>
</head>
<body>
    <h1 style="text-align:center">SPA4 with RESTful Hypermedia and CRUD</h1>
    <!-- anonymous user -->
    <div id="anonymous" style="display:none">
        <div class="toolbar">
            <button id="loginButton">Login</button>
            <span style="padding:0 1em">OAuth2 Authorization Flow with PKCE</span>
        </div>
    </div>
    <!-- authenticated user -->
    <div id="authenticated" style="display:none">
        <div class="toolbar">
            <span id="avatar"></span>
            <span id="email" style="padding:0 1em"></span>
            <button id="logoutButton">Logout</button>
        </div>
        <p>This is the list of products:</p>
        <div id="product-list"></div>
    </div>
</body>
</html>

This is the code of the spa4.js with OAuth 2.0 Authorization and CRUD.

JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444(function () {
    var clientId = "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC", // specific to the Client App Registration
        token = session('token'),
        apiHypermedia = session('apiHypermedia'),
        products;

    window.addEventListener('load', e => {
        document.querySelector('#loginButton').addEventListener('click', login);
        document.querySelector('#logoutButton').addEventListener('click', logout);
        document.addEventListener('click', handleHypermedia);
        start();
    });

    function start() {
        $app.restful({
            "config": {
                "clientId": clientId,
                "token": function (value) {
                    if (value)
                        token = session('token', value);
                    return token;
                }
            }
        });
        if (location.hash.match(/#auth\b/))
            exchangeAuthorizationCodeForAccessToken();
        else {
            // initialize the GUI
            document.querySelector(token ? '#authenticated' : "#anonymous").style.display = '';
            if (token) {
                // parse the 'id_token' (JWT) and show the user 'picture' and 'email' claims
                if (token.id_token) {
                    var idToken = JSON.parse(atob(token.id_token.split(/\./)[1]));
                    document.querySelector('#avatar').style.backgroundImage = 'url(' + idToken.picture + ')';
                    document.querySelector('#email').textContent = idToken.email;
                }
                // 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';
            }
        }
    }

    /* authentication with "OAuth2 Authorization Code with PKCE" flow */

    function login() {
        var appUrl = location.href.match(/^(.+?)((\?|#).+)?$/);
        // get the url and data for "Authorization Code With PKCE" flow
        $app.restful({
            "hypermedia": 'oauth2 >> authorize-client-native >>',
            "body": {
                "client_id": clientId,
                "redirect_uri": appUrl[1] + "#auth",
                "scope": "offline_access openid profile email"
            }
        }).then(result => {
            // Save the entire result as the 'loginRequest' in the session storage
            // to exchange the authorization  code for an access token
            session('loginRequest', result);
            // 1) Redirect to the "authorize" link
            // 2) User will login in the primary app in the familiar environment
            // 3) The backend app will return to the SPA with the authorization code in the URL
            location.href = result._links.authorize.href;
        }).catch(restfulException);
    }

    function exchangeAuthorizationCodeForAccessToken() {
        var args = urlArgs();
        if (args.error)
            reloadApp(args.error);
        else {
            // get the access token
            var loginRequest = session('loginRequest');
            if (loginRequest) {
                session('loginRequest', null);
                if (args.state != loginRequest.state)
                    reloadApp("Forged 'state' is detected.");
                else {
                    loginRequest.token.code = args.code;
                    $app.restful({
                        "url": loginRequest.token._links["self"],
                        "body": loginRequest.token
                    })
                        .then(result => {
                            token = session('token', result);
                            $app.restful() // request the API of authenticated user
                                .then(result => {
                                    session('apiHypermedia', result);
                                    reloadApp();
                                })
                                .catch(restfulException);

                        })
                        .catch(restfulException);
                }
            }
            else
                reloadApp("The invalid 'loginRquest' is detected.");
        }
    }

    function logout() {
        $app.restful({
            "hypermedia": 'oauth2 >> revoke >>',
            "body": {
                "client_id": clientId,
                "token": token.refresh_token || token.access_token || token
            },
            "token": false // anonymous request
        }).then(result => {
            // remove the token from the session 
            session('token', null);
            session('apiHypermedia', null);
            reloadApp();
        }).catch(restfulException);
    }

    /* 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 data-index="', i, '">');
            sb.push('<td>', htmlEncode(p.productName), '</td>');
            sb.push('<td>', htmlEncode(p.categoryName), '</td>');
            sb.push('<td>', htmlEncode(p.supplierCompanyName), '</td>');
            sb.push('<td>', htmlEncode(p.unitPrice), '</td>');
            sb.push('<td>', htmlEncode(p.unitsInStock), '</td>');
            sb.push('</tr>')
        }
        sb.push('</table>');
        sb.push('<div class="toolbar">')
        var hypermedia = products._links;
        sb.push('<button data-hypermedia="new"', hypermedia.create ? '' : ' disabled="disabled"', '>New</button>');
        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 = 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('');
    }

    function handleHypermedia(e) {
        var row = e.target.closest('[data-index]')
        if (row && products)
            readProduct(row.getAttribute("data-index"));
        else {
            var btn = e.target.closest('[data-hypermedia]');
            if (btn && products) {
                var hypermedia = btn.getAttribute('data-hypermedia');
                switch (hypermedia) {
                    case 'new':
                        newProduct();
                        break;
                    case 'create':
                        postProduct();
                        break;
                    case 'edit':
                        patchProduct();
                        break;
                    case 'delete':
                        deleteProduct();
                        break;
                    case 'cancel':
                        hideForm();
                        break;
                    default:
                        refreshProductList(products._links[hypermedia]);
                        break;
                }
            }
        }
    }

    /* CRUD: Create */

    function newProduct() {
        showForm({
            title: 'New Product',
            from: products,
            buttons: [
                { text: 'Create', hypermedia: 'create' }
            ]
        });
    }

    function postProduct() {
        $app.restful({
            url: showForm.create,
            body: collectFieldValues()
        })
            .then(() => {
                hideForm();
                refreshProductList(products._links.self);
            })
            .catch(restfulException);
    }

    /* CRUD: Read and Update */

    function readProduct(selectedIndex) {
        $app.restful({
            url: products.collection[selectedIndex]._links.self
        }).then(obj => {
            showForm({
                title: 'Edit Product',
                from: obj,
                buttons: [
                    { text: 'Update', hypermedia: 'edit' },
                    { text: 'Delete', hypermedia: 'delete' }
                ]
            }).then(form => {
                form.fields.forEach(f => {
                    form.elem.querySelector('[data-field="' + f.name + '"]').value = obj[f.name];
                });
            });
        })
    }

    function patchProduct() {
        $app.restful({
            url: showForm.edit,
            body: collectFieldValues()
        })
            .then(() => {
                hideForm();
                refreshProductList(products._links.self);
            })
            .catch(restfulException);
    }

    /* CRUD: Delete */

    function deleteProduct() {
        if (confirm('Delete?')) {
            $app.restful({
                url: showForm.delete
            })
                .then(() => {
                    hideForm();
                    refreshProductList(products._links.self);
                })
                .catch(restfulException);
        }
    }
    /* form management utilities */

    function showForm(options) {
        // cover the page with the "screen" 
        var screen = document.createElement('div');
        screen.className = 'screen';
        document.body.appendChild(screen);
        document.body.className = "form-active";
        // get the form metadata and resolve the promise when done
        return new Promise((resolve, reject) => {
            var hypermediaLinks = options.from._links,
                hypermediaControlOfFirstButton = options.buttons[0].hypermedia,
                resourceSchema = showForm[hypermediaControlOfFirstButton + 'Schema'];
            if (resourceSchema)
                buildForm(); // use the cached schema
            else
                $app.restful({
                    url: hypermediaLinks[hypermediaControlOfFirstButton],
                    schemaOnly: true
                }).then(result => {
                    resourceSchema = showForm[hypermediaControlOfFirstButton + 'Schema'] = result;
                    buildForm();
                });

            // generates the form from the resource schema
            function buildForm() {
                var allFields = [];
                // enumerate the schema fields
                for (var fieldName in resourceSchema._schema) {
                    var f = resourceSchema._schema[fieldName];
                    f.name = fieldName;
                    allFields.push(f);
                }
                // exclude the primary key and lookup alias fields
                var formFields = [];
                allFields.forEach((f, index) => {
                    if (!f.key && !(index && allFields[index - 1].lookup) && f.type != 'DataView')
                        formFields.push(f);
                    if (f.lookup)
                        f.label = allFields[index + 1].label; // borrow the label from the alias
                });
                // enumerate the lookups and fetch their data
                var fetchLookups = [],
                    lookupData = {};
                formFields.forEach(f => {
                    if (f.lookup)
                        fetchLookups.push(
                            $app.restful({
                                url: hypermediaLinks['lookup-' + f.name]
                            }).then(result => {
                                lookupData[f.name] = result;
                            })
                        );
                });
                Promise.all(fetchLookups) // wait for all lookups to be fetched
                    .then(() => {
                        // generate the form from the fields
                        var html = [
                            '<div class="form">',
                            '<h2>', options.title, '</h2>',
                            '<table cellpadding="8">'];
                        formFields.forEach(f => {
                            var tagName = f.lookup ? 'select' : 'input';
                            html.push(
                                '<tr>',
                                '<td>', htmlEncode(f.label), '</td>',
                                '<td>',
                                '<', tagName, ' data-field="', f.name, '">');
                            if (f.lookup) {
                                html.push('<option value="">(select)</option>'); // allow an empty selection
                                var lookup = lookupData[f.name];
                                lookup.collection.forEach(v => {
                                    // convert the lookup object into an array of property values
                                    var values = Object.entries(v).filter(v => v[0] != '_links').map(v => v[1]);
                                    html.push(
                                        '<option value="', values[0], '">', // 1st property is the primary key
                                        htmlEncode(values[1]),              // 2nd property is the text
                                        '</option>');
                                });
                            }
                            html.push(
                                '</', tagName, '>',
                                '</td>',
                                '</tr>');
                        });
                        html.push('</table>');
                        html.push('<div class="toolbar">');
                        options.buttons.forEach(btn => {
                            var hypermediaControl = hypermediaLinks[btn.hypermedia];
                            if (hypermediaControl) {
                                html.push(
                                    '<button data-hypermedia="', btn.hypermedia, '">', btn.text, '</button>');
                                showForm[btn.hypermedia] = hypermediaControl;
                            }
                        })
                        if (hypermediaLinks.self)
                            showForm.etag = hypermediaLinks.self.etag;
                        html.push('<button data-hypermedia="cancel">Cancel</button>');
                        html.push('</div>');
                        screen.innerHTML = html.join(''); // add the form to the "screen"
                        var form = document.querySelector('.form');
                        form.style.cssText = 'margin-top:' + (screen.clientHeight - form.getBoundingClientRect().height) / 2 + 'px';
                        form.querySelector('[data-field]').focus();
                        // resolve the promise with the list of 'fields' and the 'form' element
                        resolve({ fields: formFields, elem: form });
                    });
            }
        });
    }

    function hideForm() {
        document.querySelector('.screen').remove();
        document.body.className = '';
    }

    function collectFieldValues() {
        var obj = {};
        document.querySelectorAll('.form [data-field]').forEach(fieldElem => {
            obj[fieldElem.getAttribute('data-field')] = fieldElem.value.length ? fieldElem.value : null;
        });
        return obj;
    }

    /* miscellaneous utilities */

    function urlArgs(url) {
        if (!url)
            url = location;
        if (url.href)
            url = url.href;
        var args = {};
        var iterator = /(\w+)=(.+?)(&|$)/g;
        var m = iterator.exec(url);
        while (m) {
            args[m[1]] = m[2];
            m = iterator.exec(url);
        }
        return args;
    }

    function session(name, value) {
        if (arguments.length == 1) {
            value = sessionStorage.getItem(name);
            if (typeof value == 'string')
                value = JSON.parse(value);
        }
        else {
            if (value == null)
                sessionStorage.removeItem(name);
            else
                sessionStorage.setItem(name, JSON.stringify(value));
        }
        return value;
    }

    function restfulException(ex) {
        if (ex && ex.errors) {
            alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
            if (ex.code == 401 || ex.code == 403) {
                session('token', null);
                reloadApp();
            }
        }
    }

    function reloadApp(message) {
        if (message)
            alert(message);
        location.replace(location.pathname);
    }

    function htmlEncode(text) {
        var encoder = window.encElem;
        if (!encoder)
            encoder = window.encElem = document.createElement('span');
        encoder.textContent = text;
        return encoder.innerHTML;
    }
})();

Next

Custom actions can be specified directly in the resource URLs of the POST requests.

Enhance the RESTful API of applications with custom actions to go beyond CRUD.

Labels: CRUD, Hypermedia, RESTful
Wednesday, February 9, 2022PrintSubscribe
Standalone SPA5 with REST Level 2 and OAuth 2.0

The fifth Single Page Application in the RESTful Workshop series will look just like its twin, the Standalone SPA4 with RESTful Hypermedia and OAuth 2.0. It displays a list of products from the backend application created with Code On Time. Its users are authenticated with OAuth 2.0 Authorization Code flow with PKCE. The user picture and email are also extracted from JWT courtesy of OpenID Connect.

What is different? This app is not taking advantage of the hypermedia. Instead it makes use of the REST resources with the appropriate HTTP methods. Hence the REST Level 2 designation according to the Richardson Maturity Model.

Standalone SPA5 with REST Level 2 and OAuth 2.0
Standalone SPA5 with REST Level 2 and OAuth 2.0

Registering SPA5 for OAuth 2.0 and CORs

Client app SPA5 is the standalone app and will require its own registration in the backend application database. Registration will make the SPA5 known to the backend and enable OAuth 2.0 Authorization and cross-domain requests to its RESTful API Engine.

Use the App Management API to create a registration record with the following data:

JSON
12345678910{
    "name": "Standalone SPA5 with REST Level 2 and OAuth 2.0",
    "author": "RESTful Workshop",
    "redirect_uri": "http://localhost:9091/spa5.html#auth",
    "authorization": {
        "native": true,
        "spa": false,
        "server": false
    }
}

Make a note of the client_id of the new application registration record.

App Initialization

This is the initialization code of SPA5. Make sure to set the clientId global variable to the value of the client_id from the application registration record.

JavaScript
1234567891011var clientId = "8hwEglR1NpZYEbwHnmzK58LCy3KuLXXpb6UhpIYuLhI", // specific to the Client App Registration
    token = session('token'),
    products,
    pageIndex = 0,
    pageSize = 10;
window.addEventListener('load', e => {
    document.querySelector('#loginButton').addEventListener('click', login);
    document.querySelector('#logoutButton').addEventListener('click', logout);
    document.addEventListener('click', handleActions);
    start();
});

There is no need for the apiHypermedia variable. The new app will need to keep track of the page index and size . Function handleActions will replace its counterpart handleHypermedia.

This is the original code from SPA4:

JavaScript
12345678910111213(function () {
    var clientId = "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC", // specific to the Client App Registration
        token = session('token'),
        apiHypermedia = session('apiHypermedia'),
        products;

    window.addEventListener('load', e => {
        document.querySelector('#loginButton').addEventListener('click', login);
        document.querySelector('#logoutButton').addEventListener('click', logout);
        document.addEventListener('click', handleHypermedia);
        start();
    });
})();

User Authentication

Authentication of users is largely the same in both SPA5 and SPA4.

Login

The new app explicitly specifies the url and method parameters for $app.restful method when constructing the loginRequest data.

JavaScript
123456789101112131415161718192021function login() {
    var appUrl = location.href.match(/^(.+?)((\?|#).+)?$/);
    // get the url and data for "Authorization Code With PKCE" flow
    $app.restful({
        "url": '/oauth2/v2/auth/pkce',
        "method": "POST",
        "body": {
            "client_id": clientId,
            "redirect_uri": appUrl[1] + "#auth",
            "scope": "offline_access openid profile email"
        }
    }).then(result => {
        // Save the entire result as the 'loginRequest' in the session storage
        // to exchange the authorization  code for an access token
        session('loginRequest', result);
        // 1) Redirect to the "authorize" link
        // 2) User will login in the primary app in the familiar environment
        // 3) The backend app will return to the SPA with the authorization code in the URL
        location.href = result._links.authorize.href;
    }).catch(restfulException);
}

The hypermedia-enabled version uses the single hypemedia parameter. Its value is a little easier to read than the url above.

JavaScript
1234567891011121314151617181920function login() {
    var appUrl = location.href.match(/^(.+?)((\?|#).+)?$/);
    // get the url and data for "Authorization Code With PKCE" flow
    $app.restful({
        "hypermedia": 'oauth2 >> authorize-client-native >>',
        "body": {
            "client_id": clientId,
            "redirect_uri": appUrl[1] + "#auth",
            "scope": "offline_access openid profile email"
        }
    }).then(result => {
        // Save the entire result as the 'loginRequest' in the session storage
        // to exchange the authorization code for an access token
        session('loginRequest', result);
        // 1) Redirect to the "authorize" link
        // 2) User will login in the primary app in the familiar environment
        // 3) The backend app will return to the SPA with the authorization code in the URL
        location.href = result._links.authorize.href;
    }).catch(restfulException);
}

Exchanging Code for Token

The “code for token” exchange implementation in SPA5 is using the specific url when invoking the $app.restful method. The app reloads as soon as the token is returned by the backend and preserved in the session storage.

JavaScript
1234567891011121314151617181920212223242526272829function exchangeAuthorizationCodeForAccessToken() {
    var args = urlArgs();
    if (args.error)
        reloadApp(args.error);
    else {
        // get the access token
        var loginRequest = session('loginRequest');
        if (loginRequest) {
            session('loginRequest', null);
            if (args.state != loginRequest.state)
                reloadApp("Forged 'state' is detected.");
            else {
                loginRequest.token.code = args.code;
                $app.restful({
                    "url": '/oauth2/v2/token',
                    "method": 'POST',
                    "body": loginRequest.token
                })
                    .then(result => {
                        token = session('token', result);
                        reloadApp();
                    })
                    .catch(restfulException);
            }
        }
        else
            reloadApp("The invalid 'loginRquest' is detected.");
    }
}

The app SPA4 passes the "self" hypermedia control as the value of the url parameter when calling the $app.restful method for the first time. There is also the second call of this method to obtain the API definition available to the identity of the authenticated user.

JavaScript
12345678910111213141516171819202122232425262728293031323334function exchangeAuthorizationCodeForAccessToken() {
    var args = urlArgs();
    if (args.error)
        reloadApp(args.error);
    else {
        // get the access token
        var loginRequest = session('loginRequest');
        if (loginRequest) {
            session('loginRequest', null);
            if (args.state != loginRequest.state)
                reloadApp("Forged 'state' is detected.");
            else {
                loginRequest.token.code = args.code;
                $app.restful({
                    "url": loginRequest.token._links["self"],
                    "body": loginRequest.token
                })
                    .then(result => {
                        token = session('token', result);
                        $app.restful() // request the API of authenticated user
                            .then(result => {
                                session('apiHypermedia', result);
                                reloadApp();
                            })
                            .catch(restfulException);

                    })
                    .catch(restfulException);
            }
        }
        else
            reloadApp("The invalid 'loginRquest' is detected.");
    }
}

Logout

The logout function also specifies the url and method parameter explicitly.

JavaScript
123456789101112131415function logout() {
    $app.restful({
        "url": '/oauth2/v2/revoke',
        "method": 'POST',
        "body": {
            "client_id": clientId,
            "token": token.refresh_token || token.access_token || token
        },
        "token": false // anonymous request
    }).then(result => {
        // remove the token from the session 
        session('token', null);
        reloadApp();
    }).catch(restfulException);
}

The single parameter url is providing a slightly more elegant instruction to the $app.restful method.

JavaScript
123456789101112131415function logout() {
    $app.restful({
        "hypermedia": 'oauth2 >> revoke >>',
        "body": {
            "client_id": clientId,
            "token": token.refresh_token || token.access_token || token
        },
        "token": false // anonymous request
    }).then(result => {
        // remove the token from the session 
        session('token', null);
        session('apiHypermedia', null);
        reloadApp();
    }).catch(restfulException);
}

Fetching and Displaying Data

The fetching and displaying of product list data is more verbose, refers to the specific resource URIs, and requires a certain amount of duplication of the server-side business logic in the paging implementation. The REST Level 2 vs. RESTful implementation differences are discussed in the SPA2 workshop segment.

JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576/* 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>', htmlEncode(p.productName), '</td>');
        sb.push('<td>', htmlEncode(p.categoryName), '</td>');
        sb.push('<td>', htmlEncode(p.supplierCompanyName), '</td>');
        sb.push('<td>', htmlEncode(p.unitPrice), '</td>');
        sb.push('<td>', htmlEncode(p.unitsInStock), '</td>');
        sb.push('</tr>')
    }
    sb.push('</table>');
    sb.push('<div class="toolbar">')
    var hypermedia = products._links;
    sb.push('<button data-action="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
    sb.push('<button data-action="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
    sb.push('<button data-action="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
    sb.push('<button data-action="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
    sb.push('<button data-action="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
    var hypermediaArgs = 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('');
}

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();
    }
}

Trying It Out

App SPA5 can be hosted on any web server and operating system. Use Microsoft IIS Express that comes with the app builder. Run the following command in the Command Prompt on your device with the /path parameter set to the location of the spa5.html file:

"C:\Program Files\IIS Express\iisexpress.exe" /path:c:\rest /port:9091 /clr:v4.0

Keep the Command Prompt running and open a new browser window. Enter http://localhost:9091/spa5.html in the address bar and explore the REST Level 2 client app in action.

The user experience will be identical to the SPA4.

Final Source Code

This single page app is composed of two physical files.

This is the markup of the spa5.html file.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SPA5 with REST Level 2</title>
    <script src="spa5.js"></script>
    <script src="http://localhost:42439/v2/js/restful-2.0.1.js"></script>
    <style>
        #avatar {
            display: inline-block;
            width: 32px;
            height: 32px;
            background-size: 100% auto;
            border-radius: 17px;
            border: solid 1px #888;
            background-position: center center;
        }

        #anonymous, #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 3em auto 2em auto;
            line-height: 34px;
        }

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

        button[data-action] {
            margin-right: 1em;
        }
    </style>
</head>
<body>
    <h1 style="text-align:center">SPA5 with REST Level 2</h1>
    <!-- anonymous user -->
    <div id="anonymous" style="display:none">
        <div class="toolbar">
            <button id="loginButton">Login</button>
            <span style="padding:0 1em">OAuth2 Authorization Flow with PKCE</span>
        </div>
    </div>
    <!-- authenticated user -->
    <div id="authenticated" style="display:none">
        <div class="toolbar">
            <span id="avatar"></span>
            <span id="email" style="padding:0 1em"></span>
            <button id="logoutButton">Logout</button>
        </div>
        <p>This is the list of products:</p>
        <div id="product-list"></div>
    </div>
</body>
</html>

The complete source of the spa5.js file is shown next.

JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245(function () {
    var clientId = "8hwEglR1NpZYEbwHnmzK58LCy3KuLXXpb6UhpIYuLhI", // specific to the Client App Registration
        token = session('token'),
        products,
        pageIndex = 0,
        pageSize = 10;
    window.addEventListener('load', e => {
        document.querySelector('#loginButton').addEventListener('click', login);
        document.querySelector('#logoutButton').addEventListener('click', logout);
        document.addEventListener('click', handleActions);
        start();
    });

    function start() {
        $app.restful({
            "config": {
                "clientId": clientId,
                "token": function (value) {
                    if (value)
                        token = session('token', value);
                    return token;
                }
            }
        });
        if (location.hash.match(/#auth\b/))
            exchangeAuthorizationCodeForAccessToken();
        else {
            // initialize the GUI
            document.querySelector(token ? '#authenticated' : "#anonymous").style.display = '';
            if (token) {
                // parse the 'id_token' (JWT) and show the user 'picture' and 'email' claims
                if (token.id_token) {
                    var idToken = JSON.parse(atob(token.id_token.split(/\./)[1]));
                    document.querySelector('#avatar').style.backgroundImage = 'url(' + idToken.picture + ')';
                    document.querySelector('#email').textContent = idToken.email;
                }
                // show the data if the API allows it
                refreshProductList();
            }
        }
    }

    /* authentication with "OAuth2 Authorization Code with PKCE" flow */

    function login() {
        var appUrl = location.href.match(/^(.+?)((\?|#).+)?$/);
        // get the url and data for "Authorization Code With PKCE" flow
        $app.restful({
            "url": '/oauth2/v2/auth/pkce',
            "method": "POST",
            "body": {
                "client_id": clientId,
                "redirect_uri": appUrl[1] + "#auth",
                "scope": "offline_access openid profile email"
            }
        }).then(result => {
            // Save the entire result as the 'loginRequest' in the session storage
            // to exchange the authorization  code for an access token
            session('loginRequest', result);
            // 1) Redirect to the "authorize" link
            // 2) User will login in the primary app in the familiar environment
            // 3) The backend app will return to the SPA with the authorization code in the URL
            location.href = result._links.authorize.href;
        }).catch(restfulException);
    }

    function exchangeAuthorizationCodeForAccessToken() {
        var args = urlArgs();
        if (args.error)
            reloadApp(args.error);
        else {
            // get the access token
            var loginRequest = session('loginRequest');
            if (loginRequest) {
                session('loginRequest', null);
                if (args.state != loginRequest.state)
                    reloadApp("Forged 'state' is detected.");
                else {
                    loginRequest.token.code = args.code;
                    $app.restful({
                        "url": '/oauth2/v2/token',
                        "method": 'POST',
                        "body": loginRequest.token
                    })
                        .then(result => {
                            token = session('token', result);
                            reloadApp();
                        })
                        .catch(restfulException);
                }
            }
            else
                reloadApp("The invalid 'loginRquest' is detected.");
        }
    }

function logout() {
    $app.restful({
        "url": '/oauth2/v2/revoke',
        "method": 'POST',
        "body": {
            "client_id": clientId,
            "token": token.refresh_token || token.access_token || token
        },
        "token": false // anonymous request
    }).then(result => {
        // remove the token from the session 
        session('token', null);
        reloadApp();
    }).catch(restfulException);
}

    /* 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>', htmlEncode(p.productName), '</td>');
            sb.push('<td>', htmlEncode(p.categoryName), '</td>');
            sb.push('<td>', htmlEncode(p.supplierCompanyName), '</td>');
            sb.push('<td>', htmlEncode(p.unitPrice), '</td>');
            sb.push('<td>', htmlEncode(p.unitsInStock), '</td>');
            sb.push('</tr>')
        }
        sb.push('</table>');
        sb.push('<div class="toolbar">')
        var hypermedia = products._links;
        sb.push('<button data-action="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
        sb.push('<button data-action="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
        sb.push('<button data-action="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
        sb.push('<button data-action="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
        sb.push('<button data-action="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
        var hypermediaArgs = 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('');
    }

    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 urlArgs(url) {
        if (!url)
            url = location;
        if (url.href)
            url = url.href;
        var args = {};
        var iterator = /(\w+)=(.+?)(&|$)/g;
        var m = iterator.exec(url);
        while (m) {
            args[m[1]] = m[2];
            m = iterator.exec(url);
        }
        return args;
    }

    function session(name, value) {
        if (arguments.length == 1) {
            value = sessionStorage.getItem(name);
            if (typeof value == 'string')
                value = JSON.parse(value);
        }
        else {
            if (value == null)
                sessionStorage.removeItem(name);
            else
                sessionStorage.setItem(name, JSON.stringify(value));
        }
        return value;
    }

    function restfulException(ex) {
        if (ex && ex.errors) {
            alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
            if (ex.code == 401 || ex.code == 403) {
                session('token', null);
                reloadApp();
            }
        }
    }

    function reloadApp(message) {
        if (message)
            alert(message);
        location.replace(location.pathname);
    }

    function htmlEncode(text) {
        var encoder = window.encElem;
        if (!encoder)
            encoder = window.encElem = document.createElement('span');
        encoder.textContent = text;
        return encoder.innerHTML;
    }
})();

Next

Create, edit, replace, and delete data with confidence in the CRUD with Hypermedia segment.

Monday, February 7, 2022PrintSubscribe
Standalone SPA4 with RESTful Hypermedia and OAuth 2.0

The fourth Single Page Application in the RESTful Workshop series is displaying a list of products from the backend application created with Code On Time. The screenshot shows the user authenticated with OAuth 2.0 Authorization Code flow with PKCE. The user picture and email were extracted from JWT obtained with the help of OpenID Connect.

Standalone SPA4 with RESTful Hypermedia and OAuth 2.0
Standalone SPA4 with RESTful Hypermedia and OAuth 2.0

This app looks a lot like Embedded SPA3 (Custom UI) with RESTful Hypermedia. Here is what’s different:

  • The app spa4.hml is not embedded in the host application.
  • The app and its backend are not a part of the same domain.
  • This is the standalone app. It can be hosted on any web server and operating system.
  • Users are authenticated by the backend application via OAuth 2.0 Authorization.
  • The app displays the user name and picture with the Logout button above the list of products.

User Interface and Hosting

Use your favorite text editor to create the spa4.html file in the directory of your choice. Do not place the file in the backend application folders.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SPA4 with RESTful Hypermedia</title>
    <script src="spa4.js"></script>
    <script src="https://demo.codeontime.com/v2/js/restful-2.0.1.js"></script>
    <style>
        #avatar {
            display: inline-block;
            width: 32px;
            height: 32px;
            background-size: 100% auto;
            border-radius: 17px;
            border: solid 1px #888;
            background-position: center center;
        }

        #anonymous, #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 3em auto 2em auto;
            line-height: 34px;
        }

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

        button[data-hypermedia] {
            margin-right: 1em;
        }
    </style>
</head>
<body>
    <h1 style="text-align:center">SPA4 with RESTful Hypermedia</h1>
    <!-- anonymous user -->
    <div id="anonymous" style="display:none">
        <div class="toolbar">
            <button id="loginButton">Login</button>
            <span style="padding:0 1em">OAuth2 Authorization Flow with PKCE</span>
        </div>
    </div>
    <!-- authenticated user -->
    <div id="authenticated" style="display:none">
        <div class="toolbar">
            <span id="avatar"></span>
            <span id="email" style="padding:0 1em"></span>
            <button id="logoutButton">Logout</button>
        </div>
        <p>This is the list of products:</p>
        <div id="product-list"></div>
    </div>
</body>
</html>

The page defines #anonymous and #authenticated containers. Both are styled to be hidden.

The first container defines the toolbar with the #loginButton. The second container defines the toolbar with the #avatar, #email, and #logoutButton elements. There is also the #product-list element that will be populated dynamically with the product data and paging buttons.

There are two external script references in the head of the app.

HTML
12<script src="spa4.js"></script>
<script src="http://localhost:42439/v2/js/restful-2.0.1.js"></script>

The first script spa4.js will contain the application code. The code was embedded directly within the HTML markup in the tutorials SPA1, SPA2, and SPA3. In this instance we are splitting HTML and JavaScript.

The URI of the second script will depend on your backend application and the version of Code On Time you are using. Launch the backend from the app builder and enter the /v2 path right after the port in the browser address bar. You will see the following output.

RESTful API endpoint output for anonymous user.
RESTful API endpoint output for anonymous user.

Copy the href property from the restful.js hypermedia link and use it as the source of your second script. Make sure to include the address and the port of the backend application in the final URL. This script will provide your app with the access to the $app.restful method, which allows using both the hypermedia and direct resource URIs when interacting with the backend.

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.

Code On Time automatically installs the Microsoft IIS Express development web server. Let’s use it as the host of SPA4. Run the following command in the Command Prompt on your device with the /path parameter set to the location of the spa4.html file:

"C:\Program Files\IIS Express\iisexpress.exe" /path:c:\restful /port:9090 /clr:v4.0

Keep the Command Prompt running and open a new browser window. Enter http://localhost:9090/spa4.html in the address bar and you will see the following output. It will indicate that your app is running correctly.

This output confirms that the app is successfully hosted by Microsoft IIS Express on the specified port at localhost address.
This output confirms that the app is successfully hosted by Microsoft IIS Express on the specified port at localhost address.

This app can be hosted on any domain and deployed to any web server and operating system.

Client Application Registration

The app running on http://localhost:9090/spa4.html will need to be known to the backend. Otherwise the following obstacles will be in the way:

  • An application running on the port 9090 will not be able to retrieve data from the backend application running on a different port even though both have the localhost address. Modern browsers implement the strict CORs policies, which will prevent the cross-domain interactions.
  • Users will need to authenticate themselves with the backend application, which requires a certain level of trust between the backend and the standalone client app. Unknown client applications will not be able to authenticate their users.
Client application registration enables HTTP communication between SPA4 and the backend. It also makes possible the OAuth 2.0 Authorization.

Next we will use Postman to register the client app SPA4 to make it known to the backend application.

App Management API

Here is how you can locate the built-in API that allows registration of OAuth 2.0 client apps in the backend application database.

  1. Run the Postman utility and create a new HTTP Request.
  2. Enter the local address of the backend application with the /v2 path in the request URL and press Send.
    RESTful API endpoint with "oauth2" hypermedia control.
    RESTful API endpoint with "oauth2" hypermedia control.
  3. Click on the oauth2 hypermedia link in the response body. The new HTTP request tab will open.
  4. Activate the Authorization section of the request and set the Type field to API Key. Enter 94b79da7-e3a9-4152-9213-886f4c810bec as the key Value. This is the same key that is associated with the admin user account in the server.rest.authorization.keys array in ~/app/touch-settings.json configuration file (see Authentication topic in the RESTful Backend Application tutorial).
  5. Press Send and the response body will include the hypermedia link apps along with the few others. The apps link is available to the users with Administrators or OAuth2 Admin roles.
    OAuth 2.0 API endpoint includes the "apps" hypermedia control for the users with the Administrators and OAuth Admin roles only.
    OAuth 2.0 API endpoint includes the "apps" hypermedia control for the users with the Administrators and OAuth Admin roles only.
  6. Follow the apps link, configure Authorization of the new request with the same API key, and press Send. An empty collection of apps will be displayed.
    App Management API provides the hypermedia control "create", which allows registering client apps to make them "known" to the application. Known apps can can use the implemented APIs across domain boundaries and authenticate users with OAuth 2.0 Authorization Code flows.
    App Management API provides the hypermedia control "create", which allows registering client apps to make them "known" to the application. Known apps can can use the implemented APIs across domain boundaries and authenticate users with OAuth 2.0 Authorization Code flows.
  7. Follow the create hypermedia link. The new HTTP Request tab will open. Set the request method to POST and configure the authorization key in the Authorization section.
  8. Choose the Body tab in the request and change the body type from none to raw.
  9. Change the content type of the body from Text to JSON.

It is time to register the client application.

Registering SPA4 for OAuth 2.0 Authorization

Enter the following in the body of the request:

JSON
12345678910{
    "name": "Standalone SPA4 with RESTful Hypermedia and OAuth 2.0",
    "author": "RESTful Workshop",
    "redirect_uri": "http://localhost:9090/spa4.html#auth",
    "authorization": {
        "native": true,
        "spa": false,
        "server": false
    }
}

Press Send to register the client app SPA4 in the backend application database. The client application will become known to the backend. The required CORs configuration entries will be created.

The client registration record of SPA4.
The client registration record of SPA4.

This sample response shows the new registration record of the client application.

JSON
123456789101112131415161718192021222324252627{
    "_links": {
        "self": {
            "href": "/oauth2/v2/apps/TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC"
        },
        "edit": {
            "href": "/oauth2/v2/apps/TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
            "method": "PATCH"
        },
        "delete": {
            "href": "/oauth2/v2/apps/TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
            "method": "DELETE"
        }
    },
    "name": "Standalone SPA4 with RESTful Hypermedia and OAuth 2.0",
    "author": "RESTful Workshop",
    "client_id": "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
    "client_secret": null,
    "redirect_uri": "http://localhost:9090/spa4.html#auth",
    "local_redirect_uri": null,
    "authorization": {
        "native": true,
        "spa": false,
        "server": false
    },
    "trusted": false
}

The following properties of the registration record are of interest:

  • Property client_id is the unique identifier for SPA4 client application.
  • The redirect_uri property is the location that will receive the authorization code from the backend after successful confirmation of the user identity. The code can be exchanged for an access token by SPA4. Tokens will authenticate the app users.
  • Property authorization.native is set to true. It indicates that the backend will authenticate the SPA4 users with the OAuth 2.0 Authorization Code Flow with PKCE protocol. This authorization flow is suitable both for single page applications and native mobile applications.

Don’t be intimidated by the abbreviations in the name of the authorization flow. The backend application and restful.js will take care of the details as long as you can provide the client_id and redirect_uri values.

App Initialization

Enter the following in spa4.js and save the file:

JavaScript
12345678910111213(function () {
    var clientId = "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC", // specific to the Client App Registration
        token = session('token'),
        apiHypermedia = session('apiHypermedia'),
        products;

    window.addEventListener('load', e => {
        document.querySelector('#loginButton').addEventListener('click', login);
        document.querySelector('#logoutButton').addEventListener('click', logout);
        document.addEventListener('click', handleHypermedia);
        start();
    });
})();

The global variable clientId is initialized with the client_id identifier from the client app registration record.

Global variables token and apiHypermedia are initialized with the values fetched from the current browser session. The utility function session is discussed later in this tutorial

Variables apiHypermedia and products are storing the hypermedia-enabled API and product data. They allow the app to retrieve and display the list of products as explained in the Embedded SPA1 with RESTful Hypermedia tutorial.

SPA4 is implemented with plain JavaScript without dependencies on any particular library. The objective of this tutorial is to explain how to authenticate users and work with data via the RESTful API Engine built into backend applications created with Code On Time.

Feel free to adapt the code for React, Angular, or any other libraries.

Application SPA4 comes alive in the load event handler triggered on the window object. It immediately attaches the login and logout handlers to the respective buttons and calls the start function.

Start

Add the start function to spa4.js file.

JavaScript
12345678910111213141516171819202122232425262728293031function start() {
    $app.restful({
        "config": {
            "clientId": clientId,
            "token": function (value) {
                if (value)
                    token = session('token', value);
                return token;
            }
        }
    });
    if (location.hash.match(/#auth\b/))
        exchangeAuthorizationCodeForAccessToken();
    else {
        // initialize the GUI
        document.querySelector(token ? '#authenticated' : "#anonymous").style.display = '';
        if (token) {
            // parse the 'id_token' (JWT) and show the user 'picture' and 'email' claims
            if (token.id_token) {
                var idToken = JSON.parse(atob(token.id_token.split(/\./)[1]));
                document.querySelector('#avatar').style.backgroundImage = 'url(' + idToken.picture + ')';
                document.querySelector('#email').textContent = idToken.email;
            }
            // 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';
        }
    }
}

Application starts with the configuration of the $app.restful API with the following parameters specified in the config argument:

  • The clientId parameter is borrowed directly from the global variable of the same name.
  • The token parameter is the function. If the function is called with the argument, then its value is stored in the session and assigned to the token variable. The function always returns the token value.

Both configuration parameters will be used by the $app.restful method to seamlessly refresh the access token whenever it expires. By default the token will expire every 15 minutes. The method will automatically detect the expiration and get a new access token from the backend. The new token is communicated to the app with the token function specified in the config parameter.

Next application determines if it is being loaded by a user or the backend application after confirmation of the user's identity. The former happens when a user enters http://localhost:9090/spa4.html in the browser address bar. The latter is triggered by the presence of the #auth hash in the URL, which will begin the exchange of the authorization code for an access token.

The execution flow and user experience will depend on the presence of the value in the token variable in the absence of the #auth hash. If there is a value in the token, then the avatar, email, Logout button, and the list of products will become visible. If the value of the token is not defined, then the user will see the Login button only.

The user interface of anonymous user in the standalone SPA4 with RESTful Hypermedia and OAuth 2.0
The user interface of anonymous user in the standalone SPA4 with RESTful Hypermedia and OAuth 2.0

The “code for token” exchange and processing of the JWT (id_token) will be discussed later.

Login

Add the login function to the code in spa4.js. The function will execute when the Login button is pressed. It will figure the absolute URL of the single page application by trimming the hash and parameters from the page location.

JavaScript
1234567891011121314151617181920function login() {
    var appUrl = location.href.match(/^(.+?)((\?|#).+)?$/);
    // get the url and data for "Authorization Code With PKCE" flow
    $app.restful({
        "hypermedia": 'oauth2 >> authorize-client-native >>',
        "body": {
            "client_id": clientId,
            "redirect_uri": appUrl[1] + "#auth",
            "scope": "offline_access openid profile email"
        }
    }).then(result => {
        // Save the entire result as the 'loginRequest' in the session storage
        // to exchange the authorization code for an access token
        session('loginRequest', result);
        // 1) Redirect to the "authorize" link
        // 2) User will login in the primary app in the familiar environment
        // 3) The backend app will return to the SPA with the authorization code in the URL
        location.href = result._links.authorize.href;
    }).catch(restfulException);
}

The login function will call the $app.restful method with the hypermedia and body parameters.

The hypermedia parameter will instruct the method to perform multiple HTTP requests to the RESTful API Engine of the backend. The address of the backend server will match the source of the restful.js script in the page header and does not need to be specified.

The $app.restful method will fetch the /v2 endpoint in the absence of the url parameter. If there is the hypermedia parameter, then the method will keep resolving the named hypermedia controls and transitioning to the next resource whenever the optional transition operator “>>” is following the control name.

If there is an object parameter with the name that matches the hypermedia control, then its properties are used to fulfill the HTTP request. If there is the body parameter, then it will be used to execute the last HTTP request in the chain specified in hypermedia.

Hypermedia Navigation

First the $app.restful method will fetch data from the /v2 endpoint, which will look like this:

JSON
12345678910{
    "_links": {
        "oauth2": {
            "href": "/oauth2/v2"
        },
        "restful.js": {
            "href": "/v2/js/restful-2.0.1.js"
        }
    }
}

Next the method will transition to the oauth2 hypermedia link that will return the data shown below. The data represents the OAuth 2.0 API and standard scopes implemented in the applications created with Code On Time.

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657{
    "_links": {
        "authorize": {
            "href": "/oauth2/v2/auth"
        },
        "token": {
            "href": "/oauth2/v2/token",
            "method": "POST"
        },
        "tokeninfo": {
            "href": "/oauth2/v2/tokeninfo"
        },
        "userinfo": {
            "href": "/oauth2/v2/userinfo",
            "method": "POST"
        },
        "revoke": {
            "href": "/oauth2/v2/revoke",
            "method": "POST"
        },
        "authorize-client-native": {
            "href": "/oauth2/v2/auth/pkce",
            "method": "POST"
        },
        "authorize-client-spa": {
            "href": "/oauth2/v2/auth/spa",
            "method": "POST"
        },
        "authorize-server": {
            "href": "/oauth2/v2/auth/server",
            "method": "POST"
        },
        "schema": {
            "href": "/oauth2/v2?_schema=true"
        }
    },
    "scopes": {
        "openid": {
            "hint": "View the unique user id, client app id, API endpoint, token issue and expiration date."
        },
        "profile": {
            "hint": "View the user's last and first name, birthdate, gender, picture, and preferred language."
        },
        "address": {
            "hint": "View the user's preferred postal address."
        },
        "email": {
            "hint": "View the user's email address."
        },
        "phone": {
            "hint": "View the user's phone number."
        },
        "offline_access": {
            "hint": "Access your data anytime."
        }
    }
}

Finally the $app.restful method will fetch the authorize-client-native hypermedia control by executing the POST request to the corresponding URL. The body parameter of the method will become the body of the request. This is how the body of the HTTP request will look.

JSON
12345{
    "client_id": "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
    "redirect_uri": "http://localhost:9090/spa4.html#auth",
    "scope": "offline_access openid profile email"
}

Authorization Url

The response received from the backend will be captured in the session as loginRequest by the login function.

JSON
123456789101112131415161718192021{
    "_links": {
        "authorize": {
            "href": "http://localhost:42439/oauth2/v2/auth?response_type=code&client_id=TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC&redirect_uri=http%3a%2f%2flocalhost%3a9090%2fspa4.html%23auth&scope=offline_access+openid+profile+email&state=VusPwp23sGFYfxKm&code_challenge=DzQyZyz8kaU1VjRUpoeOIM465OxvCbmOV8csABfT878&code_challenge_method=S256"
        }
    },
    "state": "VusPwp23sGFYfxKm",
    "token": {
        "_links": {
            "self": {
                "href": "http://localhost:42439/oauth2/v2/token",
                "method": "POST"
            }
        },
        "grant_type": "authorization_code",
        "code": null,
        "redirect_uri": "http://localhost:9090/spa4.html#auth",
        "client_id": "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
        "code_verifier": "aeW~AS5lac9505pe-YsVpKXDIEbBuR5Zre-gdZpNlMwTTflbKA~H7CrM5xeCbqOJ"
    }
}

The data is preserved in the session storage of the browser to make possible the “code for token” exchange.

Function login will immediately redirect the browser to the URL specified in the _links.authorize.href key.

This is the Authorization Url with the parameters wrapped to separate lines:

http://localhost:42439/oauth2/v2/auth?
response_type=code&
client_id=TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC&
redirect_uri=http%3a%2f%2flocalhost%3a9090%2fspa4.html%23auth&
scope=offline_access+openid+profile+email&
state=VusPwp23sGFYfxKm&
code_challenge=DzQyZyz8kaU1VjRUpoeOIM465OxvCbmOV8csABfT878&
code_challenge_method=S256

The URL points to the backend application. Its parameters instruct the backend to confirm the user identity and to return an authorization code back to SPA4 with the assumption that it will be exchanged for an access token according to OAuth2 Authorization Code Flow with PKCE.

The values of the output keys state and token.code_verifier are randomly generated sequences that are not preserved by the backend application. They are intended for the client application only. The parameter code_challenge in the URL is the hash of the token.code_verifier value. The value of the state key is also included in the URL. Each request to the authorize-client-native hypermedia will yield the different values in these keys.

Developers may choose to produce the Authorization Url entirely on the client with their own code if desired.

Account Access Approval and Scopes

The redirect URL will cause the application user interface of the backend to “get into a state” as soon as the user signs in. The authenticated user will see the following prompt no matter what page they are visiting.

Account Access consent form presented to the user during the OAuth 2.0 Authorization Code flow with PKCE in the application created with Code On Time.
Account Access consent form presented to the user during the OAuth 2.0 Authorization Code flow with PKCE in the application created with Code On Time.

The user is presented with the scopes requested in the Authorization Url composed by the client when the Login button was clicked. Users can either Allow or Deny the request for account access from the 3rd party application.

The list of scopes explicitly tells the user what exactly the 3rd party app will be able to do and learn. These scopes are defined in the Authorization URL created by SPA4 in the login function:

  • Scope offline_access allows the app to refresh the access token without asking the user to sign in. The client will be able to access data anytime without an explicit confirmation. Access tokens are refreshed every fifteen minutes and the trusted apps shall be allowed to maintain access as long as needed.
  • Scope openid will instruct the backend to include the id_token along with the access_token when the account access is granted or refreshed. This scope is defined in the OpenID Connect protocol, which allows client applications to discover the user information in a uniform way. The three-part JWT (JSON Web Token) is returned as the id_token during the “code for token” exchange.
  • Scope profile is the part of OpenID Connect. It allows the app to discover the basic information about the user such as the last name or picture.
  • Scope email is also part of the OpenID Connect specification. It lets the user disclose their email address to the client application.

Learn about custom scopes and the reasons to use them in the JWT, OAuth 2.0 Scopes, Roles, and Access Control List tutorial.

If the client app is the primary user interface for the backend application created with Code On Time, then presenting the users with the list of scopes may be redundant. Use the App Management API described in the Client Registration Section of the tutorial to edit the client app registration record. Set the trusted property to true in the body of the PATCH request.

Users of the trusted client applications are not presented with the list of scopes and an option to allow or deny the request. Successful identity confirmation will allow the account access automatically.

Users may choose to switch their identity if they have more than one account in the backend application. The account selector is presented when the Switch Account button is clicked in the Account Access form. This also happens if the user is already signed into the backend application at the time when the redirect from the client app to the Authorization Url occurs.

Account selector allow users to switch their identity when a client app is requesting account access.
Account selector allow users to switch their identity when a client app is requesting account access.

The user account in the screenshots has an avatar. Here is how a picture can be associated with a user account:

  1. Sign into the backend application with the admin account.
  2. Select Site Content on in the menu bar.
  3. Start creating the new site content record.
  4. Choose (custom) for the type of the content.
  5. Tap the Data input to choose a picture or drag a file onto it.
  6. Enter sys/users in the Path field.
  7. Replace the file name with the user name and keep the extension. For example, enter user.png in the File Name field for the user account picture in the PNG format.
  8. Press Save to create the picture. The avatar will become visible the next time the user signs in.

Exchanging Code for Token

PKCE stands for Proof Key of Code Exchange. This is the extension for the Authorization Code flow that allows the secure exchange of authorization code for an access token. The flow is suitable for applications that are not able to keep secrets. The single page applications have their entire code visible to the prying eyes. The same is true for the native applications installed on client devices.

The code_verifier is retained by the client app and is not known to the backend. Its hashed value is specified in the code_challenge parameter in the URL The backend will associate the parameter with the account access request from the client app. If the user has granted the access, then the authorization code will be specified in the redirect URL navigating the browser from the backend to the client application. The client must specify the original code_verifier in the request exchanging the authorization code for an access token.

The client app SPA4 will load with the following URL when the user approves the account access request (both the state and code parameters will be different for each login attempt):

http://localhost:9090/spa4.html#auth?state=VusPwp23sGFYfxKm&code=kflsjdlkfsjdlk38984374.

Function start detects the #auth hash in the URL and invokes the exchangeAuthorizationCodeForAccessToken function.

Here it is:

JavaScript
12345678910111213141516171819202122232425262728293031323334function exchangeAuthorizationCodeForAccessToken() {
    var args = urlArgs();
    if (args.error)
        reloadApp(args.error);
    else {
        // get the access token
        var loginRequest = session('loginRequest');
        if (loginRequest) {
            session('loginRequest', null);
            if (args.state != loginRequest.state)
                reloadApp("Forged 'state' is detected.");
            else {
                loginRequest.token.code = args.code;
                $app.restful({
                    "url": loginRequest.token._links["self"],
                    "body": loginRequest.token
                })
                    .then(result => {
                        token = session('token', result);
                        $app.restful() // request the API of authenticated user
                            .then(result => {
                                session('apiHypermedia', result);
                                reloadApp();
                            })
                            .catch(restfulException);

                    })
                    .catch(restfulException);
            }
        }
        else
            reloadApp("The invalid 'loginRquest' is detected.");
    }
}

If there is the error parameter in the URL, then the app will reload. Otherwise the loginRequest is extracted from the session.

If the loginRequest.state value does not match the state parameter value in the URL, then the app will reload with the “Forged ‘state’ is detected” message. Otherwise the loginRequest is removed from the session.

Next the code parameter is copied from the URl to the loginRequest.token.code key. The modified token property of the loginRequest is shown below:

JSON
12345678910111213141516{
    "state": "VusPwp23sGFYfxKm",
    "token": {
        "_links": {
            "self": {
                "href": "http://localhost:42439/oauth2/v2/token",
                "method": "POST"
            }
        },
        "grant_type": "authorization_code",
        "code": "kflsjdlkfsjdlk38984374",
        "redirect_uri": "http://localhost:9090/spa4.html#auth",
        "client_id": "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
        "code_verifier": "aeW~AS5lac9505pe-YsVpKXDIEbBuR5Zre-gdZpNlMwTTflbKA~H7CrM5xeCbqOJ"
    }
}

Application will invoke the $app.restful method with the self hypermedia from the loginRequest specified in the url parameter and loginRequest.token specified in the body parameter.

Last, it will save the access token in the session, fetch and preserve the API definition, and reload the app.

Token Variable

Reloading of the SPA4 client app by the exchangeAuthorizationCodeForAccessToken function will initialize both the token and apiHypermedia variables with data. Here is the snippet from the top of the spa4.js file:

JavaScript
1234var clientId = "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC", // specific to the Client App Registration
    token = session('token'),
    apiHypermedia = session('apiHypermedia'),
    products;

The token variable will contain the object similar to the one below:

JSON
12345678{
    "access_token": "2uRD3pVjOcQiWb7UUR9U9ZDkYikRuamp1S-LjhaT_kU96hjI.LQHvU7k9oGGU_5ERCEYrOFEml_J+zNi",
    "expires_in": 900,
    "token_type": "Bearer",
    "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQyNDM5L29hdXRoMi92MiIsImF6cCI6IlRLdXl6ZURtSVFLV0ZWbmNKY0twQ1hDV0VtY3NKUDNrQjlRcHVkZWdUckMiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjQyNDM5L3YyIiwic3ViIjoiYmYxMTk5ZDItN2ZkZC00YmJkLTgyMDgtZThhZGIzZTVkZGI1IiwiaWF0IjoxNjQ4MzIwMzQzLCJleHAiOjE2NDgzMjEyNDMsImVtYWlsIjoiYWRtaW5AYWNtZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6bnVsbCwiZ2l2ZW5fbmFtZSI6bnVsbCwiZmFtaWx5X25hbWUiOm51bGwsIm1pZGRsZV9uYW1lIjpudWxsLCJuaWNrbmFtZSI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjpudWxsLCJwcm9maWxlIjpudWxsLCJwaWN0dXJlIjoiaHR0cDovL2xvY2FsaG9zdDo0MjQzOS9vYXV0aDIvdjIvdXNlcmluZm8vcGljdHVyZXMvb3JpZ2luYWwvTm5rdi04LXNXQUhuaHFwZlBJVHJ5LXhPRWZtdEliVnlEM1FoYnI4b3MtYy5qcGVnIiwiZ2VuZGVyIjpudWxsLCJiaXJ0aGRhdGUiOm51bGwsInpvbmVpbmZvIjoiQW1lcmljYS9Mb3NfQW5nZWxlcyIsImxvY2FsZSI6ImVuLVVTIiwidXBkYXRlZF9hdCI6bnVsbCwic2NvcGUiOiJvZmZsaW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBlbWFpbCJ9.MEYgJqEFRA1hcaETSL8HY9cHiZksM6kbM_za9gp1mP8",
    "refresh_token": "TxyVpC+0bk79kXI2EldQ+qiQ-GMyUuX9jh3MhWu3M6NoM2yl9I5kzOKNRktiOMKq",
    "scope": "offline_access openid profile email"
}

This object makes it possible for the app to specify the user identity when making requests to the RESTful API Engine of the backend.

The token variable structure can be reduced just to the value of the access_token property if the user identity information or refreshing of the access token are not required.

SPA4 will function correctly with the minimalistic version of the token data.

Let’s explore the individual properties of the object in the token variable.

access_token

The access_token property contains the access token that can be specified in the Authorization header of the HTTP request to the RESTful API.

expires_in

The expires_in property specifies how soon the access token will expire. The value is expressed in seconds. By default, the backend application will configure the tokens to expire in 15 minutes. Set the server.rest.authorization.oauth2.accessTokenDuration option in the ~/app/touch-settings.json configuration file to the number of minutes the access token will remain valid.

token_type

The value of the token_type must precede the access token value specified in the Authorization header of an HTTP request. Make sure to put a “space” character between them.

id_token

The id_token property will be present only if the openid scope was specified in the Authorization Url. The value of the property is the JSON Web Token also known as the JWT.

JWT is composed of three parts separated by “.” character. Each part is the base-64-url-encoded text.

Try decoding the JWT in one of many tools available online. The first part is the header and the third part is the signature of JSON Web Token. The payload (the second part) will show the following JSON data:

JSON
123456789101112131415161718192021222324{
  "iss": "http://localhost:42439/oauth2/v2",
  "azp": "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC",
  "aud": "http://localhost:42439/v2",
  "sub": "bf1199d2-7fdd-4bbd-8208-e8adb3e5ddb5",
  "iat": 1648320343,
  "exp": 1648321243,
  "email": "admin@acme.com",
  "email_verified": true,
  "name": null,
  "given_name": null,
  "family_name": null,
  "middle_name": null,
  "nickname": null,
  "preferred_username": null,
  "profile": null,
  "picture": "http://localhost:42439/oauth2/v2/userinfo/pictures/original/Nnkv-8-sWAHnhqpfPITry-xOEfmtIbVyD3Qhbr8os-c.jpeg",
  "gender": null,
  "birthdate": null,
  "zoneinfo": "America/Los_Angeles",
  "locale": "en-US",
  "updated_at": null,
  "scope": "offline_access openid profile email"
}

The decoding tools will tell you that the signature is invalid and requires a passphrase (the 256-bit secret) to verify. The secret is known only to the backend application. You can ask the backend to verify the id_token with the tokeninfo hypermedia control.

The hypermedia control "tokeninfo" in OAuth 2.0 API validates and decodes the contents of a JWT  that was issued by the backend application.
The hypermedia control "tokeninfo" in OAuth 2.0 API validates and decodes the contents of a JWT that was issued by the backend application.

Some OAuth 2.0 Authorization flows pass a JWT as an access token. It is specified as a parameter in the URL and for that reason cannot be trusted without verification. Applications created with Code On Time do not accept JWT as an access token. Only the Authorization Code flows are supported, which are considered to be inherently secure by the industry.

The information in the id_token can be trusted since it is returned either when the authorization code is exchanged for an access token or when the latter is refreshed.

It takes literally one line of code to decode the contents of the id_token property with JavaScript. Here is the snippet of code from the start function that does it:

JavaScript
123456// parse the 'id_token' (JWT) and show the user 'picture' and 'email' claims
if (token.id_token) {
    var idToken = JSON.parse(atob(token.id_token.split(/\./)[1]));
    document.querySelector('#avatar').style.backgroundImage = 'url(' + idToken.picture + ')';
    document.querySelector('#email').textContent = idToken.email;
}

The snippet also assigns the picture and email claims from the JWT in id_token to the respective user interface elements.

Claims picture and email are included in the JSON Web Token only if the profile and email scopes are specified in the Authorization Url.

refresh_token

The value of the refresh_token property is used by the $app.restful method automatically when the access token is reported as expired by the backend application.

Developers can renew the access token at any time with the token hypermedia control in the OAuth 2.0 API of the backend.

scope

The scope property contains the space-separated list of scopes known to the backend application and requested by the client in the Authorization Url.

Fetching and Displaying Data

Function start displays the avatar and email address of the authenticated user and proceeds to render the list of products by invoking the refreshProductList function. The detailed discussion of how the products are rendered is in the Embedded SPA1 with RESTful Hypermedia tutorial.

The code is slightly modified in SPA4 since some of the Touch UI utility functions will need to be replaced with their equivalents.

JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849/* 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>', htmlEncode(p.productName), '</td>');
        sb.push('<td>', htmlEncode(p.categoryName), '</td>');
        sb.push('<td>', htmlEncode(p.supplierCompanyName), '</td>');
        sb.push('<td>', htmlEncode(p.unitPrice), '</td>');
        sb.push('<td>', 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 = 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('');
}

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

Utilities

The utility functions of the SPA4 implementation are presented below:

JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455/* miscellaneous utilities */

function urlArgs(url) {
    if (!url)
        url = location;
    if (url.href)
        url = url.href;
    var args = {};
    var iterator = /(\w+)=(.+?)(&|$)/g;
    var m = iterator.exec(url);
    while (m) {
        args[m[1]] = m[2];
        m = iterator.exec(url);
    }
    return args;
}

function session(name, value) {
    if (arguments.length == 1) {
        value = sessionStorage.getItem(name);
        if (typeof value == 'string')
            value = JSON.parse(value);
    }
    else {
        if (value == null)
            sessionStorage.removeItem(name);
        else
            sessionStorage.setItem(name, JSON.stringify(value));
    }
    return value;
}

function restfulException(ex) {
    if (ex && ex.errors) {
        alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
        if (ex.code == 401 || ex.code == 403) {
            session('token', null);
            reloadApp();
        }
    }
}

function reloadApp(message) {
    if (message)
        alert(message);
    location.replace(location.pathname);
}

function htmlEncode(text) {
    var encoder = window.encElem;
    if (!encoder)
        encoder = window.encElem = document.createElement('span');
    encoder.textContent = text;
    return encoder.innerHTML;
}

Function urlArgs will parse the parameters specified after the “?” in the url argument or in the URL of the browser address bar. The parameters are returned as properties of an object.

Another notable function is session. It creates a wrapper on top of the intrinsic sessionStorage object. If only the name argument is specified, then the function will read the corresponding variable from the storage and parse it as JSON into an object. If the value argument is specified, then its named serialized version will be persisted in the storage. If value is null, then the corresponding storage item is removed.

Demoting Touch UI

If you are creating a custom frontend on top of the RESTful API Engine and expect it to become the primary user interface, then you will have a dilemma. Users can sign into the backend application directly and bypass your creation.

On one hand you want the users to confirm their identity in the backend. On the other hand everything else must be done in the primary UI.

Developers solve the dilemma by limiting the backend UI to specific roles.

Change the ~/app/touch-settings.json configuration file of the backend to include the ui.roles key with the value set to Administrators.

Backend application will allow users with the Administrators role to sign in and access any of its pages. Other users will not be able to sign in even if they confirm their identity.

Backend application will let all users confirm their identity and let them approve the account access if they enter at the location specified in Authorization Url during the login process initiated in the client app.

That way developers can quickly produce sophisticated data management forms based on Touch UI for themselves and application administrators. RESTful resources based on the data controllers will be created in the process. Developers can take their time to carefully craft a unique user experience in the custom frontend while taking advantage of the RESTful API Engine.

Logout

Let’s wrap up this tutorial by discussing the logout functionality. Users sign out from the client app by clicking on the Logout button in the toolbar above the list of products.

JavaScript
123456789101112131415function logout() {
    $app.restful({
        "hypermedia": 'oauth2 >> revoke >>',
        "body": {
            "client_id": clientId,
            "token": token.refresh_token || token.access_token || token
        },
        "token": false // anonymous request
    }).then(result => {
        // remove the token from the session 
        session('token', null);
        session('apiHypermedia', null);
        reloadApp();
    }).catch(restfulException);
}

A single call of the $app.restful method will remove the token and apiHypermedia data from the session storage and reload the app. The client application will enter its initial state.

The hypermedia argument will instruct the method to transition to the revoke hypermedia control. The body argument includes the client_id property derived from the clientId constant declared at the top of spa4.js file. The token property of the body argument will derive its value either from the refresh_token or access_token property available in the token global variable

The OAuth 2.0 API of the backend will delete the token data. This will render any existing copies of the access_token or refresh_token values unusable.

Final Source Code

This single page app is composed of two physical files. The markup of the spa4.html is shown at the top of the tutorial. The complete source of the spa4.js file is shown next.

Are numerous data samples and code snippets getting your head spinning? Take a deep breath and have a quick look at the compact and straightforward SPA4 implementation.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225(function () {
    var clientId = "TKuyzeDmIQKWFVncJcKpCXCWEmcsJP3kB9QpudegTrC", // specific to the Client App Registration
        token = session('token'),
        apiHypermedia = session('apiHypermedia'),
        products;

    window.addEventListener('load', e => {
        document.querySelector('#loginButton').addEventListener('click', login);
        document.querySelector('#logoutButton').addEventListener('click', logout);
        document.addEventListener('click', handleHypermedia);
        start();
    });

    function start() {
        $app.restful({
            "config": {
                "clientId": clientId,
                "token": function (value) {
                    if (value)
                        token = session('token', value);
                    return token;
                }
            }
        });
        if (location.hash.match(/#auth\b/))
            exchangeAuthorizationCodeForAccessToken();
        else {
            // initialize the GUI
            document.querySelector(token ? '#authenticated' : "#anonymous").style.display = '';
            if (token) {
                // parse the 'id_token' (JWT) and show the user 'picture' and 'email' claims
                if (token.id_token) {
                    var idToken = JSON.parse(atob(token.id_token.split(/\./)[1]));
                    document.querySelector('#avatar').style.backgroundImage = 'url(' + idToken.picture + ')';
                    document.querySelector('#email').textContent = idToken.email;
                }
                // 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';
            }
        }
    }

    /* authentication with "OAuth2 Authorization Code with PKCE" flow */

    function login() {
        var appUrl = location.href.match(/^(.+?)((\?|#).+)?$/);
        // get the url and data for "Authorization Code With PKCE" flow
        $app.restful({
            "hypermedia": 'oauth2 >> authorize-client-native >>',
            "body": {
                "client_id": clientId,
                "redirect_uri": appUrl[1] + "#auth",
                "scope": "offline_access openid profile email"
            }
        }).then(result => {
            // Save the entire result as the 'loginRequest' in the session storage
            // to exchange the authorization  code for an access token
            session('loginRequest', result);
            // 1) Redirect to the "authorize" link
            // 2) User will login in the primary app in the familiar environment
            // 3) The backend app will return to the SPA with the authorization code in the URL
            location.href = result._links.authorize.href;
        }).catch(restfulException);
    }

    function exchangeAuthorizationCodeForAccessToken() {
        var args = urlArgs();
        if (args.error)
            reloadApp(args.error);
        else {
            // get the access token
            var loginRequest = session('loginRequest');
            if (loginRequest) {
                session('loginRequest', null);
                if (args.state != loginRequest.state)
                    reloadApp("Forged 'state' is detected.");
                else {
                    loginRequest.token.code = args.code;
                    $app.restful({
                        "url": loginRequest.token._links["self"],
                        "body": loginRequest.token
                    })
                        .then(result => {
                            token = session('token', result);
                            $app.restful() // request the API of authenticated user
                                .then(result => {
                                    session('apiHypermedia', result);
                                    reloadApp();
                                })
                                .catch(restfulException);

                        })
                        .catch(restfulException);
                }
            }
            else
                reloadApp("The invalid 'loginRquest' is detected.");
        }
    }

    function logout() {
        $app.restful({
            "hypermedia": 'oauth2 >> revoke >>',
            "body": {
                "client_id": clientId,
                "token": token.refresh_token || token.access_token || token
            },
            "token": false // anonymous request
        }).then(result => {
            // remove the token from the session 
            session('token', null);
            session('apiHypermedia', null);
            reloadApp();
        }).catch(restfulException);
    }

    /* 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>', htmlEncode(p.productName), '</td>');
            sb.push('<td>', htmlEncode(p.categoryName), '</td>');
            sb.push('<td>', htmlEncode(p.supplierCompanyName), '</td>');
            sb.push('<td>', htmlEncode(p.unitPrice), '</td>');
            sb.push('<td>', 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 = 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('');
    }

    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 urlArgs(url) {
        if (!url)
            url = location;
        if (url.href)
            url = url.href;
        var args = {};
        var iterator = /(\w+)=(.+?)(&|$)/g;
        var m = iterator.exec(url);
        while (m) {
            args[m[1]] = m[2];
            m = iterator.exec(url);
        }
        return args;
    }

    function session(name, value) {
        if (arguments.length == 1) {
            value = sessionStorage.getItem(name);
            if (typeof value == 'string')
                value = JSON.parse(value);
        }
        else {
            if (value == null)
                sessionStorage.removeItem(name);
            else
                sessionStorage.setItem(name, JSON.stringify(value));
        }
        return value;
    }

    function restfulException(ex) {
        if (ex && ex.errors) {
            alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
            if (ex.code == 401 || ex.code == 403) {
                session('token', null);
                reloadApp();
            }
        }
    }

    function reloadApp(message) {
        if (message)
            alert(message);
        location.replace(location.pathname);
    }

    function htmlEncode(text) {
        var encoder = window.encElem;
        if (!encoder)
            encoder = window.encElem = document.createElement('span');
        encoder.textContent = text;
        return encoder.innerHTML;
    }
})();

Built-in vs 3rd Party OAuth Provider

Modern web and native apps require a backend server application with the REST API. User authentication relies on access tokens obtained from an OAuth 2.0 cloud provider sitting on top of the backend. The runtime fees will quickly add up with the user base growth. The integration of the 3rd party infrastructure allows the quick start of the development, but makes it a monumental effort to migrate to another platform.

Applications created with Code On Time include the built-in RESTful API Engine with OAuth 2.0 Authorization. There are no runtime fees. Your apps are elevated to the REST Level 3 with the Hypermedia.

Try Code On Time and build amazing standalone and embedded applications now!

Next

Continue to the Standalone SPA5 with REST (Level 2) and OAuth 2.0 segment. You will modify the app that we just created to use direct resource URls with the explicitly specified HTTP methods.