Project Student: Webservice Client with Jersey
This is part of Project Student. Other posts are Webservice Client with Jersey, Business Layer and Persistence with Spring Data.
The first layer of the RESTful webapp onion is the webservice client. It can be used to mimic web pages containing AJAX content or by programmatic users of the webapp. N.B., the latter might include other webapps, e.g., if you have an internal RESTful server surrounded by a number of presentation servers that create conventional web pages.
Design Decisions
Jersey – I use the Jersey library for the REST calls. I considered several choices but decided to go with Jersey since it’s lightweight and won’t impose many restrictions on the developer. In contrast using a Spring library, for instance, could cause problems in an EJB3 environment in addition to pulling in additional libraries.
UUIDs – the database will use integer primary keys but the web service will use UUIDs to identify values. This is for security – if an attacker knows that account ID 1008 exists then it’s a fairly safe bet that account ID 1007 exists. More importantly user ID 0 probably exists and has additional privileges than the typical user.
This isn’t true with UUIDs – for the most part knowing one UUID gives no insight into other UUIDs. That’s not 100% accurate – some UUIDs are composed with IP addresses or timestamps so the universe of possible values can be dramatically reduced by a knowledgeable attacker – but a random UUID is “good enough” for now.
Limitations
I am taking a “implement the least functionality required” approach so the initial implementation has a number of limitations.
Authentication – there is no attempt made to provide authentication information.
Encryption – there is no attempt made to encrypt the webservice call.
Only CRUD methods – only basic CRUD methods are supported.
Remember – limitations are fine but they must be clearly documented. In the best of worlds they’ll be added to an agile backlog.
Client API
The client API is basic CRUD. We can add functionality later.
public interface CourseRestClient { /** * Get list of all courses. */ Course[] getAllCourses(); /** * Get details for specific course. * @param uuid */ Course getCourse(String uuid); /** * Create specific course. * @param name */ Course createCourse(String name); /** * Update specific course. * @param uuid * @param name */ Course updateCourse(String uuid, String name); /** * Delete course. * @param uuid */ void deleteCourse(String uuid); }
Exceptions
The API includes three runtime exceptions. The first, RestClientException, is an abstract runtime exception that is the base class for all other exceptions.
An ObjectNotFoundException is thrown when an expected value is missing. (Implementation note: this is triggered by a 404 status code.) This exception contains enough information to uniquely identify the expected object.
public class ObjectNotFoundException extends RestClientException { private static final long serialVersionUID = 1L; private final String resource; private final Class<? extends PersistentObject> objectClass; private final String uuid; public ObjectNotFoundException(final String resource, final Class<? extends PersistentObject> objectClass, final String uuid) { super("object not found: " + resource + "[" + uuid + "]"); this.resource = resource; this.objectClass = objectClass; this.uuid = uuid; } public String getResource() { return resource; } public Class<? extends PersistentObject> getObjectClass() { return objectClass; } public String getUuid() { return uuid; } }
A RestClientFailureException is a generic handler for unexpected or unhandled status codes.
public class RestClientFailureException extends RestClientException { private static final long serialVersionUID = 1L; private final String resource; private final Class<? extends PersistentObject> objectClass; private final String uuid; private final int statusCode; /** * Constructor * * @param resource * @param objectClass * @param uuid * @param response */ public RestClientFailureException(final String resource, final Class<? extends PersistentObject> objectClass, final String uuid, final ClientResponse response) { super("rest client received error: " + resource + "[" + uuid + "]"); this.resource = resource; this.objectClass = objectClass; this.uuid = uuid; this.statusCode = response.getStatus(); } public String getResource() { return resource; } public Class<? extends PersistentObject> getObjectClass() { return objectClass; } /** * Get UUID, "<none>" (during listAllX()) or "(name)" (during createX()) * * @return */ public String getUuid() { return uuid; } /** * Get standard HTTP status code. * * @return */ public int getStatusCode() { return statusCode; } }
We’ll want to add an UnauthorizedOperationException after we add client authentication.
Client Implementation
Basic CRUD implementations are typically boilerplate so we can use an abstract class to do most of the heavy lifting. More advanced functionality will probably require this class to make Jersey calls directly.
/** * This is the Course-specific implementation. */ public class CourseRestClientImpl extends AbstractRestClientImpl<Course> implements CourseRestClient { private static final Course[] EMPTY_COURSE_ARRAY = new Course[0]; /** * Constructor. * * @param courseResource */ public CourseRestClientImpl(final String resource) { super(resource, Course.class, Course[].class); } /** * Create JSON string. * * @param name * @return */ String createJson(final String name) { return String.format("{ \"name\": \"%s\" }", name); } /** * @see com.invariantproperties.sandbox.student.webservice.client.CourseRestClient#getAllCourses() */ public Course[] getAllCourses() { return super.getAllObjects(EMPTY_COURSE_ARRAY); } /** * @see com.invariantproperties.sandbox.student.webservice.client.CourseRestClient#getCourse(java.lang.String) */ public Course getCourse(final String uuid) { return super.getObject(uuid); } /** * @see com.invariantproperties.sandbox.student.webservice.client.CourseRestClient#createCourse(java.lang.String) */ public Course createCourse(final String name) { if (name == null || name.isEmpty()) { throw new IllegalArgumentException("'name' is required"); } return createObject(createJson(name)); } /** * @see com.invariantproperties.sandbox.student.webservice.client.CourseRestClient#updateCourse(java.lang.String, * java.lang.String) */ public Course updateCourse(final String uuid, final String name) { if (name == null || name.isEmpty()) { throw new IllegalArgumentException("'name' is required"); } return super.updateObject(createJson(name), uuid); } /** * @see com.invariantproperties.sandbox.student.webservice.client.CourseRestClient#deleteCourse(java.lang.String) */ public void deleteCourse(final String uuid) { super.deleteObject(uuid); } }
The abstract base class does the heavy lifting.
public class AbstractRestClientImpl<T extends PersistentObject> { private final String resource; private final Class<T> objectClass; private final Class<T[]> objectArrayClass; /** * Constructor. * * @param resource */ public AbstractRestClientImpl(final String resource, final Class<T> objectClass, final Class<T[]> objectArrayClass) { this.resource = resource; this.objectClass = objectClass; this.objectArrayClass = objectArrayClass; } /** * Helper method for testing. * * @return */ Client createClient() { return Client.create(); } /** * List all objects. This is a risky method since there's no attempt at * pagination. */ public T[] getAllObjects(final T[] emptyListClass) { final Client client = createClient(); try { final WebResource webResource = client.resource(resource); final ClientResponse response = webResource.accept( MediaType.APPLICATION_JSON).get(ClientResponse.class); if (response.getStatus() == Response.Status.OK.getStatusCode()) { T[] entities = response.getEntity(objectArrayClass); return entities; } else { throw new RestClientFailureException(resource, objectClass, "<none>", response); } } finally { client.destroy(); } } /** * Get a specific object. */ public T getObject(String uuid) { final Client client = createClient(); try { final WebResource webResource = client.resource(resource + uuid); final ClientResponse response = webResource.accept( MediaType.APPLICATION_JSON).get(ClientResponse.class); if (response.getStatus() == Response.Status.OK.getStatusCode()) { final T entity = response.getEntity(objectClass); return entity; } else if (response.getStatus() == Response.Status.NOT_FOUND .getStatusCode()) { throw new ObjectNotFoundException(resource, objectClass, uuid); } else { throw new RestClientFailureException(resource, objectClass, uuid, response); } } finally { client.destroy(); } } /** * Create an object with the specified values. */ public T createObject(final String json) { final Client client = createClient(); try { final WebResource webResource = client.resource(resource); final ClientResponse response = webResource .type(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .post(ClientResponse.class, json); if (response.getStatus() == Response.Status.CREATED.getStatusCode()) { final T entity = response.getEntity(objectClass); return entity; } else { throw new RestClientFailureException(resource, objectClass, "(" + json + ")", response); } } finally { client.destroy(); } } /** * Update an object with the specified json. */ public T updateObject(final String json, final String uuid) { final Client client = createClient(); try { final WebResource webResource = client.resource(resource + uuid); final ClientResponse response = webResource .type(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .post(ClientResponse.class, json); if (response.getStatus() == Response.Status.OK.getStatusCode()) { final T entity = response.getEntity(objectClass); return entity; } else if (response.getStatus() == Response.Status.NOT_FOUND .getStatusCode()) { throw new ObjectNotFoundException(resource, objectClass, uuid); } else { throw new RestClientFailureException(resource, objectClass, uuid, response); } } finally { client.destroy(); } } /** * Delete specified object. */ public void deleteObject(String uuid) { final Client client = createClient(); try { final WebResource webResource = client.resource(resource + uuid); final ClientResponse response = webResource.accept( MediaType.APPLICATION_JSON).delete(ClientResponse.class); if (response.getStatus() == Response.Status.GONE.getStatusCode()) { // do nothing } else if (response.getStatus() == Response.Status.NOT_FOUND .getStatusCode()) { // do nothing - delete is idempotent } else { throw new RestClientFailureException(resource, objectClass, uuid, response); } } finally { client.destroy(); } } }
Unit Testing
We now come to our test code. Important: we want to test our code’s behavior and not its implementation.
public class CourseRestClientImplTest { private static final String UUID = "uuid"; private static final String NAME = "name"; @Test public void testGetAllCoursesEmpty() { CourseRestClient client = new CourseRestClientMock(200, new Course[0]); Course[] results = client.getAllCourses(); assertEquals(0, results.length); } @Test public void testGetAllCoursesNonEmpty() { Course course = new Course(); course.setUuid(UUID); CourseRestClient client = new CourseRestClientMock(200, new Course[] { course }); Course[] results = client.getAllCourses(); assertEquals(1, results.length); } @Test(expected = RestClientFailureException.class) public void testGetAllCoursesError() { CourseRestClient client = new CourseRestClientMock(500, null); client.getAllCourses(); } @Test public void testGetCourse() { Course course = new Course(); course.setUuid(UUID); CourseRestClient client = new CourseRestClientMock(200, course); Course results = client.getCourse(course.getUuid()); assertEquals(course.getUuid(), results.getUuid()); } @Test(expected = ObjectNotFoundException.class) public void testGetCourseMissing() { CourseRestClient client = new CourseRestClientMock(404, null); client.getCourse(UUID); } @Test(expected = RestClientFailureException.class) public void testGetCourseError() { CourseRestClient client = new CourseRestClientMock(500, null); client.getCourse(UUID); } @Test public void testCreateCourse() { Course course = new Course(); course.setName(NAME); CourseRestClient client = new CourseRestClientMock( Response.Status.CREATED.getStatusCode(), course); Course results = client.createCourse(course.getName()); assertEquals(course.getName(), results.getName()); } @Test(expected = RestClientFailureException.class) public void testCreateCourseError() { CourseRestClient client = new CourseRestClientMock(500, null); client.createCourse(UUID); } @Test public void testUpdateCourse() { Course course = new Course(); course.setUuid(UUID); course.setName(NAME); CourseRestClient client = new CourseRestClientMock(200, course); Course results = client .updateCourse(course.getUuid(), course.getName()); assertEquals(course.getUuid(), results.getUuid()); assertEquals(course.getName(), results.getName()); } @Test(expected = ObjectNotFoundException.class) public void testUpdateCourseMissing() { CourseRestClient client = new CourseRestClientMock(404, null); client.updateCourse(UUID, NAME); } @Test(expected = RestClientFailureException.class) public void testUpdateCourseError() { CourseRestClient client = new CourseRestClientMock(500, null); client.updateCourse(UUID, NAME); } @Test public void testDeleteCourse() { Course course = new Course(); course.setUuid(UUID); CourseRestClient client = new CourseRestClientMock( Response.Status.GONE.getStatusCode(), null); client.deleteCourse(course.getUuid()); } @Test public void testDeleteCourseMissing() { CourseRestClient client = new CourseRestClientMock(404, null); client.deleteCourse(UUID); } @Test(expected = RestClientFailureException.class) public void testDeleteCourseError() { CourseRestClient client = new CourseRestClientMock(500, null); client.deleteCourse(UUID); } }
Finally we need to create a to-be-tested object with a mocked REST client. We can’t use dependency injection since Client.createClient is a static method but we’ve wrapped that call in a package-private method that we can override. That method creates a mocked Client that provides the rest of the values required from Jersey library.
class CourseRestClientMock extends CourseRestClientImpl { static final String RESOURCE = "test://rest/course/"; private Client client; private WebResource webResource; private WebResource.Builder webResourceBuilder; private ClientResponse response; private final int status; private final Object results; CourseRestClientMock(int status, Object results) { super(RESOURCE); this.status = status; this.results = results; } /** * Override createClient() so it returns mocked object. These expectations * will handle basic CRUD operations, more advanced functionality will * require inspecting JSON payload of POST call. */ Client createClient() { client = Mockito.mock(Client.class); webResource = Mockito.mock(WebResource.class); webResourceBuilder = Mockito.mock(WebResource.Builder.class); response = Mockito.mock(ClientResponse.class); when(client.resource(any(String.class))).thenReturn(webResource); when(webResource.accept(any(String.class))).thenReturn( webResourceBuilder); when(webResource.type(any(String.class))) .thenReturn(webResourceBuilder); when(webResourceBuilder.accept(any(String.class))).thenReturn( webResourceBuilder); when(webResourceBuilder.type(any(String.class))).thenReturn( webResourceBuilder); when(webResourceBuilder.get(eq(ClientResponse.class))).thenReturn( response); when( webResourceBuilder.post(eq(ClientResponse.class), any(String.class))).thenReturn(response); when( webResourceBuilder.put(eq(ClientResponse.class), any(String.class))).thenReturn(response); when(webResourceBuilder.delete(eq(ClientResponse.class))).thenReturn( response); when(response.getStatus()).thenReturn(status); when(response.getEntity(any(Class.class))).thenReturn(results); return client; } }
Integration Testing
This is the outermost layer of the onion so there’s no meaningful integration tests.
Source Code
- The source code is available at http://code.google.com/p/invariant-properties-blog/source/browse/student/student-webservices/student-ws-client.
nice tuto