projectal.api API documentation

# 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.

View Source

      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

def status(): View Source

    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).

def login(): View Source

     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.

def auth_details(): View Source

    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.

def permission_list(): View Source

    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.

def ldap_sync(): View Source

    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.

def query(payload): View Source

    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.

def date_from_timestamp(date): View Source

    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`.

def timestamp_from_date(date): View Source

    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`.

def timestamp_from_datetime(date): View Source

    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`.

def post(endpoint, payload=None, file=None, is_json=True): View Source

    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.

def get(endpoint, payload=None, is_json=True): View Source

    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.

def delete(endpoint, payload=None): View Source

    188def delete(endpoint, payload=None):
    189    """HTTP DELETE to the Projectal server."""
    190    return __request("delete", endpoint, payload)

HTTP DELETE to the Projectal server.

def put(endpoint, payload=None, file=None, form=False): View Source

    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.