Не успели просохнуть чернила на предыдущей версии приложения ContactManager, как раздался телефонный звонок, и я услышал в трубке голос приятеля, который начал осваивать разработку под Андроид и искал тестовый проект, на котором он мог бы практиковаться в работе с web-сервисами.
«Нет ничего проще!» — ответил я.
Итак, что мы имеем на текущий момент?
Веб-приложение, которое работает в браузере, для авторизации пользователь должен ввести логин и пароль. Контроллер управляет представлениями (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<Contact> 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
Автор: monzdrpower