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.
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 'loginRequest' 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 'loginRequest' 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="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-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 'loginRequest' 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.
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.