diff --git a/atlassian/confluence/server/__init__.py b/atlassian/confluence/server/__init__.py index f00ec208d..368ed8e2c 100644 --- a/atlassian/confluence/server/__init__.py +++ b/atlassian/confluence/server/__init__.py @@ -42,6 +42,25 @@ class Server(ConfluenceServerBase): Confluence Server REST API wrapper """ + _api_resources = frozenset( + { + "accessmode", + "admin", + "audit", + "content", + "contentbody", + "group", + "health", + "longtask", + "metadata", + "reindex", + "search", + "space", + "template", + "user", + } + ) + content_types = { ".gif": "image/gif", ".png": "image/png", @@ -54,6 +73,7 @@ class Server(ConfluenceServerBase): } def __init__(self, url, *args, **kwargs): + api_version_is_explicit = "api_version" in kwargs # Set default values only if not provided if "cloud" not in kwargs: kwargs["cloud"] = False @@ -61,8 +81,56 @@ def __init__(self, url, *args, **kwargs): kwargs["api_version"] = "1.0" if "api_root" not in kwargs: kwargs["api_root"] = "rest/api" - url = url.strip("/") + f"/{kwargs['api_root']}/{kwargs['api_version']}" - super(Server, self).__init__(url, *args, **kwargs) + super(Server, self).__init__(url.rstrip("/"), *args, **kwargs) + self._api_version_is_explicit = api_version_is_explicit + + def _server_api_path(self, path): + """Prefix unrooted Server REST resources without changing legacy paths.""" + if not isinstance(path, str): + return path + + normalized_path = path.lstrip("/") + resource = normalized_path.split("/", 1)[0].split("?", 1)[0] + if resource not in self._api_resources: + return path + + api_parts = [self.api_root] + if self._api_version_is_explicit: + api_parts.append(self.api_version) + api_parts.append(normalized_path) + return "/".join(str(part).strip("/") for part in api_parts if part is not None and str(part).strip("/")) + + def request( + self, + method="GET", + path="/", + data=None, + json=None, + flags=None, + params=None, + headers=None, + files=None, + trailing=None, + absolute=False, + advanced_mode=False, + allow_redirects=True, + ): + if not absolute: + path = self._server_api_path(path) + return super(Server, self).request( + method=method, + path=path, + data=data, + json=json, + flags=flags, + params=params, + headers=headers, + files=files, + trailing=trailing, + absolute=absolute, + advanced_mode=advanced_mode, + allow_redirects=allow_redirects, + ) @staticmethod def _create_body(body, representation): diff --git a/tests/confluence/test_confluence_server.py b/tests/confluence/test_confluence_server.py index 9965f1fd9..1b964cd07 100644 --- a/tests/confluence/test_confluence_server.py +++ b/tests/confluence/test_confluence_server.py @@ -30,6 +30,7 @@ def test_init_defaults(self): assert confluence.api_version == "1.0" assert confluence.api_root == "rest/api" assert confluence.cloud is False + assert confluence.url == "https://test.confluence.com" def test_init_custom_values(self): """Test ConfluenceServer client initialization with custom values.""" @@ -43,6 +44,73 @@ def test_init_custom_values(self): assert confluence.api_version == "2.0" assert confluence.api_root == "custom/api/root" + def test_default_server_requests_preserve_legacy_urls(self): + confluence = ConfluenceServer(url="https://test.confluence.com", token="test-token") + response = Response() + response.status_code = 200 + response.reason = "OK" + response._content = b'{"id": "123"}' + + with patch.object(confluence._session, "request", return_value=response) as mock_request: + assert confluence.get_page_by_id("123") == {"id": "123"} + + assert mock_request.call_args.kwargs["url"] == "https://test.confluence.com/rest/api/content/123" + + @pytest.mark.parametrize( + ("method", "path", "kwargs", "expected_path"), + [ + ("post", "rest/api/content", {"data": {}}, "rest/api/content"), + ("put", "/rest/api/content/123", {"data": {}}, "rest/api/content/123"), + ("delete", "rest/api/content/123", {"params": {}}, "rest/api/content/123"), + ], + ) + def test_legacy_rooted_request_paths_are_not_prefixed_twice(self, method, path, kwargs, expected_path): + confluence = ConfluenceServer(url="https://test.confluence.com", token="test-token") + response = Response() + response.status_code = 200 + response.reason = "OK" + + with patch.object(confluence._session, "request", return_value=response) as mock_request: + getattr(confluence, method)(path=path, advanced_mode=True, **kwargs) + + assert mock_request.call_args.kwargs["url"] == f"https://test.confluence.com/{expected_path}" + + def test_explicit_server_api_version_applies_to_unrooted_resources(self): + confluence = ConfluenceServer( + url="https://test.confluence.com", + token="test-token", + api_root="custom/api/root", + api_version="2.0", + ) + response = Response() + response.status_code = 200 + response.reason = "OK" + response._content = b'{"id": "123"}' + + with patch.object(confluence._session, "request", return_value=response) as mock_request: + assert confluence.get_page_by_id("123") == {"id": "123"} + + assert mock_request.call_args.kwargs["url"] == "https://test.confluence.com/custom/api/root/2.0/content/123" + + def test_server_ui_exports_remain_site_relative(self): + confluence = ConfluenceServer(url="https://test.confluence.com", token="test-token") + response = Response() + response.status_code = 200 + response.reason = "OK" + response._content = b"%PDF-1.4" + + with patch.object(confluence._session, "request", return_value=response) as mock_request: + assert confluence.get_page_as_pdf("123") == b"%PDF-1.4" + pdf_url = mock_request.call_args.kwargs["url"] + + mock_request.reset_mock() + response._content = b"word export" + assert confluence.get_page_as_word("123") == b"word export" + word_url = mock_request.call_args.kwargs["url"] + + assert pdf_url == "https://test.confluence.com/spaces/flyingpdf/pdfpageexport.action?pageId=123" + assert word_url == "https://test.confluence.com/exportword?pageId=123" + def test_bad_request_includes_confluence_validation_details(self, confluence_server): response = Response() response.status_code = 400