Содержание:
Гайд по созданию клиента для Skyscanner API и его публикации в jCenver и Maven Central [Часть 2]
Идея разработки клиента как отдельной библиотеки пришла в тот момент, когда я писал статью
Создание системы мониторинга цен на авиабилеты: пошаговое руководство (
часть 1,
часть 2,
часть 3).
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 1]()
Зачем это нужно? Например, чтобы можно было просто добавить ее как зависимость в проект, не думать, как и что нужно делать, а просто использовать созданный API.
Для статьи нужно иметь представление о том, что такое:
- система сборки проектов Gradle. В прошлый раз мы использовали Maven, в этот раз для публикации возьмем Gradle. Для быстрого ознакомления хватит и этой статьи.
- groupId, artifactId, version. Это для публикации клиента.
План действий
У Skyscanner API есть четыре группы запросов:
- Live Flight Search
- Places
- Browse Flight Prices
- Localisation
Так вот идея состоит в том, чтобы написать клиент с четырьмя интерфейсами для работы с этими группами, который требует только передать токен для работы с Rapid API и необходимые данные для запроса, а клиент сам заботится обо всем остальном.
Польза от этого проекта реально осязаема, так как после поиска я не нашел ни одной реализации клиента для этого API (есть два клиента на GitHub, но они используют API, которого уже нет, так что даже они не валидны на этот момент).
Как найти и получить данные для клиента я подробно описал в статье, а именно —
здесь. Это первая часть, которую нужно сделать.
Вторая часть не менее важна —
опубликовать клиент в Maven Central и JCenter. Я уже сталкивался с этим, и скажу вам, что это не самая очевидная вещь. Ведь мы хотим что-то такое:
git push mavenCentral, но в реальности это не так. Поэтому вторая часть будут именно об этом — публикации клиента в самые масштабные
хранилища Maven Central и
JCenter.
Итогом статьи будет использование клиента для проекта по мониторингу цен на авиабилеты. Если поведение не изменится после добавления клиента, значит все сделано правильно, и можно будет двигаться дальше в направлении версии 1.0-RELEASE.
Часть первая: пишем Skyscanner API клиент
Шаг 1: создаем пустой проект на основе Gradle
Через Intellij IDEA создаем
gradle проект. Выбираем
Create New Project:
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 2]()
Переходим в
Gradle и жмем
Next:
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 3]()
Выбираем название
skyscanner-flight-search-api-client, открываем
Artifact Coordinates и groupId, artifactId и version:
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 4]()
Причем вот что нужно иметь в виду:
- GroupId: можно представить себе как идентификатор аккаунта, организации или package name, под которым распространяется библиотека или несколько библиотек. GROUP_ID должен быть в формате Reverse FQDN;
- ArtifactId: название библиотеки или в терминологии Maven название «артефакта»;
- Version: рекомендуется использовать паттерн вида x.y.z, но допустимо использование любых строковых значений.
Примечание: при выборе GROUP_ID следует иметь в виду, что вам должен принадлежать выбранный домен. Иначе возникнут проблемы при его регистрации в Sonatype.
Из этого следует, что нужно выбирать GroupId таким, чтоб это был ваш домен, например, как мой
com.github.romankh3 — аккаунт на GitHub.
Далее, переиспользуем множество кода с небольшими изменениями из
flights-monitoring, на основе которого была создана статья (
Создание системы мониторинга цен на авиабилеты: пошаговое руководство).
Шаг 2: добавляем необходимые зависимости
Во время написания клиента оказалось, что библиотека Unirest переехала в гитхаб и продолжает развиваться, но уже под другим groupId. Так что теперь добавляем в
build.gradle следующую зависимость в блок dependencies:
compile 'com.konghq:unirest-java:3.2.00'
также нам для работы понадобится уже известный по прошлой статье
Lombok Project. Добавляем его так ,чтобы он работал при рантайме:
runtime 'org.projectlombok:lombok:1.18.10'
Далее, для работы с JSON файлами, будем также использовать
Jackson Project. Он имеет не одну зависимость, нам нужны аннотации и databind:
compile 'com.fasterxml.jackson.core:jackson-annotations:2.10.0'
compile 'com.fasterxml.jackson.core:jackson-databind:2.10.0'
И, конечно, для тестирования не забудем добавить JUnit и Mockito.
testCompile означает, что зависимость будет видна только для тестов. Как в maven
<scope>test</scope>
testCompile 'junit:junit:4.12'
testCompile 'org.mockito:mockito-core:2.26.0'
Ну вот, мы подготовили все требующиеся зависимости, теперь можно перейти к коду.
Шаг 3: пишем UniRestUnit и пакет с DTO объектами
Для отправки REST запросов мы создаем
UniRestUtil со статическими методами для запросов.
Эта версия 0.1, и у нее будут только те запросы, которые уже реализованы в предыдущей статье, поэтому будет один метод
get
, который принимает необходимый для работы с Rapidapi ключ и
String path. Он уже будет сформирован именно так, как необходимо для запроса.
path создан для того, чтоб сделать этот метод универсальным.
В ходе написания клиента были переработаны почти все классы, внесены изменения.
Собственно, сам UniRestUtil:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.exception.FlightSearchApiClientException;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.validation.ValidationErrorDto;
import java.util.List;
import kong.unirest.HttpResponse;
import kong.unirest.JsonNode;
import kong.unirest.Unirest;
import kong.unirest.UnirestException;
import org.apache.http.HttpStatus;
final class UniRestUtil {
private static final String HOST = "skyscanner-skyscanner-flight-search-v1.p.rapidapi.com";
static final String PLACES_FORMAT = "/apiservices/autosuggest/v1.0/%s/%s/%s/?query=%s";
static final String CURRENCIES_FORMAT = "/apiservices/reference/v1.0/currencies";
static final String COUNTRIES_FORMAT = "/apiservices/reference/v1.0/countries/%s";
static final String VALIDATIONS_KEY = "ValidationErrors";
static final String PLACES_KEY = "Places";
static final String CURRENCIES_KEY = "Currencies";
static final String COUNTRIES_KEY = "Countries";
private static ObjectMapper objectMapper = new ObjectMapper();
static HttpResponse<JsonNode> get(String xRapidApiKey, String path) {
HttpResponse<JsonNode> response;
try {
response = Unirest.get("https://" + HOST + path)
.header("x-rapidapi-host", HOST)
.header("x-rapidapi-key", xRapidApiKey)
.asJson();
if (response.getStatus() != HttpStatus.SC_OK) {
throw new FlightSearchApiClientException(
String.format("There are validation errors. statusCode = %s", response.getStatus()),
readValueWrapper(response.getBody().getObject().get(VALIDATIONS_KEY).toString(),
new TypeReference<list<validationerrordto>>() {
}));
}
} catch (UnirestException e) {
throw new FlightSearchApiClientException(String.format("Request failed, path=%s", HOST + path), e);
}
return response;
}
static <T> T readValueWrapper(String content, TypeReference<T> valueTypeRef) {
try {
return objectMapper.readValue(content, valueTypeRef);
} catch (JsonProcessingException e) {
throw new FlightSearchApiClientException("Object Mapping failure.", e);
}
}
}
Здесь можно заметить, что я сделал оболочку для checked исключений при помощи своего RuntimeException. Делается это для того, чтобы checked исключения не засоряли кодовую базу у пользователей клиента, а если и произойдет исключительная ситуация, RuntimeException передаст всю информацию пользователю клиента.
Для этого я создал метод
readValueWrapper
, который выполняет описанное выше поведение для чтения из JSON в POJO.
Также при помощи идентификаторов доступа были инкапсулированы классы, к которым не нужен доступ извне, поэтому в UniRestUtil стоит package-private идентификатор.
Собственно, вот сам RuntimeException:
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.validation.ValidationErrorDto;
import java.util.List;
public final class FlightSearchApiClientException extends RuntimeException {
private List<ValidationErrorDto> validationErrorDtos;
public FlightSearchApiClientException(String message, Throwable throwable) {
super(message, throwable);
}
public FlightSearchApiClientException(String message, List<ValidationErrorDto> errors) {
super(message);
this.validationErrorDtos = errors;
}
}
Самым большим пакетом будет пакет
model, который хранит все DTO (data transfer object) объекты. Как те, которые нужны будут для создание запроса, так и те, которые будут возвращать значения. Для большей ясности и структуры, дтошки будут поделены на группы, в которых они используются.
Почему? Потому что у объекта Place разные поля и имена полей в разных группах. Поэтому есть
BrowsePlaceDto и
PlacesPlaceDto, и соответственно, они разделены как показано на рисунке ниже:
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 5]()
Чтобы не выливать на вас все эти классы, я опишу два типа, а на другие дам
ссылку на GitHub.
Первый тип DTO — Search, то есть те, которые используются для поиска через клиент. На них накладывается валидация полей и указывается, какие из них требуемые и какие опциональные.
Рассмотрим
BrowseSearchDto:
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
@Getter
@Builder(builderMethodName = "hiddenBuilder")
public class BrowseSearchDto {
@NonNull
private String country;
@NonNull
private String currency;
@NonNull
private String locale;
@NonNull
private String originPlace;
@NonNull
private String destinationPlace;
@NonNull
private LocalDate outboundPartialDate;
private LocalDate inboundPartialDate;
}
Здесь используется три аннотации из Project Lombok:
- @Getter — генерирует геттеры для всех полей;
- @Builder — генерирует все данные, необходимые для использования паттерна Builder. Оказалось, что в @Builder’e нет геттеров, поэтому отдельно добавил @Getter;
- @NotNull — говорит Lombok, что эти поля должны иметь значения при создании объекта. Это сделано потому, что в поиске эти поля обязательны, и аннотация валидирует их при создании. Просто и быстро. Стоит также отметить, что у поля inboundPartialDate нет этой аннотации, так как оно опционально в этом запросе. Это можно увидеть здесь:
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 6]()
Второй тип DTO — это возвращающий результат. Их особенность заключается в том, что нужно добавить аннотацию для Jackson Project. Ниже приведен CountryDto:
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class CountryDto {
@JsonProperty("Code")
private String code;
@JsonProperty("Name")
private String name;
}
Где:
- @Data — аннотация из Lombok проекта, которая генерирует все геттеры, сеттеры, переопределяет
toString()
, equals()
и hashCode()
методы. Этим она улучшает читабельность кода и ускоряет время написания POJO объектов;
- @JsonProperty("Code") — это аннотация из Jackson Project, которая говорит, какое поле будет присваиваться этой переменной. То есть поле в JSON, равное Code, будет присваиваться переменной code.
![Гайд по созданию клиента для Skyscanner API и его публикации в jCenter и Maven Central [Часть 1] - 7]()
Шаг 4: Описываем интерфейсы и реализации для клиента
PlacesClient:
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.place.PlaceSearchDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.place.PlacesPlaceDto;
import java.util.List;
public interface PlacesClient {
List<PlacesPlaceDto> retrieveListPlaces(String xRapidApiKey, PlaceSearchDto placeSearchDto);
}
И реализация
PlacesClientImpl:
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.PLACES_FORMAT;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.PLACES_KEY;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.get;
import com.fasterxml.jackson.core.type.TypeReference;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.place.PlaceSearchDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.place.PlacesPlaceDto;
import java.util.List;
import kong.unirest.HttpResponse;
import kong.unirest.JsonNode;
public class PlacesClientImpl implements PlacesClient {
public List<PlacesPlaceDto> retrieveListPlaces(String xRapidApiKey, PlaceSearchDto placeSearchDto) {
HttpResponse<JsonNode> response = get(xRapidApiKey,
String.format(PLACES_FORMAT, placeSearchDto.getCountry(), placeSearchDto.getCurrency(),
placeSearchDto.getLocale(), placeSearchDto.getPlaceName()));
String jsonList = response.getBody().getObject().get(PLACES_KEY).toString();
return UniRestUtil.readValueWrapper(jsonList, new TypeReference<List<PlacesPlaceDto>>() {
});
}
}
LocalisationClient:
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.exception.FlightSearchApiClientException;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CountryDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CurrencyDto;
import java.util.List;
public interface LocalisationClient {
List<countrydto> retrieveCountries(String locale, String xRapidApiKey) throws FlightSearchApiClientException;
List<currencydto> retrieveCurrencies(String xRapidApiKey) throws FlightSearchApiClientException;
}
И реализация
LocalisationClientImpl
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.COUNTRIES_FORMAT;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.COUNTRIES_KEY;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.CURRENCIES_FORMAT;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.CURRENCIES_KEY;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.get;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.readValueWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CountryDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CurrencyDto;
import java.util.List;
import kong.unirest.HttpResponse;
import kong.unirest.JsonNode;
public class LocalisationClientImpl implements LocalisationClient {
public List<CountryDto> retrieveCountries(String locale, String xRapidApiKey) {
HttpResponse<JsonNode> response = get(xRapidApiKey, String.format(COUNTRIES_FORMAT, locale));
String jsonList = response.getBody().getObject().get(COUNTRIES_KEY).toString();
return readValueWrapper(jsonList, new TypeReference<List<CountryDto>>() {
});
}
public List<CurrencyDto> retrieveCurrencies(String xRapidApiKey) {
HttpResponse<JsonNode> response = get(xRapidApiKey, CURRENCIES_FORMAT);
String jsonList = response.getBody().getObject().get(CURRENCIES_KEY).toString();
return readValueWrapper(jsonList, new TypeReference<List<CurrencyDto>>() {
});
}
}
BrowseFlightPricesClient:
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.BrowseFlightPricesResponseDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.BrowseSearchDto;
public interface BrowseFlightPricesClient {
BrowseFlightPricesResponseDto browseQuotes(String xRapidApiKey, BrowseSearchDto searchDto);
}
И реализация BrowseFlightPricesClientImpl:
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.CURRENCIES_KEY;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.PLACES_KEY;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.get;
import static com.github.romankh3.skyscanner.api.flightsearchclient.v1.UniRestUtil.readValueWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.BrowseFlightPricesResponseDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.BrowsePlaceDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.BrowseSearchDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.CarrierDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.QuoteDto;
import com.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CurrencyDto;
import java.util.List;
import kong.unirest.HttpResponse;
import kong.unirest.JsonNode;
public class BrowseFlightPricesClientImpl implements BrowseFlightPricesClient {
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";
@Override
public BrowseFlightPricesResponseDto browseQuotes(String xRapidApiKey, BrowseSearchDto searchDto) {
HttpResponse<JsonNode> response = searchDto.getInboundPartialDate() == null ?
get(xRapidApiKey, String
.format(BROWSE_QUOTES_FORMAT, searchDto.getCountry(), searchDto.getCurrency(),
searchDto.getLocale(), searchDto.getOriginPlace(), searchDto.getDestinationPlace(),
searchDto.getOutboundPartialDate())) :
get(xRapidApiKey, String
.format(OPTIONAL_BROWSE_QUOTES_FORMAT, searchDto.getCountry(), searchDto.getCurrency(),
searchDto.getLocale(), searchDto.getOriginPlace(), searchDto.getDestinationPlace(),
searchDto.getOutboundPartialDate(), searchDto.getInboundPartialDate()));
return mapToObject(response);
}
private BrowseFlightPricesResponseDto mapToObject(HttpResponse<jsonnode> response) {
BrowseFlightPricesResponseDto flightPricesDto = new BrowseFlightPricesResponseDto();
flightPricesDto.setQuotas(readValueWrapper(response.getBody().getObject().get(QUOTES_KEY).toString(),
new TypeReference<List<QuoteDto>>() {
}));
flightPricesDto.setCarriers(readValueWrapper(response.getBody().getObject().get(CARRIERS_KEY).toString(),
new TypeReference<List<CarrierDto>>() {
}));
flightPricesDto
.setCurrencies(readValueWrapper(response.getBody().getObject().get(CURRENCIES_KEY).toString(),
new TypeReference<List<CurrencyDto>>() {
}));
flightPricesDto.setPlaces(readValueWrapper(response.getBody().getObject().get(PLACES_KEY).toString(),
new TypeReference<List<BrowsePlaceDto>>() {
}));
return flightPricesDto;
}
}
На этом работа нам над клиентом закончена,
проект хранится на GitHub.
Гайд по созданию клиента для Skyscanner API и его публикации в jCenver и Maven Central [Часть 2]