Оборачиваем API с помощью httr2
Пакет httr2
зачастую используется для разработки обёрток над каким нибудь API и предоставление её в виде R пакета, в котором каждая конечная точка API (то есть URL-адрес с параметрами) становится функцией. Эта статья поможет разобраться, как начать с очень простого API, не требующего аутентификации, а затем постепенно приводимые примеры будут усложняться. Попутно вы узнаете, как:
Конвертировать ошибки HTTP запросов в ошибки R;
Работать с различными типами аутентификации;
Ограничить или динамически управлять скоростью отправки запросов, на основе данных из заголовков ответа сервера.
Статья предполагает, что вы уже знакомы с основами разработки пакетов, если это не так рекомендую ознакомиться с главой "The Whole Game" книги "R packages".
Данная статья является достаточно подробной и объёмной, если вы хотите вкратце ознакомится с синтаксисом пакета
httr2
, то я рекомендую начинать со статьи "Работа с API на языке R, введение в пакет httr2".
library(httr2)
Содержание
Если вы интересуетесь анализом данных возможно вам будут полезны мои telegram и youtube каналы. Большая часть контента которых посвящены языку R.
Faker API
Мы начнем с очень простого API, faker API , который предоставляет набор методов для генерации случайных выборок данных. Перед тем как приступить к разработке функции, которые вы могли бы поместить в пакет, мы выполним пробный запрос, что бы разобраться с устройством этого API:
# We start by creating a request that uses the base API url
req <- request("https://fakerapi.it/api/v1")
resp <- req %>%
# Then we add on the images path
req_url_path_append("images") %>%
# Add query parameters _width and _quantity
req_url_query(`_width` = 380, `_quantity` = 1) %>%
req_perform()
# The result comes back as JSON
resp %>% resp_body_json() %>% str()
#> List of 4
#> $ status: chr "OK"
#> $ code : int 200
#> $ total : int 1
#> $ data :List of 1
#> ..$ :List of 3
#> .. ..$ title : chr "Nisi totam nobis non."
#> .. ..$ description: chr "Repellendus natus dolore eius in similique est est. Magnam maiores labore est expedita occaecati tenetur excepturi."
#> .. ..$ url : chr "http://placeimg.com/380/480/any"
Ошибки
Далее есть смысл поэкспериментировать отправив ошибочный запрос, и выяснить отдаёт ли сервер какую то полезную информацию об ошибках. В данном случае вам мешают принятые по умолчанию значения httr2
, т.к. при получении ошибочного HTTP-ответа от сервера (статус ответа отличен от 200), вы автоматически получите сообщение об ошибке, при этом работа функции будет остановлена, и вы не сможете посмотреть тело полученного ответа:
req %>%
req_url_path_append("invalid") %>%
req_perform()
#> Error in `resp_abort()`:
#> ! HTTP 404 Not Found.
Однако вы можете получить доступ к последнему ответу (успешному или нет) с помощью last_response()
:
resp <- last_response()
resp %>% resp_body_json()
#> $status
#> [1] "Not found"
#>
#> $code
#> [1] 404
#>
#> $total
#> [1] 0
Похоже, что в теле ответа никакой полезной информации, описывающей причину возникновения ошибки нет. Иногда информацию об ошибке можно найти в заголовках ответа, давайте проверим:
resp %>% resp_headers()
#> <httr2_headers>
#> Server: nginx
#> Content-Type: application/json
#> Transfer-Encoding: chunked
#> Connection: keep-alive
#> Vary: Accept-Encoding
#> X-Powered-By: PHP/7.3.16
#> Cache-Control: no-cache, private
#> Date: Tue, 10 May 2022 22:33:03 GMT
#> Access-Control-Allow-Origin: *
#> Access-Control-Allow-Methods: GET
#> Access-Control-Allow-Credentials: true
#> Access-Control-Max-Age: 86400
#> Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
#> Content-Encoding: gzip
Судя по всему в заголовках также нет никакой дополнительной, полезной информации об ошибке, поэтому мы можем оставить значение req_error()
без изменений. Далее мы ещё раз попробуем реализовать обработку ошибок при работе с API, который предоставляет более подробную информацию о причине ошибки.
User agent
Если вы планируете написанную над API обёртку представить в виде пакета, то правилом хорошего тона будет подписать пользовательский агент (user agent) отправляющий запросы. Это делается с целью, что бы разработчики API быстро могли определить к кому обращаться, если ваш пакет вызывает большое количество ошибок, или просто приводит к критическим ошибкам в API. Подписать запрос можно с помощью функции req_user_agent()
.
req %>%
req_user_agent("my_package_name (http://my.package.web.site)") %>%
req_dry_run()
#> GET /api/v1 HTTP/1.1
#> Host: fakerapi.it
#> User-Agent: my_package_name (http://my.package.web.site)
#> Accept: */*
#> Accept-Encoding: deflate, gzip, br
Основная функция генерации запроса
Сделав несколько успешных запросов к изучаемому API стоит обратить внимание, есть ли какие-нибудь общие паттерны запросов к различным конечным точкам. Делается это с целью разработки основной функции пакета, генерирующей основу HTTP запроса для всех остальных функций.
Немного изучив документацию Faker API я отметил некоторые общие паттерны:
Каждый URL-адрес имеет форму
https://fakerapi.it/api/v1/{resource}
, и данные передаются ресурсу с параметрами запроса. Все параметры начинаются с_
.Каждый ресурс имеет три общих параметра запроса:
_locale
,_quantity
и_seed
.Все конечные точки возвращают данные в виде JSON структуры.
Это привело меня к созданию следующей функции:
faker <- function(resource, ..., quantity = 1, locale = "en_US", seed = NULL) {
params <- list(
...,
quantity = quantity,
locale = locale,
seed = seed
)
names(params) <- paste0("_", names(params))
request("https://fakerapi.it/api/v1") %>%
req_url_path_append(resource) %>%
req_url_query(!!!params) %>%
req_user_agent("my_package_name (http://my.package.web.site)") %>%
req_perform() %>%
resp_body_json()
}
str(faker("images", width = 300))
#> List of 4
#> $ status: chr "OK"
#> $ code : int 200
#> $ total : int 1
#> $ data :List of 1
#> ..$ :List of 3
#> .. ..$ title : chr "Nihil beatae tenetur minus."
#> .. ..$ description: chr "Provident pariatur iste consequatur enim id neque. Odio blanditiis libero aut. Accusantium ipsam et ex est."
#> .. ..$ url : chr "http://placeimg.com/300/480/any"
Тут я сделал несколько важных решений:
Я решил указать значения по умолчанию для параметров
quantity
иlocale
. Это упрощает демонстрацию моей функции в этой статье.Я использовал значение по умолчанию
NULL
для аргументаseed
.req_url_query()
автоматически отбрасывает аргументы со значениемNULL
, это означает, что в API не отправляется значение по умолчанию, но когда вы смотрите определение функции, вы видите, что значениеseed
установлено.Я автоматически добавляю ко всем параметрам запроса префикс,
_
т.к. имена параметров в API начинаются с_
.Моя функция генерирует запрос, выполняет его и извлекает тело ответа. Такой подход будет работать в общих случаях с простыми API, для более сложных API возможно вам будет удобнее вернуть объект запроса, который можно изменить перед выполнением.
Я использовал один приём: req_url_query()
использует динамические точки, поэтому можно использовать !!!
для их преобразования, например req_url_query(req, !!!list(`_quantity` = 1, `_locale` = "en_US"))
конвертируется в req_url_query(req, `_quantity` = 1, `_locale` = "en_US")
.
Обёртывание конечных точек
faker()
является довольно обобщённой функцией — это хороший инструмент для разработчика пакета, т.к. вы можете прочитать документацию Faker API и перевести ее в вызов функции. Но это не очень удобно для пользователя пакета, который может ничего не знать о веб-API, и тем более об особенностях устройства Faker API и параметров вызовов отдельных его методов. Поэтому следующим шагом в процессе разработки пакета - обёртки к API является обертывание отдельных конечных точек их собственными функциями.
Например, возьмем конечную точку persons
с тремя дополнительными параметрами: gender
(мужчина или женщина), birthday_start
и birthday_end
. Простейшая обёртка этой конечной точки будет выглядеть примерно следующим образом:
faker_person <- function(gender = NULL, birthday_start = NULL, birthday_end = NULL, quantity = 1, locale = "en_US", seed = NULL) {
faker(
"persons",
gender = gender,
birthday_start = birthday_start,
birthday_end = birthday_end,
quantity = quantity,
locale = locale,
seed = seed
)
}
str(faker_person("male"))
#> List of 4
#> $ status: chr "OK"
#> $ code : int 200
#> $ total : int 1
#> $ data :List of 1
#> ..$ :List of 10
#> .. ..$ id : int 1
#> .. ..$ firstname: chr "Terence"
#> .. ..$ lastname : chr "Reinger"
#> .. ..$ email : chr "brennan.effertz@barton.com"
#> .. ..$ phone : chr "+8608217930964"
#> .. ..$ birthday : chr "2021-06-01"
#> .. ..$ gender : chr "male"
#> .. ..$ address :List of 10
#> .. .. ..$ id : int 0
#> .. .. ..$ street : chr "950 Barrows Plains Suite 474"
#> .. .. ..$ streetName : chr "Barrows Extensions"
#> .. .. ..$ buildingNumber: chr "864"
#> .. .. ..$ city : chr "North Cicero"
#> .. .. ..$ zipcode : chr "39030"
#> .. .. ..$ country : chr "Tokelau"
#> .. .. ..$ county_code : chr "TD"
#> .. .. ..$ latitude : num -57.3
#> .. .. ..$ longitude : num -40.4
#> .. ..$ website : chr "http://mills.com"
#> .. ..$ image : chr "http://placeimg.com/640/480/people"
Можно сделать эту функцию ещё более удобной для пользователя, проверив типы ввода и преобразовав полученный результат в таблицу. Я по-быстрому накидал небольшой вариант преобразования полученного ответа в таблицу с использованием функционала пакета purrr
; в зависимости от ваших потребностей и предпочтений вы можете использовать для той же операции базовый R или tidyr::hoist()
.
library(purrr)
faker_person <- function(gender = NULL, birthday_start = NULL, birthday_end = NULL, quantity = 1, locale = "en_US", seed = NULL) {
if (!is.null(gender)) {
gender <- match.arg(gender, c("male", "female"))
}
if (!is.null(birthday_start)) {
if (!inherits(birthday_start, "Date")) {
stop("`birthday_start` must be a date")
}
birthday_start <- format(birthday_start, "%Y-%m-%d")
}
if (!is.null(birthday_end)) {
if (!inherits(birthday_end, "Date")) {
stop("`birthday_end` must be a date")
}
birthday_end <- format(birthday_end, "%Y-%m-%d")
}
json <- faker(
"persons",
gender = gender,
birthday_start = birthday_start,
birthday_end = birthday_end,
quantity = quantity,
locale = locale,
seed = seed
)
tibble::tibble(
firstname = map_chr(json$data, "firstname"),
lastname = map_chr(json$data, "lastname"),
email = map_chr(json$data, "email"),
gender = map_chr(json$data, "gender")
)
}
faker_person("male", quantity = 5)
#> # A tibble: 5 × 4
#> firstname lastname email gender
#> <chr> <chr> <chr> <chr>
#> 1 Trey Kassulke haufderhar@konopelski.net male
#> 2 Weldon Stiedemann elta.wolf@yahoo.com male
#> 3 Leonard Runolfsson francisco.jacobson@hotmail.com male
#> 4 Rashawn Hegmann fstroman@hotmail.com male
#> 5 Derick Crooks nikolaus.russel@gmail.com male
Следующими шагами разработки пакета будет экспорт и документирование этой функции; Я не буду описывать в данной статье эти операции т.к. они не касаются непосредственно httr2
.
Управление секретными данными
Немного отвлечёмся от работы непосредственно с вызовами API и поговорим об управлении секретными данными. Секретные данные важны т.к. практически каждый API с которым вы будете работать, за исключением очень простых вроде Faker API, будут требовать от вас некой идентификации, зачастую идентификация пользователей в API реализована через ключи API или токены.
Описанный в этом разделе подход может быть для вас избыточным. Например, если у вас всего один токен, который вы используете в нескольких скриптах пакета, то достаточно будет поместить его в файл .Renviron
и обращаться к нему с помощью Sys.getenv()
. Но со временем количество хранимых API ключей и токенов будет расти, и вам потребуется разобраться с более эффективными способами хранения и распространения секретных данных, которые вам предоставляет пакет httr2.
Основы
httr2
предоставляет вам функции secret_encrypt()
и secret_decrypt()
позволяющие шифровать секретные данные, и использовать их в своём коде не беспокоясь о том, что они попадут в третьи руки. Процесс шифрования состоит из трёх основных шагов:
С помощью функции
secret_make_key()
создаётся ключ шифрования, который используется для шифрования и дешифрования секретов с использованием симметричной криптографии:
key <- secret_make_key()
key
#> [1] "-6cGNKmH2WTfH5pVUll-sg"
(Обратите внимание, что в secret_make_key()
используется криптографически безопасный генератор случайных чисел, предоставляемый OpenSSL; на него не влияют настройки RNG R, и нет никакого способа сделать его воспроизводимым.)
Далее шифруете секретные данные с помощью
secret_encrypt()
и сохраняете полученный текст непосредственно в исходном коде вашего пакета:
secret_scrambled <- secret_encrypt("secret I need to work with an API", key)
secret_scrambled
#> [1] "ohd9iBHJ66k5j8trIPVeENIPmINN2YWs4ceD1l6tz3B8GjotwFhI4f92lHDCSW_p6A"
При необходимости вы дешифруете ваши данные, используя
secret_decrypt()
:
secret_decrypt(secret_scrambled, key)
#> [1] "secret I need to work with an API"
Пакетные ключи и секретные данные
Вы можете создать любое количество ключей шифрования, но я настоятельно рекомендую создавать один ключ для каждого пакета, который я буду называть ключом пакета. В этом разделе я покажу, как сохранить этот ключ, так чтобы к нему имели доступ только вы и написанные вами автоматические тесты.
В httr2
заложена идея, что ключ должен хранится в переменной окружения. Итак, первый шаг — сделать созданный вами ключ пакета доступным на вашем локальном компьютере, добавив строку с переменной на уровене пользователя в файл .Renviron
(который вы можете открыть или при необходимости создать с помощью usethis::edit_r_environ()
):
YOURPACKAGE_KEY=key_you_generated_with_secret_make_key
Теперь (после перезапуска R) вы сможете воспользоваться специальной возможностью secret_encrypt()
и secret_decrypt()
: аргументом key
может быть имя переменной среды, а не сам ключ шифрования. На самом деле, это наиболее эффективное использование данного аргумента.
secret_scrambled <- secret_encrypt("secret I need to work with an API", "YOURPACKAGE_KEY")
secret_scrambled
#> [1] "aoErRT9hj9M5N_zFZ4ehQIdKTKplbwaCovmYwrtpLkYt1HKa4aiKBWxriMjtpV2KBA"
secret_decrypt(secret_scrambled, "YOURPACKAGE_KEY")
#> [1] "secret I need to work with an API"
Вам также нужно будет сделать ключ доступным в GitHub Actions вашего репозитория (как check, так и pkgdown), чтобы к ключю имели доступ ваши автоматические тесты. Для этого требуется два шага:
Добавьте ключ в раздел repository secrets.
Расшарьте ключ на рабочие процессы, которым он нужен, добавив строку в соответствующий рабочий процесс:
env:
YOURPACKAGE_KEY: ${{ secrets.YOURPACKAGE_KEY }}
Вы можете посмотреть, как это реализовано в httr2
в рабочем процессе GitHub.
Другие платформы непрерывной интеграции предлагают аналогичные способы сделать ключ доступным в качестве безопасной переменной среды.
Когда ключ пакета недоступен
Есть несколько важных случаев, когда ваш код не будет иметь доступа к ключу вашего пакета: в CRAN, на личных машинах внешних разработчиков и при прогонке автоматических тестов. Поэтому, если вы хотите поделиться своим пакетом на CRAN или облегчить другим пользователям возможность внести свой вклад в его развитие, вам нужно убедиться, что ваши примеры, виньетки и тесты работают без ошибок:
В виньетках вы можете запустить
knitr::opts_chunk(eval = secret_has_key("YOURPACKAGE_KEY"))
, чтобы код внутри чанков выполнялся только в том случае, если ваш ключ доступен.В примерах вы можете окружить блоки кода, для которых требуется ключ, с помощью
if (httr2::secret_has_key("YOURPACKAGE_KEY")) {}
.Тесты не требуют от вас дополнительных действий, т.к. когда
secret_decrypt()
запускается вtestthat
, он автоматически запускаетskip()
для пропуска теста, если ключ недоступен.
NYTimes Books API
Далее мы рассмотрим NYTimes Books API. Данный API требует от вас простую авторизацию через ключи API, которыми необходимо подписывать каждый отправляемый запрос. Разрабатывая пакет для работы с API требующий указания API ключи в каждом запросе, вы столкнётесь с двумя проблемами:
Как организовать авто тесты не раскрывая свой ключ API;
Как упростить пользователям пакета передачу API ключа в каждый запрос, не дублируя его в каждую отдельную функцию.
Итак, на данном этапе вам уже понятно, как работает приведённый ниже код для получения моего ключа API NYTimes Book:
my_key <- secret_decrypt("4Nx84VPa83dMt3X6bv0fNBlLbv3U4D1kHM76YisKEfpCarBm1UHJHARwJHCFXQSV", "HTTR2_KEY")EY")
Я начну с решения первой проблемы, ко второму мы вернёмся в самом конце этого раздела, потому что с ним проще разобраться, когда у нас есть готовая функция.
В соображении безопасности
Обратите внимание, передавать API ключ в качестве параметра запроса относительно небезопасно; этот метод аутентификации используется в случае когда сам ключ API не даёт доступа к выполнеию каких то критических действий от вашего имени, и такие ключи обычно легко генерируются. В данном примере потребуется не больше пары минут, чтобы сгенерировать собственный ключ API NYTimes, поэтому вряд ли кто-то будет тратить своё время ради того, что бы получить ваш API ключ.
Основная проблема передачи учетных данных через параметры URL-адреса заключается в том, что их легко раскрыть, т.к. в httr2
не предусмотрены механизмы скрытыя конфеденциальной информации хранящейся в параметрах запроса. Это означает, что есть не малая вероятность раскрыть свой ключ, используя req_perform(verbosity = 1)
, req_dry_run()
или даже просто выведя объект запроса в консоль. Вы увидите это в приведенных ниже примерах — это плохая практика для реального пакета, но в данном случае это не проблема, т.к. ключ NYTimes Books API не даёт каких либо привилегий.
Базовый запрос
Теперь давайте выполним тестовый запрос и посмотрим на ответ:
resp <- request("https://api.nytimes.com/svc/books/v3") %>%
req_url_path_append("/reviews.json") %>%
req_url_query(`api-key` = my_key, isbn = 9780307476463) %>%
req_perform()
resp
Как и большинство современных API, NYTimes Books API возвращает результат в JSON формате:
resp %>%
resp_body_json() %>%
str()
Прежде чем привести этот код в вид функции немного поэксперементируем с ошибочными запросами.
Обработка ошибок
Что произойдет, в случае ошибки? Например, если мы преднамеренно предоставим неверный ключ:
resp <- request("https://api.nytimes.com/svc/books/v3") %>%
req_url_path_append("/reviews.json") %>%
req_url_query(`api-key` = "invalid", isbn = 9780307476463) %>%
req_perform()
Посмотреть, есть ли в ответе какая-либо дополнительная полезная информация, можно с помощью last_response():
resp <- last_response()
resp
resp %>% resp_body_json()
Полезную дополнительную информация об ошибке можно найти в faultstring
:
resp %>% resp_body_json() %>% .$fault %>% .$faultstring
Для того, что бы наш пакет выводил эту дополнительную информацию об ошибках полученных в ходе работы с API необходимо использовать функцию req_error()
и её аргумент body
. В body
необходимо передать функцию, принимающую в качестве аргумента объект ответа от сервера, и возвращающую строку с дополнительной информацией о причине ошибке. Давайте попробуем доработать наш запрос:
nytimes_error_body <- function(resp) {
resp %>% resp_body_json() %>% .$fault %>% .$faultstring
}
resp <- request("https://api.nytimes.com/svc/books/v3") %>%
req_url_path_append("/reviews.json") %>%
req_url_query(`api-key` = "invalid", isbn = 9780307476463) %>%
req_error(body = nytimes_error_body) %>%
req_perform()
Ограничения скорости
Другим распространенным источником ошибок является ограничение скорости — этот лимит используется многими серверами, для избежание черезмерного потребления ресурсов одним пользователем. На странице часто задаваемых вопросов описаны ограничения скорости для API NYT:
Существует два ограничения скорости: 4000 запросов в день и 10 запросов в минуту. Вы должны выдержать паузу в 6 секунд между запросами, чтобы избежать превышения предельного лимита количества отправленных запросов в минуту. Если вам нужен более высокий предел скорости, свяжитесь с нами по дресу code@nytimes.com.
Не редко API в ответе возвращают допонительную информацию, о том, какую паузу необходимо выждать для успешной отправки следующего запроса, если вы превысили какой то из описанных выще лимитов. Часто эта информация хранится в заголовке Retry-After
.
Я намеренно нарушил лимит скорости, быстро сделав 11 запросов; к сожалению, хотя код статуса ответа был стандартным 429 (Too many requests), он не содержал ни в теле ответа, ни в заголовках никакой информации о том, какую паузу необходимо выдержать перед отправкой следующего запроса. Это означает, что мы не можем использовать req_retry()
, которая ожидает информацию о времени таймаута в ответе сервера. Вместо этого мы будем использовать req_throttle()
, которая позволяет ограничить количество отправляемых запросов, в данном случае мы будем уверены, что отправляем не более 10 запросов каждые 60 секунд:
req <- request("https://api.nytimes.com/svc/books/v3") %>%
req_url_path_append("/reviews.json") %>%
req_url_query(`api-key` = "invalid", isbn = 9780307476463) %>%
req_throttle(10 / 60)
По умолчанию req_throttle()
разделяет ограничение на все запросы к указанному хосту (т.е. api.nytimes.com
). Поскольку документы предполагают, что ограничение скорости применяется к отдельным конечным точкам API, вы можете использовать аргумент realm
, чтобы более точно определить конечную точку, на которую действует указанное вами ограничение скорости отправки запроса:
req <- request("https://api.nytimes.com/svc/books/v3") %>%
req_url_path_append("/reviews.json") %>%
req_url_query(`api-key` = "invalid", isbn = 9780307476463) %>%
req_throttle(10 / 60, realm = "https://api.nytimes.com/svc/books")
Оборачиваем функцию
Объединение всех вышеперечисленных примеров дает примерно такую функцию:
nytimes_books <- function(api_key, path, ...) {
request("https://api.nytimes.com/svc/books/v3") %>%
req_url_path_append("/reviews.json") %>%
req_url_query(..., `api-key` = api_key) %>%
req_error(body = nytimes_error_body) %>%
req_throttle(10 / 60, realm = "https://api.nytimes.com/svc/books") %>%
req_perform() %>%
resp_body_json()
}
drunk <- nytimes_books(my_key, "/reviews.json", isbn = "0316453382")
drunk$results[[1]]$summary
Чтобы доработать этот код, до уровня пакета, надо:
Добавить явные аргументы и убедиться, что они имеют правильный тип.
Задокументировать и экспортировать функцию.
Преобразовать полученный список в более удобную для пользователя структуру данных (возможно, в фрейм данных с одной строкой на обзор).
Также лучше предоставить пользователю удобный способ использовать свой собственный ключ API.
Пользовательский ключ
Хорошим местом для хранения API ключа являются переменные среды, т.к. их легко установить, не вводя ничего в консоли (которая может быть случайно передана через ваш файл .Rhistory
), и их легко установить в автоматизированных процессах. Затем вы должны написать функцию для получения ключа API, возвращающую сообщение, если он не найден:
get_api_key <- function() {
key <- Sys.getenv("NYTIMES_KEY")
if (identical(key, "")) {
stop("No API key found, please supply with `api_key` argument or with NYTIMES_KEY env var")
}
key
}
Теперь можно доработать nytimes_books()
, и использовать get_api_key()
как значение по умолчанию для аргумента api_key
. Поскольку аргумент теперь является необязательным, мы можем переместить его в конец списка аргументов, так как он понадобится только в исключительных случаях.
nytimes_books <- function(path, ..., api_key = get_api_key()) {
...
}
Вы можете сделать этот подход более удобным для пользователя, предоставив вспомогательную функцию, которая устанавливает переменную среды:
set_api_key <- function(key = NULL) {
if (is.null(key)) {
key <- askpass::askpass("Please enter your API key")
}
Sys.setenv("NYTIMES_KEY" = key)
}
Использование askpass()
(или её аналогов) является хорошей практикой, поскольку это даёт возможность скрыть вводимый пользователем ключь, в отличае от использования для этого консоли.
Рекомендуется доработать get_api_key()
добавив автоматическое использование зашифрованного ключа, чтобы упростить написание авто тестов:
get_api_key <- function() {
key <- Sys.getenv("NYTIMES_KEY")
if (!identical(key, "")) {
return(key)
}
if (is_testing()) {
return(testing_key())
} else {
stop("No API key found, please supply with `api_key` argument or with NYTIMES_KEY env var")
}
}
is_testing <- function() {
identical(Sys.getenv("TESTTHAT"), "true")
}
testing_key <- function() {
secret_decrypt("4Nx84VPa83dMt3X6bv0fNBlLbv3U4D1kHM76YisKEfpCarBm1UHJHARwJHCFXQSV", "HTTR2_KEY")
}
Github Gists API
Gist — это сервис хранения фрагментов кода, созданный командой GitHub. Также пользователи могут использовать его в паре с текстовым редактором Sublime Text, что даёт возможность сохранять фрагмент кода в пару кликов.
Далее мы рассмотрим API, который может вносить изменения от имени пользователя, а не просто извлекать данные: GitHub gist API . При этом используются разные методы HTTP для выполнения разных действий, таких как создание, обновление и удаление gist'ов. Но прежде чем мы перейдем к ним, давайте разберемся с аутентификацией, ограничением скорости и ошибками.
Аутентификация
Самый простой способ пройти аутентификацию с помощью GitHub API — использовать персональный токен доступа. Токен — это альтернатива сочетанию имени пользователя и пароля. У вас есть один логин + пароль на сайте; так же у вас может быть сгенерирован один токен на каждый вариант использования. Это позволяет каждому варианту использования иметь минимальный набор разрешений, и вы можете легко отозвать один токен, не затрагивая другие.
Я создал персональный токен доступа специально для этой статьи, который имеет единственное разрешение - gist, и, как и в предыдущем примере, сохранил его в зашифрованном виде:
token <- secret_decrypt("Guz59woxKoIO_JVtp2IzU3mFIU3ULtaUEa8xvvpYUBdVthR8jhxzc3bMZFhA9HL-ZK6YZudOI6g", "HTTR2_KEY")
Если хотите запустить приведённые в этом разделе примеры создайте персональный токен в настройках GitHub, и дайде ему доступ к разрешению gist. Старайтесь давать токенам понятное описательное имя, которое будет напоминать вам о его мотивирующем варианте использования.
Чтобы аутентифицировать запрос с помощью персонального токена, его необходимо передать в заголовке Authorization
с префиксом «token» :
req <- request("https://api.github.com/gists") %>%
req_headers(Authorization = paste("token", token))
req %>% req_perform()
В связи с тем, что заголовок Authorization
зачастую содержит секретную информацию httr2
по умолчанию редактирует её.
req
req %>% req_dry_run()
Ошибки
После того, как у вас заработает аутентификация, стоит поработать над обработкой ошибок, так как это поможет вам отлаживать неудачные запросы. По моему опыту, документация API зачастую описывает ошибки очень поверхностно, поэтому вам придется немного поэкспериментировать. К тому же, не редко в больших API разные конечные точки возвращают разный объём информации в разных формах об ошибках. Работа над ошибками осуществляется итеративно, улучшая код каждый раз, когда сталкиваетесь с новой проблемой.
Хоть GitHub и документирует свои ошибки , я всё же рекомендую, создать заведемно ошибочный запрос и посмотреть, что произойдет:
resp <- request("https://api.github.com/gists") %>%
req_url_query(since = "abcdef") %>%
req_headers(Authorization = paste("token", token)) %>%
req_perform()
Согласно документации, я получаю ошибку 422 «Unprocessable Entity». Но структура ответа отличается от описанной в документации, которая предполагает, что должна быть строка message
и список errors
:
resp <- last_response()
resp
resp %>% resp_body_json()
В любом случае нам необходимо написать функцию, которая извлекает данные и форматирует их для представления информации о причине ошибки конечному пользователю пакета:
gist_error_body <- function(resp) {
body <- resp_body_json(resp)
message <- body$message
if (!is.null(body$documentation_url)) {
message <- c(message, paste0("See docs at <", body$documentation_url, ">"))
}
message
}
gist_error_body(resp)
Теперь эту функцию можно передать в аргумент body
функции req_error()
и она будет автоматически включена в вывод сообщения об ошибке при сбое запроса:
request("https://api.github.com/gists") %>%
req_url_query(since = "yesterday") %>%
req_headers(Authorization = paste("token", token)) %>%
req_error(body = gist_error_body) %>%
req_perform()
Обратите внимание, что каждый элемент вектора, созданный с помощью gh_error_body()
становится маркером в результирующем сообщении об ошибке.
Ограничение скорости отправки запросов
Далее стоит обратить внимание, что произойдет, если запросы будут ограничены по скорости. К счастью, GitHub постоянно информирует об оставшихся лимитах на выполнение запроса с помощью заголовков ответов.
resp <- req %>% req_perform()
resp %>% resp_headers("ratelimit")
В httr2
предусмотрен встроенный механизм остановки и ожидания паузы, при достижении лимитов скорости. Для этого необходимо определить две функции. Первая проверяет, содержит ли ответ ошибку связанную с тайм аутом сервера, т.е. есть ли смысл выждать определённую паузу и повторно отправить запрос. GitHub API при превышении лимита скорости отдаёт ответ со статусом 403 и заголовком X-RateLimit-Remaining: 0
:
gist_is_transient <- function(resp) {
resp_status(resp) == 403 &&
resp_header(resp, "X-RateLimit-Remaining") == "0"
}
gist_is_transient(resp)
Затем нам нужна функция, подсказывающая, какую паузу необходимо выждать перед повторной попыткой отправки запроса. GitHub отдаёт информацию об отметке времени, когда именно будет завершен таймаут сервера, в UNIXTIME формате (количество секунд с 1970-01-01) в заголовке X-RateLimit-Reset
. Для того, что бы преобразовать эти данные в количество секунд ожидания, мы сначала преобразуем его в число (поскольку заголовки HTTP всегда являются строками), а затем вычтем текущее время (в секундах с 1970-01-01):
gist_after <- function(resp) {
time <- as.numeric(resp_header(resp, "X-RateLimit-Reset"))
time - unclass(Sys.time())
}
gist_after(resp)
Затем мы передаем созданные только что функции в req_retry()
, с целью передать httr2
информацию, необходимую для автоматической обработки ограничения скорости:
request("http://api.github.com") %>%
req_retry(
is_transient = gist_is_transient,
after = gist_after,
max_seconds = 60
)
К тому же, для активации req_retry()
вам обязательно необходимо использовать один из аргументов: max_tries
или max_seconds
.
Оборачиваем в функцию
Давайте объединим все, что мы выяснили в этом разделе, в одну функцию, которая создает запрос:
req_gist <- function(token) {
request("https://api.github.com/gists") %>%
req_headers(Authorization = paste("token", token)) %>%
req_error(body = gist_error_body) %>%
req_retry(
is_transient = gist_is_transient,
after = gist_after
)
}
# проверим как она работает:
req_gist(token) %>%
req_perform()
Мы будем использовать этот пример как основу для решения следующей задачи: отправки gist.
Отправка данных
Для того, что бы создать Gist нам необходимо изменить метод запроса на POST, и добавить тело запроса, с описанием создаваемого Gist в JSON формате. Функция req_body_json()
выполняет оба действия:
req <- req_gist(token) %>%
req_body_json(list(
description = "This is my cool gist!",
files = list(test.R = list(content = "print('Hi!')")),
public = FALSE
))
req %>% req_dry_run()
В зависимости от требований API, который вы оборачиваете, вам может понадобится отправлять данные закодированные другим способом. req_body_form()
и req_body_multipart()
упростят кодирование данных в двух других распространенных формах. Если API требует какой то другой формат, то вы можете использовать req_body_raw()
.
Как правило, API возвращает некоторые полезные данные о только что созданном ресурсе. В данном случае я извлеку из ответа идентификатор gist, для того, чтобы использовать его в следующих примерах, завершая удалением gist, дабы не плодить кучу повторяющихся сущностей.
resp <- req %>% req_perform()
id <- resp %>% resp_body_json() %>% .$id
id
Изменение Gist
Для изменение существующей Gist необходимо снова отправить данные в формате JSON, но на этот раз методом PATCH
. Поэтому после добавления данных в тело запроса я переопределяю метод с помощью req_method()
:
req <- req_gist(token) %>%
req_url_path_append(id) %>%
req_body_json(list(description = "This is a simple gist")) %>%
req_method("PATCH")
req %>% req_dry_run()
Удаление Gist
Удаление Gist аналогично двум предыдущим примерам, за исключением того, что мы не отправляем никаких данных, нам просто надо изменить метод по умолчанию с GET
на DELETE
.
req <- req_gist(token) %>%
req_url_path_append(id) %>%
req_method("DELETE")
req %>% req_dry_run()
req %>% req_perform()
OAuth
Если API предоставляет доступ к веб-сайту, на котором у пользователя уже есть учетная запись (например, Twitter, Instagram, Facebook, Google, GitHub и т. д.), он, скорее всего, будет использовать протокол OAuth, чтобы позволить вам авторизоваться от имени пользователя. OAuth — это система авторизации, разработанная таким образом, чтобы вам не приходилось передавать своё имя пользователя и пароль приложению; вместо этого приложение запрашивает разрешение на использование вашей учетной записи. Наверняка вы сталкивались с этой схемой авторизации ранее; она используется в большинстве случаев, когда один веб-сайт использует другой веб-сайт от вашего имени.
OAuth — это широкая система аутентификации, которая имеет множество различных реализаций, что затрудняет предоставление универсальных советов. Следующий пример основан на моем опыте работы с несколькими OAuth, использующими API, но вполне вероятно, что вам придётся дорабатывать этот пример при работе с другими API.
Oauth Клиенты
Первым шагом в работе с любым API поддерживающим OAuth является создание клиента. Этот процесс включает в себя регистрацию учетной записи разработчика на веб-сайте API и создание нового приложения OAuth. Процесс создание клиента различается от API к API, но после его создания вы получите идентификатор клиента и, в большинстве случаев, секрет клиента.
(Созданный клиент понадобится вам для тестирования вашего пакета, и наверняка вы включите его в его финальную сборку. Встраивание клиента в пакет удобно для его пользователей, но не всегда реализуемо, особенно если лимиты скорости отправки запросов в API установлены на уровне конкретного клиента, а не пользователя. В любом случае вы должны предоставить пользователю пакета возможность пройти аутентификацию используя собственный клиент.)
Если API позволяет пройти аутентификации вашего приложения не передавая секрет клиента, лучше исключить секрет из кода вашего пакета, но в большинстве случаев вам придётся его оставить. Вы можете скрыть секрет клиента с помощью функции obfuscate()
; этот подход не даёт 100% гарантии защити от утечки секрета приложения, но в большинстве случаев злоумышленнику будет проще зарегистрировать собственный клиент, чем пытаться расшифровать ваш. В любом случае, сам по себе секрет OAuth клиента как правило не позволяет выполнять каких либо критических действий, поэтому даже если кто-то его украдет, он не сможет причинить большого вреда.
Для шифровки секрета клиента используйте obfuscate()
:
obfuscate("secret")
Затем используйте идентификатор клиента с веб-сайта вместе с зашифрованным секретом для создания объекта клиента. В следующем примере кода продемонстрированно приложение GitHub OAuth, которое я создал специально для этой статьи:
client <- oauth_client(
id = "28acfec0674bb3da9f38",
secret = obfuscated("J9iiGmyelHltyxqrHXW41ZZPZamyUNxSX1_uKnvPeinhhxET_7FfUs2X0LLKotXY2bpgOMoHRCo"),
token_url = "https://github.com/login/oauth/access_token",
name = "hadley-oauth-test"
)
Для создания объекта OAuth клиента вам потребуется выяснить token_url
из документации к API с которым вы работаете. Хотел бы я дать хороший совет о том, как его проще найти, но увы универсального совета в данном случае нет.
Обратите внимание, что при печати клиента в консоли, секрет автоматически редактируется:
client
Потоки
После того, как вы создали OAuth клиент, вам нужно использовать его с потоком , чтобы получить токен. OAuth предоставляет ряд различных «потоков», наиболее распространенным из которых является поток «код авторизации», который реализуется с помощью req_oauth_auth_code()
. Вы можете протестировать его, запустив приведённый ниже код:
token <- oauth_flow_auth_code(client, auth_url = "https://github.com/login/oauth/authorize")
Запуск этого кода в интерактивном режиме откроет веб-страницу на GitHub, которая потребует от вас интерактивно подтвердить разрешение на использование вашей учетной записи GitHub.
Другие потоки предоставляют разные способы получения токена:
req_oauth_client_credentials()
используется, когда клиенту необходимо выполнять действия от своего имени (а не от имени какого-либо другого пользователя, например вас). Обычно этот подход используется в связке с сервисными аккаунтами, которые позволяют проходить авторизацию не используя браузер.req_oauth_device()
использует поток «устройство», который предназначен для таких устройств, как телевизоры, которые не имеют простого способа ввода данных. Он также хорошо работает с консоли.req_oauth_bearer_jwt()
использует JWT, подписанный закрытым ключом.req_oauth_password()
обменивает имя пользователя и пароль на токен доступа.req_oauth_refresh()
работает напрямую с токеном обновления, который у вас уже есть. Удобно для тестирования.
Есть один исторически важный поток OAuth, который httr2
не поддерживает: неявный поток предоставления (implicit grant flow). По большому счёту это устаревший поток, который никогда не подходило для нативных приложений, поскольку основан на методе возврата токена доступа, который работает только внутри веб-браузера.
При создании обёртки API вам необходимо внимательно изучить документацию, чтобы выяснить, какие потоки авторизации доступны. Обычно предпочтение отдаётся потоку с обменом кода авторизации на токен (двухэтапная авторизация), но если ваш API не поддерживает данный метод, стоит внимательно изучить все остальные, доступные в нём методы авторизации. Увы, но многие API-интерфейсы реализуют потоки аутентификации по своему, принебрегая существующими спецификациями. Если с первого раза вам неудалось получить токен, придётся какое то время поэксперементировать. Иногда разбор процесса авторизации может быть крайне долгим и утомительным, но других альтернатив нет. При тестировании аторизации я рекомендую использовать with_verbosity()
, что бы детально отслеживать, что именно httr2
отправляет на сервер. Затем полученную детальную информацию необходимо сравнить с тем, что написано в документации, и сыргать в "найди 10 отличий".
Токены
Суть потока авторизации — получить токен. Вы можете использовать req_auth_bearer_token()
для извлечения токен доступа, хранящегося внутри объекта токена:
request("https://api.github.com/user") %>%
req_auth_bearer_token(token$access_token) %>%
req_perform() %>%
resp_body_json() %>%
.$name
#> [1] "Hadley Wickham"
В большиснтве случаев более предпочтительно будет позволить httr2
управлять всем процессом, переключившись с oauth_flow_{name}
на req_oauth_{name}
:
request("https://api.github.com/user") %>%
req_oauth_auth_code(client, auth_url = "https://github.com/login/oauth/authorize") %>%
req_perform() %>%
resp_body_json()
Это важно, поскольку большинство API предоставляют краткосрочные токены, который необходимо регулярно обновлять с помощью токенов обновления, чей срок действия значительно дольше. httr2
автоматически обновит токен, если срок его действия истек или если в запросе возникает ошибка 401, а заголовок invalid_token
содержит сообщение WWW-authenticate
.
Кэширование
По умолчанию req_oauth_auth_code()
и другие функции потоков будут кэшировать токен в памяти, чтобы запросы отправляемые в рамках одного сеанса использовали один и тот же токен. В некоторых случаях может потребоваться сохранить токен, чтобы он автоматически использовался между разными сеансами. Реализовать хранение можно с помощью аргумента cache_disk = TRUE
функции req_oauth_auth_code()
, но вам необходимо тщательно подумать о безопасности хранения токенов доступа на диске.
httr2
реализует всё, чтобы надежно хранить учетные данные сохраняя их в локальном каталоге кеша ( rappdirs::user_cache_dir("httr2"))
который доступен только текущему пользователю и зашифрован, поэтому их будет сложно прочитать другими пакетами, кроме httr2
. Однако нет способа предотвратить использование httr2
другими пользователями R на вашем ПК, и гарантированно защитить токены, от утечки, поэтому, если вы решите кэшировать токены на диске, то необходимо сообщить об этом пользователю вашего пакета и дать ему возможность отказаться.
Посмотреть, какие клиенты кэшировали токены, просмотрев каталог кеша, используемый httr2
можно с помощью приведённого ниже примера кода:
dir(rappdirs::user_cache_dir("httr2"), recursive = TRUE)
httr2
автоматически удаляет все кэшированные токены старше 30 дней при каждой загрузке. Это значит, что вам придётся повторно аутентифицироваться не реже одного раза в месяц, но это предотвратит зависание токенов на диске, если вы забыли, что создали их.