projectal.api
Core API functions to communicate with the Projectal server.
Get the status()
of the Projectal server, run a query()
,
or make custom HTTP requests to any Projectal API method.
Verb functions (GET, POST, etc.)
The HTTP verb functions provided here are used internally by this library; in general, you should not need to use these functions directly unless this library's implementation of an API method is insufficient for your needs.
The response is validated automatically for all verbs. A
projectal.errors.ProjectalException
is thrown if the response
fails, otherwise you get a dict
containing the JSON response.
Login and session state
This module handles logins and session state for the library.
It's done for you automatically by the module when you make the
first authenticated request. See login()
for details.
1""" 2Core API functions to communicate with the Projectal server. 3 4Get the `status()` of the Projectal server, run a `query()`, 5or make custom HTTP requests to any Projectal API method. 6 7**Verb functions (GET, POST, etc.)** 8 9The HTTP verb functions provided here are used internally by 10this library; in general, you should not need to use these 11functions directly unless this library's implementation of 12an API method is insufficient for your needs. 13 14The response is validated automatically for all verbs. A 15`projectal.errors.ProjectalException` is thrown if the response 16fails, otherwise you get a `dict` containing the JSON response. 17 18**Login and session state** 19 20This module handles logins and session state for the library. 21It's done for you automatically by the module when you make the 22first authenticated request. See `login()` for details. 23""" 24from time import sleep 25from datetime import timezone, datetime 26 27import requests 28from packaging import version 29from requests import PreparedRequest 30import requests.utils 31 32 33try: 34 from simplejson.errors import JSONDecodeError 35except ImportError: 36 from json.decoder import JSONDecodeError 37 38from .errors import * 39import projectal 40 41 42def status(): 43 """Get runtime details of the Projectal server (with version number).""" 44 _check_creds_or_fail() 45 response = requests.get(_build_url("/management/status"), verify=projectal.__verify) 46 if response.status_code == 429: 47 timeout_seconds = int( 48 # Wait for 60 seconds by default if header is missing for whatever reason 49 response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60") 50 ) 51 sleep(timeout_seconds + 1) 52 return status() 53 return response.json() 54 55 56def _check_creds_or_fail(): 57 """Correctness check: can't proceed if no API details supplied.""" 58 if not projectal.api_base: 59 raise LoginException("Projectal URL (projectal.api_base) is not set") 60 if not projectal.api_username or not projectal.api_password: 61 raise LoginException("API credentials are missing") 62 63 64def _check_version_or_fail(): 65 """ 66 Check the version number of the Projectal instance. If the 67 version number is below the minimum supported version number 68 of this API client, raise a ProjectalVersionException. 69 """ 70 status = projectal.status() 71 if status["status"] != "UP": 72 raise LoginException("Projectal server status check failed") 73 v = projectal.status()["version"] 74 min = projectal.MIN_PROJECTAL_VERSION 75 if version.parse(v) >= version.parse(min): 76 return True 77 m = "Minimum supported Projectal version: {}. Got: {}".format(min, v) 78 raise ProjectalVersionException(m) 79 80 81def login(): 82 """ 83 Log in using the credentials supplied to the module. If successful, 84 stores the cookie in memory for reuse in future requests. 85 86 **You do not need to manually call this method** to use this library. 87 The library will automatically log in before the first request is 88 made or if the previous session has expired. 89 90 This method can be used to check if the account credentials are 91 working correctly. 92 """ 93 _check_version_or_fail() 94 95 payload = {"username": projectal.api_username, "password": projectal.api_password} 96 if projectal.api_application_id: 97 payload["applicationId"] = projectal.api_application_id 98 response = requests.post( 99 _build_url("/auth/login"), json=payload, verify=projectal.__verify 100 ) 101 # Handle errors here 102 if response.status_code == 200 and response.json()["status"] == "OK": 103 projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies) 104 projectal.api_auth_details = auth_details() 105 return True 106 if response.status_code == 429: 107 timeout_seconds = int( 108 # Wait for 60 seconds by default if header is missing for whatever reason 109 response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60") 110 ) 111 sleep(timeout_seconds + 1) 112 return login() 113 raise LoginException("Check the API URL and your credentials") 114 115 116def auth_details(): 117 """ 118 Returns some details about the currently logged-in user account, 119 including all permissions available to it. 120 """ 121 return projectal.get("/api/user/details") 122 123 124def permission_list(): 125 """ 126 Returns a list of all permissions that exist in Projectal. 127 """ 128 return projectal.get("/api/permission/list") 129 130 131def ldap_sync(): 132 """Initiate an on-demand user sync with the LDAP/AD server configured in your 133 Projectal server settings. If not configured, returns a HTTP 405 error.""" 134 return projectal.post("/api/ldap/sync", None) 135 136 137def query(payload): 138 """ 139 Executes a query and returns the result. See the 140 [Query API](https://projectal.com/docs/latest#tag/Query) for details. 141 """ 142 return projectal.post("/api/query/match", payload) 143 144 145def date_from_timestamp(date): 146 """Returns a date string from a timestamp. 147 E.g., `1647561600000` returns `2022-03-18`.""" 148 if not date: 149 return None 150 return str(datetime.utcfromtimestamp(int(date) / 1000).date()) 151 152 153def timestamp_from_date(date): 154 """Returns a timestamp from a date string. 155 E.g., `2022-03-18` returns `1647561600000`.""" 156 if not date: 157 return None 158 return int( 159 datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() 160 * 1000 161 ) 162 163 164def timestamp_from_datetime(date): 165 """Returns a timestamp from a datetime string. 166 E.g. `2022-03-18 17:00` returns `1647622800000`.""" 167 if not date: 168 return None 169 return int( 170 datetime.strptime(date, "%Y-%m-%d %H:%M") 171 .replace(tzinfo=timezone.utc) 172 .timestamp() 173 * 1000 174 ) 175 176 177def post(endpoint, payload=None, file=None, is_json=True): 178 """HTTP POST to the Projectal server.""" 179 return __request("post", endpoint, payload, file=file, is_json=is_json) 180 181 182def get(endpoint, payload=None, is_json=True): 183 """HTTP GET to the Projectal server.""" 184 return __request("get", endpoint, payload, is_json=is_json) 185 186 187def delete(endpoint, payload=None): 188 """HTTP DELETE to the Projectal server.""" 189 return __request("delete", endpoint, payload) 190 191 192def put(endpoint, payload=None, file=None, form=False): 193 """HTTP PUT to the Projectal server.""" 194 return __request("put", endpoint, payload, file=file, form=form) 195 196 197def __request(method, endpoint, payload=None, file=None, form=False, is_json=True): 198 """ 199 Make an API request. If this is the first request made in the module, 200 this function will issue a login API call first. 201 202 Additionally, if the response claims an expired JWT, the function 203 will issue a login API call and try the request again (max 1 try). 204 """ 205 if not projectal.cookies: 206 projectal.login() 207 fun = getattr(requests, method) 208 kwargs = {} 209 if file: 210 kwargs["files"] = file 211 kwargs["data"] = payload 212 elif form: 213 kwargs["data"] = payload 214 else: 215 kwargs["json"] = payload 216 217 response = fun( 218 _build_url(endpoint), 219 cookies=projectal.cookies, 220 verify=projectal.__verify, 221 **kwargs 222 ) 223 224 try: 225 # Raise error for non-200 response 226 response.raise_for_status() 227 except HTTPError as err: 228 if err.response.status_code == 401: 229 # If the error is from an expired JWT we can retry it by 230 # clearing the cookie. (Login happens on next call). 231 try: 232 r = response.json() 233 if ( 234 r.get("status", None) == "UNAUTHORIZED" 235 or r.get("message", None) == "anonymousUser" 236 or r.get("error", None) == "Unauthorized" 237 ): 238 projectal.cookies = None 239 return __request(method, endpoint, payload, file) 240 except JSONDecodeError: 241 pass 242 if err.response.status_code == 429: 243 timeout_seconds = int( 244 # Wait for 60 seconds by default if header is missing for whatever reason 245 response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60") 246 ) 247 sleep(timeout_seconds + 1) 248 return __request(method, endpoint, payload, file) 249 raise ProjectalException(response) from None 250 251 # We will treat a partial success as failure - we cannot silently 252 # ignore some errors 253 if response.status_code == 207: 254 raise ProjectalException(response) 255 256 if not is_json: 257 if response.cookies: 258 projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies) 259 return response 260 try: 261 payload = response.json() 262 # Fail if the status code in the response body (not the HTTP code!) 263 # does not match what we expect for the API endpoint. 264 __maybe_fail_status(response, payload) 265 # If we have a timestamp, record it for whoever is interested 266 if "timestamp" in payload: 267 projectal.response_timestamp = payload["timestamp"] 268 else: 269 projectal.response_timestamp = None 270 271 # If we have a 'jobCase', return the data it points to, which is 272 # what the caller is after (saves them having to do it every time). 273 if "jobCase" in payload: 274 if response.cookies: 275 projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies) 276 return payload[payload["jobCase"]] 277 if response.cookies: 278 projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies) 279 return payload 280 except JSONDecodeError: 281 # API always responds with JSON. If not, it's an error 282 raise ProjectalException(response) from None 283 284 285def __maybe_fail_status(response, payload): 286 """ 287 Check the status code in the body of the response. Raise 288 a `ProjectalException` if it does not match the "good" 289 status for that request. 290 291 The code is "OK" for everything, but /create returns "CREATED". 292 Luckily for us, /create also returns a 201, so we know which 293 codes to match up. 294 295 Requests with no 'status' key are assumed to be good. 296 """ 297 expected = "OK" 298 if response.status_code == 201: 299 expected = "CREATED" 300 301 got = payload.get("status", expected) if isinstance(payload, dict) else expected 302 if expected == got: 303 return True 304 m = "Unexpected response calling {}. Expected status: {}. Got: {}".format( 305 response.url, expected, got 306 ) 307 raise ProjectalException(response, m) 308 309 310def _build_url(endpoint): 311 req = PreparedRequest() 312 url = projectal.api_base.rstrip("/") + endpoint 313 params = {"alias": projectal.api_alias} 314 req.prepare_url(url, params) 315 return req.url
43def status(): 44 """Get runtime details of the Projectal server (with version number).""" 45 _check_creds_or_fail() 46 response = requests.get(_build_url("/management/status"), verify=projectal.__verify) 47 if response.status_code == 429: 48 timeout_seconds = int( 49 # Wait for 60 seconds by default if header is missing for whatever reason 50 response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60") 51 ) 52 sleep(timeout_seconds + 1) 53 return status() 54 return response.json()
Get runtime details of the Projectal server (with version number).
82def login(): 83 """ 84 Log in using the credentials supplied to the module. If successful, 85 stores the cookie in memory for reuse in future requests. 86 87 **You do not need to manually call this method** to use this library. 88 The library will automatically log in before the first request is 89 made or if the previous session has expired. 90 91 This method can be used to check if the account credentials are 92 working correctly. 93 """ 94 _check_version_or_fail() 95 96 payload = {"username": projectal.api_username, "password": projectal.api_password} 97 if projectal.api_application_id: 98 payload["applicationId"] = projectal.api_application_id 99 response = requests.post( 100 _build_url("/auth/login"), json=payload, verify=projectal.__verify 101 ) 102 # Handle errors here 103 if response.status_code == 200 and response.json()["status"] == "OK": 104 projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies) 105 projectal.api_auth_details = auth_details() 106 return True 107 if response.status_code == 429: 108 timeout_seconds = int( 109 # Wait for 60 seconds by default if header is missing for whatever reason 110 response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60") 111 ) 112 sleep(timeout_seconds + 1) 113 return login() 114 raise LoginException("Check the API URL and your credentials")
Log in using the credentials supplied to the module. If successful, stores the cookie in memory for reuse in future requests.
You do not need to manually call this method to use this library. The library will automatically log in before the first request is made or if the previous session has expired.
This method can be used to check if the account credentials are working correctly.
117def auth_details(): 118 """ 119 Returns some details about the currently logged-in user account, 120 including all permissions available to it. 121 """ 122 return projectal.get("/api/user/details")
Returns some details about the currently logged-in user account, including all permissions available to it.
125def permission_list(): 126 """ 127 Returns a list of all permissions that exist in Projectal. 128 """ 129 return projectal.get("/api/permission/list")
Returns a list of all permissions that exist in Projectal.
132def ldap_sync(): 133 """Initiate an on-demand user sync with the LDAP/AD server configured in your 134 Projectal server settings. If not configured, returns a HTTP 405 error.""" 135 return projectal.post("/api/ldap/sync", None)
Initiate an on-demand user sync with the LDAP/AD server configured in your Projectal server settings. If not configured, returns a HTTP 405 error.
138def query(payload): 139 """ 140 Executes a query and returns the result. See the 141 [Query API](https://projectal.com/docs/latest#tag/Query) for details. 142 """ 143 return projectal.post("/api/query/match", payload)
Executes a query and returns the result. See the Query API for details.
146def date_from_timestamp(date): 147 """Returns a date string from a timestamp. 148 E.g., `1647561600000` returns `2022-03-18`.""" 149 if not date: 150 return None 151 return str(datetime.utcfromtimestamp(int(date) / 1000).date())
Returns a date string from a timestamp.
E.g., 1647561600000
returns 2022-03-18
.
154def timestamp_from_date(date): 155 """Returns a timestamp from a date string. 156 E.g., `2022-03-18` returns `1647561600000`.""" 157 if not date: 158 return None 159 return int( 160 datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() 161 * 1000 162 )
Returns a timestamp from a date string.
E.g., 2022-03-18
returns 1647561600000
.
165def timestamp_from_datetime(date): 166 """Returns a timestamp from a datetime string. 167 E.g. `2022-03-18 17:00` returns `1647622800000`.""" 168 if not date: 169 return None 170 return int( 171 datetime.strptime(date, "%Y-%m-%d %H:%M") 172 .replace(tzinfo=timezone.utc) 173 .timestamp() 174 * 1000 175 )
Returns a timestamp from a datetime string.
E.g. 2022-03-18 17:00
returns 1647622800000
.
178def post(endpoint, payload=None, file=None, is_json=True): 179 """HTTP POST to the Projectal server.""" 180 return __request("post", endpoint, payload, file=file, is_json=is_json)
HTTP POST to the Projectal server.
183def get(endpoint, payload=None, is_json=True): 184 """HTTP GET to the Projectal server.""" 185 return __request("get", endpoint, payload, is_json=is_json)
HTTP GET to the Projectal server.
188def delete(endpoint, payload=None): 189 """HTTP DELETE to the Projectal server.""" 190 return __request("delete", endpoint, payload)
HTTP DELETE to the Projectal server.
193def put(endpoint, payload=None, file=None, form=False): 194 """HTTP PUT to the Projectal server.""" 195 return __request("put", endpoint, payload, file=file, form=form)
HTTP PUT to the Projectal server.