diff --git a/DESCRIPTION b/DESCRIPTION index 6c5430f..070b801 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,24 +1,26 @@ Package: CyteTypeR Title: CyteType for R -Version: 0.4.2 +Version: 0.4.3 Description: CyteTypeR is the R version of CyteType python package. Authors@R: person("Nygen Analytics AB", , ,"contact@nygen.io", role = c("aut", "cre")) License: MIT + file LICENSE Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 Depends: R (>= 4.1.0) Imports: + askpass, cli, crayon, dplyr, + httpuv, httr2, jsonlite, logger, Matrix, methods, + openssl, purrr, rhdf5, Seurat, @@ -37,3 +39,4 @@ Remotes: bioc::rhdf5 Config/testthat/edition: 3 VignetteBuilder: knitr +Config/roxygen2/version: 8.0.0 diff --git a/NAMESPACE b/NAMESPACE index c109489..f420747 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,7 +4,13 @@ S3method(print,cytetype_api_error) export(CleanUpArtifacts) export(CyteTypeR) export(GetResults) +export(InstallCyteTypeRCli) +export(LoginCyteTypeR) +export(LogoutCyteTypeR) +export(OpenCyteTypeDashboard) export(PrepareCyteTypeR) +export(SetupCyteTypeR) +export(ViewCyteTypeJob) export(cytetype_api_error) importFrom(Matrix,rowSums) importFrom(Seurat,AddMetaData) diff --git a/R/api.R b/R/api.R index 50cd073..d8eb9f3 100644 --- a/R/api.R +++ b/R/api.R @@ -39,7 +39,9 @@ req <- req |> req_auth_bearer_token(auth_token) } - response <- req |> req_perform() + response <- req |> + httr2::req_error(is_error = function(resp) resp_status(resp) == 429L) |> + req_perform() status_code <- resp_status(response) # Handle specific status codes with switch for clarity @@ -49,11 +51,17 @@ }, "401" = { log_debug("Authentication failed for job {job_id}") - stop("Authentication failed: Invalid or expired auth token") + .stop_authentication( + "Authentication failed: Invalid or expired auth token", + "INVALID_TOKEN" + ) }, "403" = { log_debug("Authorization failed for job {job_id}") - stop("Authorization failed: Access denied") + .stop_authentication( + "Authorization failed: Access denied", + "ACCESS_DENIED" + ) } ) diff --git a/R/auth.R b/R/auth.R new file mode 100644 index 0000000..fdac998 --- /dev/null +++ b/R/auth.R @@ -0,0 +1,1103 @@ +.CYTETYPE_DEFAULT_API_URL <- "https://cytetype.nygen.io" +.CYTETYPE_DEFAULT_DASHBOARD_URL <- "https://cytetype.nygen.io/dashboard" +.CYTETYPE_SETUP_TIMEOUT_SECONDS <- 300 + +.create_nygen_banner <- function() { + glyphs <- list( + n = c(" ", "# ### ", "## #", "# #", "# #", "# #", "# #"), + y = c("# #", "# #", "# #", " ####", " #", "# #", " ### "), + g = c(" ### ", "# #", "# #", "# #", " ####", " #", " ### "), + e = c("#### ", "# #", "#####", "# ", "# ", "# #", " ### ") + ) + letters <- strsplit("nygen", "", fixed = TRUE)[[1]] + pixel_rows <- vapply( + seq_len(7L), + function(row) { + paste( + vapply( + letters, + function(letter) glyphs[[letter]][[row]], + character(1) + ), + collapse = " " + ) + }, + character(1) + ) + + vapply( + pixel_rows, + function(row) { + pixels <- strsplit(row, "", fixed = TRUE)[[1]] + rendered <- paste0( + ifelse(pixels == "#", "\u2588\u2588", " "), + collapse = "" + ) + sub("[[:space:]]+$", "", rendered) + }, + character(1) + ) +} + +.CYTETYPE_NYGEN_BANNER <- unname(.create_nygen_banner()) + +.is_scalar_string <- function(value, allow_empty = FALSE) { + is.character(value) && + length(value) == 1L && + !is.na(value) && + (allow_empty || nzchar(value)) +} + +.normalize_api_url <- function(api_url) { + sub("/+$", "", trimws(api_url)) +} + +.validate_api_url <- function(value) { + error_message <- "API URL must be an HTTP or HTTPS server origin" + if (!.is_scalar_string(value)) { + stop(error_message, call. = FALSE) + } + + api_url <- .normalize_api_url(value) + parsed <- tryCatch( + httr2::url_parse(api_url), + error = function(e) NULL + ) + if ( + is.null(parsed) || + !parsed$scheme %in% c("http", "https") || + is.null(parsed$hostname) || + !nzchar(parsed$hostname) || + !is.null(parsed$username) || + !is.null(parsed$password) || + !is.null(parsed$query) || + !is.null(parsed$fragment) || + !is.null(parsed$path) && !parsed$path %in% c("", "/") + ) { + stop(error_message, call. = FALSE) + } + + hostname <- tolower(parsed$hostname) + if (parsed$scheme == "http" && !hostname %in% c("127.0.0.1", "localhost")) { + stop("Non-local API URLs must use HTTPS", call. = FALSE) + } + + api_url +} + +.resolve_api_url <- function(api_url = NULL) { + .validate_api_url(api_url %||% .get_default_api_url()) +} + +.credentials_path <- function() { + config_home <- Sys.getenv("XDG_CONFIG_HOME", unset = "") + if (nzchar(config_home)) { + return(file.path(path.expand(config_home), "cytetype", "credentials.json")) + } + + app_data <- Sys.getenv("APPDATA", unset = "") + if (.Platform$OS.type == "windows" && nzchar(app_data)) { + return(file.path(path.expand(app_data), "cytetype", "credentials.json")) + } + + file.path(path.expand("~"), ".config", "cytetype", "credentials.json") +} + +.validate_credentials <- function(credentials) { + if (!is.list(credentials)) { + stop("Credentials must be a JSON object", call. = FALSE) + } + + required <- c("apiUrl", "apiToken", "tokenId", "userId", "email") + missing <- required[!vapply( + required, + function(name) .is_scalar_string(credentials[[name]]), + logical(1) + )] + if (length(missing) > 0L) { + stop("Credentials are missing required fields", call. = FALSE) + } + + dashboard_url <- credentials$dashboardUrl + if (is.null(dashboard_url)) { + dashboard_url <- .CYTETYPE_DEFAULT_DASHBOARD_URL + } + if (!.is_scalar_string(dashboard_url)) { + stop("Credentials contain an invalid dashboard URL", call. = FALSE) + } + + list( + apiUrl = .validate_api_url(credentials$apiUrl), + dashboardUrl = dashboard_url, + apiToken = credentials$apiToken, + tokenId = credentials$tokenId, + userId = credentials$userId, + email = credentials$email + ) +} + +.credential_metadata <- function(credentials) { + credentials <- .validate_credentials(credentials) + credentials[c( + "apiUrl", + "dashboardUrl", + "tokenId", + "userId", + "email" + )] +} + +.credentials_directory_owner <- function(directory) { + file.info(directory)$uname +} + +.current_effective_user <- function() { + unname(Sys.info()[["effective_user"]]) +} + +.save_credentials <- function(credentials) { + credentials <- .validate_credentials(credentials) + path <- .credentials_path() + directory <- dirname(path) + + if (!dir.exists(directory) && + !dir.create(directory, recursive = TRUE, mode = "0700", showWarnings = FALSE)) { + stop("Could not create the CyteType credentials directory", call. = FALSE) + } + + if (.Platform$OS.type == "unix") { + directory_owner <- .credentials_directory_owner(directory) + effective_user <- .current_effective_user() + if ( + .is_scalar_string(directory_owner) && + .is_scalar_string(effective_user) && + !identical(directory_owner, effective_user) + ) { + stop( + paste( + "CyteType credentials directory is not owned by the current user:", + directory + ), + call. = FALSE + ) + } + if (!isTRUE(Sys.chmod(directory, mode = "0700"))) { + stop("Could not secure the CyteType credentials directory", call. = FALSE) + } + } + + temporary_path <- tempfile(pattern = ".credentials-", tmpdir = directory) + on.exit(unlink(temporary_path, force = TRUE), add = TRUE) + if (.Platform$OS.type == "unix") { + old_umask <- Sys.umask(mode = "0077") + tryCatch( + jsonlite::write_json( + credentials, + path = temporary_path, + auto_unbox = TRUE, + pretty = FALSE + ), + finally = Sys.umask(old_umask) + ) + } else { + jsonlite::write_json( + credentials, + path = temporary_path, + auto_unbox = TRUE, + pretty = FALSE + ) + } + + if (.Platform$OS.type == "unix" && + !isTRUE(Sys.chmod(temporary_path, mode = "0600"))) { + stop("Could not secure the CyteType credentials file", call. = FALSE) + } + + replaced <- file.rename(temporary_path, path) + if (!replaced && .Platform$OS.type == "windows") { + replaced <- file.copy(temporary_path, path, overwrite = TRUE) + if (replaced) { + unlink(temporary_path, force = TRUE) + } + } + if (!replaced) { + stop("Could not save the CyteType credentials file", call. = FALSE) + } + + if (.Platform$OS.type == "unix" && + !isTRUE(Sys.chmod(path, mode = "0600"))) { + stop("Could not secure the CyteType credentials file", call. = FALSE) + } + + path +} + +.load_credentials <- function(api_url = NULL) { + path <- .credentials_path() + if (!file.exists(path)) { + return(NULL) + } + + credentials <- tryCatch( + { + parsed <- jsonlite::fromJSON(path, simplifyVector = TRUE) + .validate_credentials(parsed) + }, + error = function(e) { + stop( + paste0("Invalid CyteType credentials file: ", path), + call. = FALSE + ) + } + ) + + if (!is.null(api_url)) { + normalized_api_url <- .validate_api_url(api_url) + if (!identical(credentials$apiUrl, normalized_api_url)) { + return(NULL) + } + } + + credentials +} + +.delete_credentials <- function() { + path <- .credentials_path() + if (!file.exists(path)) { + return(FALSE) + } + if (unlink(path, force = TRUE) != 0L) { + stop("Could not remove the local CyteType credentials", call. = FALSE) + } + TRUE +} + +.url_origin <- function(value) { + parsed <- httr2::url_parse(value) + list( + scheme = tolower(parsed$scheme %||% ""), + hostname = tolower(parsed$hostname %||% ""), + port = parsed$port, + username = parsed$username, + password = parsed$password + ) +} + +.resolve_dashboard_url <- function(credentials) { + credentials <- .validate_credentials(credentials) + dashboard_origin <- tryCatch( + .url_origin(credentials$dashboardUrl), + error = function(e) NULL + ) + api_origin <- .url_origin(credentials$apiUrl) + if (is.null(dashboard_origin) || !identical(dashboard_origin, api_origin)) { + return(.url_path(credentials$apiUrl, "dashboard")) + } + credentials$dashboardUrl +} + +.new_authentication_error <- function(message, error_code) { + structure( + list(message = message, call = NULL, error_code = error_code), + class = c("cytetype_auth_error", "cytetype_api_error", "error", "condition") + ) +} + +.stop_authentication <- function( + message, + error_code = "AUTHENTICATION_REQUIRED" +) { + stop(.new_authentication_error(message, error_code)) +} + +.resolve_auth_token <- function(api_url, auth_token = NULL) { + api_url <- .validate_api_url(api_url) + if (!is.null(auth_token)) { + if (!.is_scalar_string(auth_token)) { + stop("auth_token must be a non-empty character string", call. = FALSE) + } + return(auth_token) + } + + credentials <- tryCatch( + .load_credentials(api_url), + error = function(e) { + .stop_authentication( + paste0( + conditionMessage(e), + ". Run LogoutCyteTypeR(), then SetupCyteTypeR()." + ) + ) + } + ) + if (is.null(credentials)) { + .stop_authentication( + "CyteType sign-in is required. Run SetupCyteTypeR() first." + ) + } + + credentials$apiToken +} + +.base64url_encode <- function(value) { + encoded <- openssl::base64_encode(value, linebreaks = FALSE) + encoded <- sub("=+$", "", encoded) + chartr("+/", "-_", encoded) +} + +.create_pkce_pair <- function() { + verifier <- .base64url_encode(openssl::rand_bytes(64L)) + challenge <- .base64url_encode( + openssl::sha256(charToRaw(verifier)) + ) + list(verifier = verifier, challenge = challenge) +} + +.create_authorization_state <- function() { + .base64url_encode(openssl::rand_bytes(32L)) +} + +.secure_string_equal <- function(left, right) { + if (!.is_scalar_string(left, allow_empty = TRUE) || + !.is_scalar_string(right, allow_empty = TRUE)) { + return(FALSE) + } + + left_raw <- charToRaw(enc2utf8(left)) + right_raw <- charToRaw(enc2utf8(right)) + if (length(left_raw) != length(right_raw)) { + return(FALSE) + } + + difference <- 0L + for (index in seq_along(left_raw)) { + difference <- bitwOr( + difference, + bitwXor( + as.integer(left_raw[[index]]), + as.integer(right_raw[[index]]) + ) + ) + } + identical(difference, 0L) +} + +.parse_callback_query <- function(query_string) { + if (!.is_scalar_string(query_string)) { + return(list()) + } + query_string <- sub("^\\?", "", query_string) + if (!nzchar(query_string)) { + return(list()) + } + parsed <- httr2::url_parse( + paste0("http://127.0.0.1/?", query_string) + ) + parsed$query %||% list() +} + +.auth_error_message <- function(response, fallback) { + body <- tryCatch( + httr2::resp_body_json(response, simplifyVector = TRUE), + error = function(e) NULL + ) + if (!is.list(body)) { + return(fallback) + } + + detail <- body$detail + if (is.list(detail) && .is_scalar_string(detail$message)) { + return(detail$message) + } + if (.is_scalar_string(detail)) { + return(detail) + } + if (.is_scalar_string(body$message)) { + return(body$message) + } + fallback +} + +.perform_auth_request <- function(request, fallback) { + response <- tryCatch( + request |> + httr2::req_error(is_error = function(resp) FALSE) |> + httr2::req_perform(), + error = function(e) { + .stop_authentication(fallback, "NETWORK_ERROR") + } + ) + + status <- httr2::resp_status(response) + if (status < 200L || status >= 300L) { + error_code <- if (status == 401L) { + "INVALID_TOKEN" + } else { + "AUTHENTICATION_FAILED" + } + body <- tryCatch( + httr2::resp_body_json(response, simplifyVector = TRUE), + error = function(e) NULL + ) + if (is.list(body) && is.list(body$detail) && + .is_scalar_string(body$detail$error_code)) { + error_code <- body$detail$error_code + } + .stop_authentication( + .auth_error_message(response, fallback), + error_code + ) + } + response +} + +.exchange_cli_token <- function(api_url, code, verifier, redirect_uri) { + response <- .perform_auth_request( + httr2::request(.url_path(api_url, "auth", "cli", "token")) |> + httr2::req_method("POST") |> + httr2::req_body_json( + list( + code = code, + codeVerifier = verifier, + redirectUri = redirect_uri + ), + auto_unbox = TRUE + ) |> + httr2::req_timeout(30), + "Could not exchange the authorization code" + ) + + data <- tryCatch( + httr2::resp_body_json(response, simplifyVector = TRUE), + error = function(e) NULL + ) + tryCatch( + .validate_credentials(c( + list(apiUrl = api_url), + data + )), + error = function(e) { + .stop_authentication( + "Server returned invalid CLI credentials", + "INVALID_RESPONSE" + ) + } + ) +} + +.html_escape <- function(value) { + value <- gsub("&", "&", value, fixed = TRUE) + value <- gsub("<", "<", value, fixed = TRUE) + value <- gsub(">", ">", value, fixed = TRUE) + value <- gsub('"', """, value, fixed = TRUE) + gsub("'", "'", value, fixed = TRUE) +} + +.replace_template_placeholder <- function( + template, + placeholder, + value, + expected_count +) { + matches <- gregexpr(placeholder, template, fixed = TRUE)[[1]] + count <- if (identical(matches, -1L)) 0L else length(matches) + if (!identical(count, expected_count)) { + stop( + paste0("Invalid callback template placeholder: ", placeholder), + call. = FALSE + ) + } + + if (count == 0L) { + return(template) + } + + match_lengths <- attr(matches, "match.length") + pieces <- character(count * 2L + 1L) + source_start <- 1L + piece_index <- 1L + for (index in seq_len(count)) { + pieces[[piece_index]] <- substr( + template, + source_start, + matches[[index]] - 1L + ) + pieces[[piece_index + 1L]] <- value + source_start <- matches[[index]] + match_lengths[[index]] + piece_index <- piece_index + 2L + } + pieces[[piece_index]] <- substr( + template, + source_start, + nchar(template) + ) + paste0(pieces, collapse = "") +} + +.load_callback_template <- function() { + path <- system.file( + "templates", + "cli_callback.html", + package = "CyteTypeR", + mustWork = TRUE + ) + paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n") +} + +.render_callback_page <- function(message, credentials = NULL) { + substitutions <- list( + "__META_REFRESH__" = "", + "__STATE_CLASS__" = "error", + "__ICON__" = "!", + "__MESSAGE__" = .html_escape(message), + "__SUCCESS_HIDDEN__" = "hidden", + "__ERROR_HIDDEN__" = "", + "__EMAIL__" = "", + "__DASHBOARD_URL__" = "" + ) + + if (!is.null(credentials)) { + dashboard_url <- .html_escape( + .resolve_dashboard_url(credentials) + ) + substitutions[["__META_REFRESH__"]] <- paste0( + '' + ) + substitutions[["__STATE_CLASS__"]] <- "success" + substitutions[["__ICON__"]] <- "\u2713" + substitutions[["__SUCCESS_HIDDEN__"]] <- "" + substitutions[["__ERROR_HIDDEN__"]] <- "hidden" + substitutions[["__EMAIL__"]] <- .html_escape(credentials$email) + substitutions[["__DASHBOARD_URL__"]] <- dashboard_url + } + + expected_counts <- c( + "__META_REFRESH__" = 1L, + "__STATE_CLASS__" = 1L, + "__ICON__" = 1L, + "__MESSAGE__" = 2L, + "__SUCCESS_HIDDEN__" = 1L, + "__ERROR_HIDDEN__" = 1L, + "__EMAIL__" = 1L, + "__DASHBOARD_URL__" = 1L + ) + template <- .load_callback_template() + for (placeholder in names(substitutions)) { + template <- .replace_template_placeholder( + template, + placeholder, + substitutions[[placeholder]], + expected_counts[[placeholder]] + ) + } + template +} + +.callback_response <- function(status, message, credentials = NULL) { + list( + status = as.integer(status), + headers = list( + "Content-Type" = "text/html; charset=utf-8", + "Cache-Control" = "no-store", + "Referrer-Policy" = "no-referrer", + "X-Content-Type-Options" = "nosniff", + "X-Frame-Options" = "DENY", + "Content-Security-Policy" = paste( + "default-src 'none'; style-src 'unsafe-inline';", + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ) + ), + body = .render_callback_page(message, credentials) + ) +} + +.create_callback_app <- function( + api_url, + state, + verifier, + redirect_uri, + result +) { + function(request) { + if (!identical(request$PATH_INFO, "/callback")) { + return(.callback_response(404L, "Unknown callback path.")) + } + + query <- tryCatch( + .parse_callback_query(request$QUERY_STRING), + error = function(e) list() + ) + returned_state <- query$state %||% "" + if (!.is_scalar_string(returned_state, allow_empty = TRUE) || + !.secure_string_equal(returned_state, state)) { + return(.callback_response(400L, "Authorization state did not match.")) + } + + callback_error <- query$error %||% "" + if (.is_scalar_string(callback_error)) { + result$value <- .new_authentication_error( + "CyteType authorization was denied", + "ACCESS_DENIED" + ) + result$complete <- TRUE + return(.callback_response(400L, "CyteType authorization failed.")) + } + + code <- query$code %||% "" + if (!.is_scalar_string(code)) { + return(.callback_response(400L, "Authorization code was missing.")) + } + + credentials <- tryCatch( + .exchange_cli_token(api_url, code, verifier, redirect_uri), + error = function(e) e + ) + result$value <- credentials + result$complete <- TRUE + if (inherits(credentials, "condition")) { + return(.callback_response(400L, "CyteType setup failed.")) + } + + .callback_response( + 200L, + "CyteType setup is complete.", + credentials + ) + } +} + +.is_wsl_environment <- function() { + Sys.info()[["sysname"]] == "Linux" && + (nzchar(Sys.getenv("WSL_DISTRO_NAME", unset = "")) || + nzchar(Sys.getenv("WSL_INTEROP", unset = ""))) +} + +.system_name <- function() { + unname(Sys.info()[["sysname"]]) +} + +.find_browser_launcher <- function(command) { + unname(Sys.which(command)) +} + +.launch_browser_process <- function(command, args) { + tryCatch( + { + status <- system2( + command, + args = args, + stdout = FALSE, + stderr = FALSE, + wait = FALSE + ) + is.null(status) || identical(status, 0L) + }, + error = function(e) FALSE + ) +} + +.browse_url_silently <- function(url) { + tryCatch( + { + suppressWarnings(suppressMessages(utils::browseURL(url))) + TRUE + }, + error = function(e) FALSE + ) +} + +.open_browser_silently <- function(url) { + if (.is_wsl_environment()) { + launcher <- .find_browser_launcher("rundll32.exe") + if (!nzchar(launcher)) { + return(FALSE) + } + return(.launch_browser_process( + launcher, + c("url.dll,FileProtocolHandler", shQuote(url)) + )) + } + + system_name <- .system_name() + if (identical(system_name, "Linux")) { + launcher <- .find_browser_launcher("xdg-open") + if (!nzchar(launcher)) { + return(FALSE) + } + return(.launch_browser_process(launcher, shQuote(url))) + } + if (identical(system_name, "Darwin")) { + launcher <- .find_browser_launcher("open") + if (!nzchar(launcher)) { + return(FALSE) + } + return(.launch_browser_process(launcher, shQuote(url))) + } + + .browse_url_silently(url) +} + +.authorization_url <- function(api_url, redirect_uri, state, challenge) { + url <- httr2::url_parse( + .url_path(api_url, "auth", "cli", "authorize") + ) + url$query <- list( + redirectUri = redirect_uri, + state = state, + codeChallenge = challenge + ) + httr2::url_build(url) +} + +.start_callback_server <- function(app_factory, max_attempts = 10L) { + for (attempt in seq_len(max_attempts)) { + port <- httpuv::randomPort( + min = 1024L, + max = 49151L, + host = "127.0.0.1" + ) + app <- app_factory(port) + server <- tryCatch( + httpuv::startServer( + "127.0.0.1", + port, + list(call = app), + quiet = TRUE + ), + error = function(e) NULL + ) + if (!is.null(server)) { + return(list(server = server, port = port)) + } + } + + .stop_authentication( + "Could not start the local CyteType callback server", + "CALLBACK_SERVER_ERROR" + ) +} + +.stop_callback_server <- function(server) { + httpuv::stopServer(server) +} + +.service_callback_server <- function(timeout_ms = 100) { + httpuv::service(timeoutMs = timeout_ms) +} + +.monotonic_time <- function() { + unname(proc.time()[["elapsed"]]) +} + +.print_setup_banner <- function() { + cat( + "\n", + paste(.CYTETYPE_NYGEN_BANNER, collapse = "\n"), + "\n\nNygen Analytics: ", + cli::col_blue("https://nygen.io"), + "\n\n", + sep = "" + ) +} + +.run_browser_setup <- function( + api_url, + timeout_seconds = .CYTETYPE_SETUP_TIMEOUT_SECONDS +) { + state <- .create_authorization_state() + pkce <- .create_pkce_pair() + result <- new.env(parent = emptyenv()) + result$complete <- FALSE + result$value <- NULL + callback_server <- .start_callback_server( + function(port) { + redirect_uri <- paste0( + "http://127.0.0.1:", + port, + "/callback" + ) + .create_callback_app( + api_url, + state, + pkce$verifier, + redirect_uri, + result + ) + } + ) + on.exit(.stop_callback_server(callback_server$server), add = TRUE) + redirect_uri <- paste0( + "http://127.0.0.1:", + callback_server$port, + "/callback" + ) + + authorize_url <- .authorization_url( + api_url, + redirect_uri, + state, + pkce$challenge + ) + cli::cli_inform(c( + "Opening CyteType sign-in in your browser.", + "If it does not open automatically, use this URL:", + "{.url {authorize_url}}" + )) + if (!.open_browser_silently(authorize_url)) { + cli::cli_warn("Could not launch a web browser.") + } + + started_at <- .monotonic_time() + while ( + !isTRUE(result$complete) && + .monotonic_time() - started_at < timeout_seconds + ) { + .service_callback_server(timeout_ms = 100) + } + + if (!isTRUE(result$complete)) { + .stop_authentication("CyteType setup timed out", "TIMEOUT") + } + .service_callback_server(timeout_ms = 100) + if (inherits(result$value, "condition")) { + stop(result$value) + } + result$value +} + +#' Set up CyteType authentication +#' +#' Validates matching saved credentials. When none are available, opens +#' CyteType sign-in in a browser and stores the resulting personal API key. +#' +#' @param api_url Optional CyteType server origin. When `NULL`, uses +#' `CYTETYPE_API_URL` or `https://cytetype.nygen.io`. +#' @param force Whether to skip saved credential validation and authenticate +#' with a new key. +#' @return The saved credential metadata, invisibly. +#' @details +#' The browser flow verifies the user's email with a one-time code and returns +#' to a local callback server. CyteTypeR and the Python client use the same +#' credentials file. For development, pass the development server origin as +#' `api_url`. If a saved key is invalid or inactive, use `force = TRUE`. +#' @examples +#' \dontrun{ +#' SetupCyteTypeR() +#' SetupCyteTypeR(force = TRUE) +#' SetupCyteTypeR(api_url = "") +#' } +#' @export +SetupCyteTypeR <- function(api_url = NULL, force = FALSE) { + if (!is.logical(force) || length(force) != 1L || is.na(force)) { + stop("force must be TRUE or FALSE", call. = FALSE) + } + api_url <- .resolve_api_url(api_url) + .print_setup_banner() + existing <- if (force) NULL else .load_credentials(api_url) + if (!is.null(existing)) { + credentials <- tryCatch( + { + response <- .perform_auth_request( + httr2::request(.url_path( + api_url, + "auth", + "cli", + "credentials" + )) |> + httr2::req_auth_bearer_token(existing$apiToken) |> + httr2::req_timeout(30), + "Could not validate the saved API key" + ) + data <- tryCatch( + httr2::resp_body_json(response, simplifyVector = TRUE), + error = function(e) NULL + ) + tryCatch( + .validate_credentials(c( + list(apiUrl = api_url, apiToken = existing$apiToken), + data + )), + error = function(e) { + .stop_authentication( + "Server returned invalid CLI credentials", + "INVALID_RESPONSE" + ) + } + ) + }, + cytetype_auth_error = function(error) { + if (error$error_code %in% c("INVALID_TOKEN", "TOKEN_INACTIVE")) { + .stop_authentication( + paste0( + conditionMessage(error), + ". Run SetupCyteTypeR(force = TRUE) or ", + "`cytetyper setup --force` to re-authenticate with a new key" + ), + error$error_code + ) + } + stop(error) + } + ) + .save_credentials(credentials) + dashboard_url <- .resolve_dashboard_url(credentials) + cli::cli_inform(c( + "CyteType is already configured for {credentials$email}.", + "Dashboard: {.url {dashboard_url}}" + )) + return(invisible(.credential_metadata(credentials))) + } + + credentials <- .run_browser_setup(api_url) + path <- .save_credentials(credentials) + dashboard_url <- .resolve_dashboard_url(credentials) + cli::cli_inform(c( + "CyteType API key saved for {credentials$email} in {path}.", + "Dashboard: {.url {dashboard_url}}" + )) + invisible(.credential_metadata(credentials)) +} + +#' Save an existing CyteType API key +#' +#' Validates an API key and stores it for later CyteTypeR requests. +#' +#' @param api_token Optional API key. When omitted, a hidden prompt is shown. +#' @param api_url Optional CyteType server origin. When `NULL`, uses +#' `CYTETYPE_API_URL` or `https://cytetype.nygen.io`. +#' @return The saved credential metadata, invisibly. +#' @examples +#' \dontrun{ +#' LoginCyteTypeR() +#' LoginCyteTypeR(api_url = "") +#' } +#' @export +LoginCyteTypeR <- function(api_token = NULL, api_url = NULL) { + api_url <- .resolve_api_url(api_url) + if (is.null(api_token)) { + api_token <- askpass::askpass("API key: ") + } + if (!.is_scalar_string(api_token) || !nzchar(trimws(api_token))) { + stop("API key is required", call. = FALSE) + } + api_token <- trimws(api_token) + + response <- .perform_auth_request( + httr2::request(.url_path(api_url, "auth", "cli", "credentials")) |> + httr2::req_auth_bearer_token(api_token) |> + httr2::req_timeout(30), + "Could not validate the API key" + ) + data <- tryCatch( + httr2::resp_body_json(response, simplifyVector = TRUE), + error = function(e) NULL + ) + credentials <- tryCatch( + .validate_credentials(c( + list(apiUrl = api_url, apiToken = api_token), + data + )), + error = function(e) { + .stop_authentication( + "Server returned invalid CLI credentials", + "INVALID_RESPONSE" + ) + } + ) + + path <- .save_credentials(credentials) + dashboard_url <- .resolve_dashboard_url(credentials) + cli::cli_inform(c( + "Signed in as {credentials$email}.", + "CyteType API key saved in {path}.", + "Dashboard: {.url {dashboard_url}}" + )) + invisible(.credential_metadata(credentials)) +} + +#' Remove local CyteType credentials +#' +#' @return `TRUE` when credentials were removed, otherwise `FALSE`, invisibly. +#' @details +#' This removes only the local credentials file. Revoke an API key from the +#' dashboard when it must also be invalidated on the server. +#' @examples +#' \dontrun{ +#' LogoutCyteTypeR() +#' } +#' @export +LogoutCyteTypeR <- function() { + removed <- .delete_credentials() + if (removed) { + cli::cli_inform( + "Local CyteType API key removed. Revoke it from the dashboard if needed." + ) + } else { + cli::cli_inform("No local CyteType API key was found.") + } + invisible(removed) +} + +#' Open the CyteType dashboard +#' +#' @return The dashboard URL, invisibly. +#' @details +#' Uses the dashboard associated with saved credentials. When credentials are +#' absent, opens the production dashboard at `https://cytetype.nygen.io`. +#' @examples +#' \dontrun{ +#' OpenCyteTypeDashboard() +#' } +#' @export +OpenCyteTypeDashboard <- function() { + credentials <- .load_credentials() + target <- if (is.null(credentials)) { + .CYTETYPE_DEFAULT_DASHBOARD_URL + } else { + .resolve_dashboard_url(credentials) + } + cli::cli_inform("{.url {target}}") + .open_browser_silently(target) + invisible(target) +} + +#' Open a CyteType job +#' +#' @param job_id CyteType job identifier. +#' @param api_url Optional CyteType server origin. When `NULL`, uses the origin +#' from saved credentials or the configured default. +#' @return The sign-in URL for the job, invisibly. +#' @examples +#' \dontrun{ +#' ViewCyteTypeJob("") +#' ViewCyteTypeJob("", api_url = "") +#' } +#' @export +ViewCyteTypeJob <- function(job_id, api_url = NULL) { + if (!.is_scalar_string(job_id)) { + stop("job_id must be a non-empty character string", call. = FALSE) + } + + credentials <- .load_credentials() + if (is.null(api_url)) { + api_url <- if (is.null(credentials)) { + .get_default_api_url() + } else { + credentials$apiUrl + } + } + api_url <- .validate_api_url(api_url) + + redirect <- paste0( + "/report/", + utils::URLencode(job_id, reserved = TRUE) + ) + target_parts <- httr2::url_parse(.url_path(api_url, "login")) + target_parts$query <- list(redirect = redirect) + target <- httr2::url_build(target_parts) + cli::cli_inform("{.url {target}}") + .open_browser_silently(target) + invisible(target) +} diff --git a/R/cli.R b/R/cli.R new file mode 100644 index 0000000..e16f5cc --- /dev/null +++ b/R/cli.R @@ -0,0 +1,374 @@ +.CYTETYPER_PROGRAM_NAME <- "cytetyper" + +.cytetyper_help <- function() { + paste( + "Authenticate with CyteType and open your jobs.", + "", + "Usage:", + " cytetyper setup [--api-url URL] [--force]", + " cytetyper get-key [--api-url URL] [--force]", + " cytetyper login [--api-url URL]", + " cytetyper logout", + " cytetyper dashboard", + " cytetyper view JOB_ID [--api-url URL]", + " cytetyper --version", + "", + "Commands:", + " setup Sign in and save a personal API key.", + " get-key Alias for setup.", + " login Save and validate an existing API key.", + " logout Remove the locally saved API key.", + " dashboard Open the CyteType dashboard.", + " view Open a CyteType job report.", + "", + "Options:", + " --api-url URL CyteType server origin. Defaults to", + " CYTETYPE_API_URL or the production server.", + " --force Re-authenticate without validating saved credentials.", + " -h, --help Show help.", + " --version Show the installed CyteTypeR version.", + sep = "\n" + ) +} + +.cytetyper_command_help <- function(command) { + switch( + command, + setup = paste( + "Usage: cytetyper setup [--api-url URL] [--force]", + "", + "Sign in through the browser and save a personal API key.", + "", + "--force re-authenticates without validating saved credentials.", + sep = "\n" + ), + `get-key` = paste( + "Usage: cytetyper get-key [--api-url URL] [--force]", + "", + "Alias for cytetyper setup.", + sep = "\n" + ), + login = paste( + "Usage: cytetyper login [--api-url URL]", + "", + "Securely prompt for, validate, and save an existing API key.", + sep = "\n" + ), + logout = paste( + "Usage: cytetyper logout", + "", + "Remove the locally saved API key.", + sep = "\n" + ), + dashboard = paste( + "Usage: cytetyper dashboard", + "", + "Open the CyteType dashboard.", + sep = "\n" + ), + view = paste( + "Usage: cytetyper view JOB_ID [--api-url URL]", + "", + "Open a CyteType job report.", + sep = "\n" + ), + .cytetyper_help() + ) +} + +.cytetyper_stdout <- function(text) { + cat(text, "\n", sep = "", file = stdout()) +} + +.cytetyper_stderr <- function(text) { + cat(text, "\n", sep = "", file = stderr()) +} + +.parse_cytetyper_command <- function( + args, + command, + allow_api_url = FALSE, + allow_force = FALSE, + positional_count = 0L +) { + api_url <- NULL + force <- FALSE + positionals <- character() + show_help <- FALSE + index <- 1L + + while (index <= length(args)) { + argument <- args[[index]] + if (argument %in% c("-h", "--help")) { + show_help <- TRUE + index <- index + 1L + next + } + + if (identical(argument, "--force")) { + if (!allow_force) { + stop( + paste0("Unknown option for ", command, ": --force"), + call. = FALSE + ) + } + if (force) { + stop("--force may only be provided once", call. = FALSE) + } + force <- TRUE + index <- index + 1L + next + } + + if (identical(argument, "--api-url")) { + if (!allow_api_url) { + stop( + paste0("Unknown option for ", command, ": --api-url"), + call. = FALSE + ) + } + if (!is.null(api_url)) { + stop("--api-url may only be provided once", call. = FALSE) + } + index <- index + 1L + if (index > length(args) || !nzchar(args[[index]])) { + stop("--api-url requires a value", call. = FALSE) + } + api_url <- args[[index]] + index <- index + 1L + next + } + + if (startsWith(argument, "--api-url=")) { + if (!allow_api_url) { + stop( + paste0("Unknown option for ", command, ": --api-url"), + call. = FALSE + ) + } + if (!is.null(api_url)) { + stop("--api-url may only be provided once", call. = FALSE) + } + api_url <- substring(argument, nchar("--api-url=") + 1L) + if (!nzchar(api_url)) { + stop("--api-url requires a value", call. = FALSE) + } + index <- index + 1L + next + } + + if (startsWith(argument, "-")) { + stop( + paste0("Unknown option for ", command, ": ", argument), + call. = FALSE + ) + } + + positionals <- c(positionals, argument) + index <- index + 1L + } + + if (!show_help && length(positionals) != positional_count) { + expected <- if (positional_count == 0L) { + "no positional arguments" + } else { + paste0(positional_count, " positional argument") + } + stop( + paste0(command, " requires ", expected), + call. = FALSE + ) + } + + list( + apiUrl = api_url, + force = force, + positionals = positionals, + showHelp = show_help + ) +} + +.cytetyper_package_version <- function() { + as.character(utils::packageVersion("CyteTypeR")) +} + +.dispatch_cytetyper_command <- function(args) { + if (length(args) == 0L || identical(args, "--help") || + identical(args, "-h")) { + .cytetyper_stdout(.cytetyper_help()) + return(0L) + } + if (identical(args, "--version")) { + .cytetyper_stdout( + paste(.CYTETYPER_PROGRAM_NAME, .cytetyper_package_version()) + ) + return(0L) + } + + command <- args[[1]] + command_args <- args[-1] + supported_commands <- c( + "setup", + "get-key", + "login", + "logout", + "dashboard", + "view" + ) + if (!command %in% supported_commands) { + stop(paste0("Unknown command: ", command), call. = FALSE) + } + + allows_api_url <- command %in% c("setup", "get-key", "login", "view") + allows_force <- command %in% c("setup", "get-key") + positional_count <- if (identical(command, "view")) 1L else 0L + parsed <- .parse_cytetyper_command( + command_args, + command, + allow_api_url = allows_api_url, + allow_force = allows_force, + positional_count = positional_count + ) + if (parsed$showHelp) { + .cytetyper_stdout(.cytetyper_command_help(command)) + return(0L) + } + + if (command %in% c("setup", "get-key")) { + SetupCyteTypeR(api_url = parsed$apiUrl, force = parsed$force) + } else if (identical(command, "login")) { + LoginCyteTypeR(api_url = parsed$apiUrl) + } else if (identical(command, "logout")) { + LogoutCyteTypeR() + } else if (identical(command, "dashboard")) { + OpenCyteTypeDashboard() + } else if (identical(command, "view")) { + ViewCyteTypeJob( + job_id = parsed$positionals[[1]], + api_url = parsed$apiUrl + ) + } + + 0L +} + +.cytetyper_cli_main <- function(args = commandArgs(trailingOnly = TRUE)) { + tryCatch( + .dispatch_cytetyper_command(args), + interrupt = function(error) { + .cytetyper_stderr("Cancelled.") + 130L + }, + error = function(error) { + .cytetyper_stderr(paste0("Error: ", conditionMessage(error))) + 1L + } + ) +} + +.cytetyper_os_type <- function() { + .Platform$OS.type +} + +.cytetyper_launcher_name <- function(os_type = .cytetyper_os_type()) { + if (identical(os_type, "windows")) { + "cytetyper.cmd" + } else { + "cytetyper" + } +} + +.cytetyper_rscript_path <- function() { + path <- unname(Sys.which("Rscript")) + if (!.is_scalar_string(path)) { + stop("Could not locate Rscript on PATH", call. = FALSE) + } + normalizePath(path, mustWork = TRUE) +} + +.cytetyper_launcher_source <- function( + launcher_name = .cytetyper_launcher_name() +) { + system.file( + "exec", + launcher_name, + package = "CyteTypeR", + mustWork = TRUE + ) +} + +#' Install the cytetyper terminal command +#' +#' @param destination Directory in which to install the command. Defaults to +#' the directory containing the active `Rscript` executable. The default is +#' recommended because the launcher uses the adjacent `Rscript` executable. +#' @param overwrite Whether to replace an existing command. +#' @return The installed command path, invisibly. +#' @details +#' After installation, run `cytetyper setup` in a terminal to authenticate +#' against `https://cytetype.nygen.io`. During development, use +#' `cytetyper setup --api-url ""`. +#' @examples +#' \dontrun{ +#' InstallCyteTypeRCli() +#' } +#' @export +InstallCyteTypeRCli <- function(destination = NULL, overwrite = FALSE) { + if (is.null(destination)) { + destination <- dirname(.cytetyper_rscript_path()) + } + if (!.is_scalar_string(destination)) { + stop("destination must be a directory path", call. = FALSE) + } + destination <- normalizePath(destination, mustWork = FALSE) + if (!dir.exists(destination)) { + stop("CLI destination directory does not exist", call. = FALSE) + } + if (!is.logical(overwrite) || length(overwrite) != 1L || + is.na(overwrite)) { + stop("overwrite must be TRUE or FALSE", call. = FALSE) + } + + launcher_name <- .cytetyper_launcher_name() + source <- .cytetyper_launcher_source(launcher_name) + target <- file.path(destination, launcher_name) + if (file.exists(target) && !overwrite) { + stop( + paste0( + "The cytetyper command already exists at ", + target, + ". Set overwrite = TRUE to replace it." + ), + call. = FALSE + ) + } + + copied <- suppressWarnings(file.copy( + source, + target, + overwrite = overwrite, + copy.mode = TRUE, + copy.date = FALSE + )) + if (!copied) { + stop( + paste0( + "Could not install cytetyper in ", + destination, + ". Check that the directory is writable." + ), + call. = FALSE + ) + } + + if (!identical(.cytetyper_os_type(), "windows")) { + Sys.chmod(target, mode = "0755") + if (file.access(target, mode = 1L) != 0L) { + unlink(target, force = TRUE) + stop("Could not make the cytetyper command executable", call. = FALSE) + } + } + + cli::cli_inform("Installed {.file {target}}.") + invisible(target) +} diff --git a/R/client.R b/R/client.R index f7d4dc2..1e6a72b 100644 --- a/R/client.R +++ b/R/client.R @@ -265,6 +265,9 @@ results_resp <- tryCatch( .api_response_helper(job_id, api_url, 'results', auth_token), error = function(e) { + if (inherits(e, "cytetype_auth_error")) { + stop(e) + } make_response( "failed", message = paste("Job completed but results unavailable:", e$message), @@ -316,6 +319,9 @@ )) }, error = function(e) { + if (inherits(e, "cytetype_auth_error")) { + stop(e) + } return(make_response( "error", message = paste("Error checking job status:", e$message) diff --git a/R/cytetype.R b/R/cytetype.R index 49d5030..af74d82 100644 --- a/R/cytetype.R +++ b/R/cytetype.R @@ -13,7 +13,9 @@ #' cluster assignments. Default is "seurat_clusters". #' @param gene_symbols Character string specifying the gene symbol field name. Default is 'gene_symbols'. #' @param n_top_genes Integer specifying the maximum number of top marker genes -#' per cluster to include (filtered by avg_log2FC > 1). Default is 50. +#' per cluster to include after ordering by `avg_log2FC`. Filter +#' `marker_table` before calling this function when a fold-change threshold is +#' required. Default is 50. #' @param aggregate_metadata Logical indicating whether to aggregate metadata #' across cells within each cluster. Default is `TRUE`. #' @param min_percentage Numeric threshold for minimum percentage. @@ -26,7 +28,7 @@ #' @param coordinates_key Character string specifying which dimensional reduction #' to use for visualization coordinates (e.g., "umap", "tsne"). Default is "umap". #' @param max_cells_per_group Integer specifying maximum cells per cluster for -#' subsampling (currently unused). Default is 1000. +#' visualization subsampling. Default is 1000. #' @param vars_h5_path Character string specifying the local file path for the #' generated vars.h5 artifact (feature expression). Default is `"vars.h5"`. #' @param obs_duckdb_path Character string specifying the local file path for the @@ -217,9 +219,16 @@ PrepareCyteTypeR <- function(obj, #' @param results_prefix Character. Prefix for keys added to `obj@meta.data` and `obj@misc` storing results; the annotation column is `obj@meta.data[[paste(results_prefix, group_key, sep = "_")]]`. Default is `"cytetype"`. #' @param poll_interval_seconds Integer. How often (seconds) to poll the API for results. Default from options (10). #' @param timeout_seconds Integer. Maximum time (seconds) to wait for results before erroring. Default from options (7200). -#' @param api_url Optional character. CyteType API base URL. If `NULL`, uses the option/default URL. Default is `NULL`. -#' @param auth_token Optional character. Bearer token for API auth. If `NULL`, uses none. Default is `NULL`. -#' @param save_query Logical. Whether to save the request payload to a JSON file. Default is `TRUE`. +#' @param api_url Optional character. CyteType API server origin. If `NULL`, +#' uses `CYTETYPE_API_URL`, the configured R option, or +#' `https://cytetype.nygen.io`. +#' @param auth_token Optional character. Bearer token for API authentication. +#' If `NULL`, uses credentials saved by [SetupCyteTypeR()] or +#' [LoginCyteTypeR()]. +#' @param save_query Logical. Whether to save the request payload to a JSON +#' file. Default is `TRUE`. Set this to `FALSE` when `llm_configs` contains +#' provider credentials because the saved payload includes the complete LLM +#' configuration. #' @param query_filename Character. Filename for the saved query when `save_query` is `TRUE`. Default is `"query.json"`. #' @param upload_timeout_seconds Integer. Socket read timeout (seconds) for each artifact upload. Default is 3600. #' @param upload_max_workers Integer. Maximum number of parallel upload workers. Default is 6. @@ -232,13 +241,13 @@ PrepareCyteTypeR <- function(obj, #' @details #' The function performs the following workflow: #' 1. Constructs the analysis query from prepared data -#' 2. Saves query and job details to local JSON files -#' 3. Submits job to CyteType API +#' 2. Optionally saves the query to a JSON file +#' 3. Submits the job and records its details in the returned object #' 4. Polls for job completion with progress updates -#' 5. Retrieves results and integrates annotations into Seurat object +#' 5. Retrieves results and integrates annotations into the Seurat object #' -#' Job details are automatically saved to `job_details_{job_id}.json` for -#' later reference or manual result retrieval. +#' Job details are stored in the returned Seurat object's `misc` slot for later +#' retrieval with [GetResults()]. #' #' @examples #' \dontrun{ @@ -285,7 +294,7 @@ CyteTypeR <- function(obj, show_progress = TRUE, override_existing_results = FALSE ){ - api_url <- api_url %||% .get_default_api_url() + api_url <- .resolve_api_url(api_url) poll_interval_seconds <- poll_interval_seconds %||% .get_default_poll_interval() timeout_seconds <- timeout_seconds %||% .get_default_timeout() if (upload_timeout_seconds <= 0) { @@ -318,6 +327,7 @@ CyteTypeR <- function(obj, coordinates_key <- prepped_data$coordinates_key %||% "umap" .validate_input_data(prepped_data) + auth_token <- .resolve_auth_token(api_url, auth_token) if (!is.null(llm_configs) && length(llm_configs) > 0L) { if (is.null(names(llm_configs)) && is.list(llm_configs[[1]])) { @@ -427,31 +437,69 @@ CyteTypeR <- function(obj, #' Retrieve CyteType Analysis Results #' #' @description -#' If \code{obj} and \code{results_prefix} are given, tries to load from \code{obj@misc} first; -#' if not found but job details exist, fetches from the API and stores in \code{obj}. +#' If \code{obj} and \code{results_prefix} are given, tries to load from +#' \code{obj@misc} first. If no local result exists but job details are +#' available, fetches the result from the API. #' If only \code{job_id} is given, fetches from the API and returns (saves JSON). #' #' @param obj Seurat object that may contain stored results (optional). #' @param job_id Job ID from submission (optional if \code{obj} + \code{results_prefix} have stored job details). #' @param results_prefix Prefix used when storing results. Default \code{"cytetype"}. -#' @param auth_token Optional bearer token for API fetch. +#' @param auth_token Optional bearer token for API fetch. If `NULL`, uses +#' saved CyteType credentials. When retrieving a job stored in `obj`, an +#' explicit token also requires a matching `api_url`. +#' @param api_url Optional CyteType server origin for standalone or legacy job +#' retrieval. When `NULL`, uses `CYTETYPE_API_URL` or +#' `https://cytetype.nygen.io`. #' @return Result list (annotations, summary, etc.), or transformed results when fetching by \code{job_id} only. #' @seealso [CyteTypeR()] for job submission #' @importFrom jsonlite write_json #' @export -GetResults <- function(obj = NULL, job_id = NULL, results_prefix = "cytetype", auth_token = NULL) { +GetResults <- function(obj = NULL, + job_id = NULL, + results_prefix = "cytetype", + auth_token = NULL, + api_url = NULL) { if (!is.null(obj) && !is.null(results_prefix)) { results_key <- paste0(results_prefix, "_results") job_details_key <- paste0(results_prefix, "_jobDetails") if (!is.null(obj@misc) && !is.null(obj@misc[[results_key]])) { - return(obj@misc[[results_key]]$result) + stored_results <- obj@misc[[results_key]] + if ( + is.list(stored_results) && + !is.data.frame(stored_results) && + all(c("job_id", "result") %in% names(stored_results)) + ) { + return(stored_results$result) + } + return(stored_results) } if (!is.null(obj@misc) && !is.null(obj@misc[[job_details_key]])) { details <- obj@misc[[job_details_key]] job_id <- details$job_id - api_url <- details$api_url - token <- auth_token %||% details$auth_token - status_resp <- .make_results_request(job_id, api_url, token) + job_api_url <- .resolve_api_url(details$api_url %||% api_url) + if (!is.null(auth_token)) { + if (is.null(api_url)) { + .stop_authentication( + paste( + "api_url is required when auth_token is supplied", + "for a job stored in a Seurat object" + ) + ) + } + explicit_api_url <- .validate_api_url(api_url) + if (!identical(explicit_api_url, job_api_url)) { + .stop_authentication( + paste( + "api_url must match the API origin stored with the job", + "before auth_token can be used" + ), + "API_ORIGIN_MISMATCH" + ) + } + } + token <- .resolve_auth_token(job_api_url, auth_token) + status_resp <- .make_results_request(job_id, job_api_url, token) if (status_resp$status == "completed" && !is.null(status_resp$result)) { result <- .normalize_result_annotations(status_resp$result) if (!is.null(details$group_key) && !is.null(details$cluster_labels)) @@ -460,7 +508,15 @@ GetResults <- function(obj = NULL, job_id = NULL, results_prefix = "cytetype", a obj@misc[[results_key]] <- list(job_id = job_id, result = result) return(result) } - if (status_resp$status == "failed") stop("Job ", job_id, " failed.") + if (status_resp$status == "failed") { + stop(status_resp$message %||% paste("Job", job_id, "failed."), call. = FALSE) + } + if (status_resp$status == "error") { + stop( + status_resp$message %||% paste("Could not retrieve job", job_id), + call. = FALSE + ) + } return(NULL) } } @@ -468,7 +524,8 @@ GetResults <- function(obj = NULL, job_id = NULL, results_prefix = "cytetype", a if (is.null(obj)) stop("Provide either obj (with stored job details) or job_id.") stop("No stored results or job details for prefix '", results_prefix, "'.") } - api_url <- .get_default_api_url() + api_url <- .resolve_api_url(api_url) + auth_token <- .resolve_auth_token(api_url, auth_token) response <- .api_response_helper(job_id, api_url, "results", auth_token) write_json(response$data, path = paste0("cytetypeR_results_", job_id, ".json"), diff --git a/R/seurat_helpers.R b/R/seurat_helpers.R index 357e01d..d20c085 100644 --- a/R/seurat_helpers.R +++ b/R/seurat_helpers.R @@ -264,10 +264,11 @@ # Store job details in Seurat object misc (no auth_token in stored list). .store_job_details_seurat <- function(obj, job_id, api_url, results_prefix, group_key = NULL, cluster_labels = NULL) { + api_url <- .validate_api_url(api_url) if (is.null(obj@misc)) obj@misc <- list() obj@misc[[paste0(results_prefix, "_jobDetails")]] <- list( job_id = job_id, - report_url = file.path(api_url, "report", job_id), + report_url = .url_path(api_url, "report", job_id), api_url = api_url, group_key = group_key, cluster_labels = cluster_labels diff --git a/R/zzz.R b/R/zzz.R index 397f23a..fd34a94 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -3,12 +3,15 @@ # Load defaults .onLoad <- function(libname, pkgname) { - # Set default configuration options - options( - cytetype.default.api.url = "https://prod.cytetype.nygen.io", - cytetype.default.poll.interval = 10L, - cytetype.default.timeout = 7200L - ) + if (is.null(getOption("cytetype.default.api.url"))) { + options(cytetype.default.api.url = .CYTETYPE_DEFAULT_API_URL) + } + if (is.null(getOption("cytetype.default.poll.interval"))) { + options(cytetype.default.poll.interval = 10L) + } + if (is.null(getOption("cytetype.default.timeout"))) { + options(cytetype.default.timeout = 7200L) + } # Configure logging if available if (requireNamespace("cli", quietly = TRUE)) { options(cytetype.use.cli = TRUE) @@ -43,7 +46,11 @@ # Get Default API URL .get_default_api_url <- function() { - getOption("cytetype.default.api.url", "https://prod.cytetype.nygen.io") + configured_api_url <- Sys.getenv("CYTETYPE_API_URL", unset = "") + if (nzchar(configured_api_url)) { + return(.normalize_api_url(configured_api_url)) + } + getOption("cytetype.default.api.url", .CYTETYPE_DEFAULT_API_URL) } # Get Default Poll Interval diff --git a/README.md b/README.md index e5d19dc..71d6e1e 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,76 @@

CyteTypeR

-

Automated, evidence-based cell type annotation for single-cell transcriptomics

-

+

Agentic, Evidence-Based Cell Type Annotation for Single-Cell RNA-seq in R

+

R-CMD-check - - - License: CC BY-NC-SA 4.0 + R Version + + License: MIT

---- -> 🎉 **NEW:** [Preprint published November 7, 2025](https://www.biorxiv.org/content/10.1101/2025.11.06.686964v1) on bioRxiv -> 📅 **FREE Webinar:** [Register now](https://attendee.gotowebinar.com/register/1731194703386732893) — Learn CyteType from the developers +**CyteTypeR** is an end-to-end cell type annotation system for **single-cell RNA sequencing (scRNA-seq)**, designed for repeatable analysis pipelines rather than one-off prompting. It combines cluster-level marker genes, expression context, study metadata, literature retrieval, ontology mapping, and a dedicated review step in a structured workflow that operates directly on Seurat objects. + +For AnnData workflows, use [CyteType](https://github.com/NygenAnalytics/CyteType). ---- +> [!IMPORTANT] +> CyteType requires an API key. API use is free for academic and non-commercial research. Commercial API use requires a [license](#license). ## Why CyteTypeR? Manual cell type annotation takes weeks and varies between experts. CyteTypeR delivers consistent, expert-level annotations in minutes using a multi-agent AI system where specialized agents collaborate on marker analysis, literature evidence, and Cell Ontology mapping. -CyteType Overview +## Quick Start + +### 1. Install -- **Save weeks of manual curation** — Annotate entire datasets at expert level in minutes, not days -- **Drop-in integration** — 3 lines of code, works with existing Scanpy/Seurat workflows -- **No setup friction** — No API keys required; built-in LLM with optional custom configuration -- **Standards-compliant output** — Automatic Cell Ontology term mapping (CL IDs) -- **Comprehensive annotations** — Cell types, subtypes, activation states, confidence scores, and lineage -- **Transparent and auditable** — Interactive HTML reports show evidence, reasoning, and confidence for every annotation +```R +install.packages("devtools") +devtools::install_github("NygenAnalytics/CyteTypeR") +``` -**[See example report](https://prod.cytetype.nygen.io/report/6420a807-8bf3-4c33-8731-7617edfc2ad0?v=251124)** +### 2. Set up your API key ---- +```R +library(CyteTypeR) +SetupCyteTypeR() +``` -## Installation +This opens passwordless CyteType sign-in in your browser and saves the API key locally for automatic use from R. You can also [create or manage API keys in the dashboard](https://cytetype.nygen.io/dashboard). -``` R -# Using devtools -install.packages("devtools") +Already have an API key? Save and validate it locally once: -# Install from GitHub -library(devtools) -install_github("NygenAnalytics/CyteTypeR") +```R +LoginCyteTypeR() ``` -## Quick Start +### 2.1. (Optional) Install and use the CLI + +Install the `cytetyper` command into the active R environment: ```R -# Load package -library(CyteTypeR) +InstallCyteTypeRCli() +``` + +Then authenticate directly from the terminal: + +```sh +cytetyper setup +cytetyper dashboard +cytetyper view +cytetyper logout +``` +### 3. Annotate with Seurat + +```R +# Assumes a normalized, clustered Seurat object and marker table prepped_data <- PrepareCyteTypeR( - pbmc, - pbmc.markers, - n_top_genes = 10, - group_key = 'seurat_clusters', - aggregate_metadata = TRUE, + seurat_obj, + marker_genes, + group_key = "seurat_clusters", coordinates_key = "umap" ) @@ -66,50 +80,56 @@ metadata <- list( experiment_name = 'pbmc_human_samples_study' ) -results <- CyteTypeR( - obj=pbmc, - prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", +annotated_seurat <- CyteTypeR( + obj = seurat_obj, + prepped_data = prepped_data, + study_context = "Human PBMC from a healthy donor", metadata = metadata ) ``` -> **Note:** No API keys required for default configuration. See [custom LLM configuration](docs/configurations.md#llm-configurations) for advanced options. +[Read the complete Seurat workflow](docs/get-started.md). -**Using Scanpy/Anndata?** → [CyteType](https://github.com/NygenAnalytics/CyteType) ---- +## What You Get -## Output Reports +- **Annotations:** Cell type, subtype, and activation state for every cluster +- **Cell Ontology mapping:** Standardized CL IDs for comparison across studies +- **Confidence and quality control:** Confidence values, plus match scores against your existing labels +- **Supporting evidence:** Publications and condition-specific references behind each call -Each analysis generates an HTML report documenting annotation decisions, marker genes, confidence scores, and Cell Ontology mappings: +## Example Report -CyteType Report Example +Each analysis generates an HTML report with annotation decisions, reviewer comments, supporting evidence, and an embedded chat interface connected to your expression data. -**[View example report with embedded chat interface](https://prod.cytetype.nygen.io/report/e70e2883-7713-4121-94f2-5b57eabd1468?v=260303)** +CyteType HTML report showing cell type annotations and marker genes ---- +[View example report](https://cytetype.nygen.io/report/e70e2883-7713-4121-94f2-5b57eabd1468?v=260303) ## Benchmarks -Validated across multiple datasets, tissues, and organisms. CyteType's agentic architecture consistently outperforms other methods across multiple LLMs: - -**📊 Performance:** 388% improvement over GPTCellType, 268% over CellTypist, 101% over SingleR - -CyteType Benchmark Results +Across PBMC, bone marrow, tumor microenvironment, and cross-species datasets, the multi-agent approach outperforms existing annotation methods: -**[Browse results from single-cell atlases →](docs/examples.md)** +| Compared with | Improvement | +|---------------|-------------| +| GPTCellType | +388% | +| CellTypist | +268% | +| SingleR | +101% | -## Need Help? +Methods and full results are in the [preprint](https://www.biorxiv.org/content/10.1101/2025.11.06.686964v1). You can also [browse results on atlas-scale datasets](docs/examples.md). -📖 [Configuration options](docs/configurations.md) -💬 [Join Discord](https://discord.gg/V6QFM4AN) for support +## Resources ---- +- 📖 [Get Started](docs/get-started.md): Prepare a Seurat object and run an annotation. +- ⚙️ [Configuration](docs/configurations.md): Configure API access, LLM providers, and runtime settings. +- 🧬 [Example Datasets and Reports](docs/examples.md): Browse atlas-scale analyses. +- 🐍 [Python client](https://github.com/NygenAnalytics/CyteType): Use CyteType with AnnData and Scanpy. +- 🎥 [Introduction video](https://vimeo.com/nygen/cytetype): Watch a quick overview of CyteType. +- 💬 [Discord community](https://discord.gg/V6QFM4AN): Ask questions and get support. ## Citation -If you use CyteTypeR in your research, please cite our preprint: +> Ahuja G, Antill A, Su Y, Dall'Olio GM, Basnayake S, Karlsson G, Dhapola P. Multi-agent AI enables evidence-based cell annotation in single-cell transcriptomics. *bioRxiv* 2025. doi: [10.1101/2025.11.06.686964](https://www.biorxiv.org/content/10.1101/2025.11.06.686964v1) ```bibtex @article{cytetype2025, @@ -122,12 +142,8 @@ If you use CyteTypeR in your research, please cite our preprint: } ``` ---- - ## License -CyteTypeR is free for academic and non-commercial research use under CC BY‑NC‑SA 4.0 — see [LICENSE.md](LICENSE.md) - -For commercial use, please contact us at [contact@nygen.io](mailto:contact@nygen.io) - +CyteTypeR package code is available under the [MIT License](LICENSE.md). +CyteType API access is free for academic and non-commercial research. Commercial API use and higher-rate access require a separate license from Nygen Analytics AB. Contact [contact@nygen.io](mailto:contact@nygen.io). diff --git a/docs/configurations.md b/docs/configurations.md index c33bb2e..4bd5566 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -1,42 +1,112 @@ -## Configuration +## API configuration -### LLM configurations +CyteTypeR uses `https://cytetype.nygen.io` by default. An explicit `api_url` takes precedence over the `CYTETYPE_API_URL` environment variable and the package default. -``` r +For normal production use, no URL configuration is required: -# For one LLM configuration: +```r +SetupCyteTypeR() +``` + +### Development API + +Replace `` with the development URL supplied for your environment: + +```r +SetupCyteTypeR(api_url = "") +``` + +```sh +export CYTETYPE_API_URL="" +cytetyper setup +``` + +You can also configure one command: + +```sh +cytetyper setup --api-url "" +``` + +Remote API URLs must use HTTPS. Plain HTTP is accepted only for `localhost` and `127.0.0.1`. + +## Authentication + +`SetupCyteTypeR()` validates matching saved credentials or opens the browser-based email and one-time-code flow when none are available. If the saved key is invalid or inactive, use `SetupCyteTypeR(force = TRUE)` or `cytetyper setup --force` to authenticate with a new key. `LoginCyteTypeR()` validates and saves an existing API key through a hidden prompt. + +```r +SetupCyteTypeR() +LoginCyteTypeR() +LogoutCyteTypeR() +``` + +```sh +cytetyper setup +cytetyper login +cytetyper logout +``` + +CyteTypeR and the Python client share the same local credentials. The active credentials are tied to the API URL used during sign-in. + +## LLM configurations + +An external LLM key is optional. Without `llm_configs`, CyteType uses its managed default model. + +Store provider keys in environment variables instead of writing them directly in scripts: + +```r llm_configs <- list( - provider = "openai", - name = "gpt-4o-mini", - apiKey = "your-openai-key", - baseUrl = "https://api.openai.com/v1", - modelSettings = list(temperature = 0.0, max_tokens = 4096L) + provider = "openai", + name = "gpt-4o-mini", + apiKey = Sys.getenv("OPENAI_API_KEY"), + baseUrl = "https://api.openai.com/v1", + modelSettings = list(temperature = 0.0, max_tokens = 4096L) +) + +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "PBMC blood samples from humans", + metadata = metadata, + llm_configs = llm_configs, + save_query = FALSE +) +``` + +Set `save_query = FALSE` whenever `llm_configs` contains provider credentials. Current query-file output includes the complete LLM configuration. + +Supported provider names in this package version are: + +```r +c( + "anthropic", "bedrock", "fireworks", "google", "groq", + "huggingface", "mistral", "openai", "openrouter", "vertex", "xai" ) +``` + +### Multiple models +Pass an unnamed list of model configuration lists: -# List of named lists for multiple configurations +```r llm_configs <- list( list( provider = "openai", name = "gpt-4o-mini", - apiKey = "your-openai-key", - baseUrl = "https://api.openai.com/v1", - modelSettings = list(temperature = 0.0, max_tokens = 4096L) + apiKey = Sys.getenv("OPENAI_API_KEY") ), list( - provider = "anthropic", - name = "claude-3-sonnet-123124", - apiKey = "your-anthropic-key", - modelSettings = list(temperature = 0.0, max_tokens = 4096L) + provider = "anthropic", + name = "claude-sonnet-4-20250514", + apiKey = Sys.getenv("ANTHROPIC_API_KEY"), + allowFallback = TRUE ) ) -# Example of usage: -result <- CyteTypeR(obj = pbmc, - prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", - metadata = metadata, - llm_configs = llm_configs +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + llm_configs = llm_configs, + save_query = FALSE ) ``` diff --git a/docs/examples.md b/docs/examples.md index 53e7bff..b1a29a1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,13 +5,13 @@ The following are notebooks used to run CyteType on all the single-cell datasets | Dataset | Links | | --- | --- | -| **Tabula Sapiens** | [Colab](https://colab.research.google.com/drive/1EyQXaruDJBPICUvlUY1E19zxOm_L4_VU?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/15332f10-2048-4099-ab1e-baf2ab9e39c3) - [H5ad](https://drive.google.com/file/d/1URo7niPqAo-9HGVH8f3QJfqll9lc8JN_/view?usp=drive_link) | -| **GTEX v9** | [Colab](https://colab.research.google.com/drive/1uvqG2eVaUuNe66e0_7bp682uCdKx6-KL?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/5242f3b8-0078-417d-954e-00d1bb19bdf6) - [H5ad](https://drive.google.com/file/d/1EIpudRyasLUHR6J2v8fdpmBTbCE2__UF/view?usp=drive_link) | -| **Hypomap** | [Colab](https://colab.research.google.com/drive/1OuTnh8xHoXaINCGcgu_1q-jANwXL8ggF?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/3840b662-bacf-4067-b93d-4e57c1f21187) - [H5ad](https://drive.google.com/file/d/1QMvZNdoDlKpOmyguAXSk45-YVz97v4tM/view?usp=drive_link) | -| **Human Lung Cell Atlas (Core)** | [Colab](https://colab.research.google.com/drive/1FoTD-XzLNDPgYSlgVsxnLwPnWF5YiKny?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/6da1458a-392f-4bce-b6c9-4ccb308c8797) - [H5ad](https://drive.google.com/file/d/13O0dyUnwJKLPm8fncRt597S5hs2COsxx/view?usp=drive_link) | -| **Immune Cell Atlas** | [Colab](https://colab.research.google.com/drive/1Kum9S_kU76QvS__42ABd-Xp1GpH4c9jU?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/05ff7629-8f0c-4b95-ac65-30bba9b384c5) - [H5ad](https://drive.google.com/file/d/1iqkC7dG1ovgKsU_8HdZ2eyELIxB0sM3t/view?usp=drive_link) | -| **Mouse Pancreatic Cell Atlas** | [Colab](https://colab.research.google.com/drive/1fg9W3Lz-E_yAVoqs_6XrQsYkfsfnzFey?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/6d248cd2-6b61-4beb-bc58-1d63c7a2fc34) - [H5ad](https://drive.google.com/file/d/19qpRfz4WGuUsRNl0YKuy3YENfHKI6pz-/view?usp=drive_link) | -| **Diabetic Kidney Disease** | [Colab](https://colab.research.google.com/drive/1kb3urFbl0PEPW4T_ti0DBTAmi5YK_-t1?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/0da4eaef-f165-4800-a4e3-c5cf8ec165ad) - [H5ad](https://drive.google.com/file/d/1yZXYlfZHLYcPL18Jy25J4v8kWQYhSsd7/view?usp=drive_link) | +| **Tabula Sapiens** | [Colab](https://colab.research.google.com/drive/1EyQXaruDJBPICUvlUY1E19zxOm_L4_VU?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/15332f10-2048-4099-ab1e-baf2ab9e39c3) - [H5ad](https://drive.google.com/file/d/1URo7niPqAo-9HGVH8f3QJfqll9lc8JN_/view?usp=drive_link) | +| **GTEX v9** | [Colab](https://colab.research.google.com/drive/1uvqG2eVaUuNe66e0_7bp682uCdKx6-KL?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/5242f3b8-0078-417d-954e-00d1bb19bdf6) - [H5ad](https://drive.google.com/file/d/1EIpudRyasLUHR6J2v8fdpmBTbCE2__UF/view?usp=drive_link) | +| **Hypomap** | [Colab](https://colab.research.google.com/drive/1OuTnh8xHoXaINCGcgu_1q-jANwXL8ggF?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/3840b662-bacf-4067-b93d-4e57c1f21187) - [H5ad](https://drive.google.com/file/d/1QMvZNdoDlKpOmyguAXSk45-YVz97v4tM/view?usp=drive_link) | +| **Human Lung Cell Atlas (Core)** | [Colab](https://colab.research.google.com/drive/1FoTD-XzLNDPgYSlgVsxnLwPnWF5YiKny?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/6da1458a-392f-4bce-b6c9-4ccb308c8797) - [H5ad](https://drive.google.com/file/d/13O0dyUnwJKLPm8fncRt597S5hs2COsxx/view?usp=drive_link) | +| **Immune Cell Atlas** | [Colab](https://colab.research.google.com/drive/1Kum9S_kU76QvS__42ABd-Xp1GpH4c9jU?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/05ff7629-8f0c-4b95-ac65-30bba9b384c5) - [H5ad](https://drive.google.com/file/d/1iqkC7dG1ovgKsU_8HdZ2eyELIxB0sM3t/view?usp=drive_link) | +| **Mouse Pancreatic Cell Atlas** | [Colab](https://colab.research.google.com/drive/1fg9W3Lz-E_yAVoqs_6XrQsYkfsfnzFey?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/6d248cd2-6b61-4beb-bc58-1d63c7a2fc34) - [H5ad](https://drive.google.com/file/d/19qpRfz4WGuUsRNl0YKuy3YENfHKI6pz-/view?usp=drive_link) | +| **Diabetic Kidney Disease** | [Colab](https://colab.research.google.com/drive/1kb3urFbl0PEPW4T_ti0DBTAmi5YK_-t1?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/0da4eaef-f165-4800-a4e3-c5cf8ec165ad) - [H5ad](https://drive.google.com/file/d/1yZXYlfZHLYcPL18Jy25J4v8kWQYhSsd7/view?usp=drive_link) | ## CellHint Organ Atlases @@ -20,22 +20,22 @@ Data was annotated in across three notebooks: [Colab 1/3](https://colab.research | Tissue | Links | | --- | --- | -| **Blood** | [CyteType report](https://prod.cytetype.nygen.io/report/d0c219b4-2b4a-4b27-bac9-aea280a972f1) | -| **Bone Marrow** | [CyteType report](https://prod.cytetype.nygen.io/report/bc5099b5-42c2-4ba7-8fdd-bc7b5dd3e84d) | -| **Heart** | [CyteType report](https://prod.cytetype.nygen.io/report/2ffd6cf1-ec98-43d1-82d7-1fc3e9a11b8c) | -| **Hippocampus** | [CyteType report](https://prod.cytetype.nygen.io/report/60b98429-2338-4408-a07c-bb60e82ac793) | -| **Intestine** | [CyteType report](https://prod.cytetype.nygen.io/report/e0a2ca37-872f-489c-8de1-d84434d409fe) | -| **Kidney** | [CyteType report](https://prod.cytetype.nygen.io/report/7dd1f0ea-7eec-4968-b353-8b52707de5ac) | -| **Liver** | [CyteType report](https://prod.cytetype.nygen.io/report/a429348c-530a-486c-8980-3349a583b8c4) | -| **Lung** | [CyteType report](https://prod.cytetype.nygen.io/report/75e41a21-f771-4ebc-829a-82f93529a147) | -| **Lymph Node** | [CyteType report](https://prod.cytetype.nygen.io/report/b911e212-fe37-4bdc-a7f3-51e9146bf8cc) | -| **Pancreas** | [CyteType report](https://prod.cytetype.nygen.io/report/b620245a-1ae0-4025-aab7-52ada6dcc6cb) | -| **Skeletal Muscle** | [CyteType report](https://prod.cytetype.nygen.io/report/3f35a45d-aa1b-42cb-92d2-e739623a402b) | -| **Spleen** | [CyteType report](https://prod.cytetype.nygen.io/report/4b64ec02-ac01-45b5-84b9-0f16708cbd85) | +| **Blood** | [CyteType report](https://cytetype.nygen.io/report/d0c219b4-2b4a-4b27-bac9-aea280a972f1) | +| **Bone Marrow** | [CyteType report](https://cytetype.nygen.io/report/bc5099b5-42c2-4ba7-8fdd-bc7b5dd3e84d) | +| **Heart** | [CyteType report](https://cytetype.nygen.io/report/2ffd6cf1-ec98-43d1-82d7-1fc3e9a11b8c) | +| **Hippocampus** | [CyteType report](https://cytetype.nygen.io/report/60b98429-2338-4408-a07c-bb60e82ac793) | +| **Intestine** | [CyteType report](https://cytetype.nygen.io/report/e0a2ca37-872f-489c-8de1-d84434d409fe) | +| **Kidney** | [CyteType report](https://cytetype.nygen.io/report/7dd1f0ea-7eec-4968-b353-8b52707de5ac) | +| **Liver** | [CyteType report](https://cytetype.nygen.io/report/a429348c-530a-486c-8980-3349a583b8c4) | +| **Lung** | [CyteType report](https://cytetype.nygen.io/report/75e41a21-f771-4ebc-829a-82f93529a147) | +| **Lymph Node** | [CyteType report](https://cytetype.nygen.io/report/b911e212-fe37-4bdc-a7f3-51e9146bf8cc) | +| **Pancreas** | [CyteType report](https://cytetype.nygen.io/report/b620245a-1ae0-4025-aab7-52ada6dcc6cb) | +| **Skeletal Muscle** | [CyteType report](https://cytetype.nygen.io/report/3f35a45d-aa1b-42cb-92d2-e739623a402b) | +| **Spleen** | [CyteType report](https://cytetype.nygen.io/report/4b64ec02-ac01-45b5-84b9-0f16708cbd85) | ## Cell Landscapes from BIS Cell atlases hosted by [BIS](https://bis.zju.edu.cn/) from various organims and specific tissues | Tissue | Links | | --- | --- | -| Human Cell Landscape | [Colab](https://colab.research.google.com/drive/1czLW33FYbnPOmPvnfddsvehXM491UDGq?usp=sharing) - [CyteType Report](https://prod.cytetype.nygen.io/report/581616bf-3c96-4e58-a290-881b40378309) - [Homepage](https://bis.zju.edu.cn/HCL/) | +| Human Cell Landscape | [Colab](https://colab.research.google.com/drive/1czLW33FYbnPOmPvnfddsvehXM491UDGq?usp=sharing) - [CyteType Report](https://cytetype.nygen.io/report/581616bf-3c96-4e58-a290-881b40378309) - [Homepage](https://bis.zju.edu.cn/HCL/) | diff --git a/docs/get-started.md b/docs/get-started.md index 3ce00f1..327a9f6 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -1,39 +1,91 @@ # Get Started -Steps for a basic CyteType job to get started using a sample pbmc dataset from 10X Genomics as an example. +This guide demonstrates a basic CyteType job using a sample PBMC dataset from 10X Genomics. -**For AnnData or the original python client version, check out: [CyteType](https://github.com/NygenAnalytics/CyteType)** +**For AnnData or the Python client, see [CyteType](https://github.com/NygenAnalytics/CyteType).** + +## Authentication + +Sign in once on each machine before submitting a job: + +```R +library(CyteTypeR) +SetupCyteTypeR() +``` + +The browser flow verifies your email with a one-time code and saves a personal API key locally. If you already have an API key, use `LoginCyteTypeR()` to enter it through a hidden prompt. + +To use CyteTypeR directly from the terminal, first install the launcher into the active R environment: + +```R +InstallCyteTypeRCli() +``` + +```sh +cytetyper setup +cytetyper dashboard +cytetyper view +cytetyper logout +``` + +The R and Python clients share the same local CyteType credentials file. + +### Development environments + +Replace `` with the development URL supplied for your environment: + +```R +SetupCyteTypeR(api_url = "") +``` + +```sh +cytetyper setup --api-url "" +``` + +Alternatively, configure the current terminal session: + +```sh +export CYTETYPE_API_URL="" +cytetyper setup +``` ## Quick Start Example ``` R # Load package library(CyteTypeR) -prepped_data <- PrepareCyteTypeR(pbmc, - pbmc.markers, - n_top_genes = 10, - group_key = 'seurat_clusters', - aggregate_metadata = TRUE, - coordinates_key = "umap") +# Sign in if this machine is not configured +SetupCyteTypeR() + +prepped_data <- PrepareCyteTypeR( + pbmc, + pbmc.markers, + n_top_genes = 10, + group_key = "seurat_clusters", + aggregate_metadata = TRUE, + coordinates_key = "umap" +) metadata <- list( - title = 'My scRNA-seq analysis of human pbmc', - run_label = 'initial_analysis', - experiment_name = 'pbmc_human_samples_study') - -results <- CyteTypeR(obj=pbmc, - prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", - metadata = metadata - ) + title = "My scRNA-seq analysis of human PBMCs", + run_label = "initial_analysis", + experiment_name = "pbmc_human_samples_study" +) + +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "PBMC blood samples from humans", + metadata = metadata +) ``` ## Pre-processing -The current version of CyteTypeR works with analyzed datasets in Seurat objects and marker tables generated by the ```FindAllMarkers()``` function. You'll need to complete some minimal pre-processing steps before running CyteTypeR. + +CyteTypeR requires a Seurat object with normalized RNA expression, cluster assignments, marker genes, and optionally a dimensional reduction for report visualization. An example of the expected marker genes table can be found here: [marker-table-example.tsv](/inst/marker-table-example.tsv) ``` R - # Load libraries library(dplyr) library(patchwork) @@ -41,11 +93,10 @@ library(Matrix) library(Seurat) library(CyteTypeR) - # Load an example dataset from MTX format files from 10X pbmc.data <- Read10X(data.dir = "./data/filtered_gene_bc_matrices/hg19/") -# Initialize the Seurat object with the raw (non-normalized data). +# Initialize and normalize the Seurat object pbmc <- CreateSeuratObject(counts = pbmc.data, project = "pbmc3k", min.cells = 3, min.features = 200) pbmc <- NormalizeData(pbmc, normalization.method = "LogNormalize", scale.factor = 10000) pbmc <- FindVariableFeatures(pbmc, selection.method = "vst", nfeatures = 2000) @@ -53,70 +104,61 @@ pbmc <- FindVariableFeatures(pbmc, selection.method = "vst", nfeatures = 2000) all.genes <- rownames(pbmc) pbmc <- ScaleData(pbmc, features = all.genes) -## Cluster the cells and run UMAP (IMPORTANT: this step is currently required for using CyteTypeR) +# Cluster the cells and run UMAP pbmc <- FindNeighbors(pbmc, dims = 1:10) pbmc <- FindClusters(pbmc, resolution = 0.5) - pbmc <- RunUMAP(pbmc, dims = 1:10) -# Find markers for all Clusters (IMPORTANT: this step is currently required for using CyteTypeR) +# Find and filter markers for all clusters pbmc.markers <- FindAllMarkers(pbmc, only.pos = TRUE) - -# Apply filtering criteria for markers -pbmc.markers %>% +pbmc.markers <- pbmc.markers %>% group_by(cluster) %>% dplyr::filter(avg_log2FC > 1) - ``` ## Running CyteTypeR -Like the original, CyteTypeR is run with 2 steps for efficiency: -* Step 1: prepare the data for job submission -* Step 2: Submit job and retrieve results - -``` R -## Prep data for job submission to cytetype api -prepped_data <- PrepareCyteTypeR(pbmc, - pbmc.markers, - n_top_genes = 10, - group_key = 'seurat_clusters', - aggregate_metadata = TRUE, - coordinates_key = "umap") - -## Adding metadata on submission -metadata <- list( - title = 'My scRNA-seq analysis of human pbmc', - run_label = 'initial_analysis', - experiment_name: 'pbmc_human_samples_study') - - -## Submit job to cytetype -pbmc.results <- CyteTypeR(obj=pbmc, - prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", - metadata = metadata - ) +CyteTypeR first prepares local artifacts, then submits the job and retrieves the result. +``` R +# Prepare data for submission +prepped_data <- PrepareCyteTypeR( + pbmc, + pbmc.markers, + n_top_genes = 10, + group_key = "seurat_clusters", + aggregate_metadata = TRUE, + coordinates_key = "umap" +) + +# Add metadata to the report +metadata <- list( + title = "My scRNA-seq analysis of human PBMCs", + run_label = "initial_analysis", + experiment_name = "pbmc_human_samples_study" +) + +# Submit the job and add results to the Seurat object +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "PBMC blood samples from humans", + metadata = metadata +) ``` -## CyteType Results -A link to the CyteType report will be created for successfully submitted annotation jobs. +## Open reports and retrieve results -``` -## Example output message -INFO [2025-09-03 13:49:51] Submitting job to https://nygen-labs-prod--cytetype-api.modal.run/annotate -INFO [2025-09-03 13:49:59] CyteType job (id: 6e081560-864c-492a-a1aa-138fa547f0fe) submitted. Polling for results... -INFO [2025-09-03 13:50:04] Report (updates automatically) available at: -https://nygen-labs-prod--cytetype-api.modal.run/report/6e081560-864c-492a-a1aa-138fa547f0fe -INFO [2025-09-03 13:50:04] If disconnected, retrieve results with: GetResults() -[DONE] [✔✔✔] 3/3 completed -INFO [2025-09-03 13:59:55] Job 6e081560-864c-492a-a1aa-138fa547f0fe completed successfully. +Successful submissions print a report URL under `https://cytetype.nygen.io`. Open the dashboard or a known job from R: +```R +OpenCyteTypeDashboard() +ViewCyteTypeJob("") ``` ### Results table CyteType results are saved under "misc" in the seurat object e.g.```seurat_obj@misc$cytetype_results``` Example of the results table: [cytetypeR_table_export.tsv](/inst/cytetypeR_table_export.tsv) + ``` ## View results table > View(pbmc@misc[["cytetype_results"]]) @@ -126,8 +168,17 @@ Example of the results table: [cytetypeR_table_export.tsv](/inst/cytetypeR_table [1] "clusterId" "annotation" "ontologyTerm" "granularAnnotation" "cellState" [6] "justification" "supportingMarkers" "conflictingMarkers" "missingExpression" "unexpectedExpression" -``` +Using CLI: +```sh +cytetyper dashboard +cytetyper view +``` +Results from a completed run are stored in the returned Seurat object. With the default prefix, the transformed result table is available at: +```R +View(annotated_pbmc@misc[["cytetype_results"]]) +GetResults(annotated_pbmc) +``` diff --git a/exec/cytetyper b/exec/cytetyper new file mode 100644 index 0000000..a6e9c12 --- /dev/null +++ b/exec/cytetyper @@ -0,0 +1,27 @@ +#!/bin/sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +exec "$SCRIPT_DIR/Rscript" --vanilla -e ' +status <- tryCatch( + { + suppressPackageStartupMessages(library(CyteTypeR)) + CyteTypeR:::.cytetyper_cli_main(commandArgs(trailingOnly = TRUE)) + }, + interrupt = function(error) { + cat("Cancelled.\n", file = stderr()) + 130L + }, + error = function(error) { + cat( + "Error: Unable to load CyteTypeR: ", + conditionMessage(error), + "\n", + sep = "", + file = stderr() + ) + 1L + } +) +quit(save = "no", status = as.integer(status), runLast = FALSE) +' "$@" diff --git a/exec/cytetyper.cmd b/exec/cytetyper.cmd new file mode 100644 index 0000000..9c36151 --- /dev/null +++ b/exec/cytetyper.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +"%~dp0Rscript.exe" --vanilla -e "status <- tryCatch({suppressPackageStartupMessages(library(CyteTypeR)); CyteTypeR:::.cytetyper_cli_main(commandArgs(trailingOnly = TRUE))}, interrupt = function(error) {cat('Cancelled.\n', file = stderr()); 130L}, error = function(error) {cat('Error: Unable to load CyteTypeR: ', conditionMessage(error), '\n', sep = '', file = stderr()); 1L}); quit(save = 'no', status = as.integer(status), runLast = FALSE)" %* +exit /b %ERRORLEVEL% diff --git a/inst/templates/cli_callback.html b/inst/templates/cli_callback.html new file mode 100644 index 0000000..972ae4d --- /dev/null +++ b/inst/templates/cli_callback.html @@ -0,0 +1,180 @@ + + + + + + __META_REFRESH__ + __MESSAGE__ + + + +
+
CyteType by Nygen Analytics
+ +

__MESSAGE__

+ +
+

Signed in as __EMAIL__

+

Your API key has been saved on this device.

+ Open dashboard +

+ Redirecting to your dashboard in 5 seconds. +

+ +
+ +

+ Return to R and try again. +

+
+ + diff --git a/man/CyteTypeR.Rd b/man/CyteTypeR.Rd index 9200ee8..4aedb0c 100644 --- a/man/CyteTypeR.Rd +++ b/man/CyteTypeR.Rd @@ -44,11 +44,18 @@ CyteTypeR( \item{timeout_seconds}{Integer. Maximum time (seconds) to wait for results before erroring. Default from options (7200).} -\item{api_url}{Optional character. CyteType API base URL. If \code{NULL}, uses the option/default URL. Default is \code{NULL}.} +\item{api_url}{Optional character. CyteType API server origin. If \code{NULL}, +uses \code{CYTETYPE_API_URL}, the configured R option, or +\verb{https://cytetype.nygen.io}.} -\item{auth_token}{Optional character. Bearer token for API auth. If \code{NULL}, uses none. Default is \code{NULL}.} +\item{auth_token}{Optional character. Bearer token for API authentication. +If \code{NULL}, uses credentials saved by \code{\link[=SetupCyteTypeR]{SetupCyteTypeR()}} or +\code{\link[=LoginCyteTypeR]{LoginCyteTypeR()}}.} -\item{save_query}{Logical. Whether to save the request payload to a JSON file. Default is \code{TRUE}.} +\item{save_query}{Logical. Whether to save the request payload to a JSON +file. Default is \code{TRUE}. Set this to \code{FALSE} when \code{llm_configs} contains +provider credentials because the saved payload includes the complete LLM +configuration.} \item{query_filename}{Character. Filename for the saved query when \code{save_query} is \code{TRUE}. Default is \code{"query.json"}.} @@ -74,14 +81,14 @@ and integration of annotations back into the Seurat object. The function performs the following workflow: \enumerate{ \item Constructs the analysis query from prepared data -\item Saves query and job details to local JSON files -\item Submits job to CyteType API +\item Optionally saves the query to a JSON file +\item Submits the job and records its details in the returned object \item Polls for job completion with progress updates -\item Retrieves results and integrates annotations into Seurat object +\item Retrieves results and integrates annotations into the Seurat object } -Job details are automatically saved to \verb{job_details_\{job_id\}.json} for -later reference or manual result retrieval. +Job details are stored in the returned Seurat object's \code{misc} slot for later +retrieval with \code{\link[=GetResults]{GetResults()}}. } \examples{ \dontrun{ diff --git a/man/GetResults.Rd b/man/GetResults.Rd index 88f7060..dafd882 100644 --- a/man/GetResults.Rd +++ b/man/GetResults.Rd @@ -8,7 +8,8 @@ GetResults( obj = NULL, job_id = NULL, results_prefix = "cytetype", - auth_token = NULL + auth_token = NULL, + api_url = NULL ) } \arguments{ @@ -18,14 +19,21 @@ GetResults( \item{results_prefix}{Prefix used when storing results. Default \code{"cytetype"}.} -\item{auth_token}{Optional bearer token for API fetch.} +\item{auth_token}{Optional bearer token for API fetch. If \code{NULL}, uses +saved CyteType credentials. When retrieving a job stored in \code{obj}, an +explicit token also requires a matching \code{api_url}.} + +\item{api_url}{Optional CyteType server origin for standalone or legacy job +retrieval. When \code{NULL}, uses \code{CYTETYPE_API_URL} or +\verb{https://cytetype.nygen.io}.} } \value{ Result list (annotations, summary, etc.), or transformed results when fetching by \code{job_id} only. } \description{ -If \code{obj} and \code{results_prefix} are given, tries to load from \code{obj@misc} first; -if not found but job details exist, fetches from the API and stores in \code{obj}. +If \code{obj} and \code{results_prefix} are given, tries to load from +\code{obj@misc} first. If no local result exists but job details are +available, fetches the result from the API. If only \code{job_id} is given, fetches from the API and returns (saves JSON). } \seealso{ diff --git a/man/InstallCyteTypeRCli.Rd b/man/InstallCyteTypeRCli.Rd new file mode 100644 index 0000000..14c45b4 --- /dev/null +++ b/man/InstallCyteTypeRCli.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cli.R +\name{InstallCyteTypeRCli} +\alias{InstallCyteTypeRCli} +\title{Install the cytetyper terminal command} +\usage{ +InstallCyteTypeRCli(destination = NULL, overwrite = FALSE) +} +\arguments{ +\item{destination}{Directory in which to install the command. Defaults to +the directory containing the active \code{Rscript} executable. The default is +recommended because the launcher uses the adjacent \code{Rscript} executable.} + +\item{overwrite}{Whether to replace an existing command.} +} +\value{ +The installed command path, invisibly. +} +\description{ +Install the cytetyper terminal command +} +\details{ +After installation, run \verb{cytetyper setup} in a terminal to authenticate +against \verb{https://cytetype.nygen.io}. During development, use +\verb{cytetyper setup --api-url ""}. +} +\examples{ +\dontrun{ +InstallCyteTypeRCli() +} +} diff --git a/man/LoginCyteTypeR.Rd b/man/LoginCyteTypeR.Rd new file mode 100644 index 0000000..5da4825 --- /dev/null +++ b/man/LoginCyteTypeR.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{LoginCyteTypeR} +\alias{LoginCyteTypeR} +\title{Save an existing CyteType API key} +\usage{ +LoginCyteTypeR(api_token = NULL, api_url = NULL) +} +\arguments{ +\item{api_token}{Optional API key. When omitted, a hidden prompt is shown.} + +\item{api_url}{Optional CyteType server origin. When \code{NULL}, uses +\code{CYTETYPE_API_URL} or \verb{https://cytetype.nygen.io}.} +} +\value{ +The saved credential metadata, invisibly. +} +\description{ +Validates an API key and stores it for later CyteTypeR requests. +} +\examples{ +\dontrun{ +LoginCyteTypeR() +LoginCyteTypeR(api_url = "") +} +} diff --git a/man/LogoutCyteTypeR.Rd b/man/LogoutCyteTypeR.Rd new file mode 100644 index 0000000..073e462 --- /dev/null +++ b/man/LogoutCyteTypeR.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{LogoutCyteTypeR} +\alias{LogoutCyteTypeR} +\title{Remove local CyteType credentials} +\usage{ +LogoutCyteTypeR() +} +\value{ +\code{TRUE} when credentials were removed, otherwise \code{FALSE}, invisibly. +} +\description{ +Remove local CyteType credentials +} +\details{ +This removes only the local credentials file. Revoke an API key from the +dashboard when it must also be invalidated on the server. +} +\examples{ +\dontrun{ +LogoutCyteTypeR() +} +} diff --git a/man/OpenCyteTypeDashboard.Rd b/man/OpenCyteTypeDashboard.Rd new file mode 100644 index 0000000..b3d68f1 --- /dev/null +++ b/man/OpenCyteTypeDashboard.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{OpenCyteTypeDashboard} +\alias{OpenCyteTypeDashboard} +\title{Open the CyteType dashboard} +\usage{ +OpenCyteTypeDashboard() +} +\value{ +The dashboard URL, invisibly. +} +\description{ +Open the CyteType dashboard +} +\details{ +Uses the dashboard associated with saved credentials. When credentials are +absent, opens the production dashboard at \verb{https://cytetype.nygen.io}. +} +\examples{ +\dontrun{ +OpenCyteTypeDashboard() +} +} diff --git a/man/PrepareCyteTypeR.Rd b/man/PrepareCyteTypeR.Rd index de36cbf..0cd382b 100644 --- a/man/PrepareCyteTypeR.Rd +++ b/man/PrepareCyteTypeR.Rd @@ -33,7 +33,9 @@ cluster assignments. Default is "seurat_clusters".} \item{gene_symbols}{Character string specifying the gene symbol field name. Default is 'gene_symbols'.} \item{n_top_genes}{Integer specifying the maximum number of top marker genes -per cluster to include (filtered by avg_log2FC > 1). Default is 50.} +per cluster to include after ordering by \code{avg_log2FC}. Filter +\code{marker_table} before calling this function when a fold-change threshold is +required. Default is 50.} \item{aggregate_metadata}{Logical indicating whether to aggregate metadata across cells within each cluster. Default is \code{TRUE}.} @@ -52,7 +54,7 @@ calculations. Default is 5000.} to use for visualization coordinates (e.g., "umap", "tsne"). Default is "umap".} \item{max_cells_per_group}{Integer specifying maximum cells per cluster for -subsampling (currently unused). Default is 1000.} +visualization subsampling. Default is 1000.} \item{vars_h5_path}{Character string specifying the local file path for the generated vars.h5 artifact (feature expression). Default is \code{"vars.h5"}.} diff --git a/man/SetupCyteTypeR.Rd b/man/SetupCyteTypeR.Rd new file mode 100644 index 0000000..1165455 --- /dev/null +++ b/man/SetupCyteTypeR.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{SetupCyteTypeR} +\alias{SetupCyteTypeR} +\title{Set up CyteType authentication} +\usage{ +SetupCyteTypeR(api_url = NULL, force = FALSE) +} +\arguments{ +\item{api_url}{Optional CyteType server origin. When \code{NULL}, uses +\code{CYTETYPE_API_URL} or \verb{https://cytetype.nygen.io}.} + +\item{force}{Whether to skip saved credential validation and authenticate +with a new key.} +} +\value{ +The saved credential metadata, invisibly. +} +\description{ +Validates matching saved credentials. When none are available, opens +CyteType sign-in in a browser and stores the resulting personal API key. +} +\details{ +The browser flow verifies the user's email with a one-time code and returns +to a local callback server. CyteTypeR and the Python client use the same +credentials file. For development, pass the development server origin as +\code{api_url}. If a saved key is invalid or inactive, use \code{force = TRUE}. +} +\examples{ +\dontrun{ +SetupCyteTypeR() +SetupCyteTypeR(force = TRUE) +SetupCyteTypeR(api_url = "") +} +} diff --git a/man/ViewCyteTypeJob.Rd b/man/ViewCyteTypeJob.Rd new file mode 100644 index 0000000..fc7244e --- /dev/null +++ b/man/ViewCyteTypeJob.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{ViewCyteTypeJob} +\alias{ViewCyteTypeJob} +\title{Open a CyteType job} +\usage{ +ViewCyteTypeJob(job_id, api_url = NULL) +} +\arguments{ +\item{job_id}{CyteType job identifier.} + +\item{api_url}{Optional CyteType server origin. When \code{NULL}, uses the origin +from saved credentials or the configured default.} +} +\value{ +The sign-in URL for the job, invisibly. +} +\description{ +Open a CyteType job +} +\examples{ +\dontrun{ +ViewCyteTypeJob("") +ViewCyteTypeJob("", api_url = "") +} +} diff --git a/tests/testthat/test-api.R b/tests/testthat/test-api.R index 694dc59..dd83b77 100644 --- a/tests/testthat/test-api.R +++ b/tests/testthat/test-api.R @@ -60,6 +60,61 @@ test_that(".make_req omits Authorization header when token is NULL", { expect_false("Authorization" %in% names(req$headers)) }) +test_that(".api_response_helper preserves authentication failures", { + httr2::local_mocked_responses(function(req) { + httr2::response( + status_code = 401L, + headers = list("Content-Type" = "application/json"), + body = charToRaw('{"detail":"Invalid API token"}') + ) + }) + + expect_error( + CyteTypeR:::.api_response_helper( + "job-123", + "https://api.example.com", + "results", + "expired-token" + ), + "Invalid or expired auth token", + class = "cytetype_auth_error" + ) +}) + +test_that(".api_response_helper preserves rate-limit guidance", { + httr2::local_mocked_responses(function(req) { + httr2::response( + status_code = 429L, + headers = list("Content-Type" = "application/json"), + body = charToRaw( + paste0( + '{"detail":{"error_code":"RATE_LIMIT_EXCEEDED",', + '"message":"Free-tier limit reached"}}' + ) + ) + ) + }) + + rate_limit_error <- tryCatch( + CyteTypeR:::.api_response_helper( + "job-123", + "https://api.example.com", + "results" + ), + error = identity + ) + + expect_s3_class(rate_limit_error, "error") + expect_identical( + conditionMessage(rate_limit_error), + paste0( + "Free-tier limit reached\n", + "Use your own LLM API key via llm_configs to bypass free-tier limits, ", + "or wait before retrying." + ) + ) +}) + # --- .put_to_presigned_url --- test_that(".put_to_presigned_url returns ETag on successful PUT", { diff --git a/tests/testthat/test-auth.R b/tests/testthat/test-auth.R new file mode 100644 index 0000000..08d9f35 --- /dev/null +++ b/tests/testthat/test-auth.R @@ -0,0 +1,1092 @@ +.auth_credentials <- function( + api_url = "https://api.example.com", + dashboard_url = "https://api.example.com/dashboard", + api_token = "cyt_p_test_secret" +) { + list( + apiUrl = api_url, + dashboardUrl = dashboard_url, + apiToken = api_token, + tokenId = "token-id", + userId = "user-id", + email = "researcher@example.com" + ) +} + +.with_temp_config_home <- function(code) { + config_home <- tempfile("cytetype-config-") + dir.create(config_home) + old <- Sys.getenv("XDG_CONFIG_HOME", unset = NA_character_) + on.exit({ + unlink(config_home, recursive = TRUE, force = TRUE) + if (is.na(old)) { + Sys.unsetenv("XDG_CONFIG_HOME") + } else { + Sys.setenv(XDG_CONFIG_HOME = old) + } + }, add = TRUE) + Sys.setenv(XDG_CONFIG_HOME = config_home) + force(code) +} + +test_that("API URL validation accepts origins and rejects unsafe values", { + expect_identical( + CyteTypeR:::.validate_api_url(" https://api.example.com/ "), + "https://api.example.com" + ) + expect_identical( + CyteTypeR:::.validate_api_url("http://127.0.0.1:8000/"), + "http://127.0.0.1:8000" + ) + expect_identical( + CyteTypeR:::.validate_api_url("http://localhost:8000"), + "http://localhost:8000" + ) + + invalid <- c( + "http://api.example.com", + "https://user@api.example.com", + "https://api.example.com/path", + "https://api.example.com?query=value", + "https://api.example.com#fragment", + "ftp://api.example.com" + ) + for (value in invalid) { + expect_error( + CyteTypeR:::.validate_api_url(value), + "API URL|Non-local" + ) + } +}) + +test_that("credentials round trip through the shared secure path", { + .with_temp_config_home({ + credentials <- .auth_credentials() + path <- CyteTypeR:::.save_credentials(credentials) + + expect_identical( + path, + file.path( + Sys.getenv("XDG_CONFIG_HOME"), + "cytetype", + "credentials.json" + ) + ) + expect_identical( + CyteTypeR:::.load_credentials("https://api.example.com/"), + credentials + ) + expect_null( + CyteTypeR:::.load_credentials("https://other.example.com") + ) + + if (.Platform$OS.type == "unix") { + expect_identical(as.character(file.info(dirname(path))$mode), "700") + expect_identical(as.character(file.info(path)$mode), "600") + } + }) +}) + +test_that("credential save repairs existing POSIX directory permissions", { + skip_if(.Platform$OS.type != "unix", "POSIX permissions required") + .with_temp_config_home({ + directory <- dirname(CyteTypeR:::.credentials_path()) + dir.create(directory, recursive = TRUE) + Sys.chmod(directory, mode = "0777") + + CyteTypeR:::.save_credentials(.auth_credentials()) + + expect_identical(as.character(file.info(directory)$mode), "700") + }) +}) + +test_that("credential save rejects a directory owned by another user", { + skip_if(.Platform$OS.type != "unix", "POSIX ownership required") + .with_temp_config_home({ + testthat::local_mocked_bindings( + .credentials_directory_owner = function(directory) "another-user", + .current_effective_user = function() "current-user", + .package = "CyteTypeR" + ) + + expect_error( + CyteTypeR:::.save_credentials(.auth_credentials()), + "not owned by the current user" + ) + expect_false(file.exists(CyteTypeR:::.credentials_path())) + }) +}) + +test_that("legacy credentials default to the production dashboard", { + .with_temp_config_home({ + path <- CyteTypeR:::.credentials_path() + dir.create(dirname(path), recursive = TRUE) + jsonlite::write_json( + list( + apiUrl = "https://api.example.com", + apiToken = "cyt_p_test_secret", + tokenId = "token-id", + userId = "user-id", + email = "researcher@example.com" + ), + path, + auto_unbox = TRUE + ) + + credentials <- CyteTypeR:::.load_credentials() + + expect_identical( + credentials$dashboardUrl, + "https://cytetype.nygen.io/dashboard" + ) + }) +}) + +test_that("invalid credentials file has a clear error", { + .with_temp_config_home({ + path <- CyteTypeR:::.credentials_path() + dir.create(dirname(path), recursive = TRUE) + writeLines('{"apiUrl":"https://api.example.com"}', path) + + expect_error( + CyteTypeR:::.load_credentials(), + "Invalid CyteType credentials file" + ) + }) +}) + +test_that("dashboard URL is constrained to the API origin", { + same_origin <- .auth_credentials( + dashboard_url = "https://api.example.com/custom-dashboard" + ) + expect_identical( + CyteTypeR:::.resolve_dashboard_url(same_origin), + "https://api.example.com/custom-dashboard" + ) + + other_origin <- .auth_credentials( + dashboard_url = "https://dashboard.example.com/dashboard" + ) + expect_identical( + CyteTypeR:::.resolve_dashboard_url(other_origin), + "https://api.example.com/dashboard" + ) +}) + +test_that("PKCE and authorization state satisfy the server contract", { + pkce <- CyteTypeR:::.create_pkce_pair() + state <- CyteTypeR:::.create_authorization_state() + + expect_match(pkce$verifier, "^[A-Za-z0-9_-]{43,128}$") + expect_match(pkce$challenge, "^[A-Za-z0-9_-]{43}$") + expect_match(state, "^[A-Za-z0-9_-]{16,256}$", perl = TRUE) + expected_challenge <- CyteTypeR:::.base64url_encode( + openssl::sha256(charToRaw(pkce$verifier)) + ) + expect_identical(pkce$challenge, expected_challenge) +}) + +test_that("state comparison checks every equal-length byte", { + expect_true(CyteTypeR:::.secure_string_equal( + "state_value_1234567890", + "state_value_1234567890" + )) + expect_false(CyteTypeR:::.secure_string_equal( + "state_value_1234567890", + "state_value_1234567891" + )) + expect_false(CyteTypeR:::.secure_string_equal( + "state_value_1234567890", + "short" + )) +}) + +test_that("callback query parsing accepts the httpuv leading question mark", { + query_string <- paste0( + "code=header.payload.signature", + "&state=state_value_1234567890" + ) + + without_prefix <- CyteTypeR:::.parse_callback_query(query_string) + with_prefix <- CyteTypeR:::.parse_callback_query( + paste0("?", query_string) + ) + + expect_identical(with_prefix, without_prefix) + expect_identical(with_prefix$code, "header.payload.signature") + expect_identical(with_prefix$state, "state_value_1234567890") +}) + +test_that("callback validates state and exchanges the authorization code", { + credentials <- .auth_credentials(api_url = "https://api.example.com") + exchange <- list() + testthat::local_mocked_bindings( + .exchange_cli_token = function(api_url, code, verifier, redirect_uri) { + exchange <<- list( + api_url = api_url, + code = code, + verifier = verifier, + redirect_uri = redirect_uri + ) + credentials + }, + .package = "CyteTypeR" + ) + + result <- new.env(parent = emptyenv()) + result$complete <- FALSE + result$value <- NULL + app <- CyteTypeR:::.create_callback_app( + "https://api.example.com", + "state_value_1234567890", + "verifier", + "http://127.0.0.1:49152/callback", + result + ) + response <- app(list( + PATH_INFO = "/callback", + QUERY_STRING = "?code=signed-code&state=state_value_1234567890" + )) + + expect_identical(response$status, 200L) + expect_true(result$complete) + expect_identical(result$value, credentials) + expect_identical(exchange$code, "signed-code") + expect_identical(exchange$verifier, "verifier") + expect_false(grepl(credentials$apiToken, response$body, fixed = TRUE)) + expect_false(grepl("signed-code", response$body, fixed = TRUE)) + expect_match(response$body, 'class="card success"', fixed = TRUE) + expect_match( + response$body, + "

CyteType setup is complete.

", + fixed = TRUE + ) + expect_match(response$body, credentials$email, fixed = TRUE) + expect_match( + response$body, + 'content="5;url=https://api.example.com/dashboard"', + fixed = TRUE + ) + expect_match( + response$body, + 'href="https://api.example.com/dashboard"', + fixed = TRUE + ) + expect_match(response$body, 'class="progress"', fixed = TRUE) + expect_match( + response$body, + "CyteType by Nygen Analytics", + fixed = TRUE + ) + expect_identical(response$headers[["Cache-Control"]], "no-store") + expect_match( + response$headers[["Content-Security-Policy"]], + "default-src 'none'", + fixed = TRUE + ) +}) + +test_that("callback rejects wrong state without exchanging a token", { + testthat::local_mocked_bindings( + .exchange_cli_token = function(...) { + fail("A callback with the wrong state must not exchange a token") + }, + .package = "CyteTypeR" + ) + result <- new.env(parent = emptyenv()) + result$complete <- FALSE + result$value <- NULL + app <- CyteTypeR:::.create_callback_app( + "https://api.example.com", + "expected_state_123456", + "verifier", + "http://127.0.0.1:49152/callback", + result + ) + + response <- app(list( + PATH_INFO = "/callback", + QUERY_STRING = "code=signed-code&state=wrong_state_12345678" + )) + + expect_identical(response$status, 400L) + expect_false(result$complete) + expect_match(response$body, "Authorization state did not match", fixed = TRUE) + expect_match(response$body, 'class="card error"', fixed = TRUE) + expect_match(response$body, "Return to R and try again.", fixed = TRUE) + expect_false(grepl('http-equiv="refresh"', response$body, fixed = TRUE)) +}) + +test_that("callback records authorization denial", { + result <- new.env(parent = emptyenv()) + result$complete <- FALSE + result$value <- NULL + app <- CyteTypeR:::.create_callback_app( + "https://api.example.com", + "expected_state_123456", + "verifier", + "http://127.0.0.1:49152/callback", + result + ) + + response <- app(list( + PATH_INFO = "/callback", + QUERY_STRING = paste0( + "error=access_denied&state=", + "expected_state_123456" + ) + )) + + expect_identical(response$status, 400L) + expect_true(result$complete) + expect_s3_class(result$value, "cytetype_auth_error") + expect_identical(result$value$error_code, "ACCESS_DENIED") +}) + +test_that("callback template escapes all server-provided values", { + credentials <- .auth_credentials( + dashboard_url = paste0( + "https://api.example.com/dashboard?", + 'next="quoted"&tag=' + ) + ) + credentials$email <- "" + + body <- CyteTypeR:::.render_callback_page( + 'Setup & "retry"', + credentials + ) + + expect_match(body, "Setup <failed> & "retry"", fixed = TRUE) + expect_match( + body, + "<researcher&admin@example.com>", + fixed = TRUE + ) + expect_match(body, "next="quoted"&tag=<unsafe>", fixed = TRUE) + expect_false(grepl("", body, fixed = TRUE)) +}) + +test_that("auth error parser accepts plain and structured detail", { + structured <- httr2::response( + status_code = 401L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list(detail = list( + error_code = "INVALID_TOKEN", + message = "Invalid API token" + )), + auto_unbox = TRUE + )) + ) + plain <- httr2::response( + status_code = 401L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list(detail = "Invalid authorization code"), + auto_unbox = TRUE + )) + ) + + expect_identical( + CyteTypeR:::.auth_error_message(structured, "fallback"), + "Invalid API token" + ) + expect_identical( + CyteTypeR:::.auth_error_message(plain, "fallback"), + "Invalid authorization code" + ) +}) + +test_that("manual login validates and saves without displaying the API key", { + .with_temp_config_home({ + token <- "cyt_p_existing_secret" + httr2::local_mocked_responses(function(req) { + expect_match(req$url, "/auth/cli/credentials$", perl = TRUE) + expect_true("Authorization" %in% names(req$headers)) + httr2::response( + status_code = 200L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list( + tokenId = "token-id", + userId = "user-id", + email = "researcher@example.com", + dashboardUrl = "https://api.example.com/dashboard" + ), + auto_unbox = TRUE + )) + ) + }) + + messages <- capture.output( + credentials <- LoginCyteTypeR( + api_token = token, + api_url = "https://api.example.com" + ), + type = "message" + ) + + stored_credentials <- CyteTypeR:::.load_credentials() + expect_false("apiToken" %in% names(credentials)) + expect_identical(stored_credentials$apiToken, token) + expect_identical( + credentials, + CyteTypeR:::.credential_metadata(stored_credentials) + ) + expect_false(any(grepl(token, messages, fixed = TRUE))) + }) +}) + +test_that("failed manual login preserves existing credentials", { + .with_temp_config_home({ + existing <- .auth_credentials() + CyteTypeR:::.save_credentials(existing) + invalid_token <- "cyt_p_invalid_secret" + httr2::local_mocked_responses(function(req) { + httr2::response( + status_code = 401L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list(detail = list( + error_code = "INVALID_TOKEN", + message = "Invalid API token" + )), + auto_unbox = TRUE + )) + ) + }) + + expect_error( + LoginCyteTypeR( + api_token = invalid_token, + api_url = "https://api.example.com" + ), + "Invalid API token", + class = "cytetype_auth_error" + ) + expect_identical(CyteTypeR:::.load_credentials(), existing) + }) +}) + +test_that("setup reuses matching credentials without opening a browser", { + .with_temp_config_home({ + existing <- .auth_credentials() + CyteTypeR:::.save_credentials(existing) + httr2::local_mocked_responses(function(req) { + expect_match(req$url, "/auth/cli/credentials$", perl = TRUE) + expect_true("Authorization" %in% names(req$headers)) + httr2::response( + status_code = 200L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list( + tokenId = "refreshed-token-id", + userId = "refreshed-user-id", + email = "current@example.com", + dashboardUrl = "https://api.example.com/dashboard" + ), + auto_unbox = TRUE + )) + ) + }) + testthat::local_mocked_bindings( + .run_browser_setup = function(...) { + fail("Existing setup must not open a browser") + }, + .package = "CyteTypeR" + ) + + console_output <- capture.output( + messages <- capture.output( + result <- SetupCyteTypeR("https://api.example.com"), + type = "message" + ), + type = "output" + ) + + stored <- CyteTypeR:::.load_credentials() + expect_identical(stored$apiToken, existing$apiToken) + expect_identical(stored$tokenId, "refreshed-token-id") + expect_identical(stored$userId, "refreshed-user-id") + expect_identical(stored$email, "current@example.com") + expect_identical( + result, + CyteTypeR:::.credential_metadata(stored) + ) + expect_true(any(grepl(stored$email, messages, fixed = TRUE))) + expect_true(any(grepl( + CyteTypeR:::.CYTETYPE_NYGEN_BANNER[[1]], + console_output, + fixed = TRUE + ))) + expect_true(any(grepl("Nygen Analytics:", console_output, fixed = TRUE))) + expect_true(any(grepl("https://nygen.io", console_output, fixed = TRUE))) + expect_false(any(grepl( + existing$apiToken, + c(console_output, messages), + fixed = TRUE + ))) + }) +}) + +test_that("setup rejects invalid saved credentials without changing them", { + .with_temp_config_home({ + existing <- .auth_credentials() + CyteTypeR:::.save_credentials(existing) + httr2::local_mocked_responses(function(req) { + httr2::response( + status_code = 401L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list(detail = list( + error_code = "INVALID_TOKEN", + message = "Invalid API token" + )), + auto_unbox = TRUE + )) + ) + }) + testthat::local_mocked_bindings( + .run_browser_setup = function(...) { + fail("Invalid setup must not open a browser") + }, + .package = "CyteTypeR" + ) + + expect_error( + suppressMessages(SetupCyteTypeR("https://api.example.com")), + "cytetyper setup --force", + class = "cytetype_auth_error" + ) + expect_identical(CyteTypeR:::.load_credentials(), existing) + }) +}) + +test_that("setup preserves inactive-token errors and saved credentials", { + .with_temp_config_home({ + existing <- .auth_credentials() + CyteTypeR:::.save_credentials(existing) + httr2::local_mocked_responses(function(req) { + httr2::response( + status_code = 403L, + headers = list("Content-Type" = "application/json"), + body = charToRaw(jsonlite::toJSON( + list(detail = list( + error_code = "TOKEN_INACTIVE", + message = "API token has been deactivated" + )), + auto_unbox = TRUE + )) + ) + }) + testthat::local_mocked_bindings( + .run_browser_setup = function(...) { + fail("Inactive setup must not open a browser") + }, + .package = "CyteTypeR" + ) + + error <- tryCatch( + suppressMessages(SetupCyteTypeR("https://api.example.com")), + cytetype_auth_error = identity + ) + expect_s3_class(error, "cytetype_auth_error") + expect_identical(error$error_code, "TOKEN_INACTIVE") + expect_match(conditionMessage(error), "cytetyper setup --force") + expect_identical(CyteTypeR:::.load_credentials(), existing) + }) +}) + +test_that("setup reports validation network errors without changing credentials", { + .with_temp_config_home({ + existing <- .auth_credentials() + CyteTypeR:::.save_credentials(existing) + httr2::local_mocked_responses(function(req) { + stop("offline") + }) + testthat::local_mocked_bindings( + .run_browser_setup = function(...) { + fail("Failed validation must not open a browser") + }, + .package = "CyteTypeR" + ) + + error <- tryCatch( + suppressMessages(SetupCyteTypeR("https://api.example.com")), + cytetype_auth_error = identity + ) + expect_s3_class(error, "cytetype_auth_error") + expect_identical(error$error_code, "NETWORK_ERROR") + expect_match(conditionMessage(error), "Could not validate the saved API key") + expect_false(grepl("--force", conditionMessage(error), fixed = TRUE)) + expect_identical(CyteTypeR:::.load_credentials(), existing) + }) +}) + +test_that("forced setup skips validation and replaces saved credentials", { + .with_temp_config_home({ + existing <- .auth_credentials() + refreshed <- .auth_credentials( + api_token = "cyt_p_reauthenticated_secret" + ) + refreshed$email <- "current@example.com" + CyteTypeR:::.save_credentials(existing) + testthat::local_mocked_bindings( + .perform_auth_request = function(...) { + fail("Forced setup must not validate saved credentials") + }, + .run_browser_setup = function(...) refreshed, + .package = "CyteTypeR" + ) + + messages <- capture.output( + result <- SetupCyteTypeR( + "https://api.example.com", + force = TRUE + ), + type = "message" + ) + + expect_identical(CyteTypeR:::.load_credentials(), refreshed) + expect_identical( + result, + CyteTypeR:::.credential_metadata(refreshed) + ) + expect_false(any(grepl(refreshed$apiToken, messages, fixed = TRUE))) + }) +}) + +test_that("browser setup orchestrates authorize, callback, and exchange", { + credentials <- .auth_credentials() + callback_app <- NULL + callback_response <- NULL + opened_url <- NULL + exchange <- list() + service_calls <- 0L + stopped <- FALSE + testthat::local_mocked_bindings( + .start_callback_server = function(app_factory) { + callback_app <<- app_factory(49152L) + list(server = list(), port = 49152L) + }, + .stop_callback_server = function(server) { + stopped <<- TRUE + invisible(NULL) + }, + .open_browser_silently = function(url) { + opened_url <<- url + TRUE + }, + .service_callback_server = function(timeout_ms = 100) { + service_calls <<- service_calls + 1L + if (service_calls == 1L) { + authorize <- httr2::url_parse(opened_url) + callback_response <<- callback_app(list( + PATH_INFO = "/callback", + QUERY_STRING = paste0( + "?code=signed-code&state=", + authorize$query$state + ) + )) + } + invisible(NULL) + }, + .exchange_cli_token = function(api_url, code, verifier, redirect_uri) { + exchange <<- list( + api_url = api_url, + code = code, + verifier = verifier, + redirect_uri = redirect_uri + ) + credentials + }, + .package = "CyteTypeR" + ) + + messages <- capture.output( + result <- CyteTypeR:::.run_browser_setup( + "https://api.example.com", + timeout_seconds = 5 + ), + type = "message" + ) + + authorize <- httr2::url_parse(opened_url) + expect_identical(result, credentials) + expect_identical( + authorize$path, + "/auth/cli/authorize" + ) + expect_identical( + authorize$query$redirectUri, + "http://127.0.0.1:49152/callback" + ) + expect_match(authorize$query$state, "^[A-Za-z0-9_-]+$") + expect_match(authorize$query$codeChallenge, "^[A-Za-z0-9_-]{43}$") + expect_false(grepl(exchange$verifier, opened_url, fixed = TRUE)) + expect_identical(exchange$api_url, "https://api.example.com") + expect_identical(exchange$code, "signed-code") + expect_identical( + exchange$redirect_uri, + "http://127.0.0.1:49152/callback" + ) + expect_identical(callback_response$status, 200L) + expect_match(callback_response$body, 'class="card success"', fixed = TRUE) + expect_true(stopped) + expect_true(any(grepl( + "If it does not open automatically", + messages, + fixed = TRUE + ))) +}) + +test_that("browser setup times out cleanly", { + stopped <- FALSE + testthat::local_mocked_bindings( + .open_browser_silently = function(url) TRUE, + .start_callback_server = function(app_factory) { + expect_true(is.function(app_factory)) + list(server = list(), port = 49152L) + }, + .stop_callback_server = function(server) { + stopped <<- TRUE + invisible(NULL) + }, + .service_callback_server = function(timeout_ms = 100) { + invisible(NULL) + }, + .package = "CyteTypeR" + ) + + expect_error( + suppressMessages(CyteTypeR:::.run_browser_setup( + "https://api.example.com", + timeout_seconds = 0 + )), + "timed out", + class = "cytetype_auth_error" + ) + expect_true(stopped) +}) + +test_that("WSL browser launch uses the Windows URL handler", { + launched <- list() + url <- "https://api.example.com/auth/cli/authorize?state=test" + testthat::local_mocked_bindings( + .is_wsl_environment = function() TRUE, + .find_browser_launcher = function(command) { + expect_identical(command, "rundll32.exe") + "/mnt/c/Windows/System32/rundll32.exe" + }, + .launch_browser_process = function(command, args) { + launched <<- list(command = command, args = args) + TRUE + }, + .system_name = function() { + fail("WSL must not use the native platform branch") + }, + .browse_url_silently = function(url) { + fail("WSL must not use the fallback browser") + }, + .package = "CyteTypeR" + ) + + expect_true(CyteTypeR:::.open_browser_silently(url)) + expect_identical( + launched$command, + "/mnt/c/Windows/System32/rundll32.exe" + ) + expect_identical( + launched$args, + c("url.dll,FileProtocolHandler", shQuote(url)) + ) +}) + +test_that("WSL browser launch does not fall back when handler is missing", { + testthat::local_mocked_bindings( + .is_wsl_environment = function() TRUE, + .find_browser_launcher = function(command) "", + .launch_browser_process = function(...) { + fail("Missing launchers must not be invoked") + }, + .browse_url_silently = function(url) { + fail("WSL must not use the fallback browser") + }, + .package = "CyteTypeR" + ) + + expect_false(CyteTypeR:::.open_browser_silently( + "https://api.example.com/auth/cli/authorize" + )) +}) + +test_that("dashboard, job view, and logout do not expose the token", { + .with_temp_config_home({ + credentials <- .auth_credentials( + dashboard_url = "https://other.example.com/dashboard" + ) + CyteTypeR:::.save_credentials(credentials) + opened <- character() + testthat::local_mocked_bindings( + .open_browser_silently = function(url) { + opened <<- c(opened, url) + TRUE + }, + .package = "CyteTypeR" + ) + + dashboard <- suppressMessages(OpenCyteTypeDashboard()) + job <- suppressMessages(ViewCyteTypeJob("job/with space")) + + expect_identical(dashboard, "https://api.example.com/dashboard") + expect_false(any(grepl(credentials$apiToken, opened, fixed = TRUE))) + parsed_job <- httr2::url_parse(job) + expect_identical(parsed_job$hostname, "api.example.com") + expect_identical( + parsed_job$query$redirect, + "/report/job%2Fwith%20space" + ) + + expect_true(suppressMessages(LogoutCyteTypeR())) + expect_null(CyteTypeR:::.load_credentials()) + expect_false(suppressMessages(LogoutCyteTypeR())) + }) +}) + +test_that("auth token resolution is explicit or server-specific", { + .with_temp_config_home({ + credentials <- .auth_credentials() + CyteTypeR:::.save_credentials(credentials) + + expect_identical( + CyteTypeR:::.resolve_auth_token( + "https://api.example.com", + "explicit-token" + ), + "explicit-token" + ) + expect_identical( + CyteTypeR:::.resolve_auth_token("https://api.example.com"), + credentials$apiToken + ) + expect_error( + CyteTypeR:::.resolve_auth_token("https://other.example.com"), + "SetupCyteTypeR", + class = "cytetype_auth_error" + ) + }) +}) + +test_that("GetResults resolves credentials for stored remote jobs", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + obj@misc$cytetype_jobDetails <- list( + job_id = "job-123", + api_url = "https://api.example.com" + ) + credentials <- .auth_credentials() + request <- list() + testthat::local_mocked_bindings( + .load_credentials = function(api_url = NULL) { + expect_identical(api_url, "https://api.example.com") + credentials + }, + .make_results_request = function(job_id, api_url, auth_token = NULL) { + request <<- list( + job_id = job_id, + api_url = api_url, + auth_token = auth_token + ) + list(status = "pending", result = NULL) + }, + .package = "CyteTypeR" + ) + + expect_null(GetResults(obj)) + expect_identical(request$job_id, "job-123") + expect_identical(request$api_url, "https://api.example.com") + expect_identical(request$auth_token, credentials$apiToken) +}) + +test_that("GetResults rejects an untrusted stored server before network access", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + obj@misc$cytetype_jobDetails <- list( + job_id = "job-123", + api_url = "https://modified.example.com/path" + ) + testthat::local_mocked_bindings( + .load_credentials = function(...) { + fail("Invalid stored URLs must be rejected before credential lookup") + }, + .make_results_request = function(...) { + fail("Invalid stored URLs must be rejected before network access") + }, + .package = "CyteTypeR" + ) + + expect_error(GetResults(obj), "server origin") +}) + +test_that("GetResults will not send an explicit token to a stored origin", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + obj@misc$cytetype_jobDetails <- list( + job_id = "job-123", + api_url = "https://modified.example.com" + ) + testthat::local_mocked_bindings( + .make_results_request = function(...) { + fail("A mismatched token must be rejected before network access") + }, + .package = "CyteTypeR" + ) + + expect_error( + GetResults(obj, auth_token = "explicit-token"), + "api_url is required", + class = "cytetype_auth_error" + ) + expect_error( + GetResults( + obj, + auth_token = "explicit-token", + api_url = "https://api.example.com" + ), + "must match", + class = "cytetype_auth_error" + ) +}) + +test_that("GetResults sends an explicit token only with a matching origin", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + obj@misc$cytetype_jobDetails <- list( + job_id = "job-123", + api_url = "https://api.example.com" + ) + request <- list() + testthat::local_mocked_bindings( + .load_credentials = function(...) { + fail("Explicit tokens must not read stored credentials") + }, + .make_results_request = function(job_id, api_url, auth_token = NULL) { + request <<- list( + job_id = job_id, + api_url = api_url, + auth_token = auth_token + ) + list(status = "pending", result = NULL) + }, + .package = "CyteTypeR" + ) + + expect_null(GetResults( + obj, + auth_token = "explicit-token", + api_url = "https://api.example.com/" + )) + expect_identical(request$auth_token, "explicit-token") + expect_identical(request$api_url, "https://api.example.com") +}) + +test_that("GetResults reports remote request errors", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + obj@misc$cytetype_jobDetails <- list( + job_id = "job-123", + api_url = "https://api.example.com" + ) + testthat::local_mocked_bindings( + .load_credentials = function(api_url = NULL) .auth_credentials(), + .make_results_request = function(...) { + list( + status = "error", + result = NULL, + message = "Authentication failed" + ) + }, + .package = "CyteTypeR" + ) + + expect_error(GetResults(obj), "Authentication failed") +}) + +test_that("GetResults returns wrapped local results without authentication", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + expected <- list(annotations = list()) + obj@misc$cytetype_results <- list( + job_id = "job-123", + result = expected + ) + testthat::local_mocked_bindings( + .load_credentials = function(...) { + fail("Local results must not require credentials") + }, + .package = "CyteTypeR" + ) + + expect_identical(GetResults(obj), expected) +}) + +test_that("GetResults supports locally transformed result tables", { + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) + expected <- data.frame( + clusterId = "1", + annotation = "T cell" + ) + obj@misc$cytetype_results <- expected + testthat::local_mocked_bindings( + .load_credentials = function(...) { + fail("Local results must not require credentials") + }, + .package = "CyteTypeR" + ) + + expect_identical(GetResults(obj), expected) +}) diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R new file mode 100644 index 0000000..ab78201 --- /dev/null +++ b/tests/testthat/test-cli.R @@ -0,0 +1,220 @@ +.capture_cytetyper_error <- function(args) { + messages <- capture.output( + status <- CyteTypeR:::.cytetyper_cli_main(args), + type = "message" + ) + list(status = status, messages = messages) +} + +test_that("setup commands pass an explicit development API URL", { + api_url <- "https://development.example.com" + setup_calls <- list() + testthat::local_mocked_bindings( + SetupCyteTypeR = function(api_url = NULL, force = FALSE) { + setup_calls[[length(setup_calls) + 1L]] <<- list( + apiUrl = api_url, + force = force + ) + invisible(list()) + }, + .package = "CyteTypeR" + ) + + expect_identical( + CyteTypeR:::.cytetyper_cli_main(c("setup", "--api-url", api_url)), + 0L + ) + expect_identical( + CyteTypeR:::.cytetyper_cli_main(c( + "get-key", + paste0("--api-url=", api_url), + "--force" + )), + 0L + ) + expect_identical( + setup_calls, + list( + list(apiUrl = api_url, force = FALSE), + list(apiUrl = api_url, force = TRUE) + ) + ) +}) + +test_that("CLI commands route to the matching R functions", { + calls <- list() + testthat::local_mocked_bindings( + LoginCyteTypeR = function(api_token = NULL, api_url = NULL) { + calls$login <<- list(apiToken = api_token, apiUrl = api_url) + invisible(list()) + }, + LogoutCyteTypeR = function() { + calls$logout <<- TRUE + invisible(TRUE) + }, + OpenCyteTypeDashboard = function() { + calls$dashboard <<- TRUE + invisible("https://api.example.com/dashboard") + }, + ViewCyteTypeJob = function(job_id, api_url = NULL) { + calls$view <<- list(jobId = job_id, apiUrl = api_url) + invisible("https://api.example.com/login") + }, + .package = "CyteTypeR" + ) + + expect_identical( + CyteTypeR:::.cytetyper_cli_main(c( + "login", + "--api-url=https://api.example.com" + )), + 0L + ) + expect_identical(CyteTypeR:::.cytetyper_cli_main("logout"), 0L) + expect_identical(CyteTypeR:::.cytetyper_cli_main("dashboard"), 0L) + expect_identical( + CyteTypeR:::.cytetyper_cli_main(c( + "view", + "job/with space", + "--api-url", + "https://api.example.com" + )), + 0L + ) + + expect_null(calls$login$apiToken) + expect_identical(calls$login$apiUrl, "https://api.example.com") + expect_true(calls$logout) + expect_true(calls$dashboard) + expect_identical(calls$view$jobId, "job/with space") + expect_identical(calls$view$apiUrl, "https://api.example.com") +}) + +test_that("CLI help and version return successfully", { + testthat::local_mocked_bindings( + .cytetyper_package_version = function() "0.4.2-test", + .package = "CyteTypeR" + ) + + help_output <- capture.output( + help_status <- CyteTypeR:::.cytetyper_cli_main(character()) + ) + command_help <- capture.output( + command_status <- CyteTypeR:::.cytetyper_cli_main(c("setup", "--help")) + ) + version_output <- capture.output( + version_status <- CyteTypeR:::.cytetyper_cli_main("--version") + ) + + expect_identical(help_status, 0L) + expect_match(paste(help_output, collapse = "\n"), "cytetyper setup") + expect_identical(command_status, 0L) + expect_match( + paste(command_help, collapse = "\n"), + "Usage: cytetyper setup", + fixed = TRUE + ) + expect_match(paste(command_help, collapse = "\n"), "--force", fixed = TRUE) + expect_identical(version_status, 0L) + expect_identical(version_output, "cytetyper 0.4.2-test") +}) + +test_that("CLI parse failures return status one with concise errors", { + unknown <- .capture_cytetyper_error("unknown") + missing_url <- .capture_cytetyper_error(c("setup", "--api-url")) + missing_job <- .capture_cytetyper_error("view") + extra_argument <- .capture_cytetyper_error(c("logout", "extra")) + duplicate_url <- .capture_cytetyper_error(c( + "setup", + "--api-url=https://one.example.com", + "--api-url=https://two.example.com" + )) + duplicate_force <- .capture_cytetyper_error(c( + "setup", + "--force", + "--force" + )) + unsupported_force <- .capture_cytetyper_error(c("login", "--force")) + + for (result in list( + unknown, + missing_url, + missing_job, + extra_argument, + duplicate_url, + duplicate_force, + unsupported_force + )) { + expect_identical(result$status, 1L) + expect_match(result$messages[[1]], "^Error: ") + } + expect_match(unknown$messages[[1]], "Unknown command") + expect_match(missing_url$messages[[1]], "requires a value") + expect_match(missing_job$messages[[1]], "1 positional argument") + expect_match(extra_argument$messages[[1]], "no positional arguments") + expect_match(duplicate_url$messages[[1]], "only be provided once") + expect_match(duplicate_force$messages[[1]], "only be provided once") + expect_match(unsupported_force$messages[[1]], "Unknown option") +}) + +test_that("launcher selection supports Unix and Windows R environments", { + expect_identical( + CyteTypeR:::.cytetyper_launcher_name("unix"), + "cytetyper" + ) + expect_identical( + CyteTypeR:::.cytetyper_launcher_name("windows"), + "cytetyper.cmd" + ) + expect_true(file.exists( + CyteTypeR:::.cytetyper_launcher_source("cytetyper") + )) + expect_true(file.exists( + CyteTypeR:::.cytetyper_launcher_source("cytetyper.cmd") + )) + launcher <- readLines( + CyteTypeR:::.cytetyper_launcher_source("cytetyper"), + warn = FALSE + ) + expect_false(any(grepl("--args", launcher, fixed = TRUE))) +}) + +test_that("CLI installer targets the active R environment bin", { + destination <- tempfile("cytetyper-bin-") + dir.create(destination) + on.exit(unlink(destination, recursive = TRUE, force = TRUE), add = TRUE) + launcher_name <- CyteTypeR:::.cytetyper_launcher_name() + testthat::local_mocked_bindings( + .cytetyper_rscript_path = function() { + file.path(destination, "Rscript") + }, + .package = "CyteTypeR" + ) + + messages <- capture.output( + path <- InstallCyteTypeRCli(), + type = "message" + ) + + expect_identical( + normalizePath(path, winslash = "/", mustWork = TRUE), + normalizePath( + file.path(destination, launcher_name), + winslash = "/", + mustWork = TRUE + ) + ) + expect_true(file.exists(path)) + expect_true(any(grepl(path, messages, fixed = TRUE))) + if (identical(CyteTypeR:::.cytetyper_os_type(), "windows")) { + expect_identical(readLines(path, n = 1L), "@echo off") + } else { + expect_identical(readLines(path, n = 1L), "#!/bin/sh") + expect_identical(unname(file.access(path, mode = 1L)), 0L) + } + expect_error(InstallCyteTypeRCli(), "already exists") + expect_identical( + suppressMessages(InstallCyteTypeRCli(overwrite = TRUE)), + path + ) +}) diff --git a/tests/testthat/test-client.R b/tests/testthat/test-client.R index f9bf66d..b4da3f1 100644 --- a/tests/testthat/test-client.R +++ b/tests/testthat/test-client.R @@ -246,6 +246,28 @@ test_that(".make_results_request returns 'error' on unexpected exception", { expect_true(grepl("connection refused", resp$message)) }) +test_that(".make_results_request preserves authentication failures", { + testthat::local_mocked_bindings( + .api_response_helper = function(...) { + CyteTypeR:::.stop_authentication( + "Authentication failed: Invalid or expired auth token", + "INVALID_TOKEN" + ) + }, + .package = "CyteTypeR" + ) + + expect_error( + CyteTypeR:::.make_results_request( + "job1", + "https://example.com", + "expired-token" + ), + "Invalid or expired auth token", + class = "cytetype_auth_error" + ) +}) + # --- .poll_for_results tests --- test_that("poll stops after consecutive 'error' statuses", { diff --git a/tests/testthat/test-cytetype-build-upload.R b/tests/testthat/test-cytetype-build-upload.R index ec167c4..76c973c 100644 --- a/tests/testthat/test-cytetype-build-upload.R +++ b/tests/testthat/test-cytetype-build-upload.R @@ -1,6 +1,13 @@ # Helper: minimal Seurat and prepped_data for build/upload path .local_seurat_and_prepped <- function(build_succeeded = FALSE) { - obj <- Seurat::CreateSeuratObject(Matrix::Matrix(1, 2, 2)) + obj <- Seurat::CreateSeuratObject( + Matrix::sparseMatrix( + i = c(1L, 2L), + j = c(1L, 2L), + x = c(1, 1), + dims = c(2L, 2L) + ) + ) obj$cluster <- "1" prepped_data <- list( studyInfo = "", @@ -25,6 +32,7 @@ test_that("build_succeeded FALSE with require_artifacts TRUE stops", { CyteTypeR::CyteTypeR( x$obj, x$prepped_data, api_url = "https://example.com", + auth_token = "test-token", save_query = FALSE, require_artifacts = TRUE ), @@ -41,6 +49,7 @@ test_that("build_succeeded FALSE with require_artifacts FALSE skips uploads and out <- CyteTypeR::CyteTypeR( x$obj, x$prepped_data, api_url = "https://example.com", + auth_token = "test-token", save_query = FALSE, require_artifacts = FALSE ) @@ -56,6 +65,7 @@ test_that("upload failure with require_artifacts TRUE stops with upload error", CyteTypeR::CyteTypeR( x$obj, x$prepped_data, api_url = "https://example.com", + auth_token = "test-token", save_query = FALSE, require_artifacts = TRUE ), @@ -73,8 +83,142 @@ test_that("upload failure with require_artifacts FALSE continues and completes", out <- CyteTypeR::CyteTypeR( x$obj, x$prepped_data, api_url = "https://example.com", + auth_token = "test-token", save_query = FALSE, require_artifacts = FALSE ) expect_s4_class(out, "Seurat") }) + +test_that("missing credentials stop before uploads or submission", { + upload_called <- FALSE + submit_called <- FALSE + testthat::local_mocked_bindings( + .load_credentials = function(api_url = NULL) NULL, + .upload_obs_duckdb = function(...) { + upload_called <<- TRUE + stop("unexpected upload") + }, + .submit_job = function(...) { + submit_called <<- TRUE + stop("unexpected submission") + }, + .package = "CyteTypeR" + ) + x <- .local_seurat_and_prepped(build_succeeded = TRUE) + + expect_error( + CyteTypeR::CyteTypeR( + x$obj, + x$prepped_data, + api_url = "https://example.com", + save_query = FALSE + ), + "SetupCyteTypeR", + class = "cytetype_auth_error" + ) + expect_false(upload_called) + expect_false(submit_called) +}) + +test_that("stored credentials are used for submission", { + captured <- list() + credentials <- list( + apiUrl = "https://example.com", + dashboardUrl = "https://example.com/dashboard", + apiToken = "stored-token", + tokenId = "token-id", + userId = "user-id", + email = "researcher@example.com" + ) + testthat::local_mocked_bindings( + .load_credentials = function(api_url = NULL) { + expect_identical(api_url, "https://example.com") + credentials + }, + .submit_job = function(payload, api_url, auth_token = NULL) { + captured <<- list(api_url = api_url, auth_token = auth_token) + "job1" + }, + .poll_for_results = function(...) NULL, + .package = "CyteTypeR" + ) + x <- .local_seurat_and_prepped(build_succeeded = FALSE) + + out <- CyteTypeR::CyteTypeR( + x$obj, + x$prepped_data, + api_url = "https://example.com", + save_query = FALSE, + require_artifacts = FALSE + ) + + expect_s4_class(out, "Seurat") + expect_identical(captured$api_url, "https://example.com") + expect_identical(captured$auth_token, "stored-token") + expect_false( + "auth_token" %in% + names(out@misc$cytetype_jobDetails) + ) +}) + +test_that("explicit token overrides stored credentials", { + captured_token <- NULL + testthat::local_mocked_bindings( + .load_credentials = function(...) { + fail("Explicit tokens must not read stored credentials") + }, + .submit_job = function(payload, api_url, auth_token = NULL) { + captured_token <<- auth_token + "job1" + }, + .poll_for_results = function(...) NULL, + .package = "CyteTypeR" + ) + x <- .local_seurat_and_prepped(build_succeeded = FALSE) + + CyteTypeR::CyteTypeR( + x$obj, + x$prepped_data, + api_url = "https://example.com", + auth_token = "explicit-token", + save_query = FALSE, + require_artifacts = FALSE + ) + + expect_identical(captured_token, "explicit-token") +}) + +test_that("completed workflow stores results that GetResults can return", { + result <- list( + annotations = list(list( + clusterId = "1", + annotation = "T cell", + ontologyTerm = "T cell", + ontologyTermID = "CL:0000084", + cellState = "", + granularAnnotation = "" + )) + ) + testthat::local_mocked_bindings( + .submit_job = function(...) "job1", + .poll_for_results = function(...) result, + .package = "CyteTypeR" + ) + x <- .local_seurat_and_prepped(build_succeeded = FALSE) + + out <- CyteTypeR::CyteTypeR( + x$obj, + x$prepped_data, + api_url = "https://example.com", + auth_token = "explicit-token", + save_query = FALSE, + require_artifacts = FALSE + ) + + expect_s3_class(out@misc$cytetype_results, "data.frame") + expect_identical( + GetResults(out), + out@misc$cytetype_results + ) +}) diff --git a/tests/testthat/test-defaults.R b/tests/testthat/test-defaults.R index 00112b0..00f77a2 100644 --- a/tests/testthat/test-defaults.R +++ b/tests/testthat/test-defaults.R @@ -1,17 +1,55 @@ test_that(".get_default_api_url returns default when option unset", { old <- getOption("cytetype.default.api.url") on.exit(options(cytetype.default.api.url = old), add = TRUE) + old_env <- Sys.getenv("CYTETYPE_API_URL", unset = NA_character_) + on.exit({ + if (is.na(old_env)) { + Sys.unsetenv("CYTETYPE_API_URL") + } else { + Sys.setenv(CYTETYPE_API_URL = old_env) + } + }, add = TRUE) + Sys.unsetenv("CYTETYPE_API_URL") options(cytetype.default.api.url = NULL) - expect_identical(CyteTypeR:::.get_default_api_url(), "https://prod.cytetype.nygen.io") + expect_identical(CyteTypeR:::.get_default_api_url(), "https://cytetype.nygen.io") }) test_that(".get_default_api_url respects option when set", { old <- getOption("cytetype.default.api.url") on.exit(options(cytetype.default.api.url = old), add = TRUE) + old_env <- Sys.getenv("CYTETYPE_API_URL", unset = NA_character_) + on.exit({ + if (is.na(old_env)) { + Sys.unsetenv("CYTETYPE_API_URL") + } else { + Sys.setenv(CYTETYPE_API_URL = old_env) + } + }, add = TRUE) + Sys.unsetenv("CYTETYPE_API_URL") options(cytetype.default.api.url = "https://custom.example.com") expect_identical(CyteTypeR:::.get_default_api_url(), "https://custom.example.com") }) +test_that(".get_default_api_url gives environment variable precedence", { + old <- getOption("cytetype.default.api.url") + on.exit(options(cytetype.default.api.url = old), add = TRUE) + old_env <- Sys.getenv("CYTETYPE_API_URL", unset = NA_character_) + on.exit({ + if (is.na(old_env)) { + Sys.unsetenv("CYTETYPE_API_URL") + } else { + Sys.setenv(CYTETYPE_API_URL = old_env) + } + }, add = TRUE) + options(cytetype.default.api.url = "https://option.example.com") + Sys.setenv(CYTETYPE_API_URL = "https://environment.example.com/") + + expect_identical( + CyteTypeR:::.get_default_api_url(), + "https://environment.example.com" + ) +}) + test_that(".get_default_poll_interval returns default when option unset", { old <- getOption("cytetype.default.poll.interval") on.exit(options(cytetype.default.poll.interval = old), add = TRUE) diff --git a/tests/testthat/test-resolve-seurat-assay-rna.R b/tests/testthat/test-resolve-seurat-assay-rna.R index 3972761..49d248d 100644 --- a/tests/testthat/test-resolve-seurat-assay-rna.R +++ b/tests/testthat/test-resolve-seurat-assay-rna.R @@ -16,7 +16,7 @@ test_that(".resolve_seurat_assay_rna warns when DefaultAssay is not RNA but RNA dims = c(2L, 2L), dimnames = list(c("g1", "g2"), c("c1", "c2")) ) obj <- suppressWarnings(Seurat::CreateSeuratObject(counts = counts, assay = "RNA")) - obj[["SCT"]] <- obj[["RNA"]] + suppressWarnings(obj[["SCT"]] <- obj[["RNA"]]) Seurat::DefaultAssay(obj) <- "SCT" expect_warning( diff --git a/vignettes/configurations.Rmd b/vignettes/configurations.Rmd index ab0ba94..4d11aa0 100644 --- a/vignettes/configurations.Rmd +++ b/vignettes/configurations.Rmd @@ -1,39 +1,128 @@ --- title: "Configurations" -output: - rmarkdown::html_vignette: - -date: "2025-08-28" +output: rmarkdown::html_vignette +date: "`r Sys.Date()`" vignette: > %\VignetteIndexEntry{Configurations} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- -## Configuration +## API configuration + +CyteTypeR uses `https://cytetype.nygen.io` by default. An explicit `api_url` takes precedence over the `CYTETYPE_API_URL` environment variable and the package default. + +For normal production use, no URL configuration is required: + +```{r eval=FALSE} +SetupCyteTypeR() +``` + +### Development API + +Replace `` with the development URL supplied for your environment: + +```{r eval=FALSE} +SetupCyteTypeR(api_url = "") +``` + +For a terminal session: + +```sh +export CYTETYPE_API_URL="" +cytetyper setup +``` + +You can also provide the URL for one command: + +```sh +cytetyper setup --api-url "" +``` + +Remote API URLs must use HTTPS. Plain HTTP is accepted only for `localhost` and `127.0.0.1`. + +## Authentication + +`SetupCyteTypeR()` validates matching saved credentials or opens the browser-based email and one-time-code flow when none are available. If the saved key is invalid or inactive, use `SetupCyteTypeR(force = TRUE)` or `cytetyper setup --force` to authenticate with a new key. `LoginCyteTypeR()` validates and saves an existing API key through a hidden prompt. -### LLM configurations +```{r eval=FALSE} +SetupCyteTypeR() +LoginCyteTypeR() +LogoutCyteTypeR() +``` + +The equivalent terminal commands are: + +```sh +cytetyper setup +cytetyper login +cytetyper logout +``` + +CyteTypeR and the Python client share the same local credentials. The active credentials are tied to the API URL used during sign-in. -``` r +## LLM configurations +An external LLM key is optional. Without `llm_configs`, CyteType uses its managed default model. -# Use a named list for llm configs -llm_configs=list( - "provider" = "openai", # one of: anthropic, bedrock, google, groq, mistral, openai, openrouter - "name" = "gpt-4o-mini", - "apiKey" = "your-api-key", - "baseUrl" = "https://api.openai.com/v1", # optional - "modelSettings" = list( # optional - "temperature" = 0.0, - "max_tokens" = 4096 +Store provider keys in environment variables instead of writing them directly in scripts: + +```{r eval=FALSE} +llm_configs <- list( + provider = "openai", + name = "gpt-4o-mini", + apiKey = Sys.getenv("OPENAI_API_KEY"), + baseUrl = "https://api.openai.com/v1", + modelSettings = list( + temperature = 0.0, + max_tokens = 4096L ) ) +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "PBMC blood samples from humans", + metadata = metadata, + llm_configs = llm_configs, + save_query = FALSE +) +``` + +Set `save_query = FALSE` whenever `llm_configs` contains provider credentials. Current query-file output includes the complete LLM configuration. + +Supported provider names in this package version are: + +```{r eval=FALSE} +c( + "anthropic", "bedrock", "fireworks", "google", "groq", + "huggingface", "mistral", "openai", "openrouter", "vertex", "xai" +) +``` + +### Multiple models + +Pass an unnamed list of model configuration lists: + +```{r eval=FALSE} +llm_configs <- list( + list( + provider = "openai", + name = "gpt-4o-mini", + apiKey = Sys.getenv("OPENAI_API_KEY") + ), + list( + provider = "anthropic", + name = "claude-sonnet-4-20250514", + apiKey = Sys.getenv("ANTHROPIC_API_KEY"), + allowFallback = TRUE + ) +) -result <- CyteTypeR(obj = pbmc, - prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", - metadata = metadata, - llm_configs = llm_configs +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + llm_configs = llm_configs, + save_query = FALSE ) ``` diff --git a/vignettes/get-started.Rmd b/vignettes/get-started.Rmd index 3282b03..7283062 100644 --- a/vignettes/get-started.Rmd +++ b/vignettes/get-started.Rmd @@ -1,5 +1,5 @@ --- -title: "CyteTypeR: Characterization of scRNA-seq cell clusters" +title: "Getting Started" output: rmarkdown::html_vignette: toc: true @@ -19,57 +19,124 @@ vignette: > ## Introduction -CyteTypeR is the R version of CyteType, a python package for deep characterization of cell clusters from single cell RNA-seq data . Current version of this package interfaces with Seurat objects after clustering and characterize cell clusters through CyteType API. +CyteTypeR is the R client for [CyteType](https://github.com/NygenAnalytics/CyteType). It prepares clustered Seurat objects, submits annotation jobs to the CyteType API, and adds the returned annotations to the Seurat object. -This vignettte demonstrates a basic workflow using a sample PBMC dataset that has been processed through the standard Seurat analysis pipeline. +This vignette demonstrates a basic workflow using a PBMC dataset processed with the standard Seurat workflow. ## Installation + ```{r eval=FALSE} # Using devtools install.packages("devtools") + # Install from GitHub library(devtools) install_github("NygenAnalytics/CyteTypeR") +``` + +## Authentication + +### Sign in from R + +Sign in once on each machine. CyteType opens a browser, verifies your email with a one-time code, and saves a personal API key locally. + +```{r eval=FALSE} +library(CyteTypeR) + +SetupCyteTypeR() +``` + +If you already have a CyteType API key, save it through a hidden prompt: + +```{r eval=FALSE} +LoginCyteTypeR() +``` + +### Sign in from the terminal + +Install the `cytetyper` command into the active R environment: + +```{r eval=FALSE} +InstallCyteTypeRCli() +``` + +Then use it directly from the terminal: + +```sh +cytetyper setup +cytetyper dashboard +cytetyper view +cytetyper logout +``` + +The R and Python clients use the same local CyteType credentials file. + +### Use a development environment + +Replace `` with the development URL supplied for your environment. +```{r eval=FALSE} +SetupCyteTypeR(api_url = "") +``` + +```sh +cytetyper setup --api-url "" +``` + +Alternatively, configure the development URL for the current shell: + +```sh +export CYTETYPE_API_URL="" +cytetyper setup ``` ## Quick Start + ```{r eval=FALSE} # Load package library(CyteTypeR) -prepped_data <- PrepareCyteTypeR(pbmc, - pbmc.markers, - n_top_genes = 10, - group_key = 'seurat_clusters', - aggregate_metadata = TRUE, - coordinates_key = "umap") +# Sign in once if this machine is not configured +SetupCyteTypeR() + +prepped_data <- PrepareCyteTypeR( + pbmc, + pbmc.markers, + n_top_genes = 10, + group_key = "seurat_clusters", + aggregate_metadata = TRUE, + coordinates_key = "umap" +) metadata <- list( - title = 'My scRNA-seq analysis of human pbmc', - run_label = 'initial_analysis', - experiment_name = 'pbmc_human_samples_study') - -results <- CyteTypeR(prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", - metadata = metadata - ) + title = "My scRNA-seq analysis of human PBMCs", + run_label = "initial_analysis", + experiment_name = "pbmc_human_samples_study" +) + +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "PBMC blood samples from humans", + metadata = metadata +) ``` ## Pre-processing -Current version of CyteTypeR works with Seurat objects and requires minimally some basic pre-processing before CyteTypeR can be used. -``` {r eval=FALSE} -# Load libraries #### +CyteTypeR requires a Seurat object with normalized RNA expression, cluster assignments, marker genes, and optionally a dimensional reduction for report visualization. + +``` {r eval=FALSE} +# Load libraries library(dplyr) library(patchwork) library(Matrix) library(Seurat) - -# Load the dataset #### +# Load the dataset pbmc.data <- Read10X(data.dir = "./data/filtered_gene_bc_matrices/hg19/") -# Initialize the Seurat object with the raw (non-normalized data). + +# Initialize and normalize the Seurat object pbmc <- CreateSeuratObject(counts = pbmc.data, project = "pbmc3k", min.cells = 3, min.features = 200) pbmc <- NormalizeData(pbmc, normalization.method = "LogNormalize", scale.factor = 10000) pbmc <- FindVariableFeatures(pbmc, selection.method = "vst", nfeatures = 2000) @@ -77,43 +144,66 @@ pbmc <- FindVariableFeatures(pbmc, selection.method = "vst", nfeatures = 2000) all.genes <- rownames(pbmc) pbmc <- ScaleData(pbmc, features = all.genes) -# Cluster the cells and run UMAP ##### +# Cluster the cells and run UMAP pbmc <- FindNeighbors(pbmc, dims = 1:10) pbmc <- FindClusters(pbmc, resolution = 0.5) - pbmc <- RunUMAP(pbmc, dims = 1:10) -# Find markers for all Clusters ##### +# Find and filter markers for all clusters pbmc.markers <- FindAllMarkers(pbmc, only.pos = TRUE) - -# (optional:) -pbmc.markers %>% +pbmc.markers <- pbmc.markers %>% group_by(cluster) %>% dplyr::filter(avg_log2FC > 1) - ``` ## Run CyteTypeR + ``` {r eval=FALSE} -## Prep data for job submission to cytetype api -prepped_data <- PrepareCyteTypeR(pbmc, - pbmc.markers, - n_top_genes = 10, - group_key = 'seurat_clusters', - aggregate_metadata = TRUE, - coordinates_key = "umap") - -## Adding metadata on +# Prepare data for submission +prepped_data <- PrepareCyteTypeR( + pbmc, + pbmc.markers, + n_top_genes = 10, + group_key = "seurat_clusters", + aggregate_metadata = TRUE, + coordinates_key = "umap" +) + +# Add metadata to the report metadata <- list( - title = 'My scRNA-seq analysis of human pbmc', - run_label = 'initial_analysis', - experiment_name = 'pbmc_human_samples_study') + title = "My scRNA-seq analysis of human PBMCs", + run_label = "initial_analysis", + experiment_name = "pbmc_human_samples_study" +) + +# Submit the job and add results to the Seurat object +annotated_pbmc <- CyteTypeR( + obj = pbmc, + prepped_data = prepped_data, + study_context = "PBMC blood samples from humans", + metadata = metadata +) +``` +## Open reports and retrieve results -## Submit job to cytetype -results <- CyteTypeR(prepped_data = prepped_data, - study_context = "pbmc blood samples from humans", - metadata = metadata - ) +Successful submissions print a report URL under `https://cytetype.nygen.io`. You can reopen the dashboard or a known job from R: +```{r eval=FALSE} +OpenCyteTypeDashboard() +ViewCyteTypeJob("") +``` + +The same operations are available from the terminal: + +```sh +cytetyper dashboard +cytetyper view +``` + +Results from a completed run are stored in the returned Seurat object. With the default prefix, the transformed result table is available at: + +```{r eval=FALSE} +View(annotated_pbmc@misc[["cytetype_results"]]) +GetResults(annotated_pbmc) ```