Содержание:

Гайд по созданию клиента для 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;

/**
* Unit with static methods for using Unirest library.
*
* @author Roman Beskrovnyi
* @since 0.1
*/
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;

/**
* A {@link RuntimeException} that is thrown in case of an flight monitoring failures.
*
* @author Roman Beskrovnyi
* @since 0.1
*/
public final class FlightSearchApiClientException extends RuntimeException {

   private List<ValidationErrorDto> validationErrorDtos;

   /**
    * Constructs a new {@link FlightSearchApiClientException} with the specified detail message and cause.
    * Note that the detail message associated with cause is not automatically incorporated in this {@link
    * FlightSearchApiClientException}'s detail message.
    *
    * @param message the detail message (which is saved for later retrieval by the Throwable.getMessage() method).
    * @param throwable the cause (which is saved for later retrieval by the Throwable.getCause() method).
    * (A null value is permitted, and indicates that the cause is nonexistent or unknown.)
    */
   public FlightSearchApiClientException(String message, Throwable throwable) {
       super(message, throwable);
   }

   /**
    * Constructs a new {@link FlightSearchApiClientException} with specified collection of the
    * {@link ValidationErrorDto} objects.
    *
    * @param message the detail message (which is saved for later retrieval by the Throwable.getMessage() method).
    * @param errors the collection of the {@link ValidationErrorDto} which contain errors from Skyscanner API.
    */
   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;

/**
* DTO object for search in Browse Flight Search calls.
*
* @since 0.1
* @author Roman Beskrovnyi
*/
@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 transfer object for Country.
*
* @since 0.1
* @author Roman Beskrovnyi
*/
@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;

/**
* Get a list of places that match a query string.
*
* @author Roman Beskrovnyi
* @since 0.1
*/
public interface PlacesClient {

   /**
    * Get a list of places that match a query string based on arguments.
    *
    * @param xRapidApiKey key for getting access to rapid api.
    * @param placeSearchDto {@link PlacesPlaceDto} object for search places
    * @return the collection of the {@link PlacesPlaceDto} objects.
    */
   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;

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

   /**
    * {@inheritDoc}
    */
   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;

/**
* Retrieve the market countries that we 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.
*
* @author Roman Beskrovnyi
* @since 0.1
*/
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.
    */
   List<countrydto> retrieveCountries(String locale, String xRapidApiKey) throws FlightSearchApiClientException;

   /**
    * Retrieve the currencies that we ScyScanner flight search API.
    *
    * @return the collection of the {@link CurrencyDto} objects.
    */
   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;

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

   /**
    * {@inheritDoc}
    */
   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>>() {
       });
   }

   /**
    * {@inheritDoc}
    */
   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;

/**
* Retrieve the market countries that Skyscanner 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.
*
* @author Roman Beskrovnyi
* @since 0.1
*/
public interface BrowseFlightPricesClient {

   /**
    * Retrieve the cheapest quotes from our cache prices.
    *
    * @param xRapidApiKey key for getting access to rapid api.
    * @param searchDto {@link BrowseSearchDto} object for search.
    * @return {@link BrowseFlightPricesResponseDto} object with all the data related to provided search dto.
    */
   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;

/**
* {@inheritDoc}
*/
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]