1
+ """
2
+ Confluence base module for shared functionality between API versions
3
+ """
4
+ import logging
5
+ from typing import Dict , List , Optional , Union , Any , Tuple
6
+ from urllib .parse import urlparse
7
+
8
+ from atlassian .rest_client import AtlassianRestAPI
9
+
10
+ log = logging .getLogger (__name__ )
11
+
12
+
13
+ class ConfluenceEndpoints :
14
+ """
15
+ Class to define endpoint mappings for different Confluence API versions.
16
+ These endpoints can be accessed through the ConfluenceBase get_endpoint method.
17
+ """
18
+ V1 = {
19
+ "page" : "rest/api/content" ,
20
+ "page_by_id" : "rest/api/content/{id}" ,
21
+ "child_pages" : "rest/api/content/{id}/child/page" ,
22
+ "content_search" : "rest/api/content/search" ,
23
+ "space" : "rest/api/space" ,
24
+ "space_by_key" : "rest/api/space/{key}" ,
25
+ }
26
+
27
+ V2 = {
28
+ 'page_by_id' : 'api/v2/pages/{id}' ,
29
+ 'page' : 'api/v2/pages' ,
30
+ 'child_pages' : 'api/v2/pages/{id}/children/page' ,
31
+ 'search' : 'api/v2/search' ,
32
+ 'spaces' : 'api/v2/spaces' ,
33
+ 'space_by_id' : 'api/v2/spaces/{id}' ,
34
+ 'page_properties' : 'api/v2/pages/{id}/properties' ,
35
+ 'page_property_by_key' : 'api/v2/pages/{id}/properties/{key}' ,
36
+ 'page_labels' : 'api/v2/pages/{id}/labels' ,
37
+ 'space_labels' : 'api/v2/spaces/{id}/labels' ,
38
+
39
+ # Comment endpoints for V2 API
40
+ 'page_footer_comments' : 'api/v2/pages/{id}/footer-comments' ,
41
+ 'page_inline_comments' : 'api/v2/pages/{id}/inline-comments' ,
42
+ 'blogpost_footer_comments' : 'api/v2/blogposts/{id}/footer-comments' ,
43
+ 'blogpost_inline_comments' : 'api/v2/blogposts/{id}/inline-comments' ,
44
+ 'attachment_comments' : 'api/v2/attachments/{id}/footer-comments' ,
45
+ 'custom_content_comments' : 'api/v2/custom-content/{id}/footer-comments' ,
46
+ 'comment' : 'api/v2/comments' ,
47
+ 'comment_by_id' : 'api/v2/comments/{id}' ,
48
+ 'comment_children' : 'api/v2/comments/{id}/children' ,
49
+
50
+ # Whiteboard endpoints
51
+ 'whiteboard' : 'api/v2/whiteboards' ,
52
+ 'whiteboard_by_id' : 'api/v2/whiteboards/{id}' ,
53
+ 'whiteboard_children' : 'api/v2/whiteboards/{id}/children' ,
54
+ 'whiteboard_ancestors' : 'api/v2/whiteboards/{id}/ancestors' ,
55
+
56
+ # Custom content endpoints
57
+ 'custom_content' : 'api/v2/custom-content' ,
58
+ 'custom_content_by_id' : 'api/v2/custom-content/{id}' ,
59
+ 'custom_content_children' : 'api/v2/custom-content/{id}/children' ,
60
+ 'custom_content_ancestors' : 'api/v2/custom-content/{id}/ancestors' ,
61
+ 'custom_content_labels' : 'api/v2/custom-content/{id}/labels' ,
62
+ 'custom_content_properties' : 'api/v2/custom-content/{id}/properties' ,
63
+ 'custom_content_property_by_key' : 'api/v2/custom-content/{id}/properties/{key}' ,
64
+
65
+ # More v2 endpoints will be added in Phase 2 and 3
66
+ }
67
+
68
+
69
+ class ConfluenceBase (AtlassianRestAPI ):
70
+ """Base class for Confluence operations with version support"""
71
+
72
+ @staticmethod
73
+ def _is_cloud_url (url : str ) -> bool :
74
+ """
75
+ Securely validate if a URL is a Confluence Cloud URL.
76
+
77
+ Args:
78
+ url: The URL to validate
79
+
80
+ Returns:
81
+ bool: True if the URL is a valid Confluence Cloud URL
82
+ """
83
+ parsed = urlparse (url )
84
+ # Ensure we have a valid URL with a hostname
85
+ if not parsed .hostname :
86
+ return False
87
+
88
+ # Check if the hostname ends with .atlassian.net or .jira.com
89
+ hostname = parsed .hostname .lower ()
90
+ return hostname .endswith ('.atlassian.net' ) or hostname .endswith ('.jira.com' )
91
+
92
+ def __init__ (
93
+ self ,
94
+ url : str ,
95
+ * args ,
96
+ api_version : Union [str , int ] = 1 ,
97
+ ** kwargs
98
+ ):
99
+ """
100
+ Initialize the Confluence Base instance with version support.
101
+
102
+ Args:
103
+ url: The Confluence instance URL
104
+ api_version: API version, 1 or 2, defaults to 1
105
+ args: Arguments to pass to AtlassianRestAPI constructor
106
+ kwargs: Keyword arguments to pass to AtlassianRestAPI constructor
107
+ """
108
+ if self ._is_cloud_url (url ) and "/wiki" not in url :
109
+ url = AtlassianRestAPI .url_joiner (url , "/wiki" )
110
+ if "cloud" not in kwargs :
111
+ kwargs ["cloud" ] = True
112
+
113
+ super (ConfluenceBase , self ).__init__ (url , * args , ** kwargs )
114
+ self .api_version = int (api_version )
115
+ if self .api_version not in [1 , 2 ]:
116
+ raise ValueError ("API version must be 1 or 2" )
117
+
118
+ def get_endpoint (self , endpoint_key : str , ** kwargs ) -> str :
119
+ """
120
+ Get the appropriate endpoint based on the API version.
121
+
122
+ Args:
123
+ endpoint_key: The key for the endpoint in the endpoints dictionary
124
+ kwargs: Format parameters for the endpoint
125
+
126
+ Returns:
127
+ The formatted endpoint URL
128
+ """
129
+ endpoints = ConfluenceEndpoints .V1 if self .api_version == 1 else ConfluenceEndpoints .V2
130
+
131
+ if endpoint_key not in endpoints :
132
+ raise ValueError (f"Endpoint key '{ endpoint_key } ' not found for API version { self .api_version } " )
133
+
134
+ endpoint = endpoints [endpoint_key ]
135
+
136
+ # Format the endpoint if kwargs are provided
137
+ if kwargs :
138
+ endpoint = endpoint .format (** kwargs )
139
+
140
+ return endpoint
141
+
142
+ def _get_paged (
143
+ self ,
144
+ url : str ,
145
+ params : Optional [Dict ] = None ,
146
+ data : Optional [Dict ] = None ,
147
+ flags : Optional [List ] = None ,
148
+ trailing : Optional [bool ] = None ,
149
+ absolute : bool = False ,
150
+ ):
151
+ """
152
+ Get paged results with version-appropriate pagination.
153
+
154
+ Args:
155
+ url: The URL to retrieve
156
+ params: The query parameters
157
+ data: The request data
158
+ flags: Additional flags
159
+ trailing: If True, a trailing slash is added to the URL
160
+ absolute: If True, the URL is used absolute and not relative to the root
161
+
162
+ Yields:
163
+ The result elements
164
+ """
165
+ if params is None :
166
+ params = {}
167
+
168
+ if self .api_version == 1 :
169
+ # V1 API pagination (offset-based)
170
+ while True :
171
+ response = self .get (
172
+ url ,
173
+ trailing = trailing ,
174
+ params = params ,
175
+ data = data ,
176
+ flags = flags ,
177
+ absolute = absolute ,
178
+ )
179
+ if "results" not in response :
180
+ return
181
+
182
+ for value in response .get ("results" , []):
183
+ yield value
184
+
185
+ # According to Cloud and Server documentation the links are returned the same way:
186
+ # https://developer.atlassian.com/cloud/confluence/rest/api-group-content/#api-wiki-rest-api-content-get
187
+ # https://developer.atlassian.com/server/confluence/pagination-in-the-rest-api/
188
+ url = response .get ("_links" , {}).get ("next" )
189
+ if url is None :
190
+ break
191
+ # From now on we have relative URLs with parameters
192
+ absolute = False
193
+ # Params are now provided by the url
194
+ params = {}
195
+ # Trailing should not be added as it is already part of the url
196
+ trailing = False
197
+
198
+ else :
199
+ # V2 API pagination (cursor-based)
200
+ while True :
201
+ response = self .get (
202
+ url ,
203
+ trailing = trailing ,
204
+ params = params ,
205
+ data = data ,
206
+ flags = flags ,
207
+ absolute = absolute ,
208
+ )
209
+
210
+ if "results" not in response :
211
+ return
212
+
213
+ for value in response .get ("results" , []):
214
+ yield value
215
+
216
+ # Check for next cursor in _links or in response headers
217
+ next_url = response .get ("_links" , {}).get ("next" )
218
+
219
+ if not next_url :
220
+ # Check for Link header
221
+ if hasattr (self , "response" ) and self .response and "Link" in self .response .headers :
222
+ link_header = self .response .headers ["Link" ]
223
+ if 'rel="next"' in link_header :
224
+ import re
225
+ match = re .search (r'<([^>]*)>;' , link_header )
226
+ if match :
227
+ next_url = match .group (1 )
228
+
229
+ if not next_url :
230
+ break
231
+
232
+ # Use the next URL directly
233
+ # Check if the response has a base URL provided (common in Confluence v2 API)
234
+ base_url = response .get ("_links" , {}).get ("base" )
235
+ if base_url and next_url .startswith ('/' ):
236
+ # Construct the full URL using the base URL from the response
237
+ url = f"{ base_url } { next_url } "
238
+ absolute = True
239
+ else :
240
+ url = next_url
241
+ # Check if the URL is absolute (has http:// or https://) or contains the server's domain
242
+ if next_url .startswith (('http://' , 'https://' )) or self .url .split ('/' )[2 ] in next_url :
243
+ absolute = True
244
+ else :
245
+ absolute = False
246
+ params = {}
247
+ trailing = False
248
+
249
+ return
250
+
251
+ @staticmethod
252
+ def factory (url : str , api_version : int = 1 , * args , ** kwargs ) -> 'ConfluenceBase' :
253
+ """
254
+ Factory method to create a Confluence client with the specified API version
255
+
256
+ Args:
257
+ url: Confluence Cloud base URL
258
+ api_version: API version to use (1 or 2)
259
+ *args: Variable length argument list
260
+ **kwargs: Keyword arguments
261
+
262
+ Returns:
263
+ Configured Confluence client for the specified API version
264
+
265
+ Raises:
266
+ ValueError: If api_version is not 1 or 2
267
+ """
268
+ if api_version == 1 :
269
+ from .confluence import Confluence
270
+ return Confluence (url , * args , ** kwargs )
271
+ elif api_version == 2 :
272
+ from .confluence_v2 import ConfluenceV2
273
+ return ConfluenceV2 (url , * args , ** kwargs )
274
+ else :
275
+ raise ValueError (f"Unsupported API version: { api_version } . Use 1 or 2." )
0 commit comments