Универсальный API-клиент

Описание

Механизм позволяющий легко построить API-клиент для общения со внешними сервисами.

Особенности клиентов

  • Минимальное взаимодействие с retrofit (Отсутствие взаимодействий с getResponse(Call<*>) конструкциями)

  • Клиенты кэшируются

  • Поддержка изменений конфигурации клиентов во время исполнения

Quick Start

  1. Создаем интерфейс под API-клиент

  2. Отмечаем методы необходимыми аннотациями

  3. Настраиваем конфигурацию клиента

  4. Вызываем созданный API-клиент с помощью фабрики

  5. Profit

1. Заведение интерфейса

В примере будет использоваться несуществующий сервис DummyService.

Сервис DummyService является REST API приложением

Заведём интерфейс DummyServiceAPI.

// Интерфейс сервиса API-клиента
@Api(name = "dummy")
interface DummyServiceAPI

Аннотация @Api

Данная аннотация обязательна для объявления API-клиента.

Параметры

  • name - название (обязательное поле)

  • responseType - формат общения, JSON (по-умолчанию), XML

  • errorType - класс типа ошибки, используется для формирования сообщения об ошибки

Обратите внимание, что @Api.name задает имя клиенту, оно должно быть уникальным, это имя должно использоваться при указании конфигурации клиента (см. 3. Конфигурация сервиса)

Дополнительные аннотации интерфейса указаны в разделе Дополнительные возможности

2. Создание методов

Определим метод для получения объекта из сервиса, и сам получаемый объект.

Эндпоинт /dummy/api/v0/get-wrap/{value} сервиса DummyService возвращает введённую строку как объект {value: String}
// Интерфейс сервиса API-клиента
@Api(name = "dummy")
interface DummyServiceAPI {

    @GET("/dummy/api/v0/get-wrap/{value}")
    fun getWrappedString(@Path("value") value: String): WrappedStringResponse

}

// Модель возвращаемого объекта
data class WrappedStringResponse(
    var value: String
)
Для большего понимания как работать с аннотациями библиотеки retrofit (@GET, @Path, …​) рекомендуется изучить документацию retrofit

Дополнительные аннотации методов и их использование указаны в разделе Дополнительные возможности

3. Конфигурация сервиса

Прежде чем, проверять наш API-клиент, для начала нужно определить конфигурацию. Конфигурация клиента берётся из глобальной конфигурации ERP.

Шаблон настройки клиента

api.{apiname}.{parameter}={value}
# Где:
# apiname - уникальное имя клиента, которое указано в `@Api.name`
# parameter - параметр конфигурации api
# value - значение параметра

Стандартные настройки API

# Название API (по-умолчанию: "")
api.dummy.name=Dummy

# URL API (по-умолчанию: null}
api.dummy.url=http://localhost:8083/

# Игнорирование SSL (по-умолчанию: false)
api.dummy.ignoreSSL=1

Настройки аутентификации API

В данном подразделе, будут поочередно использоваться связки конфигураций для каждого типа аутентификации

NONE (по-умолчанию)

# Авторизация отсутствует
api.dummy.auth.type=NONE

BASIC

 # Простая аутентификация
api.dummy.auth.type=BASIC

# Логин сервиса
api.dummy.auth.username=admin

# Пароль сервиса
api.dummy.auth.password=admin

# Header для отправки данных аутентификации (по-умолчанию Authorization)
api.dummy.auth.header=Authorization

STATIC_TOKEN

# Авторизация по токену
api.dummy.auth.type=STATIC_TOKEN

# Токен сервиса
api.dummy.auth.token=oKwWNJQ2fdFcspWNiZVRno3oqOrbuIwK

# Header для отправки данных аутентификации (по-умолчанию Authorization)
api.dummy.auth.header=Authorization

EXTERNAL_TOKEN

# Авторизация по токен генератору из класса
api.dummy.auth.type=EXTERNAL_TOKEN

# Тип токена {JWT, BEARER} (по-умолчанию JWT)
api.dummy.auth.tokenType=JWT

# Место сохранения {LOCAL, REDIS} (по-умолчанию: LOCAL)
api.dummy.auth.tokenRepositoryProvider=LOCAL

# Время истечения токена в секундах (по-умолчанию: 1 день)
api.dummy.auth.tokenExpireTime=86400

# Header для отправки данных аутентификации (по-умолчанию: Authorization)
api.dummy.auth.header=Authorization

CUSTOM

# Авторизация по собственному обработчику запросов
api.dummy.auth.type=CUSTOM
Подробнее об использовании аутентификации EXTERNAL_TOKEN и CUSTOM будет описано в соответствующих разделах.

Настройки запросов API

# Максимальное время обработки отправки запроса (по-умолчанию: 10000)
api.dummy.http.connectTimeout=10000

# Максимальное время обработки получения запроса (по-умолчанию: 10000)
api.dummy.http.readTimeout=10000

Конфигурация пула потоков

# Количество одновременно отправляемых запросов
# Принимает целое положительное число
# По-умолчанию 64
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.worker.pool.maxRequests=

# Количество одновременно отправляемых запросов в рамках хоста
# Принимает целое положительное число
# По-умолчанию 5
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.worker.pool.maxRequestsPerHost=

# Максимальное количество неиспользуемых активных соединений
# Принимает целое положительное число
# По-умолчанию 5
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.http.pool.idle=

# Количество времени поддержания неиспользумого активного соединения
# Принимает целое положительное число
# По-умолчанию 5
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.http.pool.keepAlive=

# Единица времени
# Принимает одно из значений 'java.util.concurrent.TimeUnit'
# По-умолчанию MINUTES
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.http.pool.timeUnit=

Конфигурация выключателя (Circuit Breaker)

Circuit Breaker (далее - CB)

Может принимать несколько статусов:

  • CLOSE: Нормальное состояние, когда автоматический выключатель позволяет совершать вызовы внешних сервисов.

  • OPEN: Состояние указывает на то, что автоматический выключатель сработал и предотвращает вызовы внешних сервисов. Это делается для того, чтобы предотвратить дальнейшие сбои и дать внешнему сервису время для восстановления.

  • HALF_OPEN: В этом состоянии автоматический выключатель позволяет несколько вызовов внешних сервисов, чтобы проверить, восстановился ли он. Если эти вызовы увенчаются успехом, автоматический выключатель возвращается в состояние CLOSE. Если они терпят неудачу, они возвращаются в OPEN состояние.

# Включен ли CB
# Принимает true/false
# По-умолчанию false
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.enabled=
# Параметр определяет порог процента неудачных вызовов, после которого CB переключится в состояние "OPEN".
# По умолчанию, если более 50% вызовов за последние 10 вызовов завершились неудачно, то CB перейдет в состояние "OPEN".
# Принимает целое число от 1 до 100
# По-умолчанию 50
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.failureRateThreshold=
# Длительность времени, в течение которой CB будет находиться в состоянии "OPEN", прежде чем перейти в состояние "HALF_OPEN".
# По умолчанию, CB будет оставаться в состоянии "OPEN" в течение 10000 (10 секунд).
# Принимает целое положительное число
# По-умолчанию 10000
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.waitDurationInOpenState=
# Порог процента медленных вызовов, при достижении которого CB может перейти в состояние "OPEN".
# По умолчанию, если более 50% вызовов за последние 10 вызовов считаются медленными, то CB может перейти в состояние "OPEN".
# Принимает целое число от 1 до 100
# По-умолчанию 50
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.slowCallRateThreshold=
# Длительность времени, после которой вызов считается медленным.
# По умолчанию, вызов будет считаться медленным, если его длительность превышает 60000 (60 секунд).
# Принимает целое положительное число (в мс)
# По-умолчанию 60000
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.slowCallDurationThreshold=
# Количество разрешенных вызовов в состоянии "HALF_OPEN".
# По умолчанию, CB разрешает выполнение до 3 вызовов в состоянии "HALF_OPEN".
# Принимает целое положительное число
# По-умолчанию 3
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.permittedNumberOfCallsInHalfOpenState=
# Размер окна скользящего среднего, используемого для вычисления процента неудачных вызовов.
# По умолчанию, CB учитывает последние 10 вызовов для вычисления процента неудачных вызовов.
# Принимает целое положительное число
# По-умолчанию 10
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.slidingWindowSize=
# Минимальное количество вызовов, которые должны быть выполнены, прежде чем CB начнет отслеживать процент неудачных вызовов.
# По умолчанию, CB начнет отслеживать процент неудачных вызовов только после выполнения минимум 10 вызовов.
# Принимает целое положительное число
# По-умолчанию 10
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.circuit.breaker.minimumNumberOfCalls=

Конфигурация частоты запросов (Rate Limiter)

RATE LIMITER (далее - RL)

# Включен ли RL
# Принимает true/false
# По-умолчанию false
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.rate.limiter.enabled=
# Параметр определяет длительность ожидания перед тем как запрос будет считаться неудачным из-за превышения лимита.
# По умолчанию, если запрос не может быть выполнен из-за превышения лимита,
# он будет ждать до 10000 (10 секунд) перед тем как считаться неудачным.
# Принимает целое неотрицательное число
# По-умолчанию 10000
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.rate.limiter.timeoutDuration=
# Параметр определяет период времени, через который лимиты будут обновляться.
# По умолчанию, лимиты будут обновляться каждые 500 (0,5 секунды).
# Это означает, что после каждого такого периода времени, количество доступных запросов будет восстанавливаться.
# Принимает целое положительное число
# По-умолчанию 500
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.rate.limiter.limitRefreshPeriod=
# Параметр устанавливает количество доступных запросов за каждый период обновления.
# По умолчанию, установлен лимит в 50 запросов за каждый период обновления.
# Это означает, что клиент может сделать до 50 запросов за каждые 0,5 секунды, после чего лимиты будут восстановлены.
# Принимает целое положительное число
# По-умолчанию 50
# Если указано некорректное значение, то используется значение по-умолчанию
api.dummy.rate.limiter.limitForPeriod=

Конфигурация логирования запросов

# Уровень логирования запросов (по-умолчанию: NONE)
# * NONE - логирование отсутствует
#
# * BASIC - простое логирование
# Пример:
# --> POST /greeting http/1.1 (3-byte body)
# <-- 200 OK (22ms, 6-byte body)
#
# * HEADERS - логирование заголовков
# Пример:
#  --> POST /greeting http/1.1
# Host: example.com
# Content-Type: plain/text
# Content-Length: 3
# --> END POST
#
# <-- 200 OK (22ms)
# Content-Type: plain/text
# Content-Length: 6
# <-- END HTTP
#
# * BODY - полное логирование с содержимым запроса и ответа
# Пример:
#  --> POST /greeting http/1.1
# Host: example.com
# Content-Type: plain/text
# Content-Length: 3
#
# {"Object": 123}
# --> END POST
#
# <-- 200 OK (22ms)
# Content-Type: plain/text
# Content-Length: 6
#
# {"error": 12, "message": "Why u sent me 123?"}
# <-- END HTTP

api.dummy.http.logLevel=NONE

4. Фабрика клиентов

Для получения клиента, доступна фабрика ApiClientFactory, с функцией для получения клиента getClient, принимающий класс API

// Получения клиента
DummyServiceAPI dummyService = context.getService(ApiClientFactory.class).getClient(DummyServiceAPI.class);

// Отправка запроса на получение строки Hello, World! из сервиса Dummy
String text = dummyService.getString("Hello, World!").value;

// Вывод результата
System.out.println(text);

Логи выполнения

[DummyServiceAPI] - --> GET http://localhost:8083/dummy/api/v0/get-wrap/Hello,%20World!
[DummyServiceAPI] - --> END GET
[DummyServiceAPI] - <-- 200 http://localhost:8083/dummy/api/v0/get-wrap/Hello,%20World! (5ms)
[DummyServiceAPI] - X-Content-Type-Options: nosniff
[DummyServiceAPI] - X-XSS-Protection: 1; mode=block
[DummyServiceAPI] - Cache-Control: no-cache, no-store, max-age=0, must-revalidate
[DummyServiceAPI] - Pragma: no-cache
[DummyServiceAPI] - Expires: 0
[DummyServiceAPI] - X-Frame-Options: DENY
[DummyServiceAPI] - Content-Type: application/json
[DummyServiceAPI] - Transfer-Encoding: chunked
[DummyServiceAPI] - Date: Tue, 31 Jan 2023 07:48:53 GMT
[DummyServiceAPI] - Keep-Alive: timeout=60
[DummyServiceAPI] - Connection: keep-alive
[DummyServiceAPI] -
[DummyServiceAPI] - {"value":"Hello, World!"}
[DummyServiceAPI] - <-- END HTTP (25-byte body)
Hello, World!

Дополнительные возможности

Использование конверторов

Для удобного общения между сервисами, поддерживаются конвертеры. На данный момент доступны JsonRpcConverterFactory и SoapConverterFactory позволяющие упростить взаимодействие с биллингом.

Пример API с использованием JsonRpcConverterFactory.

@Api(name = "billRB")
@ConverterFactory(factoryClass = JsonRpcConverterFactoryCreator::class)
interface BillExampleAPI {

    @BillingJsonRequest(method = "state")
    fun getContractState(scid: Int): ContractState

}
Данные конвертеры, преобразуют отправляемые объекты в структурированные по протоколу общения данные и наоборот.

Дополнительные методы аутентификации

Дополнительные методы аутентификации доступны при использовании EXTERNAL_TOKEN, CUSTOM значениях типа аутентификации в конфигурации API.

Работа с EXTERNAL_TOKEN

При работе с типом аутентификации EXTERNAL_TOKEN, класс должен быть "снаряжён" дополнительным классом, который наследует TokenProvider.

Задачей данного класса является получение актуального токена по "нестандартной" схеме получения токена.

// Пример токен провайдера
class DummyServiceTokenProvider(
    apiClass: Class<DummyServiceAPI>,
    apiConfig: ApiClientsConfig.ApiConfig
) : TokenProvider(apiClass, apiConfig) {

    override fun generateToken(): String {
        return "1234-${UUID.randomUUID()}"
    }

}

// Использование аннотации TokenProvider
@Api(name = "dummy")
@TokenProvider(providerClass = DummyServiceTokenProvider::class)
interface DummyServiceAPI {

    @GET("/dummy/api/v0/get-wrap/{value}")
    fun getWrappedString(@Path("value") value: String): WrappedStringResponse

}
Метод generateToken() будет обновлять токен, как только будет приходить ошибка 401 (UNAUTHORIZED) и повторять предыдущий запрос после смены токена.

Работа с CUSTOM

Аутентификация с типом CUSTOM позволяет встроить собственную аутентификацию, которая отличается от token-based сервисов. Используя данный тип аутентификации, можно встроить собственный интерцептор и систему аутентификации.

На официальном сайте okhttp можно прочитать подробнее об интерцепторах и аутентификаторах

При регистрации апи с CUSTOM типом аутентификации, требуется указать класс провайдера аутентификации @AuthenticationProvider.

Пример ниже показывает, как можно сделать собственный интерцептор, если аутентификация происходит по двум токенам в заголовке запроса.

// Пример CUSTOM токен провайдера
class DummyServiceTokenProvider(
    apiClass: Class<DummyServiceAPI>,
    apiConfig: ApiClientsConfig.ApiConfig
) : AuthenticationProvider(apiClass, apiConfig) {


    override fun createInterceptor(): Interceptor = Interceptor { chain ->
        val original = chain.request()

        val request = original
            .newBuilder()
            .addHeader("token1", "91uIVdUIrkfczgfhYxdWxnJ594zm1Jd3lB1wCgMObm4XCVicmKT6MFibRAeFbrw5")
            .addHeader("token2", "4aOiMnXudIzJcG3V30QggS60uDtTUkGk5ZmSlcmvV9kokrJ4xrATz686s0mCDUnA")
            .build()

        chain.proceed(request)
    }

    override fun createAuthenticator() = createDefaultAuthenticator()
}

// Использование аннотации AuthenticationProvider
@Api(name = "dummy")
@AuthenticationProvider(providerClass = DummyServiceTokenProvider::class)
interface DummyServiceAPI {

    @GET("/dummy/api/v0/get-wrap/{value}")
    fun getWrappedString(@Path("value") value: String): WrappedStringResponse

}

Тэги

Существуют аннотации-тэги, которые добавляются к методам для более гибкой настройки запросов.

@NoAuth

Использование тэга NoAuth позволяет пропустить авторизацию. Таким образом, можно обойти рекурсию по авторизации через тот же ресурс.

Пример использования:

import ru.bgcrm.context.core.ServerContext
import ru.bgcrm.util.api.factory.ApiClientFactory

class DummyServiceTokenProvider(
    apiClass: Class<DummyServiceAPI>,
    apiConfig: ApiClientsConfig.ApiConfig
)  : TokenProvider(apiClass, apiConfig) {

    override fun generateToken(): String {
        val api = ServerContext.getService<ApiClientFactory>().getClient(DummyServiceAPI::class.java)
        val result = api.auth(Crendtials("admin", "12345"))

        return result.accessToken
    }

}
import retrofit2.http.Body

@Api(name = "dummy")
@TokenProvider(providerClass = DummyServiceTokenProvider::class)
interface DummyServiceAPI {

    @NoAuth
    @POST("/dummy/api/v0/auth")
    fun auth(@Body credentials): AccessData

    @GET("/dummy/api/v0/get-wrap/{value}")
    fun getWrappedString(@Path("value") value: String): WrappedStringResponse

}

@SoapRequest

Позволяет указать дополнительные параметры запроса, нужен для SoapConverterFactory.

Создание собственного Тэга

Для создания собственного тэга, достаточно создать класс-аннотацию и пометить его аннотацией @Tag.

package ru.bgcrm.util.api.tag

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
@Tag
annotation class NoAuth

Получение тэга

Тэг можно получить в перехватичике запроса, при помощи метода okhttp3.Request::tag на выходе будет доступен объект аннотации метода

Interceptor { chain ->
    val originalRequest = chain.request()
    val noAuth = originalRequest.tag(NoAuth::class.java)
    if (noAuth != null) return@Interceptor chain.proceed(originalRequest)

    // Логика по авторизации

    return@Interceptor chain.proceed(newRequest)
}