Статьи

Реализация REST-сервисов с помощью Apache Pivot

Apache Pivot — это платформа для создания многофункциональных интернет-приложений (RIA) на Java. Несмотря на то, что он ориентирован в первую очередь на создание пользовательского интерфейса, Pivot содержит ряд функций, которые делают его пригодным и для приложений без пользовательского интерфейса.

Например, библиотеки веб-запросов Pivot упрощают написание как потребителей, так и производителей веб-служб на основе REST. В этой статье я рассмотрю реализацию базового сервиса REST и клиентского приложения на основе JUnit для тестирования сервиса. Пример основан на Pivot 1.5.1, доступном здесь .

ОТДЫХ

Следующий листинг содержит исходный код для RESTDemoServlet, который обеспечивает реализацию для службы REST. Он мало что делает — он просто позволяет вызывающей стороне создавать, читать, обновлять и удалять произвольные объекты JSON — но он служит достаточно хорошим примером того, как такие сервисы могут быть реализованы в Pivot:

package org.apache.pivot.demos.rest.server;import java.io.File;import java.io.FileInputStream;import java.io.FileOutputStream;import java.io.IOException;import java.net.MalformedURLException;import java.net.URL;import org.apache.pivot.json.JSONSerializer;import org.apache.pivot.serialization.SerializationException;import org.apache.pivot.serialization.Serializer;import org.apache.pivot.web.Query;import org.apache.pivot.web.QueryException;import org.apache.pivot.web.server.QueryServlet;public class RESTDemoServlet extends QueryServlet {    private static final long serialVersionUID = 0;    @Override    protected Object doGet(Path path) throws QueryException {        if (path.getLength() != 1) {            throw new QueryException(Query.Status.BAD_REQUEST);        }        // Read the value from the temp file        File directory = new File(System.getProperty("java.io.tmpdir"));        File file = new File(directory, path.get(0));        if (!file.exists()) {            throw new QueryException(Query.Status.NOT_FOUND);        }        Object value;        try {            JSONSerializer jsonSerializer = new JSONSerializer();            value = jsonSerializer.readObject(new FileInputStream(file));        } catch (IOException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        } catch (SerializationException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        }        return value;    }    @Override    protected URL doPost(Path path, Object value) throws QueryException {        if (path.getLength() > 0            || value == null) {            throw new QueryException(Query.Status.BAD_REQUEST);        }        // Write the value to a temp file        File directory = new File(System.getProperty("java.io.tmpdir"));        File file;        try {            file = File.createTempFile(getClass().getName(), null, directory);            JSONSerializer jsonSerializer = new JSONSerializer();            jsonSerializer.writeObject(value, new FileOutputStream(file));        } catch (IOException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        } catch (SerializationException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        }        // Return the location of the resource        URL location;        try {            location = new URL(getLocation(), file.getName());        } catch (MalformedURLException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        }        return location;    }    @Override    protected boolean doPut(Path path, Object value) throws QueryException {        if (path.getLength() != 1            || value == null) {            throw new QueryException(Query.Status.BAD_REQUEST);        }        // Write the value to the temp file        File directory = new File(System.getProperty("java.io.tmpdir"));        File file = new File(directory, path.get(0));        if (!file.exists()) {            throw new QueryException(Query.Status.NOT_FOUND);        }        try {            JSONSerializer jsonSerializer = new JSONSerializer();            jsonSerializer.writeObject(value, new FileOutputStream(file));        } catch (IOException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        } catch (SerializationException exception) {            throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);        }        return false;    }    @Override    protected void doDelete(Path path) throws QueryException {        if (path.getLength() != 1) {            throw new QueryException(Query.Status.BAD_REQUEST);        }        // Delete the file        File directory = new File(System.getProperty("java.io.tmpdir"));        File file = new File(directory, path.get(0));        if (!file.exists()) {            throw new QueryException(Query.Status.NOT_FOUND);        }        file.delete();    }    @Override    protected Serializer<?> createSerializer(Path path) throws QueryException {        return new JSONSerializer();    }}

RESTDemoServlet расширяет org.apache.pivot.web.server.QueryServlet, абстрактный базовый класс, предоставляемый платформой Pivot, чтобы упростить реализацию служб REST. Сам QueryServlet расширяет javax.servlet.http.HttpServlet и предоставляет перегруженные версии базовых методов-обработчиков HTTP, которые облегчают работу с ними в REST-ориентированном виде:

  • Объект doGet (Путь к пути)
  • URL doPost (путь к файлу, значение объекта)
  • логическое значение doPut (путь к пути, значение объекта)
  • void doDelete (Путь к пути)

Каждый метод принимает экземпляр QueryServlet.Path, который представляет путь к ресурсу, к которому осуществляется доступ, относительно расположения самого сервлета. Путь — это тип последовательности, который позволяет вызывающей стороне получать доступ к компонентам пути через числовой индекс. Так, если, например, сервлет сопоставлен с шаблоном URL «/ my_service / *», учитывая следующий URL:

http://ixnay.biz/my_service/foo/bar

Аргумент path будет содержать значения «foo» и «bar», доступные через индексы 0 и 1 соответственно.

сериализаторы

В отличие от базового класса HttpServlet, QueryServlet работает с произвольными типами Java, а не с объектами HTTP-запросов и ответов. Это позволяет разработчикам сосредоточиться на ресурсах, управляемых службой, а не на деталях более низкого уровня протокола HTTP.

QueryServlet использует «сериализатор», реализацию интерфейса org.apache.pivot.serialization.Serializer, чтобы определить, как сериализовать данные, отправляемые и возвращаемые из сервлета. Сериализатор отвечает за преобразование входного потока в значение объекта и наоборот. Интерфейс определяется следующим образом:

 

public interface Serializer<T> {    public T readObject(InputStream inputStream)         throws IOException, SerializationException;    public void writeObject(T object, OutputStream outputStream)         throws IOException, SerializationException;    public String getMIMEType(T object);

Первые два метода отвечают за чтение данных и их запись соответственно: readObject () десериализует объект из входного потока и возвращает его, а writeObject () сериализует данный объект в выходной поток. Третий метод, getMIMEType (), возвращает тип MIME, поддерживаемый сериализатором для данного значения.

Сериализатор, используемый для данного HTTP-запроса, определяется возвращаемым значением абстрактного метода createSerializer (). Этот метод вызывается QueryServlet до вызова фактического метода обработчика HTTP. В примере сервлета используется экземпляр org.apache.pivot.json.JSONSerializer, который поддерживает чтение и запись данных JSON. Pivot предоставляет ряд дополнительных сериализаторов, поддерживающих сериализацию XML, CSV и Java, среди прочего, и реализации сервисов также могут свободно определять свои собственные сериализаторы.

Исключения

Каждый метод-обработчик объявляет, что он может генерировать экземпляр org.apache.pivot.web.QueryException. Это исключение инкапсулирует ответ об ошибке HTTP. Он принимает целочисленное значение, представляющее код ответа, в качестве аргумента конструктора (класс org.apache.pivot.web.Query.Status определяет количество констант для кодов состояния, обычно используемых в ответах REST). Клиентский API веб-запроса, рассмотренный в следующем разделе, эффективно перебрасывает эти исключения, позволяя клиенту обрабатывать ответ об ошибке, возвращаемый сервером, как если бы исключение было сгенерировано локально.

Параметры строки запроса и заголовки HTTP

Хотя это не показано в этом примере, реализации сервлета запросов также могут получить доступ к аргументам строки запроса и заголовкам HTTP, включенным в запрос HTTP, а также управлять заголовками, отправленными обратно с ответом. Строковые параметры запроса доступны через метод getParameters () QueryServlet, а заголовки запроса / ответа могут быть доступны через getRequestHeaders () и getResponseHeaders () соответственно. Все три метода возвращают экземпляр org.apache.pivot.web.QueryDictionary, который позволяет вызывающей стороне манипулировать этими коллекциями с помощью методов get (), put () и remove ().

получить()

doGet () используется для обработки HTTP-запроса GET. Возвращает объект, представляющий ресурс по заданному пути. Метод doGet () в примере сервлета определяется следующим образом:

protected Object doGet(Path path) throws QueryException {    if (path.getLength() != 1) {        throw new QueryException(Query.Status.BAD_REQUEST);    }    // Read the value from the temp file    File directory = new File(System.getProperty("java.io.tmpdir"));    File file = new File(directory, path.get(0));    if (!file.exists()) {        throw new QueryException(Query.Status.NOT_FOUND);    }    Object value;    try {        JSONSerializer jsonSerializer = new JSONSerializer();        value = jsonSerializer.readObject(new FileInputStream(file));    } catch (IOException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    } catch (SerializationException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    }    return value;}

Этот метод просто загружает файл из системного временного каталога, который предположительно был создан предыдущим запросом POST (doPost () обсуждается в следующем разделе). Путь содержит одно значение, представляющее имя файла для извлечения. Если файл не существует, генерируется исключение, представляющее ошибку HTTP 404 («Не найдено»). В противном случае данные, хранящиеся в файле, десериализуются экземпляром JSONSerializer и возвращаются. Сериализатор, возвращаемый методом createSerializer () (в данном случае, другим экземпляром JSONSerializer), используется QueryServlet для записи возвращаемого значения в выходной поток сервлета. Тип MIME, возвращаемый сериализатором, используется в качестве значения заголовка ответа «Content-Type» (в данном случае это будет «application / json»).

Очевидно, что это несколько надуманный пример — не очень эффективно десериализовать файл только для повторной сериализации его, чтобы отправить его обратно клиенту. В реальном приложении данные, возвращаемые функцией doGet (), скорее всего, будут получены путем запроса одного или нескольких внутренних источников данных, таких как реляционная база данных или база данных XML или устаревшее приложение.

doPost ()

doPost () используется для обработки HTTP-запроса POST. Он в основном используется для создания нового ресурса на сервере, но также может использоваться для выполнения произвольных действий на стороне сервера.

Когда ресурс создан, doPost () возвращает URL-адрес, представляющий местоположение нового ресурса. В соответствии со спецификацией HTTP это значение возвращается в заголовке ответа «Местоположение» вместе с кодом состояния HTTP 201 («Создано»). Если запрос POST не приводит к созданию ресурса, doPost () может вернуть значение null, которое QueryServlet преобразует в HTTP-ответ 204 («Нет содержимого») и без соответствующего заголовка «Местоположение».

Метод doPost () в примере выглядит следующим образом. Он создает новый файл JSON во временном каталоге на сервере:

protected URL doPost(Path path, Object value) throws QueryException {    if (path.getLength() > 0        || value == null) {        throw new QueryException(Query.Status.BAD_REQUEST);    }    // Write the value to a temp file    File directory = new File(System.getProperty("java.io.tmpdir"));    File file;    try {        file = File.createTempFile(getClass().getName(), null, directory);        JSONSerializer jsonSerializer = new JSONSerializer();        jsonSerializer.writeObject(value, new FileOutputStream(file));    } catch (IOException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    } catch (SerializationException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    }    // Return the location of the resource    URL location;    try {        location = new URL(getLocation(), file.getName());    } catch (MalformedURLException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    }    return location;}

Первое, что делает метод — это гарантирует, что запрос действителен Если вызывающая сторона указала какие-либо компоненты пути или не предоставила значение в теле запроса, возвращается HTTP 400 («Неверный запрос»). В противном случае значение, переданное методу, сохраняется во временном файле (опять же, с использованием JSONSerializer), и возвращается местоположение нового ресурса. Значение местоположения генерируется простым добавлением имени временного файла к местоположению сервлета, полученного путем вызова QueryServlet # getLocation ().

doPut ()

doPut () обрабатывает запрос HTTP PUT. Он часто используется для обновления существующего ресурса, но также может использоваться для создания нового ресурса. Возвращаемое значение doPut () является логическим флагом, указывающим, был ли создан ресурс. Если true, HTTP 201 возвращается вызывающей стороне; в противном случае возвращается HTTP 204.

Реализация doPut () в RESTDemoServlet выглядит следующим образом. Это позволяет вызывающей стороне обновить существующий ресурс JSON на сервере:

protected boolean doPut(Path path, Object value) throws QueryException {    if (path.getLength() != 1        || value == null) {        throw new QueryException(Query.Status.BAD_REQUEST);    }    // Write the value to the temp file    File directory = new File(System.getProperty("java.io.tmpdir"));    File file = new File(directory, path.get(0));    if (!file.exists()) {        throw new QueryException(Query.Status.NOT_FOUND);    }    try {        JSONSerializer jsonSerializer = new JSONSerializer();        jsonSerializer.writeObject(value, new FileOutputStream(file));    } catch (IOException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    } catch (SerializationException exception) {        throw new QueryException(Query.Status.INTERNAL_SERVER_ERROR);    }    return false;}

Как и doPost (), он сначала проверяет формат запроса. Затем он проверяет, что ресурс действительно существует; если нет, возвращается HTTP 404. Наконец, он использует экземпляр JSONSerializer для записи обновленных данных во временный файл.

doDelete ()

doDelete () обрабатывает запрос HTTP DELETE. В случае успеха он просто удаляет ресурс, указанный путем, и возвращает HTTP 204. Исходный код этого метода показан ниже:

protected void doDelete(Path path) throws QueryException {    if (path.getLength() != 1) {        throw new QueryException(Query.Status.BAD_REQUEST);    }    // Delete the file    File directory = new File(System.getProperty("java.io.tmpdir"));    File file = new File(directory, path.get(0));    if (!file.exists()) {        throw new QueryException(Query.Status.NOT_FOUND);    }    file.delete();}

Как и другие методы, запрос сначала проверяется; затем, если файл существует, он удаляется.

Тестовый клиент JUnit

Ниже приведен список исходного кода клиентского приложения JUnit, используемого для тестирования службы:

package org.apache.pivot.demos.rest;import java.io.IOException;import java.net.URL;import org.apache.pivot.json.JSON;import org.apache.pivot.json.JSONSerializer;import org.apache.pivot.serialization.SerializationException;import org.apache.pivot.web.DeleteQuery;import org.apache.pivot.web.GetQuery;import org.apache.pivot.web.PostQuery;import org.apache.pivot.web.PutQuery;import org.apache.pivot.web.Query;import org.apache.pivot.web.QueryException;import org.junit.BeforeClass;import org.junit.Test;import static org.junit.Assert.assertEquals;import static org.junit.Assert.assertFalse;import static org.junit.Assert.assertNotNull;public class RESTDemoTest {    private static String hostname = null;    private static int port = -1;    private static boolean secure = false;    @BeforeClass    public static void oneTimeSetUp() {        hostname = System.getProperty("org.apache.pivot.demos.rest.hostname", "localhost");        port = Integer.parseInt(System.getProperty("org.apache.pivot.demos.rest.port", "-1"));        secure = Boolean.parseBoolean(System.getProperty("org.apache.pivot.demos.rest.secure", "false"));    }    @Test    public void testCRUD() throws IOException, SerializationException, QueryException {        JSONSerializer jsonSerializer = new JSONSerializer();        Object contact = jsonSerializer.readObject(getClass().getResourceAsStream("contact.json"));        // Create        PostQuery postQuery = new PostQuery(hostname, port, "/pivot-demos/rest_demo", secure);        postQuery.setValue(contact);        URL location = postQuery.execute();        assertNotNull(location);        String path = location.getPath();        // Read        GetQuery getQuery = new GetQuery(hostname, port, path, secure);        Object result = getQuery.execute();        assertEquals(JSON.get(contact, "address.street"), JSON.get(result, "address.street"));        assertEquals(contact, result);        // Update        JSON.put(contact, "name", "Joseph User");        PutQuery putQuery = new PutQuery(hostname, port, path, secure);        putQuery.setValue(contact);        boolean created = putQuery.execute();        assertFalse(created);        assertEquals(contact, getQuery.execute());        // Delete        DeleteQuery deleteQuery = new DeleteQuery(hostname, port, path, secure);        deleteQuery.execute();        assertEquals(deleteQuery.getStatus(), Query.Status.NO_CONTENT);    }    @Test(expected=QueryException.class)    public void testException() throws IOException, SerializationException, QueryException {        GetQuery getQuery = new GetQuery(hostname, port, "/pivot-demos/rest_demo/foo", secure);        getQuery.execute();    }}

The code defines two test cases: testCRUD() and testException(). The oneTimeSetUp() method is used to obtain information about the server (host name, port, and protocol) before any of the test cases are executed.

testCRUD()

The testCRUD() method executes a basic «create/read/update/delete» test. It first reads a JSON structure from a «contact.json» file that is initially used to create the resource, and later used to compare the server’s responses to the original source data. The contents of this file are as follows:

{   id: 101,    name: "Joe User",    address: {        street: "123 Main St.",        city: "Cambridge",        state: "MA",        zip: "02142"    },    phoneNumber: "(617) 555-1234",    emailAddress: "joe_user@foo.com",    imAccount: {        id: "juser1234",        type: "AIM"    }}

Create

Next, the test creates an instance of org.apache.pivot.web.PostQuery. PostQuery is a subclass of org.apache.pivot.web.Query, the abstract base class for all of Pivot’s web client classes, which also include GetQuery, PutQuery, and DeleteQuery. It creates the query using the host name, port, and secure flag that were passed to the test at startup. It also passes the value «/pivot-demos/rest_demo» to the query, since this is the path to which the demo servlet is mapped in the server’s «web.xml» file:

PostQuery postQuery = new PostQuery(hostname, port, "/pivot-demos/rest_demo", secure);

Finally, the test sets the query’s value to the contact object and executes the query:

postQuery.setValue(contact);URL location = postQuery.execute();assertNotNull(location);String path = location.getPath();

Like QueryServlet, Query uses a serializer to send data to and from the server, which, by default is an instance of JSONSerializer. Ultimately, the doPost() method described in the previous section is executed, and the resource is created on the server. The location of the new resource is passed to the caller as the return value of the execute() method. The test asserts that this value is not null and then retains it for later use by subsequent requests.

Read

Next, the test executes a GET query to retrieve the resource at the URL returned by the server in response to the initial POST:

GetQuery getQuery = new GetQuery(hostname, port, path, secure);Object result = getQuery.execute();assertEquals(JSON.get(contact, "address.street"), JSON.get(result, "address.street"));assertEquals(contact, result);

The test performs two equality checks on the return value. The first uses the get() method of the org.apache.pivot.json.JSON utility class to retrieve the values of the «address.street» field in both the original and the result data and compares them using assertEquals(). The second performs an equality comparison on the entire source and response. The latter approach is obviously useful for bulk comparisons, especially when testing creation of new resources, and the former is handy for more granular comparisons, for example to validate an update of a single field or property via PUT, which is discussed next.

Update

testCRUD() next tests the HTTP PUT method. The test case modifies the «name» field of the source object and PUTs the updated value to the server using an instance of PutQuery. It asserts that the return value is false (i.e. that the query did not result in the creation of a new resource), and then compares the locally modified structure to the value returned by another GET to the server:

JSON.put(contact, "name", "Joseph User");PutQuery putQuery = new PutQuery(hostname, port, path, secure);putQuery.setValue(contact);boolean created = putQuery.execute();assertFalse(created);assertEquals(contact, getQuery.execute());

Delete

Finally, the resource created by the initial POST request is DELETEd. The test case verifies the success of the query by validating that the returned response code is HTTP 204:

DeleteQuery deleteQuery = new DeleteQuery(hostname, port, path, secure);deleteQuery.execute();assertEquals(deleteQuery.getStatus(), Query.Status.NO_CONTENT);

Asynchronous Queries

The execute() method of the Query class is actually defined by Query’s base class, the abstract org.apache.pivot.util.concurrent.Task class. This class is used to help simplify the task of executing background operations in a Pivot application. Task defines two execute() overloads: one is abstract and takes no arguments. It is executed synchronously and is responsible for actually performing the task. The other takes an argument of type org.apache.pivot.util.concurrent.TaskListener. This version of the method executes asynchronously — it calls the no-arg version of execute() on a background thread, and the caller is notified via the listener interface when the task is complete (or fails).

For a unit test, the synchrnous version of the method is appropriate. However, in a GUI application, the UI would appear to hang if the task was executed synchronously. As a result, Pivot GUI applications typically use the asynchronous version of execute() so that the UI remains responsive while the task is being executed in the background.

textException()

The second test case is a simple test to verify that an exception is thrown as expected. The test case declares that it expects an instance of QueryException to be thrown and executes a GET query for a non-existent resource named «foo»:

@Test(expected=QueryException.class)public void testException() throws IOException, SerializationException, QueryException {    GetQuery getQuery = new GetQuery(hostname, port, "/pivot-demos/rest_demo/foo", secure);    getQuery.execute();}

When the test is executed, the exception is thrown, and the test passes.

Unfortunately, it is not currently possible to write a test case that expects a query exception with a specific response code; perhaps a future update to JUnit or Pivot will add support for such a feature.

Additional Examples

Though this application was obviously written specifically to demonstrate interaction with the sample REST service described in the previous section, there is nothing to prevent an application from using web queries to interact with other web services, including those written using other back-end frameworks. For example, the Pivot Stock Tracker sample application uses web queries to retrieve CSV-formatted stock quote data from Yahoo! Finance, and the Pivot web query tutorial application uses a web query to retrieve JSON data from a Yahoo Pipes service.

Additionally, though not demonstrated in this example, web query clients can also access and manipulate other aspects of the query including query string parameters, request headers, and response headers. The methods for doing so are identical to the corresponding methods described above for QueryServlet.

Summary

Pivot’s Web Query libraries provide a simple and intuitive means for both producing and consuming REST-based web services. They can be used standalone, as demonstrated in this example, to implement or unit test a REST service, or they can be used to easily integrate server-side data into a Pivot GUI application.

For more information on Pivot and web queries, visit the Pivot web site.