Гайд по созданию клиента для Skyscanner API и его публикации в jCenver и Maven Central [Часть 2]
Идея разработки клиента как отдельной библиотеки пришла в тот момент, когда я писал статью Создание системы мониторинга цен на авиабилеты: пошаговое руководство (часть 1, часть 2, часть 3).
Зачем это нужно? Например, чтобы можно было просто добавить ее как зависимость в проект, не думать, как и что нужно делать, а просто использовать созданный 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:
Переходим в Gradle и жмем Next:
Выбираем название skyscanner-flight-search-api-client, открываем Artifact Coordinates и groupId, artifactId и version:
Причем вот что нужно иметь в виду:
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:
И, конечно, для тестирования не забудем добавить JUnit и Mockito. testCompile означает, что зависимость будет видна только для тестов. Как в maven <scope>test</scope>
Ну вот, мы подготовили все требующиеся зависимости, теперь можно перейти к коду.
Шаг 3: пишем UniRestUnit и пакет с DTO объектами
Для отправки REST запросов мы создаем UniRestUtil со статическими методами для запросов.
Эта версия 0.1, и у нее будут только те запросы, которые уже реализованы в предыдущей статье, поэтому будет один метод get, который принимает необходимый для работы с Rapidapi ключ и String path. Он уже будет сформирован именно так, как необходимо для запроса.
path создан для того, чтоб сделать этот метод универсальным.
В ходе написания клиента были переработаны почти все классы, внесены изменения.
Собственно, сам UniRestUtil:
importcom.fasterxml.jackson.core.JsonProcessingException;importcom.fasterxml.jackson.core.type.TypeReference;importcom.fasterxml.jackson.databind.ObjectMapper;importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.exception.FlightSearchApiClientException;importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.validation.ValidationErrorDto;importjava.util.List;importkong.unirest.HttpResponse;importkong.unirest.JsonNode;importkong.unirest.Unirest;importkong.unirest.UnirestException;importorg.apache.http.HttpStatus;/**
* Unit with static methods for using Unirest library.
*
* @author Roman Beskrovnyi
* @since 0.1
*/finalclassUniRestUtil{privatestaticfinalStringHOST="skyscanner-skyscanner-flight-search-v1.p.rapidapi.com";staticfinalStringPLACES_FORMAT="/apiservices/autosuggest/v1.0/%s/%s/%s/?query=%s";staticfinalStringCURRENCIES_FORMAT="/apiservices/reference/v1.0/currencies";staticfinalStringCOUNTRIES_FORMAT="/apiservices/reference/v1.0/countries/%s";staticfinalStringVALIDATIONS_KEY="ValidationErrors";staticfinalStringPLACES_KEY="Places";staticfinalStringCURRENCIES_KEY="Currencies";staticfinalStringCOUNTRIES_KEY="Countries";privatestaticObjectMapper objectMapper =newObjectMapper();staticHttpResponse<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){thrownewFlightSearchApiClientException(String.format("There are validation errors. statusCode = %s", response.getStatus()),readValueWrapper(response.getBody().getObject().get(VALIDATIONS_KEY).toString(),newTypeReference<list<validationerrordto>>(){}));}}catch(UnirestException e){thrownewFlightSearchApiClientException(String.format("Request failed, path=%s",HOST+ path), e);}return response;}static<T>TreadValueWrapper(String content,TypeReference<T> valueTypeRef){try{return objectMapper.readValue(content, valueTypeRef);}catch(JsonProcessingException e){thrownewFlightSearchApiClientException("Object Mapping failure.", e);}}}
Здесь можно заметить, что я сделал оболочку для checked исключений при помощи своего RuntimeException. Делается это для того, чтобы checked исключения не засоряли кодовую базу у пользователей клиента, а если и произойдет исключительная ситуация, RuntimeException передаст всю информацию пользователю клиента.
Для этого я создал метод readValueWrapper, который выполняет описанное выше поведение для чтения из JSON в POJO.
Также при помощи идентификаторов доступа были инкапсулированы классы, к которым не нужен доступ извне, поэтому в UniRestUtil стоит package-private идентификатор.
Собственно, вот сам RuntimeException:
importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.validation.ValidationErrorDto;importjava.util.List;/**
* A {@link RuntimeException} that is thrown in case of an flight monitoring failures.
*
* @author Roman Beskrovnyi
* @since 0.1
*/publicfinalclassFlightSearchApiClientExceptionextendsRuntimeException{privateList<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.)
*/publicFlightSearchApiClientException(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.
*/publicFlightSearchApiClientException(String message,List<ValidationErrorDto> errors){super(message);this.validationErrorDtos = errors;}}
Самым большим пакетом будет пакет model, который хранит все DTO (data transfer object) объекты. Как те, которые нужны будут для создание запроса, так и те, которые будут возвращать значения. Для большей ясности и структуры, дтошки будут поделены на группы, в которых они используются.
Почему? Потому что у объекта Place разные поля и имена полей в разных группах. Поэтому есть BrowsePlaceDto и PlacesPlaceDto, и соответственно, они разделены как показано на рисунке ниже:
Чтобы не выливать на вас все эти классы, я опишу два типа, а на другие дам ссылку на GitHub.
Первый тип DTO — Search, то есть те, которые используются для поиска через клиент. На них накладывается валидация полей и указывается, какие из них требуемые и какие опциональные.
Рассмотрим BrowseSearchDto:
importjava.time.LocalDate;importlombok.Builder;importlombok.Getter;importlombok.NonNull;/**
* DTO object for search in Browse Flight Search calls.
*
* @since 0.1
* @author Roman Beskrovnyi
*/@Getter@Builder(builderMethodName ="hiddenBuilder")publicclassBrowseSearchDto{@NonNullprivateString country;@NonNullprivateString currency;@NonNullprivateString locale;@NonNullprivateString originPlace;@NonNullprivateString destinationPlace;@NonNullprivateLocalDate outboundPartialDate;privateLocalDate inboundPartialDate;}
Здесь используется три аннотации из Project Lombok:
@Getter — генерирует геттеры для всех полей;
@Builder — генерирует все данные, необходимые для использования паттерна Builder. Оказалось, что в @Builder’e нет геттеров, поэтому отдельно добавил @Getter;
@NotNull — говорит Lombok, что эти поля должны иметь значения при создании объекта. Это сделано потому, что в поиске эти поля обязательны, и аннотация валидирует их при создании. Просто и быстро. Стоит также отметить, что у поля inboundPartialDate нет этой аннотации, так как оно опционально в этом запросе. Это можно увидеть здесь:
Второй тип DTO — это возвращающий результат. Их особенность заключается в том, что нужно добавить аннотацию для Jackson Project. Ниже приведен CountryDto:
importcom.fasterxml.jackson.annotation.JsonProperty;importlombok.Data;/**
* Data transfer object for Country.
*
* @since 0.1
* @author Roman Beskrovnyi
*/@DatapublicclassCountryDto{@JsonProperty("Code")privateString code;@JsonProperty("Name")privateString name;}
Где:
@Data — аннотация из Lombok проекта, которая генерирует все геттеры, сеттеры, переопределяет toString(), equals() и hashCode() методы. Этим она улучшает читабельность кода и ускоряет время написания POJO объектов;
@JsonProperty("Code") — это аннотация из Jackson Project, которая говорит, какое поле будет присваиваться этой переменной. То есть поле в JSON, равное Code, будет присваиваться переменной code.
Шаг 4: Описываем интерфейсы и реализации для клиента
PlacesClient:
importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.place.PlaceSearchDto;importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.place.PlacesPlaceDto;importjava.util.List;/**
* Get a list of places that match a query string.
*
* @author Roman Beskrovnyi
* @since 0.1
*/publicinterfacePlacesClient{/**
* 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);}
importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.exception.FlightSearchApiClientException;importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CountryDto;importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.localisation.CurrencyDto;importjava.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
*/publicinterfaceLocalisationClient{/**
* 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)throwsFlightSearchApiClientException;/**
* Retrieve the currencies that we ScyScanner flight search API.
*
* @return the collection of the {@link CurrencyDto} objects.
*/List<currencydto>retrieveCurrencies(String xRapidApiKey)throwsFlightSearchApiClientException;}
importcom.github.romankh3.skyscanner.api.flightsearchclient.v1.model.browse.BrowseFlightPricesResponseDto;importcom.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
*/publicinterfaceBrowseFlightPricesClient{/**
* 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.
*/BrowseFlightPricesResponseDtobrowseQuotes(String xRapidApiKey,BrowseSearchDto searchDto);}
"@Builder — генерирует все данные, необходимые для использования паттерна Builder. Оказалось, что в @Builder’e нет геттеров, поэтому отдельно добавил @Getter;"
Дак и не должно быть геттеров в паттерне Builder. Он не для того придуман. Builder облегчает создание объекта целиком(это порождающий шаблон) в более удобной форме, чем, например, создавая множество конструкторов на все случаи жизни. Для начала не мешало бы тщательно изучить шаблоны проектирования, прежде, чем их использовать.
Этот паттерн создает объект. У созданного обьекта, само-собой есть геттеры, а вот класс Builder, который создает целевой объект, ему геттеры не нужны. Его фишка в том, чтобы не использовать конструкторы, если параметров, например, много или чтобы не плодить различные вариации конструкторов. У создающих паттернов есть специальный создающий метод(один геттер, конечно есть), который возвращает готовый объект. Так же разновидность этого паттерна может включать дополнительный класс Директор(ы), который собирает объект в заданой последовательности. Книгу о паттернах еще прозвали GoF(банда четырех).