From 4f1f19a18a8a3612f41062d40e6d1e9f365ce16b Mon Sep 17 00:00:00 2001 From: Jon Harmon Date: Wed, 4 Oct 2023 16:29:05 -0500 Subject: [PATCH] Add security requirements objects (#58) * Refactor error classes. * Implement security_requirements() * Add security to rapid(). * Document and export security_requirements. * Add to pkgdown. --- DESCRIPTION | 1 + NAMESPACE | 2 + R/as.R | 2 +- R/components-security_scheme-oauth2-scopes.R | 6 +- R/properties.R | 26 ++++ R/security_requirements.R | 125 ++++++++++++++++++ R/utils.R | 8 ++ R/validate_in.R | 15 ++- R/zz-rapid.R | 45 +++++-- _pkgdown.yml | 6 +- man/as_security_requirements.Rd | 37 ++++++ man/rapid.Rd | 10 +- man/security_requirements.Rd | 37 ++++++ .../components-security_scheme-api_key.md | 2 +- .../testthat/_snaps/security_requirements.md | 45 +++++++ tests/testthat/_snaps/zz-rapid.md | 26 +++- tests/testthat/test-security_requirements.R | 117 ++++++++++++++++ tests/testthat/test-zz-rapid.R | 24 +++- 18 files changed, 507 insertions(+), 27 deletions(-) create mode 100644 R/security_requirements.R create mode 100644 man/as_security_requirements.Rd create mode 100644 man/security_requirements.Rd create mode 100644 tests/testthat/_snaps/security_requirements.md create mode 100644 tests/testthat/test-security_requirements.R diff --git a/DESCRIPTION b/DESCRIPTION index 25f6118..6d0c4b2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -52,6 +52,7 @@ Collate: 'info-license.R' 'info.R' 'rapid-package.R' + 'security_requirements.R' 'servers-server_variables.R' 'servers-string_replacements.R' 'servers.R' diff --git a/NAMESPACE b/NAMESPACE index 6a623b1..c13c8a0 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -12,6 +12,7 @@ export(as_oauth2_security_scheme) export(as_oauth2_token_flow) export(as_rapid) export(as_scopes) +export(as_security_requirements) export(as_security_scheme) export(as_security_scheme_collection) export(as_security_scheme_details) @@ -28,6 +29,7 @@ export(oauth2_security_scheme) export(oauth2_token_flow) export(rapid) export(scopes) +export(security_requirements) export(security_scheme_collection) export(security_scheme_details) export(server_variables) diff --git a/R/as.R b/R/as.R index 8aafeb2..2ed3c0c 100644 --- a/R/as.R +++ b/R/as.R @@ -35,7 +35,7 @@ "{.arg {x_arg}} must have names {.or {.val {valid_names}}}.", "*" = "Any other names are ignored." ), - class = "rapid_missing_names", + class = "rapid_error_missing_names", call = call ) } diff --git a/R/components-security_scheme-oauth2-scopes.R b/R/components-security_scheme-oauth2-scopes.R index 74b4a09..a252555 100644 --- a/R/components-security_scheme-oauth2-scopes.R +++ b/R/components-security_scheme-oauth2-scopes.R @@ -39,12 +39,10 @@ scopes <- S7::new_class( ), constructor = function(name = character(), description = character()) { - name <- name %|0|% character() - description <- description %|0|% character() S7::new_object( S7::S7_object(), - name = name %||% character(), - description = description %||% character() + name = name %|0|% character(), + description = description %|0|% character() ) }, validator = function(self) { diff --git a/R/properties.R b/R/properties.R index 90c38f8..29d80d4 100644 --- a/R/properties.R +++ b/R/properties.R @@ -47,3 +47,29 @@ enum_property <- function(x_arg) { } ) } + +list_of_characters <- function(x_arg, ...) { + S7::new_property( + name = x_arg, + class = class_list, + setter = function(self, value) { + call <- rlang::caller_env(3) + value <- as.list(value) + value <- purrr::map( + value, + function(x) { + x <- x %|0|% character() + stbl::stabilize_chr( + x, + allow_na = FALSE, + x_arg = x_arg, + call = call, + ... + ) + } + ) + S7::prop(self, x_arg) <- value + self + } + ) +} diff --git a/R/security_requirements.R b/R/security_requirements.R new file mode 100644 index 0000000..75e2e4e --- /dev/null +++ b/R/security_requirements.R @@ -0,0 +1,125 @@ +#' @include properties.R +NULL + +#' Security schemes required to execute an operation +#' +#' The object lists the required security schemes to execute an operation or +#' operations. +#' +#' @inheritParams rlang::args_dots_empty +#' @param name Character vector (required). The names must correspond to +#' security schemes declared in the `security_schemes` property of a +#' [component_collection()]. +#' @param required_scopes A list of character vectors, each of which describe +#' the scopes required for this security scheme. The vector corresponding to a +#' given `name` can be empty. +#' +#' @return A `security_requirements` S7 object with references of security +#' required for operations. +#' @export +#' @examples +#' security_requirements() +#' security_requirements( +#' name = c("oauth2", "internalApiKey"), +#' required_scopes = list( +#' c("user", "user:email", "user:follow"), +#' character() +#' ) +#' ) +security_requirements <- S7::new_class( + "security_requirements", + package = "rapid", + properties = list( + name = class_character, + required_scopes = list_of_characters("required_scopes"), + rapid_class_requirement = S7::new_property( + getter = function(self) { + "security_scheme" + } + ) + ), + constructor = function(name = character(), ..., required_scopes = list()) { + name <- name %|0|% character() + required_scopes <- required_scopes %|0|% + purrr::rep_along(name, list(character())) + S7::new_object( + S7::S7_object(), + name = name, + required_scopes = required_scopes + ) + }, + validator = function(self) { + validate_parallel( + self, + "name", + required = "required_scopes" + ) + } +) + +S7::method(length, security_requirements) <- function(x) { + length(x@name) +} + +#' Coerce lists to as_security_requirements objects +#' +#' `as_security_requirements()` turns an existing object into a +#' `security_requirements` object. This is in contrast with +#' [security_requirements()], which builds a `security_requirements` from +#' individual properties. +#' +#' @inheritParams rlang::args_dots_empty +#' @inheritParams rlang::args_error_context +#' @param x The object to coerce. Must be empty or be a list containing a single +#' list named "security_schemes", or a name that can be coerced to +#' "security_schemes" via [snakecase::to_snake_case()]. Additional names are +#' ignored. +#' +#' @return A `security_requirements` object as returned by +#' [security_requirements()]. +#' @export +#' +#' @examples +#' as_security_requirements() +#' as_security_requirements( +#' list( +#' list( +#' oauth2 = c("user", "user:email", "user:follow") +#' ), +#' list(internalApiKey = list()) +#' ) +#' ) +as_security_requirements <- S7::new_generic( + "as_security_requirements", + dispatch_args = "x" +) + +S7::method(as_security_requirements, security_requirements) <- function(x) { + x +} + +S7::method(as_security_requirements, class_list) <- function(x, ..., arg = rlang::caller_arg(x)) { + force(arg) + x <- .list_remove_wrappers(x) + + if (!rlang::is_named2(x)) { + cli::cli_abort( + "{.arg {arg}} must be a named list.", + ) + } + security_requirements( + name = names(x), + required_scopes = unname(x) + ) +} + +S7::method(as_security_requirements, class_missing | NULL) <- function(x) { + security_requirements() +} + +S7::method(as_security_requirements, class_any) <- function(x, ..., arg = rlang::caller_arg(x)) { + cli::cli_abort( + "Can't coerce {.arg {arg}} {.cls {class(x)}} to {.cls security_requirements}.", + class = "rapid_error_unknown_coercion" + ) +} diff --git a/R/utils.R b/R/utils.R index b16c69b..70982ca 100644 --- a/R/utils.R +++ b/R/utils.R @@ -26,3 +26,11 @@ .empty_to_na <- function(x) { x %|0|% NA } + +.list_remove_wrappers <- function(x) { + if (is.list(x) && !rlang::is_named(x)) { + x <- purrr::list_c(x) + x <- .list_remove_wrappers(x) + } + x +} diff --git a/R/validate_in.R b/R/validate_in.R index 571062e..204abe6 100644 --- a/R/validate_in.R +++ b/R/validate_in.R @@ -43,9 +43,20 @@ validate_in_fixed <- function(obj, } } -.msg_some_not_in_fixed <- function(value_name, enums, missing_msgs) { +.msg_some_not_in_fixed <- function(value_name, + enums, + missing_msgs, + enum_name = "the designated values") { + enum_name <- cli::format_inline(enum_name) c( - cli::format_inline("{.arg {value_name}} must be one of {.or {.val {enums}}}."), + cli::format_inline("{.arg {value_name}} must be one of {enum_name}."), missing_msgs ) } + +validate_in_specific <- function(values, enums, value_name, ...) { + missing_msgs <- .check_all_in_enums(values, rep(list(enums), length(values))) + if (length(missing_msgs)) { + return(.msg_some_not_in_fixed(value_name, enums, missing_msgs, ...)) + } +} diff --git a/R/zz-rapid.R b/R/zz-rapid.R index 0bd6d44..36de296 100644 --- a/R/zz-rapid.R +++ b/R/zz-rapid.R @@ -11,9 +11,11 @@ NULL #' @param servers A `servers` object defined by [servers()]. #' @param components A `component_collection` object defined by #' [component_collection()]. +#' @param security A `security_requirements` object defined by +#' [security_requirements()]. #' -#' @return A `rapid` S7 object, with properties `info`, `servers`, and -#' `components`. +#' @return A `rapid` S7 object, with properties `info`, `servers`, `components`, +#' and `security`. #' @export #' #' @seealso [as_rapid()] for coercing objects to `rapid`. @@ -55,26 +57,47 @@ rapid <- S7::new_class( properties = list( info = info, servers = servers, - components = component_collection + components = component_collection, + security = security_requirements ), constructor = function(info = class_missing, ..., servers = class_missing, - components = component_collection()) { + components = component_collection(), + security = security_requirements()) { check_dots_empty() S7::new_object( S7::S7_object(), info = as_info(info), servers = as_servers(servers), - components = as_component_collection(components) + components = as_component_collection(components), + security = as_security_requirements(security) ) }, validator = function(self) { - validate_lengths( - self, - key_name = "info", - optional_any = c("components", "servers") + c( + msgs <- validate_lengths( + self, + key_name = "info", + optional_any = c("components", "security", "servers") + ), + validate_in_specific( + values = self@security@name, + enums = self@components@security_schemes@name, + value_name = "security", + enum_name = "the {.arg security_schemes} defined in {.arg components}" + ) ) + + # if (!all(self@security@name %in% self@components@security_schemes@name)) { + # msgs <- c( + # msgs, + # cli::format_inline( + # "{.arg security} must reference {.arg security_schemes} defined in {.arg components}." + # ) + # ) + # } + # msgs } ) @@ -108,10 +131,10 @@ S7::method(as_rapid, rapid) <- function(x) { S7::method(as_rapid, class_list) <- function(x) { rlang::try_fetch( {.as_class(x, rapid)}, - rapid_missing_names = function(cnd) { + rapid_error_missing_names = function(cnd) { cli::cli_abort( "{.arg x} must be comprised of properly formed, supported elements.", - class = "rapid_unsupported_elements", + class = "rapid_error_unsupported_elements", parent = cnd ) } diff --git a/_pkgdown.yml b/_pkgdown.yml index 9ada6ca..a5d4046 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -22,7 +22,7 @@ reference: - as_string_replacements - server_variables - as_server_variables - - title: components class + - title: component_collection class contents: - component_collection - as_component_collection @@ -43,3 +43,7 @@ reference: - as_oauth2_token_flow - scopes - as_scopes + - title: security_requirements class + contents: + - security_requirements + - as_security_requirements diff --git a/man/as_security_requirements.Rd b/man/as_security_requirements.Rd new file mode 100644 index 0000000..d8c7789 --- /dev/null +++ b/man/as_security_requirements.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/security_requirements.R +\name{as_security_requirements} +\alias{as_security_requirements} +\title{Coerce lists to as_security_requirements objects} +\usage{ +as_security_requirements(x, ...) +} +\arguments{ +\item{x}{The object to coerce. Must be empty or be a list containing a single +list named "security_schemes", or a name that can be coerced to +"security_schemes" via \code{\link[snakecase:caseconverter]{snakecase::to_snake_case()}}. Additional names are +ignored.} + +\item{...}{These dots are for future extensions and must be empty.} +} +\value{ +A \code{security_requirements} object as returned by +\code{\link[=security_requirements]{security_requirements()}}. +} +\description{ +\code{as_security_requirements()} turns an existing object into a +\code{security_requirements} object. This is in contrast with +\code{\link[=security_requirements]{security_requirements()}}, which builds a \code{security_requirements} from +individual properties. +} +\examples{ +as_security_requirements() +as_security_requirements( + list( + list( + oauth2 = c("user", "user:email", "user:follow") + ), + list(internalApiKey = list()) + ) +) +} diff --git a/man/rapid.Rd b/man/rapid.Rd index b67be3b..6deed5a 100644 --- a/man/rapid.Rd +++ b/man/rapid.Rd @@ -8,7 +8,8 @@ rapid( info = class_missing, ..., servers = class_missing, - components = component_collection() + components = component_collection(), + security = security_requirements() ) } \arguments{ @@ -20,10 +21,13 @@ rapid( \item{components}{A \code{component_collection} object defined by \code{\link[=component_collection]{component_collection()}}.} + +\item{security}{A \code{security_requirements} object defined by +\code{\link[=security_requirements]{security_requirements()}}.} } \value{ -A \code{rapid} S7 object, with properties \code{info}, \code{servers}, and -\code{components}. +A \code{rapid} S7 object, with properties \code{info}, \code{servers}, \code{components}, +and \code{security}. } \description{ An object that represents an API. diff --git a/man/security_requirements.Rd b/man/security_requirements.Rd new file mode 100644 index 0000000..63dd1af --- /dev/null +++ b/man/security_requirements.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/security_requirements.R +\name{security_requirements} +\alias{security_requirements} +\title{Security schemes required to execute an operation} +\usage{ +security_requirements(name = character(), ..., required_scopes = list()) +} +\arguments{ +\item{name}{Character vector (required). The names must correspond to +security schemes declared in the \code{security_schemes} property of a +\code{\link[=component_collection]{component_collection()}}.} + +\item{...}{These dots are for future extensions and must be empty.} + +\item{required_scopes}{A list of character vectors, each of which describe +the scopes required for this security scheme. The vector corresponding to a +given \code{name} can be empty.} +} +\value{ +A \code{security_requirements} S7 object with references of security +required for operations. +} +\description{ +The object lists the required security schemes to execute an operation or +operations. +} +\examples{ +security_requirements() +security_requirements( + name = c("oauth2", "internalApiKey"), + required_scopes = list( + c("user", "user:email", "user:follow"), + character() + ) +) +} diff --git a/tests/testthat/_snaps/components-security_scheme-api_key.md b/tests/testthat/_snaps/components-security_scheme-api_key.md index 137a690..b217230 100644 --- a/tests/testthat/_snaps/components-security_scheme-api_key.md +++ b/tests/testthat/_snaps/components-security_scheme-api_key.md @@ -5,7 +5,7 @@ Condition Error: ! object is invalid: - - `location` must be one of "query", "header", or "cookie". + - `location` must be one of the designated values. - "invalid place" is not in "query", "header", and "cookie". # api_key_security_scheme() works with valid objects diff --git a/tests/testthat/_snaps/security_requirements.md b/tests/testthat/_snaps/security_requirements.md new file mode 100644 index 0000000..77e3b10 --- /dev/null +++ b/tests/testthat/_snaps/security_requirements.md @@ -0,0 +1,45 @@ +# security_requirements() requires parallel parameters + + Code + security_requirements(required_scopes = "a") + Condition + Error: + ! object is invalid: + - When `name` is not defined, `required_scopes` must be empty. + - `required_scopes` has 1 value. + +# security_requirements() rapid_class_requirement field is fixed + + Code + test_result <- security_requirements() + test_result + Output + + @ name : chr(0) + @ required_scopes : list() + @ rapid_class_requirement: chr "security_scheme" + +# as_security_requirements() fails for bad classes + + Code + as_security_requirements(x) + Condition + Error: + ! Can't coerce `x` to . + +--- + + Code + as_security_requirements(x) + Condition + Error: + ! Can't coerce `x` to . + +--- + + Code + as_security_requirements(x) + Condition + Error: + ! Can't coerce `x` to . + diff --git a/tests/testthat/_snaps/zz-rapid.md b/tests/testthat/_snaps/zz-rapid.md index 2d82c6d..fc0de4b 100644 --- a/tests/testthat/_snaps/zz-rapid.md +++ b/tests/testthat/_snaps/zz-rapid.md @@ -19,6 +19,19 @@ - When `info` is not defined, `servers` must be empty. - `servers` has 3 values. +# security must reference components@security_schemes + + Code + rapid(info = info(title = "A", version = "1"), components = component_collection( + security_schemes = security_scheme_collection(name = "the_defined_one", + details = security_scheme_details(api_key_security_scheme("this_one", + location = "header")))), security = security_requirements(name = "an_undefined_one")) + Condition + Error: + ! object is invalid: + - `security` must be one of the `security_schemes` defined in `components`. + - "an_undefined_one" is not in "the_defined_one". + # rapid() returns an empty rapid Code @@ -49,6 +62,10 @@ .. .. @ name : chr(0) .. .. @ details : list() .. .. @ description: chr(0) + @ security : + .. @ name : chr(0) + .. @ required_scopes : list() + .. @ rapid_class_requirement: chr "security_scheme" # as_rapid() errors informatively for bad classes @@ -82,7 +99,7 @@ Error: ! `x` must be comprised of properly formed, supported elements. Caused by error: - ! `x` must have names "info", "servers", or "components". + ! `x` must have names "info", "servers", "components", or "security". * Any other names are ignored. --- @@ -93,7 +110,7 @@ Error: ! `x` must be comprised of properly formed, supported elements. Caused by error: - ! `x` must have names "info", "servers", or "components". + ! `x` must have names "info", "servers", "components", or "security". * Any other names are ignored. # as_rapid() fails gracefully for unsupported urls @@ -164,4 +181,9 @@ .. .. .. ..@ parameter_name: chr "Authorization" .. .. .. ..@ location : chr "header" .. .. @ description: chr "Amazon Signature authorization v4" + @ security : + .. @ name : chr "hmac" + .. @ required_scopes :List of 1 + .. .. $ : chr(0) + .. @ rapid_class_requirement: chr "security_scheme" diff --git a/tests/testthat/test-security_requirements.R b/tests/testthat/test-security_requirements.R new file mode 100644 index 0000000..60104bf --- /dev/null +++ b/tests/testthat/test-security_requirements.R @@ -0,0 +1,117 @@ +# TODO: Remove these musings. Security Requirements Objects can be: +# * In the top-level "security" field. +# * In the "security" field of an "operation" object, which is itself inside a +# "path item object". "path item" can be: +# ** inside a "paths" object. +# ** inside a "callbacks" object, in "components" ("callbacks" field). +# ** inside "components" directly (pathItems field) +# ** in the "webhooks" top-level field +# +# This is our first super-reusable object, be careful! + +test_that("security_requirements() requires parallel parameters", { + expect_error( + security_requirements(required_scopes = "a"), + "must be empty" + ) + expect_snapshot( + security_requirements(required_scopes = "a"), + error = TRUE + ) +}) + +test_that("security_requirements() rapid_class_requirement field is fixed", { + expect_snapshot({ + test_result <- security_requirements() + test_result + }) + expect_error( + test_result@rapid_class_requirement <- "a", + "Can't set read-only property" + ) + expect_identical( + test_result@rapid_class_requirement, + "security_scheme" + ) +}) + +test_that("security_requirements have expected lengths", { + expect_equal(length(security_requirements()), 0) + expect_equal(length(security_requirements(letters)), 26) + expect_equal(length(security_requirements("a", required_scopes = list(letters))), 1) +}) + +test_that("as_security_requirements() fails for bad classes", { + x <- 1:2 + expect_error( + as_security_requirements(x), + class = "rapid_error_unknown_coercion" + ) + expect_snapshot( + as_security_requirements(x), + error = TRUE + ) + x <- mean + expect_error( + as_security_requirements(x), + class = "rapid_error_unknown_coercion" + ) + expect_snapshot( + as_security_requirements(x), + error = TRUE + ) + x <- TRUE + expect_error( + as_security_requirements(x), + class = "rapid_error_unknown_coercion" + ) + expect_snapshot( + as_security_requirements(x), + error = TRUE + ) +}) + +test_that("as_security_requirements() works for security_requirements", { + expect_identical( + as_security_requirements(security_requirements()), + security_requirements() + ) +}) + +test_that("as_security_requirements() works for empties", { + expect_identical( + as_security_requirements(NULL), + security_requirements() + ) + expect_identical( + as_security_requirements(), + security_requirements() + ) +}) + +test_that("as_security_requirements() works for the simplest case", { + x <- list(list(schemeName = list())) + expect_identical( + as_security_requirements(x), + security_requirements("schemeName") + ) +}) + +test_that("as_security_requirements() works for complex cases", { + x <- list( + list( + oauth2 = c("user", "user:email", "user:follow") + ), + list(internalApiKey = list()) + ) + expect_identical( + as_security_requirements(x), + security_requirements( + name = c("oauth2", "internalApiKey"), + required_scopes = list( + c("user", "user:email", "user:follow"), + character() + ) + ) + ) +}) diff --git a/tests/testthat/test-zz-rapid.R b/tests/testthat/test-zz-rapid.R index f671807..ca84afc 100644 --- a/tests/testthat/test-zz-rapid.R +++ b/tests/testthat/test-zz-rapid.R @@ -28,6 +28,26 @@ test_that("rapid() requires info when anything is defined", { ) }) +test_that("security must reference components@security_schemes", { + expect_snapshot( + rapid( + info = info(title = "A", version = "1"), + components = component_collection( + security_schemes = security_scheme_collection( + name = "the_defined_one", + details = security_scheme_details( + api_key_security_scheme("this_one", location = "header") + ) + ) + ), + security = security_requirements( + name = "an_undefined_one" + ) + ), + error = TRUE + ) +}) + test_that("rapid() returns an empty rapid", { expect_snapshot({ test_result <- rapid() @@ -40,7 +60,7 @@ test_that("rapid() returns an empty rapid", { ) expect_identical( S7::prop_names(test_result), - c("info", "servers", "components") + c("info", "servers", "components", "security") ) }) @@ -197,7 +217,7 @@ test_that("as_rapid() fails gracefully for unsupported urls", { skip_if_not(Sys.getenv("RAPID_TEST_DL") == "true") expect_error( as_rapid(url("https://api.apis.guru/v2/openapi.yaml")), - class = "rapid_missing_names" + class = "rapid_error_unsupported_elements" ) expect_snapshot( as_rapid(url("https://api.apis.guru/v2/openapi.yaml")),