From d8ea6ce6c36d7af8cf4a4000c5b3ae1c95d84fa5 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 07:47:47 -0400 Subject: [PATCH] Use v1 bootstrap endpoint, falling back to experimental Fixes #594 Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 3 +++ rsconnect/api.py | 6 +++++- tests/test_main.py | 46 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e28b6344..3093d384 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When client credentials can't publish to a Posit Connect Cloud account because the account requires single sign-on (SSO), the error now says so, instead of suggesting the publisher role. +- `rsconnect bootstrap` uses the `/__api__/v1/bootstrap` endpoint, falling back to + the deprecated `/__api__/v1/experimental/bootstrap` endpoint on Posit Connect + versions before 2024.05.0. (#594) ## [1.32.0] - 2026-10-16 diff --git a/rsconnect/api.py b/rsconnect/api.py index 66b7a95f..2185b5ca 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -639,7 +639,11 @@ def me(self) -> UserRecord: return response def bootstrap(self) -> BootstrapOutputDTO | HTTPResponse: - response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/experimental/bootstrap")) + response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/bootstrap")) + # Connect versions before 2024.05.0 only provide the experimental endpoint. The server version + # is not exposed to unauthenticated requests, so fall back based on the 404 instead. + if isinstance(response, HTTPResponse) and response.status == 404: + response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/experimental/bootstrap")) # TODO: The place where bootstrap() is called expects a JSON object if the response is successfule, and a # HTTPResponse if it is not; then it handles the error. This is different from the other methods, and probably # should be changed in the future. For this to work, we will _not_ call .handle_bad_response() here at present. diff --git a/tests/test_main.py b/tests/test_main.py index 50a16bb1..5c47bf61 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1756,7 +1756,8 @@ def test_env_management_callback(self): class TestBootstrap(TestCase): def setUp(self): self.mock_server = "http://localhost:8080" - self.mock_uri = "http://localhost:8080/__api__/v1/experimental/bootstrap" + self.mock_uri = "http://localhost:8080/__api__/v1/bootstrap" + self.mock_experimental_uri = "http://localhost:8080/__api__/v1/experimental/bootstrap" self.jwt_keypath = "tests/testdata/jwt/secret.key" self.jwt_env_secret = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU=" @@ -1769,7 +1770,9 @@ def setUp(self): "--insecure", ] - def create_bootstrap_mock_callback(self, status, json_data): + def create_bootstrap_mock_callback(self, status, json_data, expected_uri=None): + expected_uri = expected_uri or self.mock_uri + def request_callback(request, uri, response_headers): # verify auth header is sent correctly authorization = request.headers.get("Authorization") @@ -1779,7 +1782,7 @@ def request_callback(request, uri, response_headers): self.assertTrue(has_jwt_structure(auth_split[1])) # verify uri - self.assertEqual(uri, self.mock_uri) + self.assertEqual(uri, expected_uri) return [status, {"Content-Type": "application/json"}, json.dumps(json_data)] @@ -1808,6 +1811,34 @@ def test_bootstrap(self): expected_output = json.loads(open("tests/testdata/initial-admin-responses/success.json", "r").read()) self.assertEqual(json_output, expected_output) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_bootstrap_experimental_fallback(self): + """ + Fall back to the experimental endpoint on Connect versions before 2024.05.0 + """ + + httpretty.register_uri( + httpretty.POST, + self.mock_uri, + body=self.create_bootstrap_mock_callback(404, {}), + ) + httpretty.register_uri( + httpretty.POST, + self.mock_experimental_uri, + body=self.create_bootstrap_mock_callback( + 200, {"api_key": "testapikey123"}, expected_uri=self.mock_experimental_uri + ), + ) + + runner = CliRunner() + result = runner.invoke(cli, self.default_cli_args) + + self.assertEqual(result.exit_code, 0, result.output) + + json_output = json.loads(result.output) + expected_output = json.loads(open("tests/testdata/initial-admin-responses/success.json", "r").read()) + self.assertEqual(json_output, expected_output) + @httpretty.activate(verbose=True, allow_net_connect=False) def test_bootstrap_env_var(self): """ @@ -1870,12 +1901,15 @@ def test_bootstrap_not_found_error(self): Fail reasonablly if response indicates 404 not found """ - callback = self.create_bootstrap_mock_callback(404, {}) - httpretty.register_uri( httpretty.POST, self.mock_uri, - body=callback, + body=self.create_bootstrap_mock_callback(404, {}), + ) + httpretty.register_uri( + httpretty.POST, + self.mock_experimental_uri, + body=self.create_bootstrap_mock_callback(404, {}, expected_uri=self.mock_experimental_uri), ) runner = CliRunner()