contentstack-management 1.6.0__tar.gz → 1.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/PKG-INFO +1 -1
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/__init__.py +6 -2
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/_api_client.py +13 -3
- contentstack_management-1.7.0/contentstack_management/contentstack.py +181 -0
- contentstack_management-1.7.0/contentstack_management/oauth/__init__.py +5 -0
- contentstack_management-1.7.0/contentstack_management/oauth/oauth_handler.py +409 -0
- contentstack_management-1.7.0/contentstack_management/oauth/oauth_interceptor.py +187 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/user_session/user_session.py +1 -1
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management.egg-info/PKG-INFO +1 -1
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management.egg-info/SOURCES.txt +3 -0
- contentstack_management-1.6.0/contentstack_management/contentstack.py +0 -98
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/LICENSE +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/README.md +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/_constant.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/_errors.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/aliases/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/aliases/aliases.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/assets/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/assets/assets.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/auditlogs/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/auditlogs/auditlog.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/branches/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/branches/branches.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/bulk_operations/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/bulk_operations/bulk_operation.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/common.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/content_types/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/content_types/content_type.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/delivery_token/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/delivery_token/delivery_token.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/entries/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/entries/entry.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/entry_variants/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/entry_variants/entry_variants.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/environments/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/environments/environment.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/extensions/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/extensions/extension.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/global_fields/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/global_fields/global_fields.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/labels/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/labels/label.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/locale/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/locale/locale.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/management_token/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/management_token/management_token.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/metadata/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/metadata/metadata.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/organizations/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/organizations/organization.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/publish_queue/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/publish_queue/publish_queue.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/release_items/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/release_items/release_item.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/releases/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/releases/release.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/roles/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/roles/roles.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/stack/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/stack/stack.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/taxonomies/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/taxonomies/taxonomy.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/terms/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/terms/terms.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/user_session/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/users/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/users/user.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/variant_group/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/variant_group/variant_group.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/variants/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/variants/variants.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/webhooks/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/webhooks/webhook.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/workflows/__init__.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/workflows/workflows.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management.egg-info/dependency_links.txt +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management.egg-info/requires.txt +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management.egg-info/top_level.txt +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/setup.cfg +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/setup.py +0 -0
- {contentstack_management-1.6.0 → contentstack_management-1.7.0}/tests/test_contentstack.py +0 -0
{contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/__init__.py
RENAMED
|
@@ -34,6 +34,8 @@ from .publish_queue.publish_queue import PublishQueue
|
|
|
34
34
|
from .extensions.extension import Extension
|
|
35
35
|
from .variant_group.variant_group import VariantGroup
|
|
36
36
|
from .variants.variants import Variants
|
|
37
|
+
from .oauth.oauth_handler import OAuthHandler
|
|
38
|
+
from .oauth.oauth_interceptor import OAuthInterceptor
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
__all__ = (
|
|
@@ -71,14 +73,16 @@ __all__ = (
|
|
|
71
73
|
"PublishQueue",
|
|
72
74
|
"Extension",
|
|
73
75
|
"VariantGroup",
|
|
74
|
-
"Variants"
|
|
76
|
+
"Variants",
|
|
77
|
+
"OAuthHandler",
|
|
78
|
+
"OAuthInterceptor"
|
|
75
79
|
)
|
|
76
80
|
|
|
77
81
|
__title__ = 'contentstack-management-python'
|
|
78
82
|
__author__ = 'dev-ex'
|
|
79
83
|
__status__ = 'debug'
|
|
80
84
|
__region__ = 'na'
|
|
81
|
-
__version__ = '1.
|
|
85
|
+
__version__ = '1.7.0'
|
|
82
86
|
__host__ = 'api.contentstack.io'
|
|
83
87
|
__protocol__ = 'https://'
|
|
84
88
|
__api_version__ = 'v3'
|
|
@@ -2,7 +2,7 @@ import requests
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class _APIClient:
|
|
5
|
-
def __init__(self, endpoint, headers, timeout=30, max_retries: int = 5):
|
|
5
|
+
def __init__(self, endpoint, headers, timeout=30, max_retries: int = 5, oauth_interceptor=None):
|
|
6
6
|
"""
|
|
7
7
|
The function is a constructor that initializes the endpoint, headers, timeout, and max_retries
|
|
8
8
|
attributes of an object.
|
|
@@ -25,6 +25,8 @@ class _APIClient:
|
|
|
25
25
|
self.headers = headers
|
|
26
26
|
self.timeout = timeout
|
|
27
27
|
self.max_retries = max_retries
|
|
28
|
+
self.oauth_interceptor = oauth_interceptor
|
|
29
|
+
self.oauth = {} # OAuth token storage
|
|
28
30
|
pass
|
|
29
31
|
|
|
30
32
|
def _call_request(self, method, url, headers: dict = None, params=None, data=None, json_data=None, files=None):
|
|
@@ -52,9 +54,17 @@ class _APIClient:
|
|
|
52
54
|
:return: the JSON response from the HTTP request.
|
|
53
55
|
"""
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
if self.oauth_interceptor and self.oauth_interceptor.is_oauth_configured():
|
|
58
|
+
return self.oauth_interceptor.execute_request(
|
|
59
|
+
method, url, headers=headers, params=params, data=data,
|
|
60
|
+
json=json_data, files=files, timeout=self.timeout
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if headers is None:
|
|
64
|
+
headers = {}
|
|
65
|
+
headers.update(self.headers) # Merge client headers (including authtoken) with request headers
|
|
56
66
|
response = requests.request(
|
|
57
|
-
method, url, headers=headers, params=params, data=data, json=json_data, files=files)
|
|
67
|
+
method, url, headers=headers, params=params, data=data, json=json_data, files=files, timeout=self.timeout)
|
|
58
68
|
# response.raise_for_status()
|
|
59
69
|
return response
|
|
60
70
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
import os
|
|
3
|
+
import pyotp
|
|
4
|
+
from ._api_client import _APIClient
|
|
5
|
+
from contentstack_management.organizations import organization
|
|
6
|
+
from contentstack_management.stack import stack
|
|
7
|
+
from contentstack_management.user_session import user_session
|
|
8
|
+
from contentstack_management.users import user
|
|
9
|
+
from contentstack_management.oauth.oauth_handler import OAuthHandler
|
|
10
|
+
|
|
11
|
+
version = '0.0.1'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Region(Enum):
|
|
15
|
+
US = "us"
|
|
16
|
+
EU = "eu"
|
|
17
|
+
AU = "au"
|
|
18
|
+
AZURE_EU = "azure-eu"
|
|
19
|
+
AZURE_NA = "azure-na"
|
|
20
|
+
GCP_NA = "gcp-na"
|
|
21
|
+
GCP_EU = "gcp-eu"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_agents(headers=None):
|
|
25
|
+
if headers is None:
|
|
26
|
+
headers = {}
|
|
27
|
+
local_headers = {'X-User-Agent': f'contentstack-management-python/v{version}',
|
|
28
|
+
"Content-Type": "application/json"}
|
|
29
|
+
headers.update(local_headers)
|
|
30
|
+
return headers
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Client:
|
|
34
|
+
|
|
35
|
+
# TODO: DefaultCSCredential(), needs to be implemented
|
|
36
|
+
def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
|
|
37
|
+
authtoken: str = None , management_token=None, headers: dict = None,
|
|
38
|
+
region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None,
|
|
39
|
+
oauth_config: dict = None, **kwargs):
|
|
40
|
+
self.endpoint = 'https://api.contentstack.io/v3/'
|
|
41
|
+
|
|
42
|
+
if region is not None and region is not Region.US.value:
|
|
43
|
+
if host is not None and host != 'api.contentstack.io':
|
|
44
|
+
self.endpoint = f'{scheme}{region}-api.{host}/{version}/'
|
|
45
|
+
else:
|
|
46
|
+
host = 'api.contentstack.com'
|
|
47
|
+
self.endpoint = f'{scheme}{region}-{host}/{version}/'
|
|
48
|
+
elif host is not None and host != 'api.contentstack.io':
|
|
49
|
+
self.endpoint = f'{scheme}{host}/{version}/'
|
|
50
|
+
if headers is None:
|
|
51
|
+
headers = {}
|
|
52
|
+
if early_access is not None:
|
|
53
|
+
early_access_str = ', '.join(early_access)
|
|
54
|
+
headers['x-header-ea'] = early_access_str
|
|
55
|
+
|
|
56
|
+
if authtoken is not None:
|
|
57
|
+
headers['authtoken'] = authtoken
|
|
58
|
+
|
|
59
|
+
if management_token is not None:
|
|
60
|
+
headers['authorization'] = management_token
|
|
61
|
+
headers = user_agents(headers)
|
|
62
|
+
self.client = _APIClient(endpoint=self.endpoint, headers=headers, timeout=timeout, max_retries=max_retries)
|
|
63
|
+
|
|
64
|
+
# Initialize OAuth if configuration is provided
|
|
65
|
+
self.oauth_handler = None
|
|
66
|
+
if oauth_config:
|
|
67
|
+
self.oauth_handler = OAuthHandler(
|
|
68
|
+
app_id=oauth_config.get('app_id'),
|
|
69
|
+
client_id=oauth_config.get('client_id'),
|
|
70
|
+
redirect_uri=oauth_config.get('redirect_uri'),
|
|
71
|
+
response_type=oauth_config.get('response_type', 'code'),
|
|
72
|
+
client_secret=oauth_config.get('client_secret'),
|
|
73
|
+
scope=oauth_config.get('scope'),
|
|
74
|
+
api_client=self.client
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
:param host: Optional hostname for the API endpoint.
|
|
79
|
+
:param authtoken: Optional authentication token for API requests
|
|
80
|
+
:param headers: Optional headers to be included with API requests
|
|
81
|
+
:param authorization: Optional headers to be included in the api request
|
|
82
|
+
:param region: optional region to be included in API requests,
|
|
83
|
+
We have region support options for na, eu, azure-eu, azure-na
|
|
84
|
+
:param scheme: optional scheme to be included in API requests
|
|
85
|
+
:param version: optional version to be included in API request path url,
|
|
86
|
+
:param timeout: Optional timeout value for API requests
|
|
87
|
+
:param max_requests:Optional maximum number of requests to be made
|
|
88
|
+
:param retry_on_error: Optional boolean value indicating whether to retry API requests on error.
|
|
89
|
+
:return: A client object for performing API operations.
|
|
90
|
+
-------------------------------
|
|
91
|
+
[Example:]
|
|
92
|
+
|
|
93
|
+
>>> import contentstack_management
|
|
94
|
+
>>> contentstack_client = contentstack_management.Client()
|
|
95
|
+
-------------------------------
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def login(self, email: str, password: str, tfa_token: str = None, mfa_secret: str = None):
|
|
99
|
+
"""
|
|
100
|
+
Login to Contentstack with optional TOTP support.
|
|
101
|
+
|
|
102
|
+
:param email: User's email address
|
|
103
|
+
:param password: User's password
|
|
104
|
+
:param tfa_token: Optional two-factor authentication token
|
|
105
|
+
:param mfa_secret: Optional MFA secret for automatic TOTP generation.
|
|
106
|
+
If not provided, will check MFA_SECRET environment variable
|
|
107
|
+
:return: Response object from the login request
|
|
108
|
+
"""
|
|
109
|
+
final_tfa_token = tfa_token
|
|
110
|
+
|
|
111
|
+
if not mfa_secret:
|
|
112
|
+
mfa_secret = os.getenv('MFA_SECRET')
|
|
113
|
+
|
|
114
|
+
if mfa_secret and not tfa_token:
|
|
115
|
+
final_tfa_token = self._generate_totp(mfa_secret)
|
|
116
|
+
|
|
117
|
+
return user_session.UserSession(self.client).login(email, password, final_tfa_token)
|
|
118
|
+
|
|
119
|
+
def _generate_totp(self, secret: str) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Generate a Time-Based One-Time Password (TOTP) from the provided secret.
|
|
122
|
+
|
|
123
|
+
:param secret: The MFA secret key for TOTP generation
|
|
124
|
+
:return: The current TOTP code as a string
|
|
125
|
+
"""
|
|
126
|
+
totp = pyotp.TOTP(secret)
|
|
127
|
+
return totp.now()
|
|
128
|
+
|
|
129
|
+
def logout(self):
|
|
130
|
+
return user_session.UserSession(client=self.client).logout()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def authtoken(self):
|
|
134
|
+
return self.client.headers['authtoken']
|
|
135
|
+
|
|
136
|
+
def user(self):
|
|
137
|
+
return user.User(self.client)
|
|
138
|
+
|
|
139
|
+
def organizations(self, organization_uid: str = None):
|
|
140
|
+
return organization.Organization(self.client, organization_uid)
|
|
141
|
+
|
|
142
|
+
def stack(self, api_key: str = None):
|
|
143
|
+
return stack.Stack(self.client, api_key)
|
|
144
|
+
|
|
145
|
+
def oauth(self, app_id: str, client_id: str, redirect_uri: str,
|
|
146
|
+
response_type: str = "code", client_secret: str = None,
|
|
147
|
+
scope: list = None):
|
|
148
|
+
"""
|
|
149
|
+
Create an OAuth handler for OAuth 2.0 authentication.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
app_id: Your registered App ID
|
|
153
|
+
client_id: Your OAuth Client ID
|
|
154
|
+
redirect_uri: The URL where the user is redirected after login and consent
|
|
155
|
+
response_type: OAuth response type (default: "code")
|
|
156
|
+
client_secret: Client secret for standard OAuth flows (optional for PKCE)
|
|
157
|
+
scope: Permissions requested (optional)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
OAuthHandler instance
|
|
161
|
+
|
|
162
|
+
Example:
|
|
163
|
+
>>> import contentstack_management
|
|
164
|
+
>>> client = contentstack_management.Client()
|
|
165
|
+
>>> oauth_handler = client.oauth(
|
|
166
|
+
... app_id='your-app-id',
|
|
167
|
+
... client_id='your-client-id',
|
|
168
|
+
... redirect_uri='http://localhost:3000/callback'
|
|
169
|
+
... )
|
|
170
|
+
>>> auth_url = oauth_handler.authorize()
|
|
171
|
+
>>> print(f"Visit this URL to authorize: {auth_url}")
|
|
172
|
+
"""
|
|
173
|
+
return OAuthHandler(
|
|
174
|
+
app_id=app_id,
|
|
175
|
+
client_id=client_id,
|
|
176
|
+
redirect_uri=redirect_uri,
|
|
177
|
+
response_type=response_type,
|
|
178
|
+
client_secret=client_secret,
|
|
179
|
+
scope=scope,
|
|
180
|
+
api_client=self.client
|
|
181
|
+
)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth 2.0 Handler for Contentstack Management SDK.
|
|
3
|
+
|
|
4
|
+
This module provides OAuth 2.0 authentication support including:
|
|
5
|
+
- Authorization code flow
|
|
6
|
+
- PKCE (Proof Key for Code Exchange)
|
|
7
|
+
- Token management and refresh
|
|
8
|
+
- Secure token storage
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from typing import Dict, List, Optional, Union
|
|
16
|
+
from urllib.parse import urlparse, parse_qs
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OAuthHandler:
|
|
22
|
+
"""
|
|
23
|
+
OAuth 2.0 Handler for Contentstack Management SDK.
|
|
24
|
+
|
|
25
|
+
This class manages OAuth 2.0 authentication flow including authorization,
|
|
26
|
+
token exchange, refresh, and secure storage.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Error messages
|
|
30
|
+
ACCESS_TOKEN_EXPIRED_MSG = "🔄 Access token expired, refreshing..."
|
|
31
|
+
NO_ACCESS_TOKEN_MSG = "No access token available. Please authenticate first."
|
|
32
|
+
NO_OAUTH_TOKENS_MSG = "No OAuth tokens available"
|
|
33
|
+
NO_REFRESH_TOKEN_MSG = "No refresh token available"
|
|
34
|
+
OAUTH_NOT_CONFIGURED_MSG = "OAuth is not configured. Please set up OAuth first."
|
|
35
|
+
INVALID_AUTHORIZATION_CODE_MSG = "Authorization code not found in redirect URL"
|
|
36
|
+
TOKEN_EXCHANGE_FAILED_MSG = "Token exchange failed"
|
|
37
|
+
TOKEN_REFRESH_FAILED_MSG = "Token refresh failed"
|
|
38
|
+
OAUTH_BASE_URL_NOT_SET_MSG = "OAuthBaseURL is not set"
|
|
39
|
+
OAUTH_BASE_URL = 'https://app.contentstack.com'
|
|
40
|
+
DEVELOPER_HUB_BASE_URL = 'https://developerhub-api.contentstack.com'
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
app_id: str,
|
|
45
|
+
client_id: str,
|
|
46
|
+
redirect_uri: str,
|
|
47
|
+
response_type: str = "code",
|
|
48
|
+
client_secret: Optional[str] = None,
|
|
49
|
+
scope: Optional[List[str]] = None,
|
|
50
|
+
api_client=None
|
|
51
|
+
):
|
|
52
|
+
self.app_id = app_id
|
|
53
|
+
self.client_id = client_id
|
|
54
|
+
self.redirect_uri = redirect_uri
|
|
55
|
+
self.response_type = response_type
|
|
56
|
+
self.scope = ' '.join(scope) if scope else ''
|
|
57
|
+
self.client_secret = client_secret # Optional, if provided, PKCE will be skipped
|
|
58
|
+
self.api_client = api_client
|
|
59
|
+
|
|
60
|
+
self._oauth_base_url = self._construct_oauth_base_url()
|
|
61
|
+
self._developer_hub_base_url = self._construct_developer_hub_base_url()
|
|
62
|
+
|
|
63
|
+
if self.api_client:
|
|
64
|
+
if not hasattr(self.api_client, 'oauth'):
|
|
65
|
+
self.api_client.oauth = {}
|
|
66
|
+
self.api_client.oauth.update({
|
|
67
|
+
'redirect_uri': redirect_uri,
|
|
68
|
+
'client_id': client_id,
|
|
69
|
+
'app_id': app_id
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
# PKCE setup
|
|
73
|
+
self.use_pkce = client_secret is None
|
|
74
|
+
if self.use_pkce:
|
|
75
|
+
self.code_verifier = self._generate_code_verifier()
|
|
76
|
+
self.code_challenge = self._generate_code_challenge(self.code_verifier)
|
|
77
|
+
else:
|
|
78
|
+
self.code_verifier = None
|
|
79
|
+
self.code_challenge = None
|
|
80
|
+
|
|
81
|
+
def _construct_oauth_base_url(self) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Construct OAuth base URL based on api_client endpoint using dynamic text replacement.
|
|
84
|
+
Returns:
|
|
85
|
+
OAuth base URL string
|
|
86
|
+
"""
|
|
87
|
+
if not self.api_client or not hasattr(self.api_client, 'endpoint'):
|
|
88
|
+
return self.OAUTH_BASE_URL
|
|
89
|
+
|
|
90
|
+
endpoint = self.api_client.endpoint
|
|
91
|
+
|
|
92
|
+
from urllib.parse import urlparse
|
|
93
|
+
parsed = urlparse(endpoint)
|
|
94
|
+
domain = parsed.netloc
|
|
95
|
+
if 'api.contentstack.io' in domain:
|
|
96
|
+
oauth_domain = domain.replace('api.contentstack.io', 'app.contentstack.com')
|
|
97
|
+
else:
|
|
98
|
+
oauth_domain = domain.replace('-api.', '-app.').replace('api.', 'app.')
|
|
99
|
+
oauth_url = f'https://{oauth_domain}'
|
|
100
|
+
return oauth_url
|
|
101
|
+
|
|
102
|
+
def _construct_developer_hub_base_url(self) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Construct Developer Hub base URL based on api_client endpoint using dynamic text replacement.
|
|
105
|
+
Returns:
|
|
106
|
+
Developer Hub base URL string
|
|
107
|
+
"""
|
|
108
|
+
if not self.api_client or not hasattr(self.api_client, 'endpoint'):
|
|
109
|
+
return self.DEVELOPER_HUB_BASE_URL
|
|
110
|
+
|
|
111
|
+
endpoint = self.api_client.endpoint
|
|
112
|
+
from urllib.parse import urlparse
|
|
113
|
+
parsed = urlparse(endpoint)
|
|
114
|
+
domain = parsed.netloc
|
|
115
|
+
|
|
116
|
+
if 'api.contentstack.io' in domain:
|
|
117
|
+
dev_hub_domain = domain.replace('api.contentstack.io', 'developerhub-api.contentstack.com')
|
|
118
|
+
else:
|
|
119
|
+
dev_hub_domain = domain.replace('-api.', '-developerhub-api.')
|
|
120
|
+
dev_hub_url = f'https://{dev_hub_domain}'
|
|
121
|
+
return dev_hub_url
|
|
122
|
+
|
|
123
|
+
def _generate_code_verifier(self, length: int = 128) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Generate a random code verifier for PKCE.
|
|
126
|
+
Returns:
|
|
127
|
+
Base64 URL-encoded code verifier
|
|
128
|
+
"""
|
|
129
|
+
code_verifier = secrets.token_urlsafe(length)
|
|
130
|
+
return code_verifier
|
|
131
|
+
|
|
132
|
+
def _generate_code_challenge(self, code_verifier: str) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Generate code challenge from code verifier using SHA256.
|
|
135
|
+
Returns:
|
|
136
|
+
Base64 URL-encoded SHA256 hash of the code verifier
|
|
137
|
+
"""
|
|
138
|
+
sha256_hash = hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
|
139
|
+
import base64
|
|
140
|
+
code_challenge = base64.urlsafe_b64encode(sha256_hash).decode('utf-8').rstrip('=')
|
|
141
|
+
return code_challenge
|
|
142
|
+
|
|
143
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
144
|
+
"""Get common headers for OAuth requests."""
|
|
145
|
+
headers = {
|
|
146
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
147
|
+
"Accept": "application/json"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (self.api_client and hasattr(self.api_client, 'oauth') and
|
|
151
|
+
self.api_client.oauth.get('accessToken')):
|
|
152
|
+
headers["authorization"] = f"Bearer {self.api_client.oauth['accessToken']}"
|
|
153
|
+
return headers
|
|
154
|
+
|
|
155
|
+
def authorize(self) -> str:
|
|
156
|
+
"""
|
|
157
|
+
Start the OAuth authorization flow.
|
|
158
|
+
Returns:
|
|
159
|
+
Authorization URL for user to visit
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
print(f"OAuth Handler - authorize() called with app_id: {self.app_id}, client_id: {self.client_id}") # Debug
|
|
163
|
+
if not self._oauth_base_url:
|
|
164
|
+
raise ValueError(self.OAUTH_BASE_URL_NOT_SET_MSG)
|
|
165
|
+
oauth_base = self._oauth_base_url.rstrip('/')
|
|
166
|
+
base_url = f"{oauth_base}/#!/apps/{self.app_id}/authorize"
|
|
167
|
+
params = {
|
|
168
|
+
'response_type': 'code', # Always use 'code'
|
|
169
|
+
'client_id': self.client_id
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if self.redirect_uri and self.redirect_uri.strip() and self.redirect_uri != 'None':
|
|
173
|
+
params['redirect_uri'] = self.redirect_uri
|
|
174
|
+
|
|
175
|
+
# Add PKCE parameters if using PKCE
|
|
176
|
+
if self.use_pkce:
|
|
177
|
+
if not self.code_challenge:
|
|
178
|
+
self.code_challenge = self._generate_code_challenge(self.code_verifier)
|
|
179
|
+
params['code_challenge'] = self.code_challenge
|
|
180
|
+
params['code_challenge_method'] = 'S256'
|
|
181
|
+
|
|
182
|
+
query_string = urllib.parse.urlencode(params)
|
|
183
|
+
final_url = f"{base_url}?{query_string}"
|
|
184
|
+
print(f"OAuth Handler - final authorization URL: {final_url}") # Debug
|
|
185
|
+
return final_url
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise ValueError(f"Error generating authorization URL: {e}")
|
|
189
|
+
|
|
190
|
+
def handle_redirect(self, redirect_url: str) -> Dict:
|
|
191
|
+
"""
|
|
192
|
+
Handle the redirect from OAuth authorization server.
|
|
193
|
+
Returns:
|
|
194
|
+
Dictionary containing token information
|
|
195
|
+
"""
|
|
196
|
+
parsed_url = urlparse(redirect_url)
|
|
197
|
+
query_params = parse_qs(parsed_url.query)
|
|
198
|
+
if "code" not in query_params:
|
|
199
|
+
raise ValueError(self.INVALID_AUTHORIZATION_CODE_MSG)
|
|
200
|
+
authorization_code = query_params["code"][0]
|
|
201
|
+
return self.exchange_code_for_token(authorization_code)
|
|
202
|
+
|
|
203
|
+
def exchange_code_for_token(self, authorization_code: str) -> Dict:
|
|
204
|
+
"""
|
|
205
|
+
Exchange authorization code for access token.
|
|
206
|
+
Returns:
|
|
207
|
+
Dictionary containing token information
|
|
208
|
+
"""
|
|
209
|
+
if not authorization_code or not authorization_code.strip():
|
|
210
|
+
raise ValueError("Authorization code cannot be empty")
|
|
211
|
+
data = {
|
|
212
|
+
"grant_type": "authorization_code",
|
|
213
|
+
"code": authorization_code.strip(),
|
|
214
|
+
"redirect_uri": self.redirect_uri,
|
|
215
|
+
"client_id": self.client_id,
|
|
216
|
+
"app_id": self.app_id
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if self.client_secret:
|
|
220
|
+
data["client_secret"] = self.client_secret
|
|
221
|
+
else:
|
|
222
|
+
data["code_verifier"] = self.code_verifier
|
|
223
|
+
|
|
224
|
+
headers = self._get_headers()
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
token_endpoint = f"{self._developer_hub_base_url}/token"
|
|
228
|
+
response = requests.post(
|
|
229
|
+
token_endpoint,
|
|
230
|
+
data=data,
|
|
231
|
+
headers=headers,
|
|
232
|
+
timeout=30
|
|
233
|
+
)
|
|
234
|
+
response.raise_for_status()
|
|
235
|
+
token_data = response.json()
|
|
236
|
+
self._save_tokens(token_data)
|
|
237
|
+
return token_data
|
|
238
|
+
|
|
239
|
+
except requests.RequestException as e:
|
|
240
|
+
raise requests.RequestException(f"{self.TOKEN_EXCHANGE_FAILED_MSG}: {str(e)}")
|
|
241
|
+
|
|
242
|
+
def _save_tokens(self, token_data: Dict):
|
|
243
|
+
"""
|
|
244
|
+
Save tokens and related information.
|
|
245
|
+
"""
|
|
246
|
+
# Store tokens in api_client.oauth
|
|
247
|
+
if self.api_client:
|
|
248
|
+
self.api_client.oauth["accessToken"] = token_data.get("access_token")
|
|
249
|
+
self.api_client.oauth["refreshToken"] = token_data.get("refresh_token") or self.api_client.oauth.get("refreshToken")
|
|
250
|
+
self.api_client.oauth["organizationUID"] = token_data.get("organization_uid")
|
|
251
|
+
self.api_client.oauth["userUID"] = token_data.get("user_uid")
|
|
252
|
+
expires_in = token_data.get("expires_in", 3600)
|
|
253
|
+
self.api_client.oauth["tokenExpiryTime"] = int(time.time() * 1000) + (expires_in - 60) * 1000 # Store expiry time in milliseconds
|
|
254
|
+
|
|
255
|
+
self._access_token = token_data.get("access_token")
|
|
256
|
+
self._refresh_token = token_data.get("refresh_token")
|
|
257
|
+
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
|
258
|
+
self._token_expiry_time = time.time() + expires_in
|
|
259
|
+
self._organization_uid = token_data.get("organization_uid")
|
|
260
|
+
self._user_uid = token_data.get("user_uid")
|
|
261
|
+
if self.api_client and self._access_token:
|
|
262
|
+
self.api_client.headers["Authorization"] = f"Bearer {self._access_token}"
|
|
263
|
+
|
|
264
|
+
def get_valid_access_token(self) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Get valid access token, refreshing if necessary.
|
|
267
|
+
Returns:
|
|
268
|
+
Valid access token
|
|
269
|
+
"""
|
|
270
|
+
if self.is_token_expired():
|
|
271
|
+
print(self.ACCESS_TOKEN_EXPIRED_MSG)
|
|
272
|
+
self.refresh_access_token()
|
|
273
|
+
access_token = self.get_access_token()
|
|
274
|
+
if not access_token:
|
|
275
|
+
raise ValueError(self.NO_ACCESS_TOKEN_MSG)
|
|
276
|
+
return access_token
|
|
277
|
+
|
|
278
|
+
def is_token_expired(self) -> bool:
|
|
279
|
+
"""
|
|
280
|
+
Check if access token is expired.
|
|
281
|
+
Returns:
|
|
282
|
+
True if token is expired, False otherwise
|
|
283
|
+
"""
|
|
284
|
+
if not self.api_client or not hasattr(self.api_client, 'oauth'):
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
token_expiry_time = self.api_client.oauth.get('tokenExpiryTime')
|
|
288
|
+
if not token_expiry_time:
|
|
289
|
+
return True
|
|
290
|
+
if token_expiry_time > 1e10:
|
|
291
|
+
expiry_time = token_expiry_time / 1000
|
|
292
|
+
else:
|
|
293
|
+
expiry_time = token_expiry_time
|
|
294
|
+
return time.time() >= expiry_time
|
|
295
|
+
|
|
296
|
+
def refresh_access_token(self) -> str:
|
|
297
|
+
"""
|
|
298
|
+
Refresh the access token using refresh token.
|
|
299
|
+
Returns:
|
|
300
|
+
New access token
|
|
301
|
+
"""
|
|
302
|
+
if not self.api_client or not hasattr(self.api_client, 'oauth') or not self.api_client.oauth:
|
|
303
|
+
raise ValueError(self.NO_OAUTH_TOKENS_MSG)
|
|
304
|
+
refresh_token = self.api_client.oauth.get('refreshToken')
|
|
305
|
+
if not refresh_token:
|
|
306
|
+
raise ValueError(self.NO_REFRESH_TOKEN_MSG)
|
|
307
|
+
data = {
|
|
308
|
+
"grant_type": "refresh_token",
|
|
309
|
+
"refresh_token": refresh_token,
|
|
310
|
+
"client_id": self.client_id,
|
|
311
|
+
"app_id": self.app_id
|
|
312
|
+
}
|
|
313
|
+
if self.client_secret:
|
|
314
|
+
data["client_secret"] = self.client_secret
|
|
315
|
+
headers = self._get_headers()
|
|
316
|
+
try:
|
|
317
|
+
response = requests.post(
|
|
318
|
+
f"{self._developer_hub_base_url}/token",
|
|
319
|
+
data=data,
|
|
320
|
+
headers=headers,
|
|
321
|
+
timeout=30
|
|
322
|
+
)
|
|
323
|
+
response.raise_for_status()
|
|
324
|
+
|
|
325
|
+
token_data = response.json()
|
|
326
|
+
self._save_tokens(token_data)
|
|
327
|
+
|
|
328
|
+
return self._access_token
|
|
329
|
+
except requests.RequestException as e:
|
|
330
|
+
raise requests.RequestException(f"{self.TOKEN_REFRESH_FAILED_MSG}: {str(e)}")
|
|
331
|
+
|
|
332
|
+
def logout(self, revoke_authorization: bool = True) -> bool:
|
|
333
|
+
"""
|
|
334
|
+
Logout and clear OAuth tokens.
|
|
335
|
+
Returns:
|
|
336
|
+
True if logout successful, False otherwise
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
self._clear_tokens()
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
except Exception:
|
|
343
|
+
self._clear_tokens()
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
def _clear_tokens(self):
|
|
347
|
+
"""Clear all stored tokens and related information."""
|
|
348
|
+
self._access_token = None
|
|
349
|
+
self._refresh_token = None
|
|
350
|
+
self._token_expiry_time = None
|
|
351
|
+
self._organization_uid = None
|
|
352
|
+
self._user_uid = None
|
|
353
|
+
|
|
354
|
+
if self.api_client and "Authorization" in self.api_client.headers:
|
|
355
|
+
del self.api_client.headers["Authorization"]
|
|
356
|
+
|
|
357
|
+
def get_access_token(self) -> Optional[str]:
|
|
358
|
+
"""Get the current access token."""
|
|
359
|
+
return self._access_token
|
|
360
|
+
|
|
361
|
+
def set_access_token(self, token: str):
|
|
362
|
+
"""Set the access token."""
|
|
363
|
+
self._access_token = token
|
|
364
|
+
if self.api_client:
|
|
365
|
+
self.api_client.headers["Authorization"] = f"Bearer {token}"
|
|
366
|
+
if not hasattr(self.api_client, 'oauth'):
|
|
367
|
+
self.api_client.oauth = {}
|
|
368
|
+
self.api_client.oauth['accessToken'] = token
|
|
369
|
+
|
|
370
|
+
def get_refresh_token(self) -> Optional[str]:
|
|
371
|
+
"""Get the current refresh token."""
|
|
372
|
+
return self._refresh_token
|
|
373
|
+
|
|
374
|
+
def set_refresh_token(self, token: str):
|
|
375
|
+
"""Set the refresh token."""
|
|
376
|
+
self._refresh_token = token
|
|
377
|
+
if self.api_client:
|
|
378
|
+
if not hasattr(self.api_client, 'oauth'):
|
|
379
|
+
self.api_client.oauth = {}
|
|
380
|
+
self.api_client.oauth['refreshToken'] = token
|
|
381
|
+
|
|
382
|
+
def get_organization_uid(self) -> Optional[str]:
|
|
383
|
+
"""Get the organization UID."""
|
|
384
|
+
return self._organization_uid
|
|
385
|
+
|
|
386
|
+
def set_organization_uid(self, uid: str):
|
|
387
|
+
"""Set the organization UID."""
|
|
388
|
+
self._organization_uid = uid
|
|
389
|
+
|
|
390
|
+
def get_user_uid(self) -> Optional[str]:
|
|
391
|
+
"""Get the user UID."""
|
|
392
|
+
return self._user_uid
|
|
393
|
+
|
|
394
|
+
def set_user_uid(self, uid: str):
|
|
395
|
+
"""Set the user UID."""
|
|
396
|
+
self._user_uid = uid
|
|
397
|
+
|
|
398
|
+
def get_token_expiry_time(self) -> Optional[float]:
|
|
399
|
+
"""Get the token expiry time."""
|
|
400
|
+
return self._token_expiry_time
|
|
401
|
+
|
|
402
|
+
def set_token_expiry_time(self, expiry_time: float):
|
|
403
|
+
"""Set the token expiry time."""
|
|
404
|
+
self._token_expiry_time = expiry_time
|
|
405
|
+
if self.api_client:
|
|
406
|
+
if not hasattr(self.api_client, 'oauth'):
|
|
407
|
+
self.api_client.oauth = {}
|
|
408
|
+
self.api_client.oauth['tokenExpiryTime'] = expiry_time
|
|
409
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth Interceptor for automatic token management and request handling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
import requests
|
|
9
|
+
from requests.adapters import HTTPAdapter
|
|
10
|
+
from urllib3.util.retry import Retry
|
|
11
|
+
|
|
12
|
+
class OAuthInterceptor:
|
|
13
|
+
"""
|
|
14
|
+
OAuth interceptor that automatically handles token refresh and request retries.
|
|
15
|
+
"""
|
|
16
|
+
MAX_RETRIES = 3
|
|
17
|
+
REFRESH_TIMEOUT = 30
|
|
18
|
+
TOKEN_ENDPOINT_PATH = "/token"
|
|
19
|
+
TOKEN_REFRESH_FAILED_MSG = "Token refresh failed"
|
|
20
|
+
NO_VALID_TOKENS_MSG = "OAuth: No valid tokens available"
|
|
21
|
+
TOKEN_REFRESH_FAILED_AFTER_401_MSG = "OAuth: Token refresh failed after 401"
|
|
22
|
+
# User agent strings
|
|
23
|
+
USER_AGENT = "contentstack-python-management-sdk"
|
|
24
|
+
X_USER_AGENT = "contentstack-python-management-sdk"
|
|
25
|
+
|
|
26
|
+
def __init__(self, oauth_handler):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the OAuth interceptor.
|
|
29
|
+
Args:
|
|
30
|
+
oauth_handler: The OAuthHandler instance
|
|
31
|
+
"""
|
|
32
|
+
self.oauth_handler = oauth_handler
|
|
33
|
+
self.early_access = None
|
|
34
|
+
self.refresh_lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def set_early_access(self, early_access: list):
|
|
37
|
+
"""Set early access headers."""
|
|
38
|
+
self.early_access = early_access
|
|
39
|
+
|
|
40
|
+
def is_oauth_configured(self) -> bool:
|
|
41
|
+
"""Check if OAuth is properly configured."""
|
|
42
|
+
return (self.oauth_handler is not None and
|
|
43
|
+
hasattr(self.oauth_handler, 'app_id') and
|
|
44
|
+
self.oauth_handler.app_id is not None)
|
|
45
|
+
|
|
46
|
+
def has_valid_tokens(self) -> bool:
|
|
47
|
+
"""Check if we have valid (non-expired) tokens."""
|
|
48
|
+
if not self.oauth_handler or not hasattr(self.oauth_handler, 'api_client'):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
api_client = self.oauth_handler.api_client
|
|
52
|
+
if not hasattr(api_client, 'oauth') or not api_client.oauth:
|
|
53
|
+
return False
|
|
54
|
+
return not self.oauth_handler.is_token_expired()
|
|
55
|
+
|
|
56
|
+
def _get_default_headers(self, request_url: str) -> Dict[str, str]:
|
|
57
|
+
"""
|
|
58
|
+
Get default headers for requests.
|
|
59
|
+
Returns:
|
|
60
|
+
Dictionary of headers
|
|
61
|
+
"""
|
|
62
|
+
headers = {
|
|
63
|
+
"X-User-Agent": self.X_USER_AGENT,
|
|
64
|
+
"User-Agent": self.USER_AGENT,
|
|
65
|
+
"x-header-ea": ",".join(self.early_access) if self.early_access else "true"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if self.TOKEN_ENDPOINT_PATH in request_url:
|
|
69
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
70
|
+
else:
|
|
71
|
+
headers["Content-Type"] = "application/json"
|
|
72
|
+
|
|
73
|
+
return headers
|
|
74
|
+
|
|
75
|
+
def _add_auth_header(self, headers: Dict[str, str], request_url: str) -> Dict[str, str]:
|
|
76
|
+
"""
|
|
77
|
+
Add authorization header if appropriate.
|
|
78
|
+
Returns:
|
|
79
|
+
Updated headers with authorization if needed
|
|
80
|
+
"""
|
|
81
|
+
if self.TOKEN_ENDPOINT_PATH in request_url:
|
|
82
|
+
return headers
|
|
83
|
+
if (self.oauth_handler and
|
|
84
|
+
hasattr(self.oauth_handler, 'api_client') and
|
|
85
|
+
self.oauth_handler.api_client and
|
|
86
|
+
hasattr(self.oauth_handler.api_client, 'oauth') and
|
|
87
|
+
self.oauth_handler.api_client.oauth and
|
|
88
|
+
self.oauth_handler.api_client.oauth.get('accessToken')):
|
|
89
|
+
|
|
90
|
+
headers["Authorization"] = f"Bearer {self.oauth_handler.api_client.oauth['accessToken']}"
|
|
91
|
+
return headers
|
|
92
|
+
|
|
93
|
+
def _ensure_valid_token(self) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Ensure we have a valid token, refreshing if necessary.
|
|
96
|
+
Returns:
|
|
97
|
+
True if we have a valid token, False otherwise
|
|
98
|
+
"""
|
|
99
|
+
if not self.oauth_handler or not hasattr(self.oauth_handler, 'api_client'):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
api_client = self.oauth_handler.api_client
|
|
103
|
+
if not hasattr(api_client, 'oauth') or not api_client.oauth:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Check if token is expired and refresh if needed
|
|
107
|
+
if self.oauth_handler.is_token_expired():
|
|
108
|
+
with self.refresh_lock:
|
|
109
|
+
try:
|
|
110
|
+
if self.oauth_handler.is_token_expired():
|
|
111
|
+
self.oauth_handler.refresh_access_token()
|
|
112
|
+
return True
|
|
113
|
+
except Exception as e:
|
|
114
|
+
print(f"{self.TOKEN_REFRESH_FAILED_MSG}: {e}")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def execute_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
120
|
+
"""
|
|
121
|
+
Execute a request with OAuth handling and retry logic.
|
|
122
|
+
Returns:
|
|
123
|
+
Response object
|
|
124
|
+
"""
|
|
125
|
+
if self.TOKEN_ENDPOINT_PATH in url:
|
|
126
|
+
return self._make_request(method, url, **kwargs)
|
|
127
|
+
if not self._ensure_valid_token():
|
|
128
|
+
raise requests.RequestException(self.NO_VALID_TOKENS_MSG)
|
|
129
|
+
return self._execute_with_retry(method, url, 0, **kwargs)
|
|
130
|
+
|
|
131
|
+
def _execute_with_retry(self, method: str, url: str, retry_count: int, **kwargs) -> requests.Response:
|
|
132
|
+
"""
|
|
133
|
+
Execute request with retry logic.
|
|
134
|
+
Returns:
|
|
135
|
+
Response object
|
|
136
|
+
"""
|
|
137
|
+
headers = self._get_default_headers(url)
|
|
138
|
+
headers = self._add_auth_header(headers, url)
|
|
139
|
+
if 'headers' in kwargs:
|
|
140
|
+
headers.update(kwargs['headers'])
|
|
141
|
+
kwargs['headers'] = headers
|
|
142
|
+
|
|
143
|
+
response = self._make_request(method, url, **kwargs)
|
|
144
|
+
if not response.ok and retry_count < self.MAX_RETRIES:
|
|
145
|
+
status_code = response.status_code
|
|
146
|
+
|
|
147
|
+
if (status_code == 401 and
|
|
148
|
+
self.oauth_handler and
|
|
149
|
+
hasattr(self.oauth_handler, 'api_client') and
|
|
150
|
+
self.oauth_handler.api_client and
|
|
151
|
+
hasattr(self.oauth_handler.api_client, 'oauth') and
|
|
152
|
+
self.oauth_handler.api_client.oauth and
|
|
153
|
+
self.oauth_handler.api_client.oauth.get('refreshToken')):
|
|
154
|
+
|
|
155
|
+
with self.refresh_lock:
|
|
156
|
+
try:
|
|
157
|
+
self.oauth_handler.refresh_access_token()
|
|
158
|
+
headers["Authorization"] = f"Bearer {self.oauth_handler.api_client.oauth['accessToken']}"
|
|
159
|
+
kwargs['headers'] = headers
|
|
160
|
+
return self._execute_with_retry(method, url, retry_count + 1, **kwargs)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
raise requests.RequestException(f"{self.TOKEN_REFRESH_FAILED_AFTER_401_MSG}: {e}")
|
|
163
|
+
|
|
164
|
+
if status_code == 429 or (status_code >= 500 and status_code != 501):
|
|
165
|
+
# Calculate delay with exponential backoff
|
|
166
|
+
delay = min(1000 * (2 ** retry_count), 30000) / 1000 # Convert to seconds
|
|
167
|
+
time.sleep(delay)
|
|
168
|
+
return self._execute_with_retry(method, url, retry_count + 1, **kwargs)
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
def _make_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
172
|
+
"""
|
|
173
|
+
Make the actual HTTP request.
|
|
174
|
+
Returns:
|
|
175
|
+
Response object
|
|
176
|
+
"""
|
|
177
|
+
return requests.request(method, url, **kwargs)
|
|
178
|
+
|
|
179
|
+
def get_valid_access_token(self) -> Optional[str]:
|
|
180
|
+
"""
|
|
181
|
+
Get a valid access token, refreshing if necessary.
|
|
182
|
+
Returns:
|
|
183
|
+
Valid access token or None if unavailable
|
|
184
|
+
"""
|
|
185
|
+
if self._ensure_valid_token():
|
|
186
|
+
return self.oauth_handler.api_client.oauth.get('accessToken')
|
|
187
|
+
return None
|
|
@@ -63,7 +63,7 @@ class UserSession:
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if tfa_token is not None:
|
|
66
|
-
data["user"]["
|
|
66
|
+
data["user"]["tfa_token"] = tfa_token
|
|
67
67
|
|
|
68
68
|
data = json.dumps(data)
|
|
69
69
|
response = self.client.post(_path, headers=self.client.headers, data=data, json_data=None)
|
|
@@ -44,6 +44,9 @@ contentstack_management/management_token/__init__.py
|
|
|
44
44
|
contentstack_management/management_token/management_token.py
|
|
45
45
|
contentstack_management/metadata/__init__.py
|
|
46
46
|
contentstack_management/metadata/metadata.py
|
|
47
|
+
contentstack_management/oauth/__init__.py
|
|
48
|
+
contentstack_management/oauth/oauth_handler.py
|
|
49
|
+
contentstack_management/oauth/oauth_interceptor.py
|
|
47
50
|
contentstack_management/organizations/__init__.py
|
|
48
51
|
contentstack_management/organizations/organization.py
|
|
49
52
|
contentstack_management/publish_queue/__init__.py
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
from ._api_client import _APIClient
|
|
3
|
-
from contentstack_management.organizations import organization
|
|
4
|
-
from contentstack_management.stack import stack
|
|
5
|
-
from contentstack_management.user_session import user_session
|
|
6
|
-
from contentstack_management.users import user
|
|
7
|
-
|
|
8
|
-
version = '0.0.1'
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class Region(Enum):
|
|
12
|
-
US = "us"
|
|
13
|
-
EU = "eu"
|
|
14
|
-
AU = "au"
|
|
15
|
-
AZURE_EU = "azure-eu"
|
|
16
|
-
AZURE_NA = "azure-na"
|
|
17
|
-
GCP_NA = "gcp-na"
|
|
18
|
-
GCP_EU = "gcp-eu"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def user_agents(headers=None):
|
|
22
|
-
if headers is None:
|
|
23
|
-
headers = {}
|
|
24
|
-
local_headers = {'X-User-Agent': f'contentstack-management-python/v{version}',
|
|
25
|
-
"Content-Type": "application/json"}
|
|
26
|
-
headers.update(local_headers)
|
|
27
|
-
return headers
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class Client:
|
|
31
|
-
|
|
32
|
-
# TODO: DefaultCSCredential(), needs to be implemented
|
|
33
|
-
def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
|
|
34
|
-
authtoken: str = None , management_token=None, headers: dict = None,
|
|
35
|
-
region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None,
|
|
36
|
-
**kwargs):
|
|
37
|
-
self.endpoint = 'https://api.contentstack.io/v3/'
|
|
38
|
-
if region is not None and host is not None and region is not Region.US.value:
|
|
39
|
-
self.endpoint = f'{scheme}{region}-{host}/{version}/'
|
|
40
|
-
if region is not None and host is None and region is not Region.US.value:
|
|
41
|
-
host = 'api.contentstack.com'
|
|
42
|
-
self.endpoint = f'{scheme}{region}-{host}/{version}/'
|
|
43
|
-
if host is not None and region is None:
|
|
44
|
-
self.endpoint = f'{scheme}{host}/{version}/'
|
|
45
|
-
if headers is None:
|
|
46
|
-
headers = {}
|
|
47
|
-
if early_access is not None:
|
|
48
|
-
early_access_str = ', '.join(early_access)
|
|
49
|
-
headers['x-header-ea'] = early_access_str
|
|
50
|
-
|
|
51
|
-
if authtoken is not None:
|
|
52
|
-
headers['authtoken'] = authtoken
|
|
53
|
-
|
|
54
|
-
if management_token is not None:
|
|
55
|
-
headers['authorization'] = management_token
|
|
56
|
-
headers = user_agents(headers)
|
|
57
|
-
self.client = _APIClient(endpoint=self.endpoint, headers=headers, timeout=timeout, max_retries=max_retries)
|
|
58
|
-
|
|
59
|
-
"""
|
|
60
|
-
:param host: Optional hostname for the API endpoint.
|
|
61
|
-
:param authtoken: Optional authentication token for API requests
|
|
62
|
-
:param headers: Optional headers to be included with API requests
|
|
63
|
-
:param authorization: Optional headers to be included in the api request
|
|
64
|
-
:param region: optional region to be included in API requests,
|
|
65
|
-
We have region support options for na, eu, azure-eu, azure-na
|
|
66
|
-
:param scheme: optional scheme to be included in API requests
|
|
67
|
-
:param version: optional version to be included in API request path url,
|
|
68
|
-
:param timeout: Optional timeout value for API requests
|
|
69
|
-
:param max_requests:Optional maximum number of requests to be made
|
|
70
|
-
:param retry_on_error: Optional boolean value indicating whether to retry API requests on error.
|
|
71
|
-
:return: A client object for performing API operations.
|
|
72
|
-
-------------------------------
|
|
73
|
-
[Example:]
|
|
74
|
-
|
|
75
|
-
>>> import contentstack_management
|
|
76
|
-
>>> contentstack_client = contentstack_management.Client()
|
|
77
|
-
-------------------------------
|
|
78
|
-
"""
|
|
79
|
-
|
|
80
|
-
def login(self, email: str, password: str, tfa_token: str = None):
|
|
81
|
-
return user_session.UserSession(self.client).login(email, password, tfa_token)
|
|
82
|
-
pass
|
|
83
|
-
|
|
84
|
-
def logout(self):
|
|
85
|
-
return user_session.UserSession(client=self.client).logout()
|
|
86
|
-
|
|
87
|
-
@property
|
|
88
|
-
def authtoken(self):
|
|
89
|
-
return self.client.headers['authtoken']
|
|
90
|
-
|
|
91
|
-
def user(self):
|
|
92
|
-
return user.User(self.client)
|
|
93
|
-
|
|
94
|
-
def organizations(self, organization_uid: str = None):
|
|
95
|
-
return organization.Organization(self.client, organization_uid)
|
|
96
|
-
|
|
97
|
-
def stack(self, api_key: str = None):
|
|
98
|
-
return stack.Stack(self.client, api_key)
|
|
File without changes
|
|
File without changes
|
{contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/_constant.py
RENAMED
|
File without changes
|
{contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/_errors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{contentstack_management-1.6.0 → contentstack_management-1.7.0}/contentstack_management/common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|