Не успели просохнуть чернила на предыдущей версии приложения ContactManager, как раздался телефонный звонок, и я услышал в трубке голос приятеля, который начал осваивать разработку под Андроид и искал тестовый проект, на котором он мог бы практиковаться в работе с web-сервисами.
«Нет ничего проще!» — ответил я.
Итак, что мы имеем на текущий момент?
Веб-приложение, которое работает в браузере, для авторизации пользователь должен ввести логин и пароль. Контроллер управляет представлениями (view), которые отображают пользователю определенные JSP-страницы.
В случае web-сервиса нет формы логина, нет JSP-страниц. Один сплошной HTTP: данные отправляются в запросах и возвращаются обратно в виде JSON (как вариант — XML).
План работ:
«Прямо по пунктам и пойдем» (С).
Безопасность для web-сервиса может быть реализована с использованием механизма Basic Authentication. Посмотрим, как Spring поддерживает этот механизм.
Для начала определимся с форматом запросов. У нас уже есть набор УРЛ-ов для работы с браузером, можем взять его за образец. Просто «спрячем» УРЛ-ы для веб-сервиса за мнемоническим префиксом "
Spring позволяет в одном файле security указать несколько элементов http. Воспользуемся этим. Добавляем в начало:
Важно добавить этот раздел именно в начало, перед уже имеющимися настройками Так как он является более специфическим — то должен обрабатываться первым, чтобы своевременно перехватить запрос с префиксом
Что изменилось? Очень немногое. Пропало все, что связано с JSP, проверка ролей теперь использует механизм выражений
И дальше уже приютился незаметный элемент
Как нам превратить контроллер для JSP в контроллер для web-сервиса? Очень просто. Имеющиеся методы возвращают String с именем view, либо со страницей форварда/редиректа, тогда как данные (списки договоров и их типов) передаются в атрибутах модели. Методы веб-сервиса должны возвращать объекты, пригодные для сериализации в JSON. Плюс POST-метод
Создаем новый файл
По традиции начнем со списка контактов. Старый метод выглядит так:
Чтобы не делать на каждый метод свой DTO, примем соглашение, что структурой возвращаемого JSON будет Map. Убираем параметр метода, он нам не нужен, меняем тип возвращаемого значения на Map и добавляем к возвращаемому значению аннотацию
Чтобы не откладывать дело в долгий ящик и побыстрее увидеть плоды своих усилий, отступим немного от нашего плана и сразу же попробуем протестировать этот метод.
Но важно понять, каких именно результатов мы должны ждать в тесте. Делаем новый класс
Все данные приходят в модели. Но в веб-сервисе мы собирались пересылать их в теле запроса. Может там и стоит их поискать? Попробуем.
Запускаем. Тест выполнился, но что-то в консоли ничего нет. Хм. Придется подебажить, ставим breakpoint в контроллере, запускаем. Ага, в контроллер заходит. А вот обратно что-то ничего полезного не приходит. Смотрим значение result — так и есть, в
Но с другой стороны — мы попросили превратить Map в JSON, а кто и как это будет делать, мы не сказали. Spring, конечно, может многое, но не все. Изучение документации дает нам следующий результат:
Мда, наличие двух конфигураций определенно начинает доставлять неудобства, но пока не будем на это отвлекаться. Запускаем тест, видим искомую строку. Но… вместо кириллицы кракозяблы.
Вновь гуглим, курим мануалы и исходники и находим вот такое решение: добавить charset в @RequestMapping.produces (в тот, который на уровне класса).
Выглядит не фонтан, желающие могут поискать «более другой» вариант, мы остановимся на этом.
Замечание 1. Понимание того, что происходит внутри теста облегчит конструкция
Гораздо лучше, не правда ли?
Замечание 2. Видно, что класс ContactType по умолчанию сериализуется в JSON вместе со всеми атрибутами. Но сериализация атрибута
«Нет ничего проще!» — ответил я.
Итак, что мы имеем на текущий момент?
Веб-приложение, которое работает в браузере, для авторизации пользователь должен ввести логин и пароль. Контроллер управляет представлениями (view), которые отображают пользователю определенные JSP-страницы.
В случае web-сервиса нет формы логина, нет JSP-страниц. Один сплошной HTTP: данные отправляются в запросах и возвращаются обратно в виде JSON (как вариант — XML).
План работ:
- добавить настройки безопасности
- добавить контроллер
- протестировать
«Прямо по пунктам и пойдем» (С).
1. Добавляем настройки безопасности.
Безопасность для web-сервиса может быть реализована с использованием механизма Basic Authentication. Посмотрим, как Spring поддерживает этот механизм.
Для начала определимся с форматом запросов. У нас уже есть набор УРЛ-ов для работы с браузером, можем взять его за образец. Просто «спрячем» УРЛ-ы для веб-сервиса за мнемоническим префиксом "
/ws
". То есть мы должны обрабатывать следующий набор адресов: /ws/index
, /ws/add
, /ws/delete
. Доступ к корню "/ws
" мы волюнтаристским решением запретим, ибо незачем.Spring позволяет в одном файле security указать несколько элементов http. Воспользуемся этим. Добавляем в начало:
<http realm="Contact Manager REST-service" pattern="/ws/**" use-expressions="true">
<intercept-url pattern="/ws/index*" access="hasAnyRole('ROLE_USER','ROLE_ANONYMOUS')" />
<intercept-url pattern="/ws/add*" access="hasRole('ROLE_USER')" />
<intercept-url pattern="/ws/delete/*" access="hasRole('ROLE_ADMIN')" />
<intercept-url pattern="/ws/**" access="denyAll" />
<http-basic/>
</http>
Важно добавить этот раздел именно в начало, перед уже имеющимися настройками Так как он является более специфическим — то должен обрабатываться первым, чтобы своевременно перехватить запрос с префиксом
/ws
. Что изменилось? Очень немногое. Пропало все, что связано с JSP, проверка ролей теперь использует механизм выражений
use-expressions="true"
(но это не принципиально, просто как иллюстрация). Ко всем имеющимся урлам добавлен перфикс /ws
, доступ к корню /ws
запрещен для всех (denyAll
). Примеры использования других SPEL-выражений можно найти здесь. Повторюсь — важен порядок указания масок, самая общая /ws/**
стоит последней. Атрибут realm
добавлен опять таки для красоты, он будет отображаться в окне авторизации, если кто-то решит полюбоваться на JSON списка контактов через браузер.И дальше уже приютился незаметный элемент
<http-basic/>
. За этой короткой конструкцией Spring (в своем привычном стиле) скрывает от разработчика сложности механизма этой самой Basic Authentication. И у нас нет оснований не доверять ему в этом. One down, two to go. Займемся контроллером.NB Надо не забыть, что у нас 2 файла security.xml, основной и для тестов. Изменения надо внести в оба.
2. Добавляем контроллер.
Как нам превратить контроллер для JSP в контроллер для web-сервиса? Очень просто. Имеющиеся методы возвращают String с именем view, либо со страницей форварда/редиректа, тогда как данные (списки договоров и их типов) передаются в атрибутах модели. Методы веб-сервиса должны возвращать объекты, пригодные для сериализации в JSON. Плюс POST-метод
/ws/add
будет принимать данные нового контакта так же в виде JSON строкой прямо в теле запроса. Пора к делу.Создаем новый файл
ContactWsController.java
в том же пакете, где находится старый контроллер. И сразу на уровне класса обозначим наши претензии на то, что это веб-сервис.@Controller
@RequestMapping(value = "/ws", produces = MediaType.APPLICATION_JSON_VALUE)
public class ContactWsController {
@Autowired
private ContactService contactService;
}
@RequestMapping(value="/ws")
на уровне класса задает общий префикс, который будет автоматически добавлен ко всем УРЛам отдельных методов. produces = MediaType.APPLICATION_JSON_VALUE
говорит, что по умолчанию все методы этого контроллера будут отдавать JSON. При необходимости в конкретном методе это значение можно переопределить.По традиции начнем со списка контактов. Старый метод выглядит так:
@RequestMapping("/index")
public String listContacts(Map<String, Object> map) {
map.put("contact", new Contact());
map.put("contactList", contactService.listContact());
map.put("contactTypeList", contactService.listContactType());
return "contact";
}
Чтобы не делать на каждый метод свой DTO, примем соглашение, что структурой возвращаемого JSON будет Map. Убираем параметр метода, он нам не нужен, меняем тип возвращаемого значения на Map и добавляем к возвращаемому значению аннотацию
@ResponseBody
. Она сообщит Spring, что мы хотим, чтобы это значение было сериализовано в JSON и записано в тело ответа. Новый метод выглядит так: @RequestMapping(value = "/index")
@ResponseBody
public Map<String, Object> listContacts() {
Map<String, Object> map = new HashMap<String, Object>();
map.put("contact", new Contact());
map.put("contactList", contactService.listContact());
map.put("contactTypeList", contactService.listContactType());
return map;
}
Чтобы не откладывать дело в долгий ящик и побыстрее увидеть плоды своих усилий, отступим немного от нашего плана и сразу же попробуем протестировать этот метод.
2.1 Первый тестовый метод
Но важно понять, каких именно результатов мы должны ждать в тесте. Делаем новый класс
MockMvcWsTest.groovy
, копируем в него всю основную начинку, связанную с настройкой MockMvc. И копируем старый тест: @Test
public void index_user1() {
mockMvc.perform(MockMvcRequestBuilders.get("/index")
.with(SecurityRequestPostProcessors.userDetailsService(USER1)))
.andExpect(MockMvcResultMatchers.view().name("contact"))
.andExpect(MockMvcResultMatchers.model().attributeExists("contact"))
.andExpect(MockMvcResultMatchers.model().attributeExists("contactList"))
.andExpect(MockMvcResultMatchers.model().attributeExists("contactTypeList"))
}
Все данные приходят в модели. Но в веб-сервисе мы собирались пересылать их в теле запроса. Может там и стоит их поискать? Попробуем.
@Test
public void index_user1() {
def result = mockMvc.perform(MockMvcRequestBuilders.get("/ws/index")
.with(SecurityRequestPostProcessors.userDetailsService(USER1)))
.andReturn()
// хорошо бы увидеть в консоли что-то похожее на JSON
println result.response.contentAsString
}
Запускаем. Тест выполнился, но что-то в консоли ничего нет. Хм. Придется подебажить, ставим breakpoint в контроллере, запускаем. Ага, в контроллер заходит. А вот обратно что-то ничего полезного не приходит. Смотрим значение result — так и есть, в
resolvedException
стоит HttpMediaTypeNotAcceptableException
, а mockResponse.status
= 406. Диагноз пока неутешительный «Could not find acceptable representation». Но с другой стороны — мы попросили превратить Map в JSON, а кто и как это будет делать, мы не сказали. Spring, конечно, может многое, но не все. Изучение документации дает нам следующий результат:
- в
pom.xml
нужно добавить зависимости на JSON-библиотеку.
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> <version>2.1.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.1.3</version> </dependency>
- в xml-конфигурации изменить
annotation-driven
на
<mvc:annotation-driven content-negotiation-manager="contentNegotiationManager" /> <bean id="contentNegotiationManager" class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean"> <property name="favorPathExtension" value="false" /> <property name="favorParameter" value="true" /> <property name="mediaTypes"> <value> json=application/json </value> </property> </bean>
(см.servlet-context.xml
). Важно — поменять XSD схемы на версию 3.2 (e.g.spring-mvc-3.2.xsd
)
- а в java-конфигурацию для тестов — всего одну аннотацию
@EnableWebMvc
//... @ImportResource('classpath:security.xml') @EnableWebMvc class TestConfig { /*...*/ }
Мда, наличие двух конфигураций определенно начинает доставлять неудобства, но пока не будем на это отвлекаться. Запускаем тест, видим искомую строку. Но… вместо кириллицы кракозяблы.
{"contactTypeList":[{"id":1,"code":"family","name":"СемÑÑ","defaulttype":false,"contacts":null},{"id":2,"code":"job","name":"РабоÑа","defaulttype":false,"contacts":null},{"id":3,"code":"stuff","name":"ÐнакомÑе","defaulttype":true,"contacts":null}],"contactList":[],"contact":{"id":null,"firstname":null,"lastname":null,"email":null,"telephone":null,"contacttype":null}}
Вновь гуглим, курим мануалы и исходники и находим вот такое решение: добавить charset в @RequestMapping.produces (в тот, который на уровне класса).
@RequestMapping(value = "/ws", produces = MediaType.APPLICATION_JSON_VALUE+";charset=UTF-8" )
Выглядит не фонтан, желающие могут поискать «более другой» вариант, мы остановимся на этом.
Ещё пара замечаний
Замечание 1. Понимание того, что происходит внутри теста облегчит конструкция
.andDo(MockMvcResultHandlers.print())
Она выводит в лог подробную информацию. Например, для нашего запроса лог будет выглядеть так.MockHttpServletRequest:
HTTP Method = GET
Request URI = /ws/index
Parameters = {}
Headers = {}
Handler:
Type = net.schastny.contactmanager.web.ContactWsController
Method = public java.util.Map<java.lang.String, java.lang.Object> net.schastny.contactmanager.web.ContactWsController.listContacts()
Async:
Was async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
MockHttpServletResponse:
Status = 200
Error message = null
Headers = {Content-Type=[application/json;charset=UTF-8]}
Content type = application/json;charset=UTF-8
Body = {"contactTypeList":[{"id":1,"code":"family","name":"Семья","defaulttype":false,"contacts":null},{"id":2,"code":"job","name":"Работа","defaulttype":false,"contacts":null},{"id":3,"code":"stuff","name":"Знакомые","defaulttype":true,"contacts":null}],"contactList":[],"contact":{"id":null,"firstname":null,"lastname":null,"email":null,"telephone":null,"contacttype":null}}
Forwarded URL = null
Redirected URL = null
Cookies = []
Гораздо лучше, не правда ли?
Замечание 2. Видно, что класс ContactType по умолчанию сериализуется в JSON вместе со всеми атрибутами. Но сериализация атрибута
List contacts = null совершенно излишня, и даже больше - она будет порождать ошибку No Session, когда список связанных контактов будет не пустой. Поэтому совет - все такие "обратные ссылки" в сущностях сразу помечать аннотацитей @JsonIgnore
@JsonIgnore
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.REFRESH, CascadeType.MERGE], mappedBy = "contacttype")
List<Contact> contacts = null
Ок, давайте уже заканчивать любоваться пустым списком контактов. Придадим нашему тесту более формализованный вид.
@Test
public void index_user1() {
def result = mockMvc.perform(MockMvcRequestBuilders.get("/ws/index")
.with(SecurityRequestPostProcessors.userDetailsService(USER1)))
.andDo(MockMvcResultHandlers.print())
.andReturn()
def map = new ObjectMapper().readValue(result.response.contentAsString, Map.class);
assert !map.contactList
assert map.contactTypeList.size() == 3
}
Аналогичным образом изменим тесты index_admin()
и index_na()
и перейдем к созданию новых контактов.
2.2 Добавляем контакты
Старый метод контроллера был реализован в духе минимализма.
@RequestMapping(value = "/add", method = RequestMethod.POST)
public String addContact(@ModelAttribute("contact") Contact contact, BindingResult result) {
contactService.addContact(contact);
return "redirect:/index";
}
Чтобы превратить его в метод веб-сервиса нужно:
- поменять тип возвращаемого значения на Map и добавить аннотацию
@ResponseBody
- поменять параметр метода, чтобы он принимал JSON-строку из тела запроса (
@RequestBody String json
)
- десериализовать JSON в объект, сохранить его в БД
- редиректа у нас нет, поэтому нужно вернуть Map, аналогичный тому, который возвращает метод index
- хорошо бы добавить обработку ошибок
Выразим вышеизложенное в коде:
@RequestMapping(value = "/add", method = RequestMethod.POST, consumes = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public Map<String, Object> addContactWs(@RequestBody String json) {
Contact contact = null;
try {
contact = new ObjectMapper().readValue(json, Contact.class);
contactService.addContact(contact);
Map<String, Object> map = new HashMap<String, Object>();
map.put("status", "всё хорошо"); // если с кодировкой будет что-то не так - сразу будет видно
map.put("contact", contact);
map.put("contactList", contactService.listContact());
map.put("contactTypeList", contactService.listContactType());
return map;
} catch (IOException e) {
Map<String, Object> map = new HashMap<String, Object>();
map.put("status", "ошибка");
map.put("message", e.getMessage());
return map;
}
}
В @RequestMapping
добавили атрибут consumes = MediaType.TEXT_PLAIN_VALUE
для указания типа данных, на которые мы "подписываемся". А в результат - поле status, по которому клиент будет понимать результат своего запроса. Переходим к тесту. Для отправки в запрос нам понадобится JSON. Мы могли бы получить его через Jackson ObjectMapper(), но в учебных целях мы воспользуемся Groovy JSON-builder, который доступен в стандартной библиотеке Groovy начиная с версии 1.8. Полученный JSON мы должны поместить в запрос, указать contentType и пользователя. Полностью тестовый метод будет выглядеть так:
@Test
public void add_user1() {
def contacts = contactService.listContact()
assert !contacts
def contactTypes = contactService.listContactType()
assert contactTypes
// Groovy JSON-builder
def jsonBuilder = new JsonBuilder()
jsonBuilder {
firstname 'Иван'
lastname 'Иванов'
email 'ivan.ivanov@gmail.com'
telephone '555-1234'
contacttype (
id : contactTypes[0].id
)
}
String json = jsonBuilder.toString()
MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/ws/add")
.contentType(MediaType.TEXT_PLAIN)
.content(json)
.with(SecurityRequestPostProcessors.userDetailsService(user))
MvcResult ret = mockMvc.perform(requestBuilder)
.andExpect(MockMvcResultMatchers.content().contentType("${MediaType.APPLICATION_JSON_VALUE};charset=UTF-8"))
.andDo(MockMvcResultHandlers.print())
.andReturn()
def map = new ObjectMapper().readValue(ret.response.contentAsString, Map.class);
assert map.status == 'всё хорошо'
assert map.contactList
assert map.contactTypeList
contacts = contactService.listContact()
assert contacts
assert contacts[0].id
// проверяем только что созданный контакт
Contact contact = new Contact(map.contact)
assert contact.class == Contact.class
assert contact.id == contacts[0].id
assert contact.firstname == 'Иван'
assert contact.lastname== 'Иванов'
assert contact.email == 'ivan.ivanov@gmail.com'
assert contact.telephone == '555-1234'
assert contact.contacttype.id == contactTypes[0].id
contactService.removeContact(contacts[0].id)
}
Это вариант для user1, для админа все будет аналогично. Для неавторизованного пользователя можно не заморачиваться с правильным JSON, все равно получим 401 ошибку.
@Test
public void add_na() {
ResultActions result = mockMvc.perform(MockMvcRequestBuilders.post("/ws/add")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.TEXT_PLAIN)
.characterEncoding("UTF-8")
.content('{"J":5,"0":"N"}')
//.with(SecurityRequestPostProcessors.userDetailsService(ADMIN)) убираем сведения об авторизации
)
result.andExpect(MockMvcResultMatchers.status().isUnauthorized())
}
Ну вот, наш REST-сервис готов. Точнее - почти готов. Механизм Basic Authentication прост и удобен, но небезопасен при использовании открытого соединения. Поэтому в следующей части мы научим его работать по HTTPS и кое-каким другим полезным штукам.
Продолжение следует.
Исходный код проекта на GitHub