Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Draft changes to support Dates and DateTime #891

Closed
wants to merge 6 commits into from
Closed
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
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Suggests:
geojsonsf,
redoc,
rapidoc,
sf
sf,
lubridate
RoxygenNote: 7.2.2
Collate:
'async.R'
Expand Down
13 changes: 7 additions & 6 deletions R/openapi-spec.R
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,13 @@ parametersSpecification <- function(endpointParams, pathParams, funcParams = NUL
)
if (type %in% inRaw) {
names(params$requestBody$content) <- "multipart/form-data"
property$type <- apiTypesInfo[[type]]$realType
property$type <- apiTypesInfo[[type]]$openApiType
property$format <- apiTypesInfo[[type]]$openApiFormat
property$example <- NULL
}
if (isArray) {
property$items <- list(
type = property$type,
type = apiTypesInfo[[type]]$openApiType,
format = property$format,
example = property$example
)
Expand All @@ -170,17 +171,17 @@ parametersSpecification <- function(endpointParams, pathParams, funcParams = NUL
`in` = location,
required = required,
schema = list(
type = type,
format = apiTypesInfo[[type]]$format,
type = apiTypesInfo[[type]]$openApiType,
format = apiTypesInfo[[type]]$openApiFormat,
default = funcParams[[p]]$default
)
)
if (isArray) {
paramList$schema <- list(
type = "array",
items = list(
type = type,
format = apiTypesInfo[[type]]$format
type = apiTypesInfo[[type]]$openApiType,
format = apiTypesInfo[[type]]$openApiFormat
),
default = funcParams[[p]]$default
)
Expand Down
201 changes: 142 additions & 59 deletions R/openapi-types.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,114 +2,197 @@

# calculate all OpenAPI Type information at once and use created information throughout package
apiTypesInfo <- list()
plumberToApiTypeMap <- list()
defaultApiType <- structure("string", default = TRUE)
defaultIsArray <- structure(FALSE, default = TRUE)

add_api_info_onLoad <- function() {
addApiInfo <- function(apiType, plumberTypes,
regex = NULL, converter = NULL,
format = NULL,
location = NULL,
realType = NULL) {
apiTypesInfo[[apiType]] <<-
list(
regex = regex,
converter = converter,
format = format,
location = location,
realType = realType,
# Q: Do we need to safe guard against special characters, such as `,`?
# https://github.com/rstudio/plumber/pull/532#discussion_r439584727
# A: https://swagger.io/docs/specification/serialization/
# > Additionally, the allowReserved keyword specifies whether the reserved
# > characters :/?#[]@!$&'()*+,;= in parameter values are allowed to be sent as they are,
# > or should be percent-encoded. By default, allowReserved is false, and reserved characters
# > are percent-encoded. For example, / is encoded as %2F (or %2f), so that the parameter
# > value quotes/h2g2.txt will be sent as quotes%2Fh2g2.txt
regexArray = paste0("(?:(?:", regex, "),?)+"),
converterArray = function(x) {converter(stri_split_fixed(x, ",")[[1]])}
)

for (plumberType in plumberTypes) {
plumberToApiTypeMap[[plumberType]] <<- apiType
addApiInfo <- function(
keys,
openApiType = c("string", "number", "integer", "boolean", "object"),
# note that openApiFormat is extensible - so match.arg should not be used on openApiFormat
openApiFormat = c("float", "double", "int32", "int64", "date", "date-time", "password", "byte", "binary"),
openApiRegex = NULL,
location = c("requestBody", "path", "query"),
parser = function(input) { input; }) {

if (missing(location)) {
location <- NULL
} else {
location <- match.arg(location, several.ok = TRUE)
}

if (missing(openApiType)) {
# should this be an error?
openApiType <- NULL
} else {
openApiType <- match.arg(openApiType)
}

if (missing(openApiFormat)) {
openApiFormat <- NULL
}

preferredKey <- keys[[1]]

entry <- list(
preferredKey = preferredKey,
location = location,
openApiType = openApiType,
openApiFormat = openApiFormat,
# Q: Do we need to safe guard against special characters, such as `,`?
# https://github.com/rstudio/plumber/pull/532#discussion_r439584727
# A: https://swagger.io/docs/specification/serialization/
# > Additionally, the allowReserved keyword specifies whether the reserved
# > characters :/?#[]@!$&'()*+,;= in parameter values are allowed to be sent as they are,
# > or should be percent-encoded. By default, allowReserved is false, and reserved characters
# > are percent-encoded. For example, / is encoded as %2F (or %2f), so that the parameter
# > value quotes/h2g2.txt will be sent as quotes%2Fh2g2.txt
openApiRegex = openApiRegex,
parser = parser,

# TODO - not keen on these array functions
# believe they are used only for comma separated arrays inside dynamic routes
# - not sure Plumber needs to support these
openApiRegexArray = paste0("(?:(?:", openApiRegex, "),?)+"),
# TODO - this won't work for strings that contain commas?
parserArray = function(x) {parser(stri_split_fixed(x, ",")[[1]])}
)

for (apiType in keys) {
apiTypesInfo[[apiType]] <<- entry
}
# make sure it could be called again
plumberToApiTypeMap[[apiType]] <<- apiType

invisible(TRUE)
}

addApiInfo(
"boolean",
c("bool", "boolean", "logical"),
"[01tfTF]|true|false|TRUE|FALSE",
as.logical,
c("boolean", "bool", "logical"),
openApiType = "boolean",
openApiRegex = "[01tfTF]|true|false|TRUE|FALSE",
parser = as.logical,
location = c("query", "path")
)

addApiInfo(
c("number", "numeric"),
openApiType = "number",
openApiRegex = "-?\\\\d*\\\\.?\\\\d+",
parser = as.numeric,
location = c("query", "path")
)

addApiInfo(
c("double", "dbl"),
openApiType = "number",
openApiFormat = "double",
openApiRegex = "-?\\\\d*\\\\.?\\\\d+",
parser = as.numeric,
location = c("query", "path")
)

addApiInfo(
c("float"),
openApiType = "number",
openApiFormat = "float",
openApiRegex = "-?\\\\d*\\\\.?\\\\d+",
parser = as.numeric,
location = c("query", "path")
)

addApiInfo(
c("integer", "int"),
openApiType = "integer",
openApiFormat = "int64",
openApiRegex = "-?\\\\d+",
parser = as.integer,
location = c("query", "path")
)

addApiInfo(
"number",
c("dbl", "double", "float", "number", "numeric"),
"-?\\\\d*\\\\.?\\\\d+",
as.numeric,
format = "double",
c("string", "str", "chr", "character"),
openApiType = "string",
openApiRegex = "[^/]+",
parser = as.character,
location = c("query", "path")
)

# be careful not to trap lubridate on package installation
lubridate_available <- function() {
system.file(package = "lubridate") != "";
}

parse_datetime <- function(x) {
if (lubridate_available()) lubridate::as_datetime(x) else as.Date.POSIXct(x)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the idea of the behavior being subtly different depending on whether this package is installed (we do this sometimes in Shiny and it always seems to end up hurting). Is there a semantic difference between the two?

If there is, and we strongly prefer lubridate's behavior, I'd prefer to throw an error if lubridate is not available.

Copy link
Author

@slodge slodge Jan 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From https://lubridate.tidyverse.org/reference/as_date.html

Compare to base R

These are drop in replacements for as.Date() and as.POSIXct(), with a few tweaks to make them work more intuitively.
Called on a POSIXct object, as_date() uses the tzone attribute of the object to return the same date as indicated by the printed representation of the object. This differs from as.Date, which ignores the attribute and uses only the tz argument to as.Date() ("UTC" by default).
Both functions provide a default origin argument for numeric vectors.
Both functions will generate NAs for invalid date format. Valid formats are those described by ISO8601 standard. A warning message will provide a count of the elements that were not converted.
as_datetime() defaults to using UTC.

That last point wins me over ... but should be possible to do in as.Date.POSIXct too?

I'd be very happy adding a permanent dependency to lubridate (would any plumber users really mind?), or switching to an error, or duplicating the lubridate code here (possibly with some cross-referencing to what open api spec says)

}

parse_date <- function(x) {
if (lubridate_available()) lubridate::as_date(x) else as.Date(x)
}

addApiInfo(
"integer",
c("int", "integer"),
"-?\\\\d+",
as.integer,
format = "int64",
c("date-time", "datetime"),
openApiType = "string",
# much more complex regexes are available... e.g. see https://stackoverflow.com/a/3143231/373321
# (if importing one of those, remember to use cc attribution)
openApiRegex = "\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentionally limiting the timezone to Z?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very happy to see it restricted... I encourage everyone to use UTC whenever they can (and if I can persuade the UK government to drop BST then it'd make my life so much easier...)

But from a RFC 3339 perspective, it could probably be extended.

As far as I understand it, this regex is only used in parsing path query variables (but I might have that wrong...). With that in mind, are these regexes needed at all?

If they are, then the comment points to stackoverflow.com/a/3143231/373321 with a wealth of options :)

openApiFormat = "date-time",
parser = parse_datetime,
location = c("query", "path")
)

addApiInfo(
"string",
c("chr", "str", "character", "string"),
"[^/]+",
as.character,
c("date", "Date"),
openApiType = "string",
# https://regex101.com/r/qH0sU7/1
openApiRegex = "\\\\d{4}-\\\\d{2}-\\\\d{2}",
openApiFormat = "date",
parser = parse_date,
location = c("query", "path")
)

addApiInfo(
"object",
c("list", "data.frame", "df", "object"),
location = "requestBody"
c("object", "list", "data.frame", "df", "object"),
openApiType = "string",
location = c("requestBody")
)

addApiInfo(
"file",
c("file", "binary"),
location = "requestBody",
format = "binary",
realType = "string"
openApiType = "string",
openApiFormat = "binary",
location = c("requestBody")
)
}


#' Parse the given plumber type and return the typecast value
#' Parse the given plumber type and check it is a valid value
#' @noRd
plumberToApiType <- function(type, inPath = FALSE) {
if (length(type) > 1) {
return(vapply(type, plumberToApiType, character(1), inPath, USE.NAMES = FALSE))
}

# default type is "string" type
if (is.na(type)) {
return(defaultApiType)
}

apiType <- plumberToApiTypeMap[[as.character(type)]]
if (is.null(apiType)) {
apiType <- as.character(type)
info <- apiTypesInfo[[apiType]]
if (is.null(info)) {
warning(
"Unrecognized type: ", type, ". Using type: ", defaultApiType,
call. = FALSE
)
apiType <- defaultApiType
}
if (inPath && !"path" %in% apiTypesInfo[[apiType]]$location) {
} else if (inPath && !"path" %in% info$location) {
warning(
"Unsupported path parameter type: ", type, ". Using type: ", defaultApiType,
call. = FALSE
)
apiType <- defaultApiType
} else {
apiType <- info$preferredKey
}

return(apiType)
Expand Down
18 changes: 9 additions & 9 deletions R/parse-query.R
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ createPathRegex <- function(pathDef, funcParams = NULL){
names = character(),
types = NULL,
regex = paste0("^", pathDef, "$"),
converters = NULL,
parsers = NULL,
areArrays = NULL
)
)
Expand All @@ -81,7 +81,7 @@ createPathRegex <- function(pathDef, funcParams = NULL){
plumberTypes <- stri_replace_all(match[,3], "$1", regex = "^\\[([^\\]]*)\\]$")
if (length(funcParams) > 0) {
# Override with detection of function args if type not found in map
idx <- !(plumberTypes %in% names(plumberToApiTypeMap))
idx <- !(plumberTypes %in% names(apiTypesInfo))
plumberTypes[idx] <- sapply(funcParams, `[[`, "type")[names[idx]]
}
apiTypes <- plumberToApiType(plumberTypes, inPath = TRUE)
Expand All @@ -108,7 +108,7 @@ createPathRegex <- function(pathDef, funcParams = NULL){
names = names,
types = apiTypes,
regex = paste0("^", pathRegex, "$"),
converters = typesToConverters(apiTypes, areArrays),
parsers = typesToParsers(apiTypes, areArrays),
areArrays = areArrays
)
}
Expand All @@ -119,18 +119,18 @@ typesToRegexps <- function(apiTypes, areArrays = FALSE) {
mapply(
function(x, y) {x[[y]]},
apiTypesInfo[apiTypes],
ifelse(areArrays, "regexArray", "regex"),
ifelse(areArrays, "openApiRegexArray", "openApiRegex"),
USE.NAMES = FALSE
)
}


typesToConverters <- function(apiTypes, areArrays = FALSE) {
typesToParsers <- function(apiTypes, areArrays = FALSE) {
# return list of functions
mapply(
function(x, y) {x[[y]]},
apiTypesInfo[apiTypes],
ifelse(areArrays, "converterArray", "converter"),
ifelse(areArrays, "parserArray", "parser"),
USE.NAMES = FALSE
)
}
Expand All @@ -142,10 +142,10 @@ extractPathParams <- function(def, path){
vals <- as.list(stri_match(path, regex = def$regex)[,-1])
names(vals) <- def$names

if (!is.null(def$converters)){
# Run each value through its converter
if (!is.null(def$parsers)){
# Run each value through its parser
for (i in 1:length(vals)){
vals[[i]] <- def$converters[[i]](vals[[i]])
vals[[i]] <- def$parsers[[i]](vals[[i]])
}
}

Expand Down
10 changes: 10 additions & 0 deletions tests/testthat/files/path-params.R
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ function(req){
function(id, price){
list(id=id, price=price)
}

#* @get /yearend/<when:date>
function(req){
req$args$when
}

#* @get /closing/<when:datetime>
function(req){
req$args$when
}
Loading