Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]

### Added

- Core: Added bulk_export() function to dowload bulk record data

### Changed

- Core: Switched to a requests.session object to enable persistent connection recycling.
- Core: Provide options to disable pagination and prevent api result's JSON parsing (in association with bulk_export).

### Fixed

- Core: do not prepend the base_url to the uri of an api_call if a protocol is already included (i.e. uri is already fully-formed).

## [0.9.1] - 2025-06-13

### Changed
Expand Down
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ This module is used to interact with the Xurrent API. It provides a set of class

# Plain API Call
uri = "/requests?subject=Example Subject"
connection_object.api_call(uri, 'GET')
x_api_helper.api_call(uri, 'GET')

# Convert node ID
helper.decode_api_id('ZmFiaWFuc3RlaW5lci4yNDEyMTAxMDE0MTJANG1lLWRlbW8uY29tL1JlcS83MDU3NTU') # fabiansteiner.241210101412@4me-demo.com/Req/705755
x_api_helper.decode_api_id('ZmFiaWFuc3RlaW5lci4yNDEyMTAxMDE0MTJANG1lLWRlbW8uY29tL1JlcS83MDU3NTU') # fabiansteiner.241210101412@4me-demo.com/Req/705755
# this can be used to derive the ID from the nodeID

```

#### Configuration Items
Expand Down Expand Up @@ -68,7 +67,7 @@ This module is used to interact with the Xurrent API. It provides a set of class
# creating without specifying the label, takes the last ci of the product and increments the label
# example: "wdc-02" -> "wdc-03"
data = {"name": "New CI", "type": "software", "status": "in_production", "product_id": "<product_id>"}
new_ci = ConfigurationItem.create(api_helper, data)
new_ci = ConfigurationItem.create(x_api_helper, data)
print(new_ci)

# Archive a Configuration Item (must be in an allowed state)
Expand Down Expand Up @@ -115,7 +114,6 @@ This module is used to interact with the Xurrent API. It provides a set of class
people.trash()
#restore
people.restore()

```

#### Requests
Expand All @@ -142,7 +140,6 @@ This module is used to interact with the Xurrent API. It provides a set of class
#restore

request.restore()


```

Expand Down Expand Up @@ -212,7 +209,6 @@ This module is used to interact with the Xurrent API. It provides a set of class
"text": "This is a test note",
"internal": True
})

```

#### Tasks
Expand Down Expand Up @@ -240,8 +236,6 @@ This module is used to interact with the Xurrent API. It provides a set of class
task.reject()
#approve
task.approve()


```

#### Teams
Expand Down Expand Up @@ -271,7 +265,6 @@ This module is used to interact with the Xurrent API. It provides a set of class
#### Workflows

```python

from xurrent.workflows import Workflow

workflow = Workflow.get_by_id(x_api_helper, <id>)
Expand All @@ -284,3 +277,16 @@ This module is used to interact with the Xurrent API. It provides a set of class
workflow.close(completion_reason="withdrawn", note="This is a test note")

```

### Bulk Export
```python
import csv
import io

#Request a bulk export of "people"
csvdata = x_api_helper.bulk_export("people")

#Iterate fetched export rows with the csv library, where row 1 defines the column names
for row in csv.DictReader(io.StringIO(csvdata)):
print(row["Employee Number"])
```
63 changes: 51 additions & 12 deletions src/xurrent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ def __init__(self, base_url, api_key, api_account,resolve_user=True, logger: Log
self.logger = logger
else:
self.logger = self.create_logger(False)
#Create a requests session to maintain persistent connections, with preset headers
self.__session = requests.Session()
self.__session.headers.update({
'Authorization': f'Bearer {self.api_key}',
'x-xurrent-account': self.api_account
})
if resolve_user:
# Import Person lazily
from .people import Person
Expand Down Expand Up @@ -130,39 +136,35 @@ def set_log_level(self, level: LogLevel):
handler.setLevel(level)


def api_call(self, uri: str, method='GET', data=None, per_page=100):
def api_call(self, uri: str, method='GET', data=None, per_page=100, raw=False):
"""
Make a call to the Xurrent API with support for rate limiting and pagination.
:param uri: URI to call
:param method: HTTP method to use
:param method: HTTP method to use (default: GET)
:param data: Data to send with the request (optional)
:param per_page: Number of records per page for GET requests (default: 100)
:param per_page: Number of records per page for GET requests, setting to 0/None disables pagination (default: 100)
:param raw: Do not process the request result, e.g. in the case of non-JSON data (default: False)
:return: JSON response from the API or aggregated data for paginated GET
"""
# Ensure the base URL is included in the URI
if not uri.startswith(self.base_url):
# Ensure the base URL is included in the URI, if no protocol (https://) specified
if not uri.startswith(self.base_url) and "://" not in uri[:10]:
uri = f'{self.base_url}{uri}'

headers = {
'Authorization': f'Bearer {self.api_key}',
'x-xurrent-account': self.api_account
}

aggregated_data = []
next_page_url = uri

while next_page_url:
try:
# Append pagination parameters for GET requests
if method == 'GET':
if per_page and method == 'GET':
# if contains ? or does not end with /, append per_page
next_page_url = self.__append_per_page(next_page_url, per_page)

# Log the request
self.logger.debug(f'{method} {next_page_url} {data if method != "GET" else ""}')

# Make the HTTP request
response = requests.request(method, next_page_url, headers=headers, json=data)
response = self.__session.request(method, next_page_url, json=data)

if response.status_code == 204:
return None
Expand All @@ -179,6 +181,10 @@ def api_call(self, uri: str, method='GET', data=None, per_page=100):
self.logger.error(f'Error in request: {response.status_code} - {response.text}')
response.raise_for_status()

#Stop here if we shall not process or interperet the returned data
if raw:
return response.content

# Process response
response_data = response.json()

Expand Down Expand Up @@ -206,6 +212,39 @@ def api_call(self, uri: str, method='GET', data=None, per_page=100):
# Return aggregated results for paginated GET
return aggregated_data

def bulk_export(self, type: str, export_format='csv', save_as=None, poll_timeout=5):
"""
Make a call to the Xurrent API to perform a bulk export
:param type: Resource type(s) to download, comma-delimited
:param export_format: either 'csv' or 'xlsx' (Default: csv)
:param save_as: Save the results to a file instead of returning the raw result
:param poll_timeout: Seconds to wait between export result polls (Default: 5 seconds)
:return: CSV or XSLX data from the export, ZIP if multiple types supplied
"""

#Initiate an export and get the polling token
export = self.api_call('/export', method = 'POST', data = dict(type = type, export_format = export_format))

#Begin export results poll waiting loop
while True:
self.logger.debug('Export poll wait.')
time.sleep(poll_timeout)
result = self.api_call(f"/export/{export['token']}", per_page = None)
if result['state'] in ('queued','processing'):
continue
if result['state'] == 'done':
break
self.logger.error(f'Export request failed: {result=}')
raise

#Save or Return the exported data
result = self.api_call(result["url"], per_page = None, raw = True)
if save_as:
with open(save_as, 'wb') as file:
file.write(result)
return True
return result

def custom_fields_to_object(self, custom_fields):
"""
Convert a list of custom fields to a dictionary.
Expand Down