Lab 10: REST Client
In this lab exercise we will develop a pure JavaScript REST client for the services we developed in lab9. Most modern web client applications are built using popular frameworks such as Angular or React. We will however not use any framework, and will only use the browser native XMLHttpRequest object which all the JavaScript frameworks use to interact with REST services.
Before we start developing the client application, we will modify the application developed in lab9 by adding a HTTP Servlet Context Listener. The listener will ensure that the JPA Entity Manager Factory is properly shutdown when the web application context is destroyed, ensuring that we always get clean shutdown even when the Gretty plugin is unable to cleanly shutdown the Jersey based application.
Copy the lab9 folder and rename it lab10. Change all references to lab9 in the project to lab10 (JPA configuration, integration suite, …).
ContextListener
We will modify the code from lab9 by introducing an implementation of a ServletContextListener which will ensure that the JPA EntityManagerFactory is properly shutdown when our application context is destroyed.
build.gradle
plugins { id 'java' id 'war' } group 'mis283' sourceCompatibility = 1.8 apply from: 'https://raw.github.com/akhikhl/gretty/master/pluginScripts/gretty.plugin' repositories { mavenCentral() } sourceSets { integration { java.srcDir 'src/integration/java' resources.srcDir 'src/integration/resources' } } gretty { httpPort = 8080 integrationTestTask = 'integration' } dependencies { compile 'javax.ws.rs:javax.ws.rs-api:2.1' compile 'javax.servlet:javax.servlet-api:3.1.0' compile 'org.glassfish.jersey.core:jersey-server:2.26' compile 'org.hibernate:hibernate-core:5.2.11.Final' testCompile 'junit:junit:4.12' runtime 'org.glassfish.jersey.containers:jersey-container-servlet:2.26' runtime 'org.glassfish.jersey.core:jersey-common:2.26' runtime 'org.glassfish.jersey.inject:jersey-hk2:2.26' runtime 'org.glassfish.jersey.media:jersey-media-json-jackson:2.26' runtime 'com.h2database:h2:1.4.196' integrationCompile sourceSets.main.output integrationCompile configurations.compile integrationCompile configurations.testCompile integrationRuntime configurations.runtime integrationRuntime configurations.testRuntime } task integration(type: Test) { group = LifecycleBasePlugin.VERIFICATION_GROUP description = 'Runs the integration tests.' maxHeapSize = '256m' testClassesDir = sourceSets.integration.output.classesDir classpath = sourceSets.integration.runtimeClasspath binResultsDir = file("$buildDir/integration-test-results/binary/integration") reports { html.destination = "$buildDir/reports/integration-test" junitXml.destination = "$buildDir/integration-test-results" } mustRunAfter tasks.test } // To rerun all tests and not just failures or updates test.dependsOn 'cleanTest' // To rerun all integration tests and not just failures or updates integration.dependsOn 'cleanIntegration' check.dependsOn integration
Datastore
package mis283.repository; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.persistence.Persistence; public class Datastore { private static final EntityManagerFactory emf = Persistence.createEntityManagerFactory("lab9" ); private static final Datastore singleton = new Datastore(); public static Datastore getDatastore() { return singleton; } private Datastore() { Runtime.getRuntime().addShutdownHook( new Thread( () -> shutdown() ) ); } public EntityManager getManager() { return emf.createEntityManager(); } public static void shutdown() { if (emf.isOpen()) emf.close(); } }
ContextListener
package mis283; import mis283.repository.Datastore; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import javax.servlet.annotation.WebListener; import java.util.logging.Logger; @WebListener public class ContextListener implements ServletContextListener { private static final Logger logger = Logger.getLogger("mis283"); @Override public void contextInitialized(final ServletContextEvent sce) { Datastore.getDatastore(); logger.info("Servlet context initialized"); } @Override public void contextDestroyed(final ServletContextEvent sce) { Datastore.shutdown(); logger.info("JPA EMF shutdown"); } }
With these changes, we can use the standard appRun Gretty task to start and run the REST services developed in lab9. We will keep the service running throughout this lab session as our JavaScript web client is going to communicate with these services.
CORS
Cross-origin resource sharing (CORS) is a mechanism that allows JavaScript applications to make XHR requests to resources in other sites. Without a CORS policy, the browser will prohibit (security feature) any attempts to access resources from other domains/websites.
We will add CORS support to our application via a Jersey response filter (equivalent to a Servlet Filter).
We will add CORS support to our application via a Jersey response filter (equivalent to a Servlet Filter).
package mis283.service; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerResponseContext; import javax.ws.rs.container.ContainerResponseFilter; import javax.ws.rs.core.MultivaluedMap; import java.io.IOException; public class CORSResponseFilter implements ContainerResponseFilter { @Override public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { MultivaluedMap<String, Object> headers = responseContext.getHeaders(); headers.add("Access-Control-Allow-Origin", "*"); headers.add("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT"); headers.add("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, X-Codingpedia"); } }
We will register our filter through the constructor of our Application class.
register(CORSResponseFilter.class);
XMLHttpRequest
The XMLHttpRequest (commonly abbreviated to XHR) object is provided by all modern web browsers and is the underlying means of communicating asynchronously (synchronous communication is possible, but is deprecated and may be removed in future) with a backend web server from JavaScript applications.
The standard pattern for initiating an XHR request is:
The standard pattern for initiating an XHR request is:
- Create or reuse an existing instance of XHR
- Open a connection to the backend service specifying the HTTP Method (GET, POST, PUT, etc) and the path to the resource that is being accessed.
- Set any custom request headers for the current request.
- Associate a call back function (usually specified inline) which the onload event in XHR will invoke.
- Send the async request to the backend service.
let xhr = new XMLHttpRequest(); xhr.open( 'POST', `/degrees/id/${degreeId}/update` ); xhr.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' ); xhr.onload = function () { if ( xhr.status === 200 ) { console.log( xhr.responseText ); let json = JSON.parse( xhr.responseText ); if ( json.status ) { let element = document.getElementById( `d_${degreeId}` ); let index = titles.indexOf( currentTitle ); if ( index >= 0 ) titles.splice( index, 1 ); titles.push( form.title.value ); element.parentElement.removeChild( element ); addDegree( json, form ); hideForm( degreeId ); } else { showWarning( 'Unable to update degree with specified values. Please try changing the title without any quote characters.' ); } } else { showWarning( 'Unable to update degree. Please try again later.' ); } }; xhr.send( encodeURI( `id=${degreeId}&title=${form.title.value}&duration=${form.duration.value}` ) );
Client Test
Create a new folder named lab10 which will hold the HTML files with JavaScript code that we will be using to test our services in another location (eg. Desktop).
Create a new HTML file named site_client.html under the folder. This file will present a simple interface that will let us test the basic CRUD operations that our Site service supports.
Create a new HTML file named site_client.html under the folder. This file will present a simple interface that will let us test the basic CRUD operations that our Site service supports.
<!DOCTYPE html> <html lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>Site Service REST Client</title> <script type="text/javascript"> let baseUrl = 'http://localhost:8080/lab10'; var site; function sendData( method, url, data, callback ) { let xhr = new XMLHttpRequest(); xhr.onload = callback; xhr.open( method, url ); xhr.setRequestHeader( 'Content-Type', 'application/json' ); xhr.setRequestHeader( 'Accept', 'application/json' ); xhr.send( data ); } function createSite() { function callback() { let element = document.getElementById( 'create_id' ); if ( this.status === 200 ) { console.log( this.responseText ); site = JSON.parse( this.responseText ); element.innerText = `Created Site instance with id: ${site.id}`; } else { let message = `Server returned error code: ${this.status}`; console.log( message ); element.innerText = message; } } let data = `{ "name": "Unit Test Site", "url": "http://test.com/" }`; sendData( 'PUT', `${baseUrl}/site`, data, callback ); } function retrieveSite() { let xhr = new XMLHttpRequest(); xhr.open( 'GET', `${baseUrl}/site/${site.id}` ); xhr.setRequestHeader( 'Accept', 'application/json' ); xhr.onload = function () { let element = document.getElementById( 'retrieve_id' ); if ( xhr.status === 200 ) { console.log( xhr.responseText ); let json = JSON.parse( xhr.responseText ); element.innerHTML = `<h3>Site</h3> <h4>Name: ${json.name}</h4> <h4>URL: ${json.url}</h4>`; } else { let message = `Server returned error code: ${xhr.status}`; console.log( message ); element.innerText = message; } }; xhr.send(); } function updateSite() { function callback() { let element = document.getElementById( 'update_id' ); if ( this.status === 200 ) { console.log( this.responseText ); let json = JSON.parse( this.responseText ); element.innerHTML = `<h3>Site</h3> <h4>Name: ${json.name}</h4> <h4>URL: ${json.url}</h4>`; } else { let message = `Server returned error code: ${this.status}`; console.log( message ); element.innerText = message; } } let data = `{ "id": ${site.id}, "name": "${site.name} modified", "url": "${site.url}" }`; sendData( 'POST', `${baseUrl}/site/${site.id}`, data, callback ); } function deleteSite() { let xhr = new XMLHttpRequest(); xhr.open( 'DELETE', `${baseUrl}/site/${site.id}` ); xhr.onload = function () { let element = document.getElementById( 'delete_id' ); if ( xhr.status === 200 ) { console.log( xhr.responseText ); element.innerText = `Deleted site with id: ${site.id}`; site = null; } else { let message = `Server returned error code: ${xhr.status}`; console.log( message ); element.innerText = message; } }; xhr.send(); } </script> </head> <body> <div id='create'> <button onclick='createSite()'>Create Site</button> <div id='create_id'></div> </div> <div id='retrieve'> <button onclick='retrieveSite()'>Retrieve Site</button> <div id='retrieve_id'></div> </div> <div id='update'> <button onclick='updateSite()'>Update Site</button> <div id='update_id'></div> </div> <div id='delete'> <button onclick='deleteSite()'>Delete Site</button> <div id='delete_id'></div> </div> </body> </html>
Client Application
We will now create a simple single page application that presents a web UI for managing site instances using the REST services. Create a new file named site.html under the same folder as the previous file.
<!DOCTYPE html> <html lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>Site Application</title> <script type="text/javascript"> let baseUrl = 'http://localhost:8080/lab10'; var sites = new Map(); function displaySite( siteId ) { document.getElementById( `sv_${siteId}` ).style.display = 'inline'; return false; } function hideSite( siteId ) { document.getElementById( `sv_${siteId}` ).style.display = 'none'; } function displayForm( siteId ) { document.getElementById( `se_${siteId}` ).style.display = 'inline'; } function addSite( json, form ) { let content = `<div><a href="#" onclick="return displaySite( ${json.id} );">${form.name.value}</a></div> <div id="sv_${json.id}" style="display: none;"> <h3>Name: ${form.name.value}</h3> <strong>URL:</strong> ${form.url.value}<br/> <button onclick="displayForm( ${json.id} )">Edit</button> <button onclick="deleteSite( ${json.id} )">Delete</button> <button onclick="hideSite( ${json.id} )">Close</button> <br/> <q id="sw_${json.id}" class="warning" style="display: none;"></q> </div> <div id="se_${json.id}" style="display: none;"> <form id="sf_${json.id}" onsubmit="return update( ${json.id} );"> <input type="hidden" name="id" value="${json.id}" /> <label class="label">Name:</label> <input type="text" name="name" value="${form.name.value}"/><br/> <label class="label">URL:</label> <input type="url" name="url" value="${form.url.value}" /><br/> <input type="submit" value="Save" /> <button onclick="hideForm( ${json.id} )">Close</button> </form> </div>`; let div = document.createElement( 'div' ); div.id = `s_${json.id}`; div.innerHTML = content; document.getElementById( 'sites' ).appendChild( div ); } function update( siteId ) { function showWarning( message ) { let cw = document.getElementById( `sw_${siteId}` ) cw.innerText = message; cw.style.display = 'inline'; } function isDuplicate( form ) { for (let [key,value] of sites) { if ( value.name === form.name.value && key != form.id.value ) return true; } return false; } let form = document.getElementById( `sf_${siteId}` ); if ( !isDuplicate( form ) ) { let xhr = new XMLHttpRequest(); xhr.open( 'POST', `${baseUrl}/site/${siteId}` ); xhr.setRequestHeader( 'Content-Type', 'application/json' ); xhr.setRequestHeader( 'Accept', 'application/json' ); xhr.onload = function () { if ( xhr.status === 200 ) { console.log( xhr.responseText ); let json = JSON.parse( xhr.responseText ); let element = document.getElementById( `s_${siteId}` ); sites.set( json.id, json ); element.parentElement.removeChild( element ); addSite( json, form ); hideForm( siteId ); } else { showWarning( `Unable to update site. Server returned status: ${xhr.status}` ); } }; xhr.send( `{"id": ${siteId}, "name": "${form.name.value}", "url": "${form.url.value}"}` ); } else { showWarning( `Site with name: ${form.name.value} exists.` ); } return false; } function deleteSite( siteId ) { let site = sites.get( siteId ); if ( !confirm( `Do you wish to permanently delete ${site.name}?` ) ) return false; let xhr = new XMLHttpRequest(); xhr.open( 'DELETE', `${baseUrl}/site/${siteId}` ); xhr.onload = function () { if ( xhr.status === 200 ) { console.log( xhr.responseText ); let element = document.getElementById( `s_${siteId}` ); element.parentElement.removeChild( element ); sites.delete( siteId ); } else { document.getElementById( `sw_${siteId}` ).innerText = `Unable to delete site from the server. Server returned status: ${xhr.status}.`; } }; xhr.send(); } function hideForm( siteId ) { document.getElementById( `se_${siteId}` ).style.display = 'none'; } function displayCreate() { document.getElementById( 'create' ).style.display = 'inline'; document.getElementById( 'createButton' ).style.display = 'none'; } function createSite( form ) { function showWarning( message ) { let cw = document.getElementById( 'createWarning' ); cw.style.display = 'inline'; cw.innerText = message; } function isDuplicate() { for (let value of sites.values()) { if ( value.name === form.name.value ) return true; } return false; } if ( !isDuplicate() ) { let xhr = new XMLHttpRequest(); xhr.open( 'PUT', `${baseUrl}/site` ); xhr.setRequestHeader( 'Content-Type', 'application/json' ); xhr.setRequestHeader( 'Accept', 'application/json' ); xhr.onload = function () { if ( xhr.status === 200 ) { console.log( xhr.responseText ); let json = JSON.parse( xhr.responseText ); sites.set( json.id, json ); addSite( json, form ); hideCreate(); document.getElementById( 'createButton' ).style.display = 'inline'; } else { showWarning( `Unable to create site. Server returned status: ${xhr.status}.` ); } }; xhr.send( `{"name": "${form.name.value}", "url": "${form.url.value}"}` ); } else { showWarning( `Site with name: ${form.name.value} exists.` ); } return false; } function hideCreate() { document.getElementById( 'create' ).style.display = 'none'; document.getElementById( 'createWarning' ).style.display = 'none'; document.getElementById( 'createButton' ).style.display = 'inline'; let form = document.getElementById( 'createSite' ); form.name.value = ''; form.url.value = ''; } </script> </head> <body> <h2>Manage Sites</h2> <div id="sites"> </div> <div id="create" style="display:none"> <form id="createSite" onsubmit="return createSite(this);"> <label class="label">Name:</label> <input type="text" name="name" required/> <br/> <label class="label">URL:</label> <input type="url" name="url" required /> <br/> <input type="submit" value="Create"/> <button onclick="hideCreate()">Cancel</button> </form> <q id="createWarning" class="warning" style="display: none;"></q> </div> <br/> <button id="createButton" onclick="displayCreate()">Add Site</button> </body> </html>