Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

httr2 migration and general API interaction cleanup #24

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ BugReports: http://github.com/jemus42/tRakt/issues
Depends:
R (>= 4.1)
Imports:
cli,
dplyr (>= 0.7.0),
httr,
httr2,
jsonlite (>= 0.9.14),
lubridate,
purrr (>= 0.2.3),
rappdirs,
rlang (>= 0.1.2),
stringr,
tibble,
Expand Down
13 changes: 8 additions & 5 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Generated by roxygen2: do not edit by hand

S3method(print,trakt_token)
export(":=")
export(.data)
export(as_label)
Expand All @@ -23,6 +24,7 @@ export(episodes_summary)
export(episodes_translations)
export(episodes_watching)
export(expr)
export(get_token)
export(lists_popular)
export(lists_trending)
export(movies_aliases)
Expand Down Expand Up @@ -78,7 +80,6 @@ export(shows_watched)
export(shows_watching)
export(sym)
export(syms)
export(trakt_credentials)
export(trakt_get)
export(user_collection)
export(user_comments)
Expand All @@ -97,6 +98,10 @@ export(user_ratings)
export(user_stats)
export(user_watched)
export(user_watchlist)
importFrom(cli,cli_alert_danger)
importFrom(cli,cli_alert_info)
importFrom(cli,cli_alert_success)
importFrom(cli,cli_inform)
importFrom(dplyr,across)
importFrom(dplyr,arrange)
importFrom(dplyr,bind_cols)
Expand Down Expand Up @@ -124,9 +129,6 @@ importFrom(httr,config)
importFrom(httr,content)
importFrom(httr,message_for_status)
importFrom(httr,modify_url)
importFrom(httr,oauth2.0_token)
importFrom(httr,oauth_app)
importFrom(httr,oauth_endpoint)
importFrom(httr,status_code)
importFrom(httr,stop_for_status)
importFrom(httr,user_agent)
Expand All @@ -150,6 +152,7 @@ importFrom(purrr,modify_if)
importFrom(purrr,modify_in)
importFrom(purrr,pluck)
importFrom(purrr,set_names)
importFrom(rappdirs,user_cache_dir)
importFrom(rlang,":=")
importFrom(rlang,.data)
importFrom(rlang,.env)
Expand All @@ -170,10 +173,10 @@ importFrom(stringr,str_replace)
importFrom(stringr,str_split)
importFrom(stringr,str_to_lower)
importFrom(stringr,str_trim)
importFrom(stringr,str_trunc)
importFrom(tibble,as_tibble)
importFrom(tibble,enframe)
importFrom(tibble,remove_rownames)
importFrom(tibble,tibble)
importFrom(tidyselect,any_of)
importFrom(utils,browseURL)
importFrom(utils,head)
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# tRakt 0.16.9000 (development version)

- Remove `magrittr` import and use `|>` internally, hence bumping the R dependency to `>= 4.1`.
- Switch from `{httr}` to `{httr2}`. This should have been a small under the hood change, but it also enabled major changes in the way I'm handling API secrets, allowing me to include my encrypted client secret with the package directly. THis should make it more convenient for users to make authenticated requests without having to register their own app on trakt.tv.

# tRakt 0.16.0

Expand Down
197 changes: 197 additions & 0 deletions R/api-oauth.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#' Get a trakt.tv API OAuth token
#'
#' This is an unfortunately home-brewed version of what _should_ be a simple call to [`httr2::oauth_flow_device()`],
#' but since the <trakt.tv> API plays ever so slightly fast and mildly loose with RFC 8628, that's not possible.
#'
#' @note RFC 8628 expects the device token request to have the field "device_code",
#' but the trakt.tv API expects a field named "code". That's it. It's kind of silly.
#'
#' @param cache [`TRUE`]: Cache the token to the OS-specific cache directory. See [rappdirs::user_cache_dir()].
#'
#' @export
#' @importFrom rappdirs user_cache_dir
#' @importFrom cli cli_alert_info
#' @importFrom utils browseURL
#' @examples
#' if (FALSE) {
#'
#' get_token(cache = TRUE)
#'
#' }
#'
get_token <- function(cache = TRUE) {

# Checking cache for a token first
cache_loc <- file.path(rappdirs::user_cache_dir("tRakt"), "token.rds")
if (file.exists(cache_loc)) {
token <- readRDS(cache_loc)

if (token_expired(token)) {
token <- refresh_token(token)
cache_token(token)
}

return(token)
}

# Getting a new token
device_response <- httr2::request("https://api.trakt.tv/oauth/device/code") |>
httr2::req_headers("Content-Type" = "application/json") |>
httr2::req_body_json(data = list(client_id = get_client_id())) |>
httr2::req_perform() |>
httr2::resp_body_json()

cli::cli_alert_info("Navigate to {.url {device_response$verification_url}} and enter {.strong {device_response$user_code}}")

if (interactive()) {
utils::browseURL(device_response$verification_url)
}

token <- oauth_device_token_poll(device_response)
if (cache) cache_token(token)
token
}

#' @keywords internal
#' @noRd
oauth_device_token_poll <- function(request) {
cli::cli_progress_step("Waiting for response from server", spinner = TRUE)

delay <- request$interval %||% 5
deadline <- Sys.time() + request$expires_in

token <- NULL
while (Sys.time() < deadline) {
for (i in 1:20) {
cli::cli_progress_update()
Sys.sleep(delay / 20)
}

token <- httr2::request("https://api.trakt.tv/oauth/device/token") |>
httr2::req_headers("Content-Type" = "application/json") |>
httr2::req_body_json(data = list(
code = request$device_code,
client_id = get_client_id(),
client_secret = get_client_secret(),
grant_type = "urn:ietf:params:oauth:grant-type:device_code"
)) |>
httr2::req_error(is_error = \(x) FALSE) |>
httr2::req_perform()

# 200: All good, got a token, can move on
if (httr2::resp_status(token) == 200) break
# 400: Pending, waiting for user to authorize, keep going
# 429: Slow Down -- increase delay I guess
if (httr2::resp_status(token) == 429) {
delay <- delay + 5
}

# Everything else: Various failure modes
# This feels inelegant but I think it gets the job down alright so meh
reason <- switch(as.character(httr2::resp_status(token)),
"404" = "Invalid device code, please check your credentials and try again",
"409" = "Code was already used for authentication",
"410" = "Token has expired, please try again (and maybe hurry up a little)",
"418" = "Denied - user explicitly denied this code",
NULL # fall through explicitly so we can conditionally abort
)

if (!is.null(reason)) rlang::abort(reason)

}
cli::cli_progress_done()

if (is.null(token)) {
cli::cli_alert_warning("Failed to get token, please retry.")
return(NULL)
}
if (httr2::resp_status(token) != 200) {
cli::cli_alert_warning("Failed to get token, please retry.")
return(NULL)
}

token <- httr2::resp_body_json(token)
class(token) <- "trakt_token"
token
}


get_client_secret <- function() {

env_var <- Sys.getenv("trakt_client_secret", unset = "")
key_var <- Sys.getenv("tRakt_key", unset = "")

if (env_var != "") return(env_var)

if (key_var == "") {
return(NULL)
}

httr2::secret_decrypt(tRakt_client_secret_scrambled, "tRakt_key")

}

get_client_id <- function() {
env_var <- Sys.getenv("trakt_client_id", unset = "")
if (nchar(env_var) == 64) return(env_var)

tRakt_client_id
}

token_expired <- function(token) {
Sys.time() >= as.POSIXct(token$created_at + token$expires_in)
}

cache_token <- function(token) {
cache_loc <- file.path(getOption("tRakt_cache_dir"), "token.rds")

if (!dir.exists(cache_dir)) dir.create(cache_dir)

class(token) <- "trakt_token"

saveRDS(token, file = cache_loc)
invisible(token)
}

clear_cached_token <- function() {
cache_loc <- file.path(getOption("tRakt_cache_dir"), "token.rds")
file.remove(cache_loc)
}

#' @keywords internal
refresh_token <- function(token, cache = TRUE) {
token <- httr2::request("https://api.trakt.tv/oauth/token") |>
httr2::req_headers("Content-Type" = "application/json") |>
httr2::req_body_json(data = list(
refresh_token = token$refresh_token,
client_id = get_client_id(),
client_secret = get_client_secret(),
grant_type = "refresh_token",
redirect_uri = "urn:ietf:wg:oauth:2.0:oob"
)) |>
httr2::req_perform() |>
httr2::resp_body_json()

if (cache) cache_token(token)
token
}




#' @importFrom cli cli_inform cli_alert_success cli_alert_danger
#' @noRd
#' @export
print.trakt_token <- function(x, ...) {
created_at <- as.POSIXct(x$created_at)
expires_in <- as.POSIXct(x$created_at + x$expires_in)

valid <- expires_in > Sys.time()

cli::cli_inform("{.url trakt.tv} token created {created_at}")
if (valid) {
cli::cli_alert_success("Valid until {expires_in}")
} else {
cli::cli_alert_danger("Expired since {expires_in}")
}
}
89 changes: 89 additions & 0 deletions R/api-requests.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#' Make an API call and receive parsed output
#'
#' The most basic form of API interaction: Querying a specific URL and getting
#' its parsed result. If the response is empty, the function returns an empty
#' [tibble()][tibble::tibble-package], and if there are date-time variables
#' present in the response, they are converted to `POSIXct` via
#' [lubridate::ymd_hms()] or to `Date` via [lubridate::as_date()] if the
#' variable only contains date information.
#'
#' @details
#' See [the official API reference](https://trakt.docs.apiary.io) for a detailed
#' overview of available methods. Most methods of potential interest for data
#' collection have dedicated functions in this package.

#' @param url `character(1)`: The API endpoint. Either a full URL like
#' `"https://api.trakt.tv/shows/breaking-bad"` or just the endpoint like
#' `shows/breaking-bad`.
#' @return The parsed ([jsonlite::fromJSON()]) content of the API response.
#' An empty [tibble()][tibble::tibble-package] if the response is an empty
#' `JSON` array.
#' @export
#' @importFrom httr user_agent config add_headers
#' @importFrom httr HEAD GET
#' @importFrom httr message_for_status status_code
#' @importFrom httr content
#' @importFrom jsonlite fromJSON
#' @importFrom purrr flatten
#' @importFrom purrr is_empty
#' @family API-basics
#' @examples
#' # A simple request to a direct URL
#' trakt_get("https://api.trakt.tv/shows/breaking-bad")
#'
#' # Optionally be lazy about URL specification by dropping the hostname:
#' trakt_get("shows/game-of-thrones")
trakt_get <- function(url) {
if (!grepl(pattern = "^https://api.trakt.tv", url)) {
url <- build_trakt_url(url)
}

if (!grepl(pattern = "^https://api.trakt.tv/\\w+", url)) {
rlang::abort("URL does not appear to be a valid trakt.tv API url")
}

token <- get_token()

# Software versions for user agent
versions <- c(
tRakt = paste(utils::packageVersion("tRakt"), "(https://github.com/jemus42/tRakt)"),
httr2 = as.character(utils::packageVersion("httr2")),
`r-curl` = as.character(utils::packageVersion("curl")),
libcurl = curl::curl_version()$version
)
versions <- paste0(names(versions), "/", versions, collapse = " ")

# Cache directory for responses
cache_dir <- file.path(getOption("tRakt_cache_dir"), "data")

req <- httr2::request(url) |>
httr2::req_headers(
# Additional headers required by the API
"trakt-api-key" = get_client_id(),
"Content-Type" = "application/json",
"trakt-api-version" = 2
) |>
httr2::req_auth_bearer_token(token = token$access_token) |>
httr2::req_retry(max_tries = 3) |>
httr2::req_cache(path = cache_dir, use_on_error = TRUE, debug = getOption("tRakt_debug")) |>
httr2::req_user_agent(versions)

resp <- httr2::req_perform(req)

httr2::resp_check_status(resp, info = url)

#resp <- httr2::resp_body_json(resp, simplifyVector = TRUE)
resp <- httr2::resp_body_json(resp, simplifyVector = TRUE, check_type = FALSE)

# Kept from previous version, should be refactored at some point
if (identical(resp, "") | length(resp) == 0) {
return(tibble())
}

# Do it in every other function or do it here once
if (!is.null(names(resp))) {
resp <- fix_datetime(resp)
}

resp
}
Loading
Loading