Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Bug Fixes

* `get_by_code()` percent-encodes vocabulary IDs and concept codes as individual
path segments, including literal percent sequences and slashes such as ICD-O
`8032/3`, with or without hierarchy, synonym, relationship, or vocabulary-release
query options.

* HTTP failures now raise the documented OMOPHub condition classes instead of
only httr2's generic ones (#137): 400 → `omophub_validation_error`, 401 or
`invalid_api_key` / `missing_api_key` → `omophub_auth_error`, 403 →
Expand Down
10 changes: 8 additions & 2 deletions R/concepts.R
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ ConceptsResource <- R6::R6Class(

perform_get(
private$.base_req,
paste0("concepts/by-code/", vocabulary_id, "/", concept_code),
query = if (length(query) > 0) query else NULL
paste0(
"concepts/by-code/",
utils::URLencode(vocabulary_id, reserved = TRUE, repeated = TRUE),
"/",
utils::URLencode(concept_code, reserved = TRUE, repeated = TRUE)
),
query = if (length(query) > 0) query else NULL,
endpoint_encoded = TRUE
)
},

Expand Down
18 changes: 15 additions & 3 deletions R/request.R
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,18 @@ perform_request <- function(req, endpoint, call = rlang::caller_env()) {
#' @param base_req Base request object.
#' @param endpoint API endpoint path.
#' @param query Named list of query parameters.
#' @param endpoint_encoded Whether `endpoint` is already percent-encoded.
#'
#' @returns Parsed JSON response. For paginated endpoints, returns a list with
#' `data` (the results) and `meta` (pagination info). For single-item endpoints,
#' returns the unwrapped data directly.
#' @keywords internal
perform_get <- function(base_req, endpoint, query = NULL) {
req <- base_req |>
httr2::req_url_path_append(endpoint)
perform_get <- function(base_req, endpoint, query = NULL,
endpoint_encoded = FALSE) {
req <- base_req
if (!isTRUE(endpoint_encoded)) {
req <- httr2::req_url_path_append(req, endpoint)
}

# Add query parameters, removing NULLs
if (!is.null(query)) {
Expand All @@ -82,6 +86,14 @@ perform_get <- function(base_req, endpoint, query = NULL) {
}
}

if (isTRUE(endpoint_encoded)) {
# Query updates parse and rebuild the URL, decoding escaped path separators.
# Append the already-encoded endpoint only after all query updates are done.
url <- httr2::url_parse(req$url)
url$path <- I(paste0(sub("/$", "", url$path), "/", endpoint))
req <- httr2::req_url(req, httr2::url_build(url))
}

resp <- perform_request(req, endpoint, call = rlang::caller_env())
body <- httr2::resp_body_json(resp)

Expand Down
4 changes: 3 additions & 1 deletion man/perform_get.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 83 additions & 2 deletions tests/testthat/test-concepts.R
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ test_that("concepts$get_by_code calls correct endpoint", {

called_with <- NULL
local_mocked_bindings(
perform_get = function(req, path, query = NULL) {
perform_get = function(req, path, query = NULL, endpoint_encoded = FALSE) {
called_with <<- list(path = path)
mock_concept()
}
Expand All @@ -119,13 +119,35 @@ test_that("concepts$get_by_code calls correct endpoint", {
expect_equal(called_with$path, "concepts/by-code/SNOMED/44054006")
})

test_that("concepts$get_by_code URL-encodes path segments", {
base_req <- httr2::request("https://api.omophub.com/v1")
resource <- ConceptsResource$new(base_req)

request_url <- NULL
httr2::local_mocked_responses(function(req) {
request_url <<- req$url
httr2::response(
status_code = 200,
headers = list(`content-type` = "application/json"),
body = charToRaw(jsonlite::toJSON(mock_concept(), auto_unbox = TRUE))
)
})

resource$get_by_code("ICDO3", "8032/3")

expect_equal(
request_url,
"https://api.omophub.com/v1/concepts/by-code/ICDO3/8032%2F3"
)
})

test_that("concepts$get_by_code passes include_hierarchy and vocab_release", {
base_req <- httr2::request("https://api.omophub.com/v1")
resource <- ConceptsResource$new(base_req)

called_with <- NULL
local_mocked_bindings(
perform_get = function(req, path, query = NULL) {
perform_get = function(req, path, query = NULL, endpoint_encoded = FALSE) {
called_with <<- list(query = query)
mock_concept()
}
Expand Down Expand Up @@ -469,3 +491,62 @@ test_that("concepts$recommended passes all optional filters", {
expect_equal(called_with$body$page, 2L)
expect_equal(called_with$body$page_size, 50L)
})

test_that("concepts$get_by_code preserves encoded slashes with query options", {
base_req <- httr2::request("https://api.omophub.com/v1")
resource <- ConceptsResource$new(base_req)
request_url <- NULL
httr2::local_mocked_responses(function(req) {
request_url <<- req$url
httr2::response(
status_code = 200,
headers = list(`content-type` = "application/json"),
body = charToRaw(jsonlite::toJSON(mock_concept(), auto_unbox = TRUE))
)
})

resource$get_by_code("ICDO3", "8032/3", include_hierarchy = TRUE)
expect_identical(
request_url,
"https://api.omophub.com/v1/concepts/by-code/ICDO3/8032%2F3?include_hierarchy=true"
)

resource$get_by_code(
"ICDO3", "8032/3",
include_relationships = TRUE,
include_synonyms = TRUE,
include_hierarchy = TRUE,
vocab_release = "2025.1"
)
expect_identical(
strsplit(request_url, "?", fixed = TRUE)[[1]][1],
"https://api.omophub.com/v1/concepts/by-code/ICDO3/8032%2F3"
)
expect_equal(httr2::url_parse(request_url)$query, list(
include_relationships = "true",
include_synonyms = "true",
include_hierarchy = "true",
vocab_release = "2025.1"
))
})

test_that("concepts$get_by_code treats percent sequences as literal segment input", {
resource <- ConceptsResource$new(httr2::request("https://api.omophub.com/v1"))
request_url <- NULL
httr2::local_mocked_responses(function(req) {
request_url <<- req$url
httr2::response(
status_code = 200,
headers = list(`content-type` = "application/json"),
body = charToRaw(jsonlite::toJSON(mock_concept(), auto_unbox = TRUE))
)
})

for (include_hierarchy in c(FALSE, TRUE)) {
resource$get_by_code("V%20B/1", "A%20B/1", include_hierarchy = include_hierarchy)
expect_identical(
strsplit(request_url, "?", fixed = TRUE)[[1]][1],
"https://api.omophub.com/v1/concepts/by-code/V%2520B%2F1/A%2520B%2F1"
)
}
})
Loading