Skip to content

feat: create, update, and delete integrations (stacked version) #440

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

Merged
merged 31 commits into from
Aug 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4188aa9
add content_get_integrations, rename content_set_integrations to set_…
toph-allen Aug 5, 2025
cc4cb98
add tests, update function names
toph-allen Aug 5, 2025
940ad5a
Remove association function, update docs
toph-allen Aug 5, 2025
57e4bcc
fix accidentally committed typo in Rd
toph-allen Aug 5, 2025
9a2e43f
Fix CI hopefully
toph-allen Aug 5, 2025
69534b0
fix lint
toph-allen Aug 5, 2025
95bd94e
fix _pkgdown.yml
toph-allen Aug 5, 2025
97f11a2
restore function to get oauth associations
toph-allen Aug 6, 2025
ead8571
Use method dispatch for get_integrations()
toph-allen Aug 6, 2025
562c7e7
fix tests
toph-allen Aug 6, 2025
9eee7cb
update pkgdown
toph-allen Aug 6, 2025
5c3d170
update documentation
toph-allen Aug 6, 2025
1582d27
Attempt to fix CI
toph-allen Aug 7, 2025
d7a4095
Revert "Attempt to fix CI"
toph-allen Aug 7, 2025
5543147
tidy up spillover from a future PR
toph-allen Aug 7, 2025
cccc83a
add functions to create, update, and delete integrations
toph-allen Aug 7, 2025
c4c570e
update NEWS
toph-allen Aug 7, 2025
bb6824f
fix cherrypick issues
toph-allen Aug 7, 2025
ba81b63
resolve expectations about clients at integration creation time
toph-allen Aug 7, 2025
a3a94e5
simplify as_integration
toph-allen Aug 7, 2025
1487a78
update docs and namespace
toph-allen Aug 7, 2025
5fb342c
remove cherrypick duplication
toph-allen Aug 7, 2025
f92670e
consistency and cleanup
toph-allen Aug 7, 2025
fe021c2
Merge branch 'main' into toph/create-delete-update-integrations-cherr…
toph-allen Aug 7, 2025
20f0c35
Update 60586f1c-b90ada-PATCH.R
toph-allen Aug 8, 2025
86617bb
update documentation and tests
toph-allen Aug 8, 2025
5e68c7d
update test name
toph-allen Aug 8, 2025
b6052a6
update pkgdown yaml
toph-allen Aug 8, 2025
a2c6741
attempt to make CI less flaky
toph-allen Aug 8, 2025
18c8e64
fix CI wait function
toph-allen Aug 8, 2025
354204e
update timeout time
toph-allen Aug 8, 2025
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: 3 additions & 2 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ S3method(api_build,op_head)
S3method(as.data.frame,connect_integration_list)
S3method(as.data.frame,connect_list_hits)
S3method(as.data.frame,tbl_connect)
S3method(as_integration,default)
S3method(as_integration,list)
S3method(as_tibble,connect_integration_list)
S3method(as_tibble,connect_list_hits)
S3method(connect_vars,op_base)
Expand Down Expand Up @@ -60,12 +58,14 @@ export(content_title)
export(content_update)
export(content_update_access_type)
export(content_update_owner)
export(create_integration)
export(create_random_name)
export(create_tag)
export(create_tag_tree)
export(dashboard_url)
export(delete_bundle)
export(delete_image)
export(delete_integration)
export(delete_runtime_cache)
export(delete_tag)
export(delete_thumbnail)
Expand Down Expand Up @@ -162,6 +162,7 @@ export(swap_vanity_url)
export(swap_vanity_urls)
export(tbl_connect)
export(terminate_jobs)
export(update_integration)
export(user_guid_from_username)
export(users_create_remote)
export(vanity_is_available)
Expand Down
3 changes: 3 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from a Connect server. (#431)
- `get_integrations()` can now be passed a `Content` class object to retrieve a
list of integrations associated with that piece of content. (#432)
- New functions allow you to manage the OAuth integrations on your Connect
server: `create_integration()`, `update_integration()` and
`delete_integration()`. (#434)

# connectapi 0.8.0

Expand Down
250 changes: 226 additions & 24 deletions R/integrations.R
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
#' @param x A `Connect` or `Content` R6 object.
#'
#' @return A list of class `connect_integration_list`, where each element is a `connect_integration` object
#' with the following fields (all character strings unless noted otherwise):
#' with the following fields. (Raw API fields are character strings unless noted otherwise):
#'
#' * `id`: The internal identifier of this OAuth integration.
#' * `guid`: The GUID of this OAuth integration.
Expand Down Expand Up @@ -67,15 +67,15 @@ get_integrations.default <- function(x) {
stop(
"Cannot get integrations for an object of class '",
class(x)[1],
"'"
"'. 'x' must be a 'Connect' or 'Content' object."
)
}

#' @export
get_integrations.Connect <- function(x) {
error_if_less_than(x$version, "2024.12.0")
integrations <- x$GET(v1_url("oauth", "integrations"))
integrations <- lapply(integrations, as_integration)
integrations <- purrr::map(integrations, ~ as_integration(.x, client = x))
class(integrations) <- c("connect_integration_list", class(integrations))
integrations
}
Expand All @@ -101,7 +101,7 @@ get_integrations.Content <- function(x) {
#' Convert integrations list to a data frame
#'
#' @description
#' Converts an list returned by [get_integrations()] into a data frame.
#' Converts a list returned by [get_integrations()] into a data frame.
#'
#' @param x A `connect_integration_list` object (from [get_integrations()]).
#' @param row.names Passed to [base::as.data.frame()].
Expand Down Expand Up @@ -144,25 +144,20 @@ as_tibble.connect_integration_list <- function(x, ...) {
#' Convert objects to integration class
#'
#' @param x An object to convert to an integration.
#' @param ... Unused.
#' @param client The Connect client object where the integration comes from.
#'
#' @return An integration object
as_integration <- function(x, ...) {
UseMethod("as_integration")
}

#' @export
as_integration.default <- function(x, ...) {
stop(
"Cannot convert object of class '",
class(x)[1],
"' to an integration"
)
}

#' @export
as_integration.list <- function(x, ...) {
structure(x, class = c("connect_integration", "list"))
#' @return An integration object. The object has all the fields from the
#' integrations endpoint (see [get_integrations()]) and a Connect client as a
#' `client` attribute (`attr(x, "client")`)
as_integration <- function(x, client) {
if (!inherits(x, "list")) {
stop(
"Cannot convert object of class '",
class(x)[1],
"' to an integration"
)
}
structure(x, class = c("connect_integration", "list"), client = client)
}

#' @export
Expand Down Expand Up @@ -212,10 +207,11 @@ print.connect_integration <- function(x, ...) {
#' @export
get_integration <- function(client, guid) {
validate_R6_class(client, "Connect")
as_integration(client$GET(v1_url("oauth", "integrations", guid)))
error_if_less_than(client$version, "2024.12.0")
as_integration(client$GET(v1_url("oauth", "integrations", guid)), client)
}

# Get and set integrations on content
# Get and set integrations on content ----

#' Set all OAuth integrations for a content item
#'
Expand Down Expand Up @@ -341,3 +337,209 @@ get_associations <- function(x) {
"associations"
))
}


# Manage integrations ----

#' Create an OAuth integration
#'
#' @description
#' Creates a new OAuth integration on the Posit Connect server. OAuth integrations
#' allow content to access external resources using OAuth credentials.
#'
#' You must have administrator privileges to perform this action.
#'
#' See the Posit Connect documentation on
#' [OAuth integrations](https://docs.posit.co/connect/admin/integrations/oauth-integrations/) for
#' more information.
#'
#' @param client A `Connect` R6 client object.
#' @param name A descriptive name to identify the integration.
#' @param description Optional, default `NULL.` A brief description of the integration.
#' @param template The template to use to configure this integration (e.g.,
#' "custom", "github", "google", "connect").
Copy link
Collaborator

Choose a reason for hiding this comment

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

Link to API docs for where you can get a complete list of supported values?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The API docs say "See List OAuth templates for more information" — I think our idea is that people call that endpoint. I think linking to the OAuth integration docs overview here might be better right now? 🤔

Adding a get_templates() function is tracked here: #435

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sure, whatever is best. Just flagging that we should provide users with a way of knowing what the valid values here are.

#' @param config A list containing the configuration for the integration. The
#' required fields vary depending on the template selected.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Link to API docs?

#'
#' @return A `connect_integration` object representing the newly created
#' integration. See [get_integration()] for details on the returned object.
#'
#' @seealso [get_integrations()], [get_integration()], [update_integration()],
#' [delete_integration()]
#'
#' @examples
#' \dontrun{
#' client <- connect()
#'
#' # Create a GitHub OAuth integration
#' github_integration <- create_integration(
#' client,
#' name = "GitHub Integration",
#' description = "Integration with GitHub for OAuth access",
#' template = "github",
#' config = list(
#' client_id = "your-client-id",
#' client_secret = "your-client-secret"
#' )
#' )
#'
#' # Create a custom OAuth integration
#' custom_integration <- create_integration(
#' client,
#' name = "Custom API Integration",
#' description = "Integration with our custom API",
#' template = "custom",
#' config = list(
#' auth_mode = "Confidential",
#' auth_type = "Viewer",
#' authorization_uri = "https://api.example.com/oauth/authorize",
#' client_id = "your-client-id",
#' client_secret = "your-client-secret",
#' token_uri = "https://api.example.com/oauth/token"
#' )
#' )
#' }
#'
#' @family oauth integration functions
#' @export
create_integration <- function(
client,
name,
description = NULL,
template,
config
) {
validate_R6_class(client, "Connect")
error_if_less_than(client$version, "2024.12.0")
result <- client$POST(
v1_url("oauth", "integrations"),
body = list(
name = name,
description = description,
template = template,
config = config
)
)
as_integration(result, client)
}

#' Update an OAuth integration
#'
#' @description
#' Updates an existing OAuth integration. All fields except `integration` are optional,
#' and are unchanged if not provided.
#'
#' You must have administrator privileges to perform this action.
#'
#' See the Posit Connect documentation on
#' [OAuth integrations](https://docs.posit.co/connect/admin/integrations/oauth-integrations/) for
#' more information.
#'
#' @param integration A `connect_integration` object (as returned by [get_integrations()],
#' [get_integration()], or [create_integration()]).
#' @param name A new name for the integration.
#' @param description A new description for the integration.
#' @param template The template to use (generally not changed after creation).
#' @param config A list with updated OAuth integration configuration. If `NULL`
#' (default), the configuration remains unchanged. You can update individual
#' configuration fields without affecting others.
#'
#' @return A `connect_integration` object representing the updated OAuth
#' integration. See [get_integration()] for details on the returned object.
#'
#' @seealso [get_integrations()], [get_integration()], [create_integration()],
#' [delete_integration()]
#'
#' @examples
#' \dontrun{
#' client <- connect()
#'
#' # Get an existing integration
#' integration <- get_integration(client, "your-integration-guid")
#'
#' # Update the integration's name and description
#' updated_integration <- update_integration(
#' integration,
#' name = "Updated GitHub Integration",
#' description = "A more descriptive description."
#' )
#'
#' # Update only the client secret in the configuration
#' updated_integration <- update_integration(
#' integration,
#' config = list(
#' client_secret = "your-new-client-secret"
#' )
#' )
#' }
#'
#' @family oauth integration functions
#' @export
update_integration <- function(
integration,
name = NULL,
description = NULL,
template = NULL,
config = NULL
) {
if (!inherits(integration, "connect_integration")) {
stop("'integration' must be a 'connect_integration' object")
}
client <- attr(integration, "client")
validate_R6_class(client, "Connect")
error_if_less_than(client$version, "2024.12.0")
result <- client$PATCH(
v1_url("oauth", "integrations", integration$guid),
body = list(
name = name,
description = description,
template = template,
config = config
)
)
as_integration(result, client)

Choose a reason for hiding this comment

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

Is there a reason we're returning an actual object on a PATCH method? I wouldn't expect a function updating an integration to return the new integration object but maybe that's a reasonable side effect in the context of this package.

Copy link
Collaborator Author

@toph-allen toph-allen Aug 8, 2025

Choose a reason for hiding this comment

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

Yeah, the reasons are two-fold. One is that it matches the way that if you're working with local R objects, the way you update an object is to call a function on it and assign that result to something -- like:

animals <- c("cat", "dog")

# update animals
animals <- c(animals, "bat")

In the context of integrations and Connect, it matches the way the PATCH API works (it returns the updated object, and we're just passing it on.)

So if you imagine that in the context of a script, if I'm say, updating an integration, like giving a GitHub integration a new key, it makes sense for my local representation of that object to be updated to the new version.

client <- connect()

github_integration <- get_integrations(client) |>
  purrr::keep(~ .x$template == "github")[[1]]

github_integration <- update_integration(github_integration, config = list(key = NEW_KEY))

# Now this object will reflect the updated info.
github_integration$config$key

Choose a reason for hiding this comment

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

ah, makes perfect sense. thanks!!

}

#' Delete an OAuth integration
#'
#' @description
#' Deletes an OAuth integration from the Posit Connect server. This permanently
#' removes the integration and any associated content associations.
#'
#' You must have administrator privileges to perform this action.
#'
#' See the Posit Connect documentation on
#' [OAuth integrations](https://docs.posit.co/connect/admin/integrations/oauth-integrations/) for
#' more information.
#'
#' @param integration A `connect_integration` object (as returned by [get_integrations()],
#' [get_integration()], or [create_integration()]).
#'
#' @return Returns `NULL` invisibly if successful.
#'
#' @seealso [get_integrations()], [get_integration()], [create_integration()],
#' [update_integration()]
#'
#' @examples
#' \dontrun{
#' client <- connect()
#'
#' # Get an integration to delete
#' integration <- get_integration(client, "your-integration-guid")
#'
#' # Delete the integration
#' delete_integration(integration)
#' }
#'
#' @family oauth integration functions
#' @export
delete_integration <- function(integration) {
if (!inherits(integration, "connect_integration")) {
stop("'integration' must be a 'connect_integration' object")
}
client <- attr(integration, "client")
validate_R6_class(client, "Connect")
error_if_less_than(client$version, "2024.12.0")
client$DELETE(v1_url("oauth", "integrations", integration$guid))
invisible(NULL)
}
38 changes: 33 additions & 5 deletions R/utils-ci.R
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,6 @@ compose_find_hosts <- function(prefix) {
ports <- sub(".*0\\.0\\.0\\.0:([0-9]+)->3939.*", "\\1", containers)
cat_line(glue::glue("docker: got ports {ports[1]} and {ports[2]}"))

# TODO: make this silly sleep more savvy
cat_line("connect: sleeping - waiting for connect to start")
Sys.sleep(10)

paste0("http://localhost:", ports)
}

Expand Down Expand Up @@ -139,7 +135,9 @@ update_renviron_creds <- function(
"{prefix}_API_KEY={api_key}",
.sep = "\n"
)
if (!fs::file_exists(.file)) fs::file_touch(.file)
if (!fs::file_exists(.file)) {
fs::file_touch(.file)
}
writeLines(output_environ, .file)
invisible()
}
Expand All @@ -164,6 +162,36 @@ build_test_env <- function(
# this is a regex so it will match either
hosts <- compose_find_hosts(prefix = "ci.connect")

wait_for_connect_ready <- function(host, timeout = 120) {
client <- HackyConnect$new(server = host, api_key = NULL)
start_time <- Sys.time()
last_msg <- start_time
ping_url <- client$server_url("__ping__")

while (
as.numeric(difftime(Sys.time(), start_time, units = "secs")) < timeout
) {
ok <- try(
{
res <- client$GET(url = client$server_url("__ping__"), parser = NULL)
httr::status_code(res) == 200
}
)
if (isTRUE(ok)) {
return(invisible(TRUE))
}
if (difftime(Sys.time(), last_msg, units = "secs") >= 5) {
cat_line(glue::glue("waiting for {ping_url} ..."))
last_msg <- Sys.time()
}
Sys.sleep(1)
}
stop("Connect did not become ready in time: ", ping_url)
}

wait_for_connect_ready(hosts[1])
wait_for_connect_ready(hosts[2])

cat_line("connect: creating first admin...")
a1 <- create_first_admin(
hosts[1],
Expand Down
Loading
Loading