Содержание:
- План действий для достижения цели
- Исследуем Skyscanner API
- Создаем каркас приложения на основе Spring Boot
- Пишем в проекте клиент для запросов на Skyscanner Flight Search API
- Создаем клиент сервис для Localisation запроса
- REST и том, как строятся REST эндпоинты;
- реляционных базах данных;
- работе maven (в частности, что такое dependency —зависимость);
- JSON объекте;
- принципах логирования.
- Можно выбрать перелет на конкретную дату и отслеживать цену на него. Пользователь идентифицируется по email-адресу. Как только оформляется подписка на изменение цены, пользователь получает уведомление на почту.
- Каждые 30 минут (этот промежуток настраивается через application.properties) пересчитывается минимальная цена за перелет для всех подписок. Если какая-то стала ниже, пользователь получит уведомление на почту.
- Все подписки с устаревшей датой перелета удаляются.
- Через REST API можно:
- создать подписку;
- редактировать;
- получить все подписки по email;
- удалить подписку.
План действий для достижения цели
Начать нужно с того, что информацию о перелетах нужно откуда-то брать. Обычно сайты предоставляют открытый REST API, с помощью которого можно получить информацию.API (application programming interface) — это интерфейс, с помощью которого можно взаимодействовать с приложением. Из этого можно перебросить мостик на то, что такое REST API. REST API — это интерфейс из REST запросов, с помощью которого можно общаться с веб-приложением. |
Исследуем Skyscanner API
Перейдем по ссылке на rakuten api. Сначала нужно зарегистрироваться Все это нужно, чтобы получить уникальный ключ к использованию их сайта и делать запросы на публичные API, которые выложены на нем. Один из таких api и есть нужный нам Skyscanner Flight Search. Теперь разберемся, как это работает. Найдем запрос GET List Places. На картинке показано, что нужно заполнить данные и начать Test Endpoint, в результате чего получаем ответ в виде JSON объекта справа: причем запрос будет создаваться так:
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. Выбираем следующие опции:- Maven project
- Java
- 2.1.10
- group — какой считаете нужным, например ru.javarush
- artifact — точно так же, например flights-monitoring
- в поиске dependency ищем такие:
- Spring Web
- Java Mail Sender
- Spring Data Jpa
- H2 Database
- CONTROLLER — вход в приложение. Здесь будет описан REST API
- SERVICE — слой бизнес-логики. Вся логика работы приложение будет описана здесь.
- REPOSITORY — слой работы с базой данных.
Пишем в проекте клиент для запросов на Skyscanner Flight Search API
Skyscanner любезно предоставили статью на тему, как пользоваться их API (мы не будем создавать сессию с активным запросом). Что значит "писать клиент"? Нам нужно создать запрос на определенный URL с определенными параметрами и заготовить DTO (data transfer object) для данных, передающихся обратно к нам. На сайте есть четыре группы запросов:- Live Flight Search — не будем рассматривать как ненужный на данный момент.
- Places — напишем.
- Browse Flight Prices — воспользуемся одним запросом, где можно взять всю информацию.
- Localisation — добавим его, что знать какие поддерживаются данные.
Создаем клиент сервис для Localisation запроса:
План простой как пареная репа: создаем запрос, смотрим какие параметры, смотрим, какой ответ. Там два запроса List markers и Currencies. Начнем с Currencies. По рисунку видно, что это запрос без дополнительных полей: он нужен, чтобы получить информацию о поддерживаемых валютах: Ответ в виде 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.
<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
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ