From 6b6cc1849a3c2bcf9e1457b49663610455602a8a Mon Sep 17 00:00:00 2001 From: Jon Harmon Date: Tue, 26 Mar 2024 16:34:30 -0500 Subject: [PATCH] Toward a real class_paths. --- DESCRIPTION | 12 ++-- NAMESPACE | 3 + R/components-security_schemes.R | 5 +- R/paths.R | 105 ++++++++++++++++++++++++++++++ R/rapid-package.R | 1 + R/zz-rapid.R | 54 ++++----------- man/as_paths.Rd | 27 ++++++++ man/as_rapid.Rd | 9 +-- man/class_paths.Rd | 34 ++++++++++ man/class_rapid.Rd | 5 +- tests/testthat/_snaps/paths.md | 32 +++++++++ tests/testthat/_snaps/zz-rapid.md | 45 +++++++------ tests/testthat/test-paths.R | 52 +++++++++++++++ tests/testthat/test-zz-rapid.R | 3 +- 14 files changed, 310 insertions(+), 77 deletions(-) create mode 100644 R/paths.R create mode 100644 man/as_paths.Rd create mode 100644 man/class_paths.Rd create mode 100644 tests/testthat/_snaps/paths.md create mode 100644 tests/testthat/test-paths.R diff --git a/DESCRIPTION b/DESCRIPTION index a9b8b20..181fd4d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,20 +1,22 @@ Package: rapid Title: R 'API' Descriptions -Version: 0.0.0.9000 +Version: 0.0.0.9003 Authors@R: c( person("Jon", "Harmon", , "jonthegeek@gmail.com", role = c("aut", "cre"), comment = c(ORCID = "0000-0003-4781-4346")), person("The Linux Foundation", role = "cph", comment = "OpenAPI Specification") ) -Description: Convert an 'API' description ('APID'), such as one that follows - the 'OpenAPI Specification', to an R 'API' description object (a - "rapid"). The rapid object follows the 'OpenAPI Specification' to +Description: Convert an 'API' description ('APID'), such as one that + follows the 'OpenAPI Specification', to an R 'API' description object + (a "rapid"). The rapid object follows the 'OpenAPI Specification' to make it easy to convert to and from 'API' documents. License: MIT + file LICENSE URL: https://jonthegeek.github.io/rapid/, https://github.com/jonthegeek/rapid BugReports: https://github.com/jonthegeek/rapid/issues +Depends: + R (>= 3.5.0) Imports: cli, glue, @@ -24,6 +26,7 @@ Imports: S7 (>= 0.1.1), snakecase, stbl, + tibble, tibblify, xml2, yaml @@ -40,6 +43,7 @@ RoxygenNote: 7.3.1 Collate: 'properties.R' 'security.R' + 'paths.R' 'components-security_scheme_details.R' 'components-security_schemes.R' 'components.R' diff --git a/NAMESPACE b/NAMESPACE index 1a6452d..6b72a85 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,6 +11,7 @@ export(as_oauth2_implicit_flow) export(as_oauth2_security_scheme) export(as_oauth2_token_flow) export(as_origin) +export(as_paths) export(as_rapid) export(as_reference) export(as_schema) @@ -34,6 +35,7 @@ export(class_oauth2_implicit_flow) export(class_oauth2_security_scheme) export(class_oauth2_token_flow) export(class_origin) +export(class_paths) export(class_rapid) export(class_reference) export(class_schema) @@ -50,6 +52,7 @@ importFrom(S7,"prop<-") importFrom(S7,S7_inherits) importFrom(S7,class_any) importFrom(S7,class_character) +importFrom(S7,class_data.frame) importFrom(S7,class_factor) importFrom(S7,class_list) importFrom(S7,class_logical) diff --git a/R/components-security_schemes.R b/R/components-security_schemes.R index 2eebd15..1ee1828 100644 --- a/R/components-security_schemes.R +++ b/R/components-security_schemes.R @@ -140,10 +140,7 @@ S7::method(length, class_security_schemes) <- function(x) { #' ) #' ) #' ) -as_security_schemes <- S7::new_generic( - "as_security_schemes", - "x" -) +as_security_schemes <- S7::new_generic("as_security_schemes", "x") S7::method( as_security_schemes, diff --git a/R/paths.R b/R/paths.R new file mode 100644 index 0000000..3e0e3c2 --- /dev/null +++ b/R/paths.R @@ -0,0 +1,105 @@ +#' The available paths and operations for the API +#' +#' Holds the relative paths to the individual endpoints and their operations. +#' The path is appended to the URL from the [class_servers()] object in order to +#' construct the full URL. The paths may be empty. +#' +#' @param ... A data.frame, or arguments to pass to [tibble::tibble()]. +#' +#' @return A `paths` S7 object with details about API endpoints. +#' @export +#' +#' @seealso [as_paths()] for coercing objects to `paths`. +#' +#' @examples +#' class_paths() +#' class_paths( +#' tibble::tibble( +#' endpoint = c("/endpoint1", "/endpoint2"), +#' operations = list( +#' tibble::tibble(operation_properties = 1:2), +#' tibble::tibble(operation_properties = 3:5) +#' ) +#' ) +#' ) +class_paths <- S7::new_class( + "paths", + package = "rapid", + parent = class_data.frame, + constructor = function(...) { + if (...length() == 1 && is.data.frame(..1)) { + return(S7::new_object(tibble::as_tibble(..1))) + } + S7::new_object(tibble::tibble(...)) + } +) + +#' Coerce objects to paths +#' +#' `as_paths()` turns an existing object into a `paths` object. This is in +#' contrast with [class_paths()], which builds a `paths` object from individual +#' properties. In practice, [class_paths()] and `as_paths()` are currently +#' functionally identical. However, in the future, `as_paths()` will coerce +#' other valid objects to the expected shape. +#' +#' @inheritParams rlang::args_dots_empty +#' @inheritParams rlang::args_error_context +#' @param x The object to coerce. Must be empty or be a `data.frame()`. +#' +#' @return A `paths` object as returned by [class_paths()]. +#' @export +#' +#' @examples +#' as_paths() +#' as_paths(mtcars) +as_paths <- S7::new_generic("as_paths", "x") + +S7::method(as_paths, class_data.frame) <- function(x, + ..., + arg = caller_arg(x), + call = caller_env()) { + class_paths(x) +} + +S7::method(as_paths, class_any) <- function(x, + ..., + arg = caller_arg(x), + call = caller_env()) { + as_api_object(x, class_paths, ..., arg = arg, call = call) +} + +.parse_paths <- S7::new_generic(".parse_paths", "paths") + +S7::method(.parse_paths, class_data.frame | class_paths) <- function(paths, + ...) { + paths +} + +S7::method(.parse_paths, class_list) <- function(paths, + openapi, + x, + call = caller_env()) { + if (!is.null(openapi) && openapi >= "3") { + return(.parse_openapi_spec(x, call = call)) + } + return(tibble::tibble()) +} + +.parse_openapi_spec <- function(x, call = caller_env()) { # nocov start + rlang::try_fetch( + { + tibblify::parse_openapi_spec(x) + }, + error = function(cnd) { + cli::cli_abort( + "Failed to parse paths from OpenAPI spec.", + class = "rapid_error_bad_tibblify", + call = call + ) + } + ) +} # nocov end + +S7::method(.parse_paths, class_any) <- function(paths, ...) { + return(tibble::tibble()) +} diff --git a/R/rapid-package.R b/R/rapid-package.R index 43baa04..c4bd03f 100644 --- a/R/rapid-package.R +++ b/R/rapid-package.R @@ -5,6 +5,7 @@ #' @importFrom rlang check_dots_empty #' @importFrom S7 class_any #' @importFrom S7 class_character +#' @importFrom S7 class_data.frame #' @importFrom S7 class_factor #' @importFrom S7 class_list #' @importFrom S7 class_logical diff --git a/R/zz-rapid.R b/R/zz-rapid.R index 0dbe463..bde5ca0 100644 --- a/R/zz-rapid.R +++ b/R/zz-rapid.R @@ -1,6 +1,7 @@ #' @include info.R #' @include servers.R #' @include components.R +#' @include paths.R #' @include security.R NULL @@ -12,10 +13,11 @@ NULL #' @param info An `info` object defined by [class_info()]. #' @param servers A `servers` object defined by [class_servers()]. #' @param components A `components` object defined by [class_components()]. +#' @param paths A `paths` object defined by [class_paths()]. #' @param security A `security` object defined by [class_security()]. #' #' @return A `rapid` S7 object, with properties `info`, `servers`, `components`, -#' and `security`. +#' `paths`, and `security`. #' @export #' #' @seealso [as_rapid()] for coercing objects to `rapid`. @@ -58,14 +60,14 @@ class_rapid <- S7::new_class( info = class_info, servers = class_servers, components = class_components, - paths = S7::class_data.frame, + paths = class_paths, security = class_security ), constructor = function(info = class_info(), ..., servers = class_servers(), components = class_components(), - paths = data.frame(), + paths = class_paths(), security = class_security()) { check_dots_empty() S7::new_object( @@ -73,7 +75,7 @@ class_rapid <- S7::new_class( info = as_info(info), servers = as_servers(servers), components = as_components(components), - paths = paths, + paths = as_paths(paths), security = as_security(security) ) }, @@ -106,10 +108,11 @@ S7::method(length, class_rapid) <- function(x) { #' #' @inheritParams rlang::args_dots_empty #' @inheritParams rlang::args_error_context -#' @param x The object to coerce. Must be empty or have names "info" and/or -#' "servers", or names that can be coerced to those names via -#' [snakecase::to_snake_case()]. Extra names are ignored. [url()] objects are -#' read with [jsonlite::fromJSON()] or [yaml::read_yaml()] before conversion. +#' @param x The object to coerce. Must be empty or have names "info", "servers", +#' "components", "paths", and/or "security", or names that can be coerced to +#' those names via [snakecase::to_snake_case()]. Extra names are ignored. +#' [url()] objects are read with [jsonlite::fromJSON()] or [yaml::read_yaml()] +#' before conversion. #' #' @return A `rapid` object as returned by [class_rapid()]. #' @export @@ -150,41 +153,6 @@ S7::method(as_rapid, class_list) <- function(x, ) } -.parse_paths <- S7::new_generic(".parse_paths", "paths") - -S7::method(.parse_paths, S7::class_data.frame) <- function(paths, ...) { - paths -} - -S7::method(.parse_paths, class_list) <- function(paths, - openapi, - x, - call = caller_env()) { - if (!is.null(openapi) && openapi >= "3") { - return(.parse_openapi_spec(x, call = call)) - } - return(data.frame()) -} - -.parse_openapi_spec <- function(x, call = caller_env()) { # nocov start - rlang::try_fetch( - { - tibblify::parse_openapi_spec(x) - }, - error = function(cnd) { - cli::cli_abort( - "Failed to parse paths from OpenAPI spec.", - class = "rapid_error_bad_tibblify", - call = call - ) - } - ) -} # nocov end - -S7::method(.parse_paths, class_any) <- function(paths, ...) { - return(data.frame()) -} - S7::method(as_rapid, class_any) <- function(x, ..., arg = caller_arg(x), diff --git a/man/as_paths.Rd b/man/as_paths.Rd new file mode 100644 index 0000000..6d681f1 --- /dev/null +++ b/man/as_paths.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/paths.R +\name{as_paths} +\alias{as_paths} +\title{Coerce objects to paths} +\usage{ +as_paths(x, ...) +} +\arguments{ +\item{x}{The object to coerce. Must be empty or be a \code{data.frame()}.} + +\item{...}{These dots are for future extensions and must be empty.} +} +\value{ +A \code{paths} object as returned by \code{\link[=class_paths]{class_paths()}}. +} +\description{ +\code{as_paths()} turns an existing object into a \code{paths} object. This is in +contrast with \code{\link[=class_paths]{class_paths()}}, which builds a \code{paths} object from individual +properties. In practice, \code{\link[=class_paths]{class_paths()}} and \code{as_paths()} are currently +functionally identical. However, in the future, \code{as_paths()} will coerce +other valid objects to the expected shape. +} +\examples{ +as_paths() +as_paths(mtcars) +} diff --git a/man/as_rapid.Rd b/man/as_rapid.Rd index 19b9373..5632a27 100644 --- a/man/as_rapid.Rd +++ b/man/as_rapid.Rd @@ -7,10 +7,11 @@ as_rapid(x, ...) } \arguments{ -\item{x}{The object to coerce. Must be empty or have names "info" and/or -"servers", or names that can be coerced to those names via -\code{\link[snakecase:caseconverter]{snakecase::to_snake_case()}}. Extra names are ignored. \code{\link[=url]{url()}} objects are -read with \code{\link[jsonlite:fromJSON]{jsonlite::fromJSON()}} or \code{\link[yaml:read_yaml]{yaml::read_yaml()}} before conversion.} +\item{x}{The object to coerce. Must be empty or have names "info", "servers", +"components", "paths", and/or "security", or names that can be coerced to +those names via \code{\link[snakecase:caseconverter]{snakecase::to_snake_case()}}. Extra names are ignored. +\code{\link[=url]{url()}} objects are read with \code{\link[jsonlite:fromJSON]{jsonlite::fromJSON()}} or \code{\link[yaml:read_yaml]{yaml::read_yaml()}} +before conversion.} \item{...}{These dots are for future extensions and must be empty.} } diff --git a/man/class_paths.Rd b/man/class_paths.Rd new file mode 100644 index 0000000..d1159e5 --- /dev/null +++ b/man/class_paths.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/paths.R +\name{class_paths} +\alias{class_paths} +\title{The available paths and operations for the API} +\usage{ +class_paths(...) +} +\arguments{ +\item{...}{A data.frame, or arguments to pass to \code{\link[tibble:tibble]{tibble::tibble()}}.} +} +\value{ +A \code{paths} S7 object with details about API endpoints. +} +\description{ +Holds the relative paths to the individual endpoints and their operations. +The path is appended to the URL from the \code{\link[=class_servers]{class_servers()}} object in order to +construct the full URL. The paths may be empty. +} +\examples{ +class_paths() +class_paths( + tibble::tibble( + endpoint = c("/endpoint1", "/endpoint2"), + operations = list( + tibble::tibble(operation_properties = 1:2), + tibble::tibble(operation_properties = 3:5) + ) + ) +) +} +\seealso{ +\code{\link[=as_paths]{as_paths()}} for coercing objects to \code{paths}. +} diff --git a/man/class_rapid.Rd b/man/class_rapid.Rd index 39ae461..dfcb061 100644 --- a/man/class_rapid.Rd +++ b/man/class_rapid.Rd @@ -9,6 +9,7 @@ class_rapid( ..., servers = class_servers(), components = class_components(), + paths = class_paths(), security = class_security() ) } @@ -21,11 +22,13 @@ class_rapid( \item{components}{A \code{components} object defined by \code{\link[=class_components]{class_components()}}.} +\item{paths}{A \code{paths} object defined by \code{\link[=class_paths]{class_paths()}}.} + \item{security}{A \code{security} object defined by \code{\link[=class_security]{class_security()}}.} } \value{ A \code{rapid} S7 object, with properties \code{info}, \code{servers}, \code{components}, -and \code{security}. +\code{paths}, and \code{security}. } \description{ An object that represents an API. diff --git a/tests/testthat/_snaps/paths.md b/tests/testthat/_snaps/paths.md new file mode 100644 index 0000000..a11da16 --- /dev/null +++ b/tests/testthat/_snaps/paths.md @@ -0,0 +1,32 @@ +# class_paths() returns an empty paths + + Code + test_result <- class_paths() + test_result + Output + data frame with 0 columns and 0 rows + +# as_paths() errors informatively for bad classes + + Code + as_paths(1:2) + Condition + Error in `as_paths()`: + ! Can't coerce `1:2` to . + +--- + + Code + as_paths(mean) + Condition + Error in `as_paths()`: + ! Can't coerce `mean` to . + +--- + + Code + as_paths(TRUE) + Condition + Error in `as_paths()`: + ! Can't coerce `TRUE` to . + diff --git a/tests/testthat/_snaps/zz-rapid.md b/tests/testthat/_snaps/zz-rapid.md index 176222f..47f73f5 100644 --- a/tests/testthat/_snaps/zz-rapid.md +++ b/tests/testthat/_snaps/zz-rapid.md @@ -67,7 +67,8 @@ .. .. @ name : chr(0) .. .. @ details : list() .. .. @ description: chr(0) - @ paths :'data.frame': 0 obs. of 0 variables + @ paths :Classes 'rapid::paths', 'S7_object' and 'data.frame': 0 obs. of 0 variables + Named list() @ security : .. @ name : chr(0) .. @ required_scopes : list() @@ -161,14 +162,15 @@ .. .. .. ..@ parameter_name: chr "api_key" .. .. .. ..@ location : chr "query" .. .. @ description: chr(0) - @ paths : tibble [3 x 2] (S3: tbl_df/tbl/data.frame) - $ endpoint : chr [1:3] "a" "b" "c" - $ operations:List of 3 - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + @ paths :Classes 'rapid::paths', 'S7_object' and 'data.frame': 3 obs. of 2 variables: + List of 2 + .. $ endpoint : chr [1:3] "a" "b" "c" + .. $ operations:List of 3 + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() @ security : .. @ name : chr [1:3] "ApiKeyHeaderAuth" "ApiKeyQueryAuth" "apiKey" @@ -220,14 +222,15 @@ .. .. .. ..@ parameter_name: chr "api_key" .. .. .. ..@ location : chr "query" .. .. @ description: chr(0) - @ paths : tibble [3 x 2] (S3: tbl_df/tbl/data.frame) - $ endpoint : chr [1:3] "a" "b" "c" - $ operations:List of 3 - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + @ paths :Classes 'rapid::paths', 'S7_object' and 'data.frame': 3 obs. of 2 variables: + List of 2 + .. $ endpoint : chr [1:3] "a" "b" "c" + .. $ operations:List of 3 + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() @ security : .. @ name : chr [1:3] "ApiKeyHeaderAuth" "ApiKeyQueryAuth" "apiKey" @@ -270,7 +273,8 @@ .. .. @ name : chr(0) .. .. @ details : list() .. .. @ description: chr(0) - @ paths :'data.frame': 0 obs. of 0 variables + @ paths :Classes 'rapid::paths', 'S7_object' and 'data.frame': 0 obs. of 0 variables + Named list() @ security : .. @ name : chr [1:3] "ApiKeyHeaderAuth" "ApiKeyQueryAuth" "apiKey" .. @ required_scopes :List of 3 @@ -321,14 +325,15 @@ .. .. .. ..@ parameter_name: chr "api_key" .. .. .. ..@ location : chr "query" .. .. @ description: chr(0) - @ paths : tibble [3 x 2] (S3: tbl_df/tbl/data.frame) - $ endpoint : chr [1:3] "a" "b" "c" - $ operations:List of 3 - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + @ paths :Classes 'rapid::paths', 'S7_object' and 'data.frame': 3 obs. of 2 variables: + List of 2 + .. $ endpoint : chr [1:3] "a" "b" "c" + .. $ operations:List of 3 + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() - ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) + .. ..$ : tibble [0 x 0] (S3: tbl_df/tbl/data.frame) Named list() @ security : .. @ name : chr [1:3] "ApiKeyHeaderAuth" "ApiKeyQueryAuth" "apiKey" diff --git a/tests/testthat/test-paths.R b/tests/testthat/test-paths.R new file mode 100644 index 0000000..feb8111 --- /dev/null +++ b/tests/testthat/test-paths.R @@ -0,0 +1,52 @@ +test_that("class_paths() returns an empty paths", { + expect_snapshot({ + test_result <- class_paths() + test_result + }) + expect_s3_class( + test_result, + class = c("rapid::paths", "data.frame", "S7_object"), + exact = TRUE + ) +}) + +test_that("as_paths() errors informatively for bad classes", { + expect_snapshot( + as_paths(1:2), + error = TRUE + ) + expect_snapshot( + as_paths(mean), + error = TRUE + ) + expect_snapshot( + as_paths(TRUE), + error = TRUE + ) +}) + +test_that("as_paths() returns expected objects", { + expect_identical( + as_paths(mtcars), + class_paths(mtcars) + ) + expect_identical( + as_paths(data.frame()), + class_paths() + ) + expect_identical( + as_paths(tibble::tibble()), + class_paths() + ) + expect_identical( + as_paths(), + class_paths() + ) +}) + +test_that("as_paths() works for paths", { + expect_identical( + as_paths(class_paths()), + class_paths() + ) +}) diff --git a/tests/testthat/test-zz-rapid.R b/tests/testthat/test-zz-rapid.R index 19f2ca5..699ba91 100644 --- a/tests/testthat/test-zz-rapid.R +++ b/tests/testthat/test-zz-rapid.R @@ -364,7 +364,8 @@ test_that("as_rapid() works for empty optional fields", { expect_snapshot(test_result) }) -# This pair breaks with tibblify but has more info. Save for when tibblify works. +# This pair breaks with tibblify but has more info. Save for when tibblify +# works. # test_that("as_rapid() works for yaml urls", { # skip_if_not(Sys.getenv("RAPID_TEST_DL") == "true")