Простое RESTful приложение с помощью JAX-RS


RESTful - что может быть проще? Только RESTful реализованный с помощью Java EE!

Конечно, многие с этим не согласятся и приведут кучу весомых аргументов против, и будут по своему правы. Но поверьте, в Java мире проделана огромная работа, чтобы упростить воплощение ваших REST идей в жизнь. И в этой статье мне бы хотелось наглядно это продемонстрировать.

Скажу сразу, статья не затрагивает теорию Rest-приложений, ее вы и так можете самостоятельно почерпнуть из множества статей на просторах интернета. Приведу лишь ссылку на цикл статей от Mugunth Kumar, достойных внимания: RESTful API Server – Doing it the right way (перевод на хабре: RESTful API для сервера – делаем правильно) и статью Как мы делали API для социальной сети (REST API для WEB) подробно освещающую основные аспекты идеологии REST.

Итак, самый простой способ объяснить что-то - показать это "что-то" в деле. Для примера, будет показан процесс реализации REST API для простого планировщика повседневных задач - так называемого TODO листа.

Что из себя представляет приложение TODO?

Демонстрационное приложение TODO будет позволять вести список задач. Каждая задача будет состоять из уникального идентификатора, имени, описания и статуса выполнена/невыполнена:
class Task { uuid: UUID name: String description: String completed: boolean }
Задачи будут представлены с помощью json. Пример Json представления задачи:
{
    "uuid":"550e8400-e29b-41d4-a716-446655440000",
    "name":"написать RESTful приложение",
    "description":"чтобы полученные знания принесли пользу и не были забыты, ими необходимо воспользоваться в течении первых 72 часов после получения",
    "completed":false
}
В качестве значения completed допускается true или false.

API приложения будет предусматривать следующие действия:

usecase GET as "Получить список всех заданий -- GET: /tasks" usecase GET_ACTIVE as "Получить список выполненных/невыполненных заданий -- GET: /tasks/find?completed=false{true}" usecase GET_BY_ID as "Получить конкретное задание -- GET: /tasks/{id}" usecase POST as "Добавить задание -- POST: /tasks" usecase PUT as "Создание нового задания/изменение существующего -- PUT: /tasks/{id}" usecase PATCH as "Редактировать отдельные параметры заданий -- PATCH: /tasks/{id}" usecase DELETE as "Удалить задание -- DELETE: /tasks/{id}" User -u-> (GET) User -u-> (GET_ACTIVE) User -l-> (GET_BY_ID) User -r-> (PUT) User --> (POST) User --> (PATCH) User --> (DELETE)
  • Получение списка всех заданий
  • Получение списка выполненых/невыполненных заданий
  • Получение конкретного задания
  • Добавление задания
  • Создание нового задания/изменение существующего
  • Редактирование отдельных параметров задания
  • Удаление задания


Пример специально максимально упрощен, чтобы не отвлекать читателя от API предоставляемой J2EE и быстро создать общую картину процесса реализации RESTful приложений на java.

Функциональные тесты

Статья не затрагивает тему написания клиента для разрабатываемого api. Вместо этого я предлагаю решение в виде функциональных тестов, написанное с помощью JUnit, Apache HttpComponents и Google gson.
package ru.dokwork.todo;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.http.HttpResponse;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import java.util.UUID;

import static ru.dokwork.todo.ResponseChecker.assertThat;

public class TaskServiceFT {

    private static final String URL = "http://localhost:8080/todo/tasks/";

    static final JsonObject jtask1;

    static final JsonObject jtask2;

    JsonParser parser;

    TaskServiceClient todo;

    static {
        jtask1 = new JsonObject();
        jtask1.addProperty("name", "First task");
        jtask1.addProperty("description", "First task for test.");
        jtask1.addProperty("completed", false);

        jtask2 = new JsonObject();
        jtask2.addProperty("name", "Second task");
        jtask2.addProperty("description", "Second task for test.");
        jtask2.addProperty("completed", false);
    }

    @Before
    public void setup() throws Exception {
        parser = new JsonParser();
        todo = new TaskServiceClient(URL);
        todo.removeAllExistingTasks();
    }

    @Test
    public void testGetTask() throws Exception {
        // Add task
        HttpResponse response = todo.addNewTask(jtask1);
        // Get uuid
        String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
        // Get task
        response = todo.getTask(UUID.fromString(uuid));
        // Create expected json
        JsonObject expectedObject = createJsonTask(uuid, jtask1);
        // Check answer
        assertThat(response).haveCode(200).haveContent(expectedObject);
    }

    @Test
    public void testGetAllTasks() throws Exception {
        // Add first task
        HttpResponse response = todo.addNewTask(jtask1);
        String uuid1 = response.getFirstHeader("location").getValue().substring(URL.length());
        // Add second task
        response = todo.addNewTask(jtask2);
        String uuid2 = response.getFirstHeader("location").getValue().substring(URL.length());
        // Get all tasks
        response = todo.getAll();
        // Create expected array
        JsonArray array = new JsonArray();
        array.add(createJsonTask(uuid1, jtask1));
        array.add(createJsonTask(uuid2, jtask2));
        // Check answer
        assertThat(response).haveCode(200).haveContent(array);
    }

    @Test
    public void testAddTask() throws Exception {
        // Add task
        HttpResponse response = todo.addNewTask(jtask1);
        // Check answer
        assertThat(response).haveCode(201).haveHeaders("location", 1);
        // Get uuid
        String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
        // Check new task
        response = todo.getTask(UUID.fromString(uuid));
        JsonObject addedTask = createJsonTask(uuid, jtask1);
        assertThat(response).haveContent(addedTask);
    }

    @Test
    public void testPutNewTask() throws Exception {
        // Put new task
        UUID uuid = UUID.randomUUID();
        HttpResponse response = todo.putTask(uuid, jtask1);
        // Check answer
        assertThat(response).haveCode(201).haveHeaders("location", 1);
        response.getFirstHeader("location").getValue().endsWith(uuid.toString());
        // Check new task
        response = todo.getTask(uuid);
        JsonObject addedTask = createJsonTask(uuid.toString(), jtask1);
        assertThat(response).haveContent(addedTask);
    }

    @Test
    public void testPutExistingTask() throws Exception {
        // Add task
        HttpResponse response = todo.addNewTask(jtask1);
        // Get uuid
        String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
        // Change task
        JsonObject editedTask = createJsonTask(uuid, jtask1);
        editedTask.addProperty("name", "Edited task");
        // Put edited task
        response = todo.putTask(UUID.fromString(uuid), editedTask);
        // Check answer
        assertThat(response).haveCode(204).haveHeaders("location", 1);
        response.getFirstHeader("location").getValue().endsWith(uuid.toString());
        // Check edited task
        response = todo.getTask(UUID.fromString(uuid));
        assertThat(response).haveContent(editedTask);
    }

    @Test
    public void testPatchTask() throws Exception {
        // Add task
        HttpResponse response = todo.addNewTask(jtask1);
        // Get uuid
        String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
        // Create patch
        JsonObject patch = new JsonObject();
        patch.addProperty("completed", true);
        // Patch state for task
        response = todo.patch(UUID.fromString(uuid), patch);
        // Check answer
        assertThat(response).haveCode(204).haveHeaders("location", 1);
        response.getFirstHeader("location").getValue().endsWith(uuid);
        // Check patched task
        response = todo.getTask(UUID.fromString(uuid));
        // Create expected json
        JsonObject expectedObject = createJsonTask(uuid, jtask1);
        expectedObject.addProperty("completed", true);
        // Check task
        assertThat(response).haveContent(expectedObject);
    }

    @Test
    public void testFindTasks() throws Exception {
        // Add first task
        HttpResponse response = todo.addNewTask(jtask1);
        // Get uuid for first task
        String uuid1 = response.getFirstHeader("location").getValue().substring(URL.length());
        // Get uuid for second task
        String uuid2 = UUID.randomUUID().toString();
        // Add second task
        JsonObject jsonTask2 = createJsonTask(uuid2, jtask2);
        jsonTask2.addProperty("completed", true);
        todo.putTask(UUID.fromString(uuid2), jsonTask2);
        // Get only completed tasks
        response = todo.find(true);
        // Create expected content
        JsonArray expectedContent = new JsonArray();
        expectedContent.add(createJsonTask(uuid2, jsonTask2));
        // Check answer
        assertThat(response).haveCode(200).haveContent(expectedContent);
    }

    @Test
    public void testDeleteTask() throws Exception {
        // Add task
        HttpResponse response = todo.addNewTask(jtask1);
        // Get uuid
        String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
        // Delete task
        int countBefore = todo.getTasksCount();
        response = todo.delete(UUID.fromString(uuid));
        // Check answer
        assertThat(response).haveCode(204);
        int countAfter = todo.getTasksCount();
        Assert.assertEquals(1, countBefore - countAfter);
    }

    private JsonObject createJsonTask(String uuid, JsonObject pattern) {
        JsonObject expectedObject = new JsonObject();
        expectedObject.add("uuid", parser.parse(uuid));
        expectedObject.add("name", pattern.get("name"));
        expectedObject.add("description", pattern.get("description"));
        expectedObject.add("completed", pattern.get("completed"));
        return expectedObject;
    }
}

Классы ResponseChecker и TaskServiceClient призваны сделать код функциональных тестов более наглядным.
ResponseChecker

TaskServiceClient

Обратите внимание на метод PATCH. Он не реализован (на момент написания статьи) в библиотеке Apache HttpComponents и его реализацией необходимо заняться самостоятельно. К счастью это совершенно не трудно:

public class HttpPatch extends HttpPost {
    @Override
    public String getMethod() {
        return "PATCH";
    }

    public HttpPatch() {
        super();
    }

    public HttpPatch(URI uri) {
        super(uri);
    }

    public HttpPatch(String url) {
        super(url);
    }
}
Не смотря на то, что для написания тестов использовалась библиотека JUnit, это совсем не модульные тесты. Поэтому не стоит их складывать в каталог test, а лучше создать для этих целей отдельную директорию.

Введение в JAX-RS API

Каждому ресурсу в rest-приложениях соответствует некоторый URI, а жизненный цикл ресурса управляется с помощью методов GET, POST, PUT и DELETE http протокола. JAX-RS предлагает очень прозрачную схему реализации такой идеи. Вы просто заводите сервисный класс, отвечающий за некоторую сущность и указываете базовый URL для этой сущности:

    @Path(value="/tasks")
    class TaskService {
    }

Затем реализуете методы для управления этими сущностями и связываете их с методами http протокола:

    @Path(value="/tasks")
    class TaskService {
      @GET
      public List<Task> getTasks() {
      }

      @POST
      public void addTask(Task task) {
      }
    }
просто, не так ли? На самом деле не совсем. Чудес не бывает, и никто не сможет за вас решить, в каком формате передавать ваши данные в ответ на GET запрос и откуда брать параметры для POST запроса. Все это необходимо реализовывать самостоятельно. Хорошая новость в том, что за вас о многом уже позаботились.

Так,из коробки, вы получаете возможность сериализации/десериализации данных в популярные форматы, такие как JSON и XML с помощью аннотации
@Produces({"application/xml", "application/json"})
Аннотация может быть применена как к каждому конкретному методу в отдельности, так и ко всему сервисному классу в целом. Конкретный тип представления данных выбирается исходя из указанного в запросе MIME типа, или первый, если в запросе также перечислены оба. Полный перечень типов вы можете найти на страницах спецификации: http://jsr311.java.net/nonav/releases/1.0/javax/ws/rs/core/MediaType.html

Что касается вопроса, откуда брать аргументы метода, то тут дефицита в вариантах нет. Возможными источниками могут выступать: параметры http запроса, значения веб-форм, часть пути url, значения куки, заголовки запроса и матрицы. Чтобы указать конкретный источник, вы должны пометить аргумент одной из аннотаций:
  • @FormParam
  • @PathParam
  • @QueryParam
  • @MatrixParam
  • @HeaderParam
  • @CookieParam
В этой статье я затрону только первые три из них. Более подробную информацию вы всегда можете получить со страниц сайта Oracle: http://docs.oracle.com/javaee/6/tutorial/doc/gilik.html.

@FormParam привязывает значения параметров формы к значениям параметров метода:

    @POST
    @Path("/")
    public Task addTask(@FormParam("name") String name,
                @FormParam("description") String description) {
    }

При большом количестве аргументов конструкция метода выглядит устрашающе. К счастью есть возможность этого избежать. Для этого вы можете повесить аннотации @FormParam прямо на поля класса и сделать этот класс аргументом метода:
class Task {
    @FormParam("name")
    String name;

    @FormParam("description")
    String description;
    }


    @Path(value="/tasks")
    class TaskService {

    @POST
    @Path("/")
    public Task addTask(@Form Task task) {
    }
    }

Важный нюанс такого решения: класс, с аннотированными полями, должен содержать конструктор поумолчанию!

@PathParam позволяет использовать в качестве аргументов часть пути:

    @GET
    @Path("/{id}")
    public Task getTask(@PathParam("id") String id) {
    }

@QueryParam позволяет использовать в качестве аргументов параметры HTTP запроса:

    @GET
    @Path("/task?id=123")
    public Task getTask(@QueryParam("id") String id) {
    }

URL для вызова такого метода может выглядеть так: http://todo.com/tasks/task?id=123

Чтобы избежать NullPointerException в случае, если в запросе будут пропущенные параметры, можно указать для аргумента значение поумолчанию:

    @GET
    @Path("/task")
    public Task getTask(@DefaultValue("123") @QueryParam("id") String id) {
    }

В качестве типов аргументов помеченных как @QueryParam или @PathParam могут выступать: 
  • Все примитивные типы кроме char
  • Все классы-оболочки кроме Character
  • Любой класс с конструктором, который принимает единственный аргумент типа String 
  • Любой класс со статическим методом valueOf(String)
  • List<T>, Set<T>, или SortedSet<T>, где T - тип удовлетворяющий условиям выше
Но примитивными типами аргументы методов не ограничиваются. Выше уже было показано как с помощью параметров формы принимать в методах целые классы. Или как легко превращать возвращаемые методами объекты в json или xml представление. Справедливо было бы ожидать возможность так же легко и получать объекты, созданные из json или xml запроса. И такая возможность есть! Для этого достаточно пометить метод аннотацией @Consumes и указать принимаемый медиа тип:
 
    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public void updateTask(Task task) {
    }
Такой метод будет вызван, если в заголовке запроса будет указан соответствующий тип:
POST /contacts HTTP/1.1
Content-Type: application/json
Content-Length: 32

Привет мир!

И так, общее представление о JAX-RS уже должно было сложиться. Осталось опробывать его в деле. Как и у любой спецификации в мире java, у JAX-RS есть несколько реализаций:
По ряду исключительно субъективных причин, я остановлю свой выбор на RESTeasy от Red Hat - реализации используемой в JBoss. Соответственно в качестве сервера приложения будет использоваться JBoss версии 8, носящий гордое имя Wildfly.

О процессе инсталяции сервера я рассказывать не стану и буду ожидать, что с этим вы справитесь самостоятельно. Поэтому сразу перейду к разработке.

Создадим пустой maven проект:
mvn archetype:generate -DgroupId=ru.dokwork -DartifactId=todo -DinteractiveMode=false
Удалим лишние классы, которые были созданы автоматически, и настроим скрипт:
Освежим версию java:
    <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
            <source>1.7</source>
            <target>1.7</target>
        </configuration>
    </plugin>
Настроим плагин для сборки war архивов:
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <version>2.4</version>
    </plugin>
Упростим себе жизнь, настроив процесс локального разворачивания приложения прямо из скрипта (при работе из IDE это может упростить жизнь, но я не настаиваю):
    <plugin>
        <groupId>org.wildfly.plugins</groupId>
        <artifactId>wildfly-maven-plugin</artifactId>
        <version>1.0.2.Final</version>
        <configuration>
            <hostname>127.0.0.1</hostname>
            <port>9990</port>
            <username>admin</username>
            <password>password</password>
        </configuration>
    </plugin>
Пользоваться плагином достаточно просто:
mvn wildfly:deploy
mvn wildfly:redeploy
mvn wildfly:undeploy
Думаю имена команд говорят сами за себя и разъяснений не требуют. Более подробную информацию вы можете найти здесь:  https://docs.jboss.org/wildfly/plugins/maven/latest/usage.html

Теперь добавим зависимость RESTeasy:
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-jaxrs</artifactId>
        <version>3.0.8.Final</version>
        <scope>provided</scope>
    </dependency>
И чтобы получить возможность преобразовывать возвращаемые объекты в json добавим реализацию соответствующего провайдера:
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-jackson-provider</artifactId>
        <version>3.0.8.Final</version>
        <scope>provided</scope>
    </dependency>

Ну и наконец, добавим библиотеки для тестирования:

  • Библиотека для модульного тестирования
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.10</version>
        <scope>test</scope>
    </dependency>
  • Библиотеки для работы с http:
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>fluent-hc</artifactId>
        <version>4.3.5</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.3.5</version>
        <scope>test</scope>
    </dependency>
  • И библиотека для преобразования объектов из json:
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.2.4</version>
        <scope>test</scope>
    </dependency>
pom.xml

Там где сервлеты, там и web.xml. Согласно принятой в maven структуре каталогов, этот файл должен лежать в директории src/main/webapp/WEB-INF/web.xml:
todo
├── pom.xml
└── src
      ├── main
      │   ├── java
      │   │   └── ru
      │   │       └── dokwork
      │   │           └── todo
      │   └── webapp
      │       └── WEB-INF
      │           └── web.xml
      └── test
           └── java

Для включения библиотеки resteasy в web.xml указывается слушатель и специальный сервлет:

    <listener>
      <listener-class>
      org.jboss.resteasy.plugins.server.servlet.ResteasyBootstrap
      </listener-class>
    </listener>
    <servlet>
      <servlet-name>resteasy-servlet</servlet-name>
      <servlet-class>
      org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher
      </servlet-class>
    </servlet>
Далее указывается какие адреса должен обрабатывать сервлет:

    <servlet-mapping>
      <servlet-name>resteasy-servlet</servlet-name>
      <url-pattern>/*</url-pattern>
    </servlet-mapping>
Если url-pattern отличается от /*, вы должны явно указать используемый resteasy.servlet.mapping.prefix:

    <servlet-mapping>
      <servlet-name>resteasy-servlet</servlet-name>
      <url-pattern>/resteasy/*</url-pattern>
    </servlet-mapping>
    <context-param>
      <param-name>resteasy.servlet.mapping.prefix</param-name>
      <param-value>/resteasy</param-value>
    </context-param>
Все сервисные классы должны быть явно перечислены в этом конфигурационном файле. Но к счастью есть возможность избежать этого с помощью автоматического сканирования:

    <context-param>
      <param-name>resteasy.scan</param-name>
      <param-value>true</param-value>
    </context-param>

web.xml

Приложение для хранения списка задач так и напрашивается на использование базы данных, но использование баз данных всегда сопрежено с мелкими нюансами, отвлекаться на которые в этой статье совершенно не хочется. Поэтому я отгорожусь от задачи хранения заданий с помощью интерфейса некоторого DAO класса:
interface Task { getByUUID(uuid: UUID): Task save(task: Task) remove(task: Task) findByStatus(isCompleted: boolean) Collection<Task> getAll(): Collection<Task> }
И воспользуюсь простейшей его реализацией:
MockTaskDao

Само же приложение будет состоять из класса описывающего задачу и сервиса, эти задачи обслуживающего:
Task

TaskService

Здесь вы снова не найдете поддержки метода PATCH из коробки, и снова добавить ее самостоятельно не составляет труда:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@HttpMethod("PATCH")
public @interface PATCH {
}

Полный код рассмотренного в статье примера вы можете посмотреть здесь: http://bitbucket.org/dok/todo/

Заключение

Обзор получился несколько скомканным, но если отвлечься от примеров кода (недаром они спрятаны поумолчанию), то понять общую концепцию реализации RESTful приложений на Java должно быть не трудно. Благодаря JAX-RS вам не придется заботиться о нудной работе с сервлетами, вы даже можете не замечать того, что пишете веб приложение. Всего десяток аннотаций помогут превратить ваши POJO классы в полноценные REST сервисы!


За рамками данной статьи осталась еще одна очень важная тема: реализация аутентификации и авторизации в REST приложениях. Надеюсь я найду время чтобы изучить и осветить этот вопрос в ближайших статьях.