Содержание:

Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 1] - 1Всем привет, JavaRush сообщество! Сегодня поговорим о том, как шаг за шагом написать Spring Boot приложение для мониторинга цен на авиабилеты. Статья рассчитана на людей, которые имеют представление о:
  • REST и том, как строятся REST эндпоинты;
  • реляционных базах данных;
  • работе maven (в частности, что такое dependency —зависимость);
  • JSON объекте;
  • принципах логирования.
Ожидаемое поведение:
  1. Можно выбрать перелет на конкретную дату и отслеживать цену на него. Пользователь идентифицируется по email-адресу. Как только оформляется подписка на изменение цены, пользователь получает уведомление на почту.
  2. Каждые 30 минут (этот промежуток настраивается через application.properties) пересчитывается минимальная цена за перелет для всех подписок. Если какая-то стала ниже, пользователь получит уведомление на почту.
  3. Все подписки с устаревшей датой перелета удаляются.
  4. Через REST API можно:
    • создать подписку;
    • редактировать;
    • получить все подписки по email;
    • удалить подписку.

План действий для достижения цели

Начать нужно с того, что информацию о перелетах нужно откуда-то брать. Обычно сайты предоставляют открытый REST API, с помощью которого можно получить информацию.

API (application programming interface) — это интерфейс, с помощью которого можно взаимодействовать с приложением. Из этого можно перебросить мостик на то, что такое REST API.

REST API — это интерфейс из REST запросов, с помощью которого можно общаться с веб-приложением.

Для этого будем использовать Skyscanner, а точнее, API (на сайте Rakuten API). Далее нужно выбрать правильный framework как базовый фундамент. Самый популярный и востребованный — это экосистема Spring и венец их творения — Spring Boot. Можно зайти на их оф сайт, а можно прочесть статейку на хабре. Чтобы хранить подписки пользователей будем использовать встроенную базу данных H2. Для чтения из JSON в классы и обратно будем использовать Jackson Project (вот и ссылка на нашем ресурсе). Для передачи сообщений пользователям будем использовать spring-boot-starter-mail Для того, чтобы в приложении с заданной периодичностью выполнялся пересчет минимальной цены, будем использовать Spring Scheduler. Для создания REST API будем использовать spring-boot-starter-web. Чтоб не писать borrowed code (геттеры, сеттеры, переопределять equals and hashcode, toString() для объектов), будем использовать Project Lombok. Чтобы пощупать и посмотреть REST API, воспользуемся Swagger 2 и сразу Swagger UI (user interface) для отслеживания в режиме реального времени. Вот как это выглядит сейчас: Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 1] - 2где есть 4 rest запроса, которые соответствуют созданию, редактированию, получению и удалению подписок.

Исследуем Skyscanner API

Перейдем по ссылке на rakuten api. Сначала нужно зарегистрироваться Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 1] - 3Все это нужно, чтобы получить уникальный ключ к использованию их сайта и делать запросы на публичные API, которые выложены на нем. Один из таких api и есть нужный нам Skyscanner Flight Search. Теперь разберемся, как это работает. Найдем запрос GET List Places. На картинке показано, что нужно заполнить данные и начать Test Endpoint, в результате чего получаем ответ в виде JSON объекта справа: Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 1] - 4причем запрос будет создаваться так:

https://skyscanner-skyscanner-flight-search-v1.p.rapidapi.com/apiservices/autosuggest/v1.0/{country}/{currency}/{locale}/?query={query}
и все параметры будут подставлены в эту формулу, получим:

https://skyscanner-skyscanner-flight-search-v1.p.rapidapi.com/apiservices/autosuggest/v1.0/UK/GBP/en-GB/?query=Stockholm
и к этим запросам будут передаваться два заголовка:

.header("x-rapidapi-host", "skyscanner-skyscanner-flight-search-v1.p.rapidapi.com")
.header("x-rapidapi-key", "sing-up-for-key"),
где sign-up-for-key выдается после регистрации. Нам для отслеживания падения цены нужен будет Browse Quotes эндпоинт. Найдите его сами :)

Создаем каркас приложения на основе Spring Boot

Чтобы быстро и удобно создать проект со Spring Boot, можно воспользоваться Spring Initializr. Выбираем следующие опции:
  1. Maven project
  2. Java
  3. 2.1.10
  4. group — какой считаете нужным, например ru.javarush
  5. artifact — точно так же, например flights-monitoring
  6. в поиске dependency ищем такие:
    • Spring Web
    • Java Mail Sender
    • Spring Data Jpa
    • H2 Database
И далее нажимаем Generate. Всё: готовый проект скачается как архив. Если что-то не получится, можно воспользоваться ссылкой, где я сохранил нужный проект. Конечно, лучше самому это проделать и понять как это работает. Приложение будет состоять из трех слоев:
  • CONTROLLER — вход в приложение. Здесь будет описан REST API
  • SERVICE — слой бизнес-логики. Вся логика работы приложение будет описана здесь.
  • REPOSITORY — слой работы с базой данных.
Также, отдельным пакетом будут лежать классы, относящиеся к клиенту для Skyscanner Flight Search API.

Пишем в проекте клиент для запросов на Skyscanner Flight Search API

Skyscanner любезно предоставили статью на тему, как пользоваться их API (мы не будем создавать сессию с активным запросом). Что значит "писать клиент"? Нам нужно создать запрос на определенный URL с определенными параметрами и заготовить DTO (data transfer object) для данных, передающихся обратно к нам. На сайте есть четыре группы запросов:
  1. Live Flight Search — не будем рассматривать как ненужный на данный момент.
  2. Places — напишем.
  3. Browse Flight Prices — воспользуемся одним запросом, где можно взять всю информацию.
  4. Localisation — добавим его, что знать какие поддерживаются данные.

Создаем клиент сервис для Localisation запроса:

План простой как пареная репа: создаем запрос, смотрим какие параметры, смотрим, какой ответ. Там два запроса List markers и Currencies. Начнем с Currencies. По рисунку видно, что это запрос без дополнительных полей: он нужен, чтобы получить информацию о поддерживаемых валютах: Создание системы мониторинга цен на авиабилеты: пошаговое руководство [Часть 1] - 6Ответ в виде JSON объекта, в котором коллекция одних и тех же объектов, например:

{
"Code":"LYD"
"Symbol":"د.ل.‏"
"ThousandsSeparator":","
"DecimalSeparator":"."
"SymbolOnLeft":true
"SpaceBetweenAmountAndSymbol":false
"RoundingCoefficient":0
"DecimalDigits":3
}
Создадим CurrencyDto для этого объекта:

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

/**
* Data transfer object for Currency.
*/
@Data
public class CurrencyDto {

   @JsonProperty("Code")
   private String code;

   @JsonProperty("Symbol")
   private String symbol;

   @JsonProperty("ThousandsSeparator")
   private String thousandsSeparator;

   @JsonProperty("DecimalSeparator")
   private String decimalSeparator;

   @JsonProperty("SymbolOnLeft")
   private boolean symbolOnLeft;

   @JsonProperty("SpaceBetweenAmountAndSymbol")
   private boolean spaceBetweenAmountAndSymbol;

   @JsonProperty("RoundingCoefficient")
   private int roundingCoefficient;

   @JsonProperty("DecimalDigits")
   private int decimalDigits;
}
Где:
  • @Data — аннотация из Lombok проекта и генерирует все геттеры, сеттеры, переопределяет toString(), equals() и hashCode() методы. Чем улучшает читабельность кода и ускоряет время написания POJO объектов;
  • @JsonProperty("Code") — это аннотация из Jackson Project, которая говорит, какое поле будет присваиваться этой переменной. То есть поле в JSON, равное Code, будет присваиваться переменной code.
Официальная статья от Skyscanner’a предлагает использовать для REST запросов библиотеку UniRest. Поэтому напишем еще один сервис, который будет реализовывать запросы через REST. Это будет UniRestService. Для этого добавим в maven новую зависимость:

<dependency>
  <groupId>com.mashape.unirest</groupId>
  <artifactId>unirest-java</artifactId>
  <version>1.4.9</version>
</dependency>
Далее напишем сервис, который будет выполнять REST запросы. Разумеется, для каждого клиента/сервиса мы будем создавать interface и его реализацию:

import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;

/**
* Service, which is manipulating with Rest calls.
*/
public interface UniRestService {

   /**
   * Create GET request based on provided {@param path} with needed headers.
   *
   * @param path provided path with all the needed data
   * @return {@link HttpResponse<jsonnode>} response object.
   */
   HttpResponse<jsonnode> get(String path);

}
И его реализация:

import com.github.romankh3.flightsmonitoring.exception.FlightClientException;
import com.github.romankh3.flightsmonitoring.client.service.UniRestService;
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;
import com.mashape.unirest.http.Unirest;
import com.mashape.unirest.http.exceptions.UnirestException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

/**
* {@inheritDoc}
*/
@Slf4j
@Service
public class UniRestServiceImpl implements UniRestService {

   public static final String HOST = "https://skyscanner-skyscanner-flight-search-v1.p.rapidapi.com";

   public static final String PLACES_FORMAT = "/apiservices/autosuggest/v1.0/%s/%s/%s/?query=%s";
   public static final String CURRENCIES_FORMAT = "/apiservices/reference/v1.0/currencies";
   public static final String COUNTRIES_FORMAT = "/apiservices/reference/v1.0/countries/%s";

   public static final String PLACES_KEY = "Places";
   public static final String CURRENCIES_KEY = "Currencies";
   public static final String COUNTRIES_KEY = "Countries";

   @Value("${x.rapid.api.key}")
   private String xRapidApiKey;

   /**
    * {@inheritDoc}
    */
   @Override
   public HttpResponse<jsonnode> get(String path) {
       HttpResponse<jsonnode> response = null;
       try {
           response = Unirest.get(HOST + path)
                   .header("x-rapidapi-host", "skyscanner-skyscanner-flight-search-v1.p.rapidapi.com")
                   .header("x-rapidapi-key", xRapidApiKey)
                   .asJson();
       } catch (UnirestException e) {
           throw new FlightClientException(String.format("Request failed, path=%s", HOST + path), e);
       }

       log.info("Response from Get request, on path={}, statusCode={}, response={}", path, response.getStatus(), response.getBody().toString());
       return response;
   }
}
Суть его в том, что все интересующие нас запросы создаются для GET реквестов, и этот сервис принимает уже готовый сформированный запрос, добавляет ему необходимые заголовки вида:

                   .header("x-rapidapi-host", "skyscanner-skyscanner-flight-search-v1.p.rapidapi.com")
                   .header("x-rapidapi-key", xRapidApiKey)
Чтобы взять данные из пропертей, используется аннотация @Value, как показано ниже:

@Value("${x.rapid.api.key}")
private String xRapidApiKey;
Она говорит о том, что в application.properties будет лежать проперти с именем x.rapid.api.key, которую нужно инъектировать в эту переменную. Мы избавляемся от захардкодженных значений и выводим определение этой переменной из программного кода. Более того, когда я публикую это приложение на GitHub я не добавляю значение этой проперти. Это делается из соображений безопасности. Написали сервис, который будет работать с REST запросами, теперь пришло время сервиса для Localisation. Мы же строим приложение исходя из ООП, поэтому создаем интерфейc LocalisationClient и его реализацию LocalisationClientImpl:

import com.github.romankh3.flightsmonitoring.client.dto.CountryDto;
import com.github.romankh3.flightsmonitoring.client.dto.CurrencyDto;
import java.io.IOException;
import java.util.List;

/**
* Client for SkyScanner localisation.
*/
public interface LocalisationClient {

   /**
    * Retrieve the market countries that SkyScanner flight search API support. Most suppliers (airlines,
    * travel agents and car hire dealers) set their fares based on the market (or country of purchase).
    * It is therefore necessary to specify the market country in every query.
    *
    * @param locale locale of the response.
    *
    * @return the collection of the {@link CountryDto} objects.
    *
    * @throws IOException
    */
   List<CountryDto> retrieveCountries(String locale);

   /**
    * Retrieve the currencies that we ScyScanner flight search API.
    *
    * @return the collection of the {@link CurrencyDto} objects.
    *
    * @throws IOException
    */
   List<CurrencyDto> retrieveCurrencies();

}
и реализация LocalisationClientImpl

import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.COUNTRIES_FORMAT;
import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.COUNTRIES_KEY;
import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.CURRENCIES_FORMAT;
import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.CURRENCIES_KEY;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.romankh3.flightsmonitoring.client.dto.CountryDto;
import com.github.romankh3.flightsmonitoring.client.dto.CurrencyDto;
import com.github.romankh3.flightsmonitoring.client.service.LocalisationClient;
import com.github.romankh3.flightsmonitoring.client.service.UniRestService;
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;
import com.mashape.unirest.http.exceptions.UnirestException;
import java.io.IOException;
import java.util.List;
import org.apache.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
* {@inheritDoc}
*/
@Component
public class LocalisationClientImpl implements LocalisationClient {

   @Autowired
   private UniRestService uniRestService;

   @Autowired
   private ObjectMapper objectMapper;

   /**
    * {@inheritDoc}
    */
   @Override
   public List<CountryDto> retrieveCountries(String locale) throws IOException {
       HttpResponse<JsonNode> response = uniRestService.get(String.format(COUNTRIES_FORMAT, locale));

       if (response.getStatus() != HttpStatus.SC_OK) {
           return null;
       }

       String jsonList = response.getBody().getObject().get(COUNTRIES_KEY).toString();

       return objectMapper.readValue(jsonList, new TypeReference<List<CountryDto>>() {
       });
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public List<CurrencyDto> retrieveCurrencies() throws IOException, UnirestException {

       HttpResponse<JsonNode> response = uniRestService.get(CURRENCIES_FORMAT);
       if (response.getStatus() != HttpStatus.SC_OK) {
           return null;
       }

       String jsonList = response.getBody().getObject().get(CURRENCIES_KEY).toString();

       return objectMapper.readValue(jsonList, new TypeReference<List<CurrencyDto>>() {
       });
   }
}
где
  • @Autowired — это аннотация, которая говорит о том, что нужно инъектировать объект в этот класс и использовать его без создания, то есть без операции new Object;
  • @Component — аннотация, которая говорит, что этот объект нужно добавить в Application Context, чтобы в дальнейшем его можно было инъектировать при помощи аннотации @Autowired;
  • ObjectMapper objectMapper — объект из Jackson Project, который переводит это все в Java объекты.
  • CurrencyDTO и CountryDto:

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

/**
* Data transfer object for Currency.
*/
@Data
public class CurrencyDto {

   @JsonProperty("Code")
   private String code;

   @JsonProperty("Symbol")
   private String symbol;

   @JsonProperty("ThousandsSeparator")
   private String thousandsSeparator;

   @JsonProperty("DecimalSeparator")
   private String decimalSeparator;

   @JsonProperty("SymbolOnLeft")
   private boolean symbolOnLeft;

   @JsonProperty("SpaceBetweenAmountAndSymbol")
   private boolean spaceBetweenAmountAndSymbol;

   @JsonProperty("RoundingCoefficient")
   private int roundingCoefficient;

   @JsonProperty("DecimalDigits")
   private int decimalDigits;
}
	и
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

/**
* Data transfer object for Country.
*/
@Data
public class CountryDto {

   @JsonProperty("Code")
   private String code;

   @JsonProperty("Name")
   private String name;
}
Чтобы инъектировать ObjectMapper в любую часть проекта, я добавил создание и добавление его в ApplicationContext через конфигурационный класс.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* {@link Configuration} class.
*/
@Configuration
public class Config {

   @Bean
   public ObjectMapper objectMapper() {
       ObjectMapper objectMapper = new ObjectMapper();
       objectMapper.registerModule(new JavaTimeModule());
       return objectMapper;
   }
}
аннотация @Configuration говорит Spring’у, что в этом классе будут какие-то конфигурации. И как раз для этого я добавил ObjectMapper. По образу и подобию добавляем PlacesClient и PlacesClientImpl:

import com.github.romankh3.flightsmonitoring.client.dto.PlaceDto;
import com.github.romankh3.flightsmonitoring.client.dto.PlacesDto;
import com.mashape.unirest.http.exceptions.UnirestException;
import java.io.IOException;
import java.util.List;

/**
* SkyScanner client.
*/
public interface PlacesClient {

   /**
    * Get a list of places that match a query string based on arguments.
    *
    * @param query the code of the city.
    * @param country the code of the country.
    * @param currency the code of the currency.
    * @param locale the code of the locale.
    * @return the collection of the {@link PlaceDto} objects.
    */
   List<PlacesDto> retrieveListPlaces(String query, String country, String currency, String locale)
           throws IOException, UnirestException;
}
and

import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.PLACES_FORMAT;
import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.PLACES_KEY;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.romankh3.flightsmonitoring.client.dto.PlacesDto;
import com.github.romankh3.flightsmonitoring.client.service.PlacesClient;
import com.github.romankh3.flightsmonitoring.client.service.UniRestService;
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;
import com.mashape.unirest.http.exceptions.UnirestException;
import java.io.IOException;
import java.util.List;
import org.apache.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
* {@inheritDoc}
*/
@Service
public class PlacesClientImpl implements PlacesClient {

   @Autowired
   private UniRestService uniRestService;

   @Autowired
   private ObjectMapper objectMapper;


   /**
    * {@inheritDoc}
    */
   @Override
   public List<PlacesDto> retrieveListPlaces(String query, String country, String currency, String locale)
           throws IOException, UnirestException {
       HttpResponse<JsonNode> response = uniRestService
               .get(String.format(PLACES_FORMAT, country, currency, locale, query));

       if (response.getStatus() != HttpStatus.SC_OK) {
           return null;
       }

       String jsonList = response.getBody().getObject().get(PLACES_KEY).toString();

       return objectMapper.readValue(jsonList, new TypeReference<List<PlacesDto>>() {
       });
   }
}
где PlacesDto имеет вид:

import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.romankh3.flightsmonitoring.client.service.PlacesClient;
import lombok.Data;

/**
* Using for {@link PlacesClient}.
*/
@Data
public class PlacesDto {

   @JsonProperty("PlaceId")
   private String placeId;

   @JsonProperty("PlaceName")
   private String placeName;

   @JsonProperty("CountryId")
   private String countryId;

   @JsonProperty("RegionId")
   private String regionId;

   @JsonProperty("CityId")
   private String cityId;

   @JsonProperty("CountryName")
   private String countryName;
}
И наконец клиент сервис, который будет по нужным данным возвращать минимальную цену на перелет и всю необходимую информацию: FlightPriceClient и FlightPriceClientImpl. Реализовывать будем только один запрос browseQuotes. FlightPriceClient:

import com.github.romankh3.flightsmonitoring.client.dto.FlightPricesDto;

/**
* Browse flight prices.
*/
public interface FlightPricesClient {

   /**
    * Browse quotes for current flight based on provided arguments. One-way ticket.
    *
    * @param country the country from
    * @param currency the currency to get price
    * @param locale locale for the response
    * @param originPlace origin place
    * @param destinationPlace destination place
    * @param outboundPartialDate outbound date
    * @return {@link FlightPricesDto} object.
    */
   FlightPricesDto browseQuotes(String country, String currency, String locale, String originPlace,
           String destinationPlace, String outboundPartialDate);

   /**
    * Browse quotes for current flight based on provided arguments. Round trip ticket.
    *
    * @param country the country from
    * @param currency the currency to get price
    * @param locale locale for the response
    * @param originPlace origin place
    * @param destinationPlace destination place
    * @param outboundPartialDate outbound date
    * @param inboundPartialDate inbound date
    * @return {@link FlightPricesDto} object.
    */
   FlightPricesDto browseQuotes(String country, String currency, String locale, String originPlace,
           String destinationPlace, String outboundPartialDate, String inboundPartialDate);
}
FlightPriceClientImpl

import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.CURRENCIES_KEY;
import static com.github.romankh3.flightsmonitoring.client.service.impl.UniRestServiceImpl.PLACES_KEY;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.romankh3.flightsmonitoring.client.dto.CarrierDto;
import com.github.romankh3.flightsmonitoring.client.dto.CurrencyDto;
import com.github.romankh3.flightsmonitoring.client.dto.FlightPricesDto;
import com.github.romankh3.flightsmonitoring.client.dto.PlaceDto;
import com.github.romankh3.flightsmonitoring.client.dto.QuoteDto;
import com.github.romankh3.flightsmonitoring.client.dto.ValidationErrorDto;
import com.github.romankh3.flightsmonitoring.client.service.FlightPricesClient;
import com.github.romankh3.flightsmonitoring.client.service.UniRestService;
import com.github.romankh3.flightsmonitoring.exception.FlightClientException;
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;
import java.io.IOException;
import java.util.List;
import org.apache.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
* {@inheritDoc}
*/
@Service
public class FlightPricesClientImpl implements FlightPricesClient {

   public static final String BROWSE_QUOTES_FORMAT = "/apiservices/browsequotes/v1.0/%s/%s/%s/%s/%s/%s";
   public static final String OPTIONAL_BROWSE_QUOTES_FORMAT = BROWSE_QUOTES_FORMAT + "?inboundpartialdate=%s";

   public static final String QUOTES_KEY = "Quotes";
   public static final String ROUTES_KEY = "Routes";
   public static final String DATES_KEY = "Dates";
   public static final String CARRIERS_KEY = "Carriers";
   public static final String VALIDATIONS_KEY = "ValidationErrors";

   @Autowired
   private UniRestService uniRestService;

   @Autowired
   private ObjectMapper objectMapper;

   /**
    * {@inheritDoc}
    */
   @Override
   public FlightPricesDto browseQuotes(String country, String currency, String locale, String originPlace,
           String destinationPlace, String outboundPartialDate) {

       HttpResponse<JsonNode> response = uniRestService.get(String
               .format(BROWSE_QUOTES_FORMAT, country, currency, locale, originPlace, destinationPlace,
                       outboundPartialDate));
       return mapToObject(response);
   }

   public FlightPricesDto browseQuotes(String country, String currency, String locale, String originPlace,
           String destinationPlace, String outboundPartialDate, String inboundPartialDate) {
       HttpResponse<JsonNode> response = uniRestService.get(String
               .format(OPTIONAL_BROWSE_QUOTES_FORMAT, country, currency, locale, originPlace, destinationPlace,
                       outboundPartialDate, inboundPartialDate));
       return mapToObject(response);
   }

   private FlightPricesDto mapToObject(HttpResponse<JsonNode> response) {
       if (response.getStatus() == HttpStatus.SC_OK) {
           FlightPricesDto flightPricesDto = new FlightPricesDto();
           flightPricesDto.setQuotas(readValue(response.getBody().getObject().get(QUOTES_KEY).toString(),
                   new TypeReference<List<QuoteDto>>() {
                   }));
           flightPricesDto.setCarriers(readValue(response.getBody().getObject().get(CARRIERS_KEY).toString(),
                   new TypeReference<List<CarrierDto>>() {
                   }));
           flightPricesDto.setCurrencies(readValue(response.getBody().getObject().get(CURRENCIES_KEY).toString(),
                   new TypeReference<List<CurrencyDto>>() {
                   }));
           flightPricesDto.setPlaces(readValue(response.getBody().getObject().get(PLACES_KEY).toString(),
                   new TypeReference<List<PlaceDto>>() {
                   }));
           return flightPricesDto;
       }
       throw new FlightClientException(String.format("There are validation errors. statusCode = %s", response.getStatus()),
               readValue(response.getBody().getObject().get(VALIDATIONS_KEY).toString(),
                       new TypeReference<List<ValidationErrorDto>>() {
                       }));
   }

   private <T> List<T> readValue(String resultAsString, TypeReference<List<T>> valueTypeRef) {
       List<T> list;
       try {
           list = objectMapper.readValue(resultAsString, valueTypeRef);
       } catch (IOException e) {
           throw new FlightClientException("Object Mapping failure.", e);
       }
       return list;
   }
}
где FlightClientException имеет вид:

import com.github.romankh3.flightsmonitoring.client.dto.ValidationErrorDto;
import java.util.List;

/**
* A {@link RuntimeException} that is thrown in case of an flight monitoring failures.
*/
public final class FlightClientException extends RuntimeException {

   public FlightClientException(String message) {
       super(message);
   }

   public FlightClientException(String message, Throwable throwable) {
       super(message, throwable);
   }

   public FlightClientException(String message, List<ValidationErrorDto> errors) {
       super(message);
       this.validationErrorDtos = errors;
   }

   private List<ValidationErrorDto> validationErrorDtos;
}
В итоге по данным из PlacesCl