Зміст:
- План дій для досягнення мети
- Досліджуємо Skyscanner API
- Створюємо каркас програми на основі Spring Boot
- Пишемо у проекті клієнт для запитів на Skyscanner Flight Search API
- Створюємо клієнт сервіс для Localisation запиту
- REST і те, як будуються REST ендпоінти;
- реляційних баз даних;
- роботі maven (зокрема, що таке dependency-залежність);
- JSON об'єкт;
- принципи логування.
- Можна вибрати переліт на дату і відстежувати ціну на нього. Користувач ідентифікується за email-адресаою. Як тільки оформляється підписка на зміну ціни, користувач отримує повідомлення на пошту.
- Кожні 30 хвабон (це проміжок налаштовується через application.properties) перераховується мінімальна ціна за переліт для всіх підписок. Якщо якась стала нижчою, користувач отримає повідомлення на пошту.
- Усі передплати зі застарілою датою перельоту видаляються.
- Через REST API можна:
- створити передплату;
- редагувати;
- отримати всі передплати по email;
- видалити передплату.
План дій для досягнення мети
Почати потрібно з того, що інформацію про перельоти потрібно звідкись брати. Зазвичай сайти надають відкритий API REST, за допомогою якого можна отримати інформацію.
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 - який вважаєте за потрібне, наприклад com.codegym
- 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. Ми ж будуємо додаток виходячи з ООП, тому створюємо інтерфейс 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