CRUD with Hypermedia

Create, Read, Update, and Delete data with the RESTful 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 'loginRequest' 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.


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