ContactManager, часть 4. Добавляем веб-сервис (REST)

  • Tutorial
Не успели просохнуть чернила на предыдущей версии приложения 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 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
  • +3
  • 19,1k
  • 1
Поделиться публикацией

Комментарии 1

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое