diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 71fe7c4..98187ed 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,10 +1,31 @@ -name: External Worker Build and Test +name: Flowable External Client Build and Test on: [push, pull_request] jobs: - build: + test-external-worker-client: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12.0"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - run: pip install virtualenv + - run: virtualenv venv + - run: source venv/bin/activate + - run: pip install setuptools + - run: pip install -e ".[testing]" + working-directory: ./external-worker/ + - run: python -m unittest discover + working-directory: ./external-worker/ + test-robocorp-client: runs-on: ubuntu-latest strategy: matrix: @@ -22,4 +43,10 @@ jobs: - run: virtualenv venv - run: source venv/bin/activate - run: pip install setuptools - - run: python setup.py pytest + - run: python setup.py install + working-directory: ./external-worker/ + - run: pip install -e ".[testing]" + working-directory: ./robocorp/ + - run: cp -a external-worker/flowable/external_worker_client robocorp/flowable/ # we need to copy this module over, since it's only searching locally for modules + - run: python -m unittest discover + working-directory: ./robocorp/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a280cd2..0b04614 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,38 @@ jobs: - run: source venv/bin/activate - run: pip install setuptools - run: python setup.py sdist + working-directory: ./external-worker/ - run: pip wheel --no-deps . -w dist + working-directory: ./external-worker/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - packages-dir: dist/ + packages-dir: external-worker/dist/ + verbose: true + + build-robocorp-client: + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12.0 + uses: actions/setup-python@v4 + with: + python-version: 3.12.0 + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - run: pip install virtualenv + - run: virtualenv venv + - run: source venv/bin/activate + - run: pip install setuptools + - run: python setup.py sdist + working-directory: ./robocorp/ + - run: pip wheel --no-deps . -w dist + working-directory: ./robocorp/ + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: robocorp/dist/ verbose: true diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index ad9b539..f7fb4db 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -6,7 +6,7 @@ on: - v* jobs: - build: + build-external-worker-client: runs-on: ubuntu-latest environment: release permissions: @@ -24,10 +24,40 @@ jobs: - run: source venv/bin/activate - run: pip install setuptools - run: python setup.py sdist + working-directory: ./external-worker/ - run: pip wheel --no-deps . -w dist + working-directory: ./external-worker/ - name: Publish package distributions to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ - packages-dir: dist/ + packages-dir: external-worker/dist/ + verbose: true + + build-robocorp-client: + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12.0 + uses: actions/setup-python@v4 + with: + python-version: 3.12.0 + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - run: pip install virtualenv + - run: virtualenv venv + - run: source venv/bin/activate + - run: pip install setuptools + - run: python setup.py sdist + working-directory: ./robocorp/ + - run: pip wheel --no-deps . -w dist + working-directory: ./robocorp/ + - name: Publish package distributions to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: robocorp/dist/ verbose: true diff --git a/README.md b/README.md index 5ad614d..9b4f428 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,12 @@ -# Flowable External Worker Library for Python - +# Flowable External Client Python [License: ![license](https://img.shields.io/hexpm/l/plug.svg)](https://github.com/flowable/flowable-external-client-python/blob/main/LICENSE) ![Flowable Actions CI](https://github.com/flowable/flowable-external-client-python/actions/workflows/main.yml/badge.svg?branch=main) -An _External Worker Task_ in BPMN or CMMN is a task where the custom logic of that task is executed externally to Flowable, i.e. on another server. -When the process or case engine arrives at such a task, it will create an **external job**, which is exposed over the REST API. -Through this REST API, the job can be acquired and locked. -Once locked, the custom logic is responsible for signalling over REST that the work is done and the process or case can continue. - -This project makes implementing such custom logic in Python easy by not having the worry about the low-level details of the REST API and focus on the actual custom business logic. -Integrations for other languages are available, too. - -## Authentication - -The different ways to authenticate are explained in the [documentation of the underlying requests HTTP library which is used to connect to Flowable](https://requests.readthedocs.io/en/latest/user/authentication/). -The `ExternalWorkerClient` accepts as a parameter `auth` an implementation of `requests.auth.AuthBase`. -There are default implementations for example for basic authentication e.g. `HTTPBasicAuth("admin", "test")`. -Flowable offers a bearer token implementation `FlowableCloudToken` which allows to specify an access token to the Flowable Cloud offering. - -## Installation - -To install the external worker library, execute the following command: - -``` -pip install flowable.external-worker-client -``` - -## Sample - -### Cloud - -The usage with Flowable Cloud is simpler, since everything is pre-configured. -However, it's required to either use the user credentials or to pre-configure a personal access token. - -```python -from flowable.external_worker_client import ExternalWorkerClient -from flowable.external_worker_client.cloud_token import FlowableCloudToken - -client = ExternalWorkerClient(auth=FlowableCloudToken("")) - -def my_callback(job, worker_result_builder): - print('Executed job: ' + job.id) - return worker_result_builder.success() - -subscription = client.subscribe('myTopic', my_callback) -``` - -### Local - -The following is an example how you can connect to a Flowable instance running at `http://localhost:8090` and process all messages retrieved on the topic `myTopic`: - -```python -from flowable.external_worker_client import ExternalWorkerClient -from requests.auth import HTTPBasicAuth - -client = ExternalWorkerClient('http://localhost:8090/flowable-work', auth=HTTPBasicAuth("admin", "test")) - -def my_callback(job, worker_result_builder): - print('Executed job: ' + job.id) - return worker_result_builder.success() - -subscription = client.subscribe('myTopic', my_callback) -``` +This is a collection of multiple Flowable Python clients. +Currently, the project consists out of: +* [External Worker Client](./external-worker): A library to connect custom application code to Flowable with the external worker functionality +* [Robocorp Client](./robocorp-client): A python module to execute Robocorp actions and tasks with the Flowable Robocorp task (based on the external worker). \ No newline at end of file diff --git a/external-worker/README.md b/external-worker/README.md new file mode 100644 index 0000000..b1299c8 --- /dev/null +++ b/external-worker/README.md @@ -0,0 +1,67 @@ +# Flowable External Worker Library for Python + + +[License: +![license](https://img.shields.io/hexpm/l/plug.svg)](https://github.com/flowable/flowable-external-client-python/blob/main/LICENSE) + +![Flowable Actions CI](https://github.com/flowable/flowable-external-client-python/actions/workflows/main.yml/badge.svg?branch=main) + +An _External Worker Task_ in BPMN or CMMN is a task where the custom logic of that task is executed externally to Flowable, i.e. on another server. +When the process or case engine arrives at such a task, it will create an **external job**, which is exposed over the REST API. +Through this REST API, the job can be acquired and locked. +Once locked, the custom logic is responsible for signalling over REST that the work is done and the process or case can continue. + +This project makes implementing such custom logic in Python easy by not having the worry about the low-level details of the REST API and focus on the actual custom business logic. +Integrations for other languages are available, too. + +## Authentication + +The different ways to authenticate are explained in the [documentation of the underlying requests HTTP library which is used to connect to Flowable](https://requests.readthedocs.io/en/latest/user/authentication/). +The `ExternalWorkerClient` accepts as a parameter `auth` an implementation of `requests.auth.AuthBase`. +There are default implementations for example for basic authentication e.g. `HTTPBasicAuth("admin", "test")`. +Flowable offers a bearer token implementation `FlowableCloudToken` which allows to specify an access token to the Flowable Cloud offering. + +## Installation + +To install the external worker library, execute the following command: + +``` +pip install flowable.external-worker-client +``` + +## Sample + +### Cloud + +The usage with Flowable Cloud is simpler, since everything is pre-configured. +However, it's required to either use the user credentials or to pre-configure a personal access token. + +```python +from flowable.external_worker_client import ExternalWorkerClient +from flowable.external_worker_client.cloud_token import FlowableCloudToken + +client = ExternalWorkerClient(auth=FlowableCloudToken("")) + +def my_callback(job, worker_result_builder): + print('Executed job: ' + job.id) + return worker_result_builder.success() + +subscription = client.subscribe('myTopic', my_callback) +``` + +### Local + +The following is an example how you can connect to a Flowable instance running at `http://localhost:8090` and process all messages retrieved on the topic `myTopic`: + +```python +from flowable.external_worker_client import ExternalWorkerClient +from requests.auth import HTTPBasicAuth + +client = ExternalWorkerClient('http://localhost:8090/flowable-work', auth=HTTPBasicAuth("admin", "test")) + +def my_callback(job, worker_result_builder): + print('Executed job: ' + job.id) + return worker_result_builder.success() + +subscription = client.subscribe('myTopic', my_callback) +``` diff --git a/flowable/__init__.py b/external-worker/flowable/__init__.py similarity index 100% rename from flowable/__init__.py rename to external-worker/flowable/__init__.py diff --git a/flowable/external_worker_client/__init__.py b/external-worker/flowable/external_worker_client/__init__.py similarity index 91% rename from flowable/external_worker_client/__init__.py rename to external-worker/flowable/external_worker_client/__init__.py index f4ea696..33e775b 100644 --- a/flowable/external_worker_client/__init__.py +++ b/external-worker/flowable/external_worker_client/__init__.py @@ -1,3 +1,4 @@ +import sys import threading import time from os import getpid @@ -52,7 +53,7 @@ class ExternalWorkerClient(object): def __init__( self, - flowable_host: str = "https://cloud.flowable.com/work", + flowable_host: str = "https://trial.flowable.com/work", worker_id: str = None, auth: AuthBase = None, customize_session: Callable[[Session], None] = lambda session: None @@ -103,5 +104,7 @@ def _consume( result.execute(self._restClient) else: self._restClient.complete_job(job.id) - except: - self._restClient.fail_job(job.id) + except Exception as e: + print("An error occurred during job execution " + job.id, file=sys.stderr) + print(e, file=sys.stderr) + self._restClient.fail_job(job.id, e.__str__()) diff --git a/flowable/external_worker_client/cloud_token.py b/external-worker/flowable/external_worker_client/cloud_token.py similarity index 100% rename from flowable/external_worker_client/cloud_token.py rename to external-worker/flowable/external_worker_client/cloud_token.py diff --git a/flowable/external_worker_client/engine_rest_variable.py b/external-worker/flowable/external_worker_client/engine_rest_variable.py similarity index 100% rename from flowable/external_worker_client/engine_rest_variable.py rename to external-worker/flowable/external_worker_client/engine_rest_variable.py diff --git a/flowable/external_worker_client/external_worker_acquire_job_response.py b/external-worker/flowable/external_worker_client/external_worker_acquire_job_response.py similarity index 100% rename from flowable/external_worker_client/external_worker_acquire_job_response.py rename to external-worker/flowable/external_worker_client/external_worker_acquire_job_response.py diff --git a/flowable/external_worker_client/external_worker_job_response.py b/external-worker/flowable/external_worker_client/external_worker_job_response.py similarity index 100% rename from flowable/external_worker_client/external_worker_job_response.py rename to external-worker/flowable/external_worker_client/external_worker_job_response.py diff --git a/flowable/external_worker_client/flowable_rest_exception.py b/external-worker/flowable/external_worker_client/flowable_rest_exception.py similarity index 100% rename from flowable/external_worker_client/flowable_rest_exception.py rename to external-worker/flowable/external_worker_client/flowable_rest_exception.py diff --git a/flowable/external_worker_client/list_result.py b/external-worker/flowable/external_worker_client/list_result.py similarity index 100% rename from flowable/external_worker_client/list_result.py rename to external-worker/flowable/external_worker_client/list_result.py diff --git a/flowable/external_worker_client/request_converter.py b/external-worker/flowable/external_worker_client/request_converter.py similarity index 100% rename from flowable/external_worker_client/request_converter.py rename to external-worker/flowable/external_worker_client/request_converter.py diff --git a/flowable/external_worker_client/response_converter.py b/external-worker/flowable/external_worker_client/response_converter.py similarity index 100% rename from flowable/external_worker_client/response_converter.py rename to external-worker/flowable/external_worker_client/response_converter.py diff --git a/flowable/external_worker_client/restclient.py b/external-worker/flowable/external_worker_client/restclient.py similarity index 99% rename from flowable/external_worker_client/restclient.py rename to external-worker/flowable/external_worker_client/restclient.py index 8ea9480..100fbb0 100644 --- a/flowable/external_worker_client/restclient.py +++ b/external-worker/flowable/external_worker_client/restclient.py @@ -85,6 +85,7 @@ def acquire_jobs( } ) if r.status_code != 200: + print(r.text) raise FlowableRestException(r.status_code, r.text) return list(map(response_converter.convert_to_external_worker_acquire_job_response, r.json())) diff --git a/flowable/external_worker_client/utils.py b/external-worker/flowable/external_worker_client/utils.py similarity index 100% rename from flowable/external_worker_client/utils.py rename to external-worker/flowable/external_worker_client/utils.py diff --git a/flowable/external_worker_client/worker_result.py b/external-worker/flowable/external_worker_client/worker_result.py similarity index 100% rename from flowable/external_worker_client/worker_result.py rename to external-worker/flowable/external_worker_client/worker_result.py diff --git a/setup.py b/external-worker/setup.py similarity index 75% rename from setup.py rename to external-worker/setup.py index 98a61ca..d684bb7 100644 --- a/setup.py +++ b/external-worker/setup.py @@ -1,22 +1,22 @@ -from setuptools import find_packages, setup - # read the contents of your README file from pathlib import Path +from setuptools import setup + this_directory = Path(__file__).parent long_description = (this_directory / "README.md").read_text() setup( name='flowable.external-worker-client', packages=['flowable', 'flowable.external_worker_client'], - version='1.0.0', + version='1.0.1rc1', description='Flowable External Worker Library to connect Python code to Flowable using an external worker.', long_description=long_description, long_description_content_type='text/markdown', author='Flowable', - license='', + license='Apache License, Version 2.0', install_requires=['requests>=2.27.0'], - setup_requires=['pytest-runner'], - tests_require=['pytest', 'vcrpy'], - test_suite='tests', + extras_require={ + 'testing': ['pytest', 'vcrpy'] + }, ) diff --git a/tests/__init__.py b/external-worker/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to external-worker/tests/__init__.py diff --git a/tests/basic_test.py b/external-worker/tests/basic_test.py similarity index 100% rename from tests/basic_test.py rename to external-worker/tests/basic_test.py diff --git a/tests/bpmn_utils.py b/external-worker/tests/bpmn_utils.py similarity index 100% rename from tests/bpmn_utils.py rename to external-worker/tests/bpmn_utils.py diff --git a/tests/cmmn_utils.py b/external-worker/tests/cmmn_utils.py similarity index 100% rename from tests/cmmn_utils.py rename to external-worker/tests/cmmn_utils.py diff --git a/tests/fixtures/cases/externalWorkerCase.cmmn b/external-worker/tests/fixtures/cases/externalWorkerCase.cmmn similarity index 100% rename from tests/fixtures/cases/externalWorkerCase.cmmn rename to external-worker/tests/fixtures/cases/externalWorkerCase.cmmn diff --git a/tests/fixtures/cassettes/test_acquire_jobs.yml b/external-worker/tests/fixtures/cassettes/test_acquire_jobs.yml similarity index 100% rename from tests/fixtures/cassettes/test_acquire_jobs.yml rename to external-worker/tests/fixtures/cassettes/test_acquire_jobs.yml diff --git a/tests/fixtures/cassettes/test_complete_job.yml b/external-worker/tests/fixtures/cassettes/test_complete_job.yml similarity index 100% rename from tests/fixtures/cassettes/test_complete_job.yml rename to external-worker/tests/fixtures/cassettes/test_complete_job.yml diff --git a/tests/fixtures/cassettes/test_consume.yml b/external-worker/tests/fixtures/cassettes/test_consume.yml similarity index 100% rename from tests/fixtures/cassettes/test_consume.yml rename to external-worker/tests/fixtures/cassettes/test_consume.yml diff --git a/tests/fixtures/cassettes/test_consume_with_bpmn_error.yml b/external-worker/tests/fixtures/cassettes/test_consume_with_bpmn_error.yml similarity index 100% rename from tests/fixtures/cassettes/test_consume_with_bpmn_error.yml rename to external-worker/tests/fixtures/cassettes/test_consume_with_bpmn_error.yml diff --git a/tests/fixtures/cassettes/test_consume_with_cmmn_terminate.yml b/external-worker/tests/fixtures/cassettes/test_consume_with_cmmn_terminate.yml similarity index 100% rename from tests/fixtures/cassettes/test_consume_with_cmmn_terminate.yml rename to external-worker/tests/fixtures/cassettes/test_consume_with_cmmn_terminate.yml diff --git a/tests/fixtures/cassettes/test_fail_job.yml b/external-worker/tests/fixtures/cassettes/test_fail_job.yml similarity index 100% rename from tests/fixtures/cassettes/test_fail_job.yml rename to external-worker/tests/fixtures/cassettes/test_fail_job.yml diff --git a/tests/fixtures/cassettes/test_get_job.yml b/external-worker/tests/fixtures/cassettes/test_get_job.yml similarity index 100% rename from tests/fixtures/cassettes/test_get_job.yml rename to external-worker/tests/fixtures/cassettes/test_get_job.yml diff --git a/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_1_execute_first.yml b/external-worker/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_1_execute_first.yml similarity index 100% rename from tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_1_execute_first.yml rename to external-worker/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_1_execute_first.yml diff --git a/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_2_execute_second.yml b/external-worker/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_2_execute_second.yml similarity index 100% rename from tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_2_execute_second.yml rename to external-worker/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_2_execute_second.yml diff --git a/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_execute_first.yml b/external-worker/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_execute_first.yml similarity index 100% rename from tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_execute_first.yml rename to external-worker/tests/fixtures/cassettes/test_job_with_bpmn_error_without_error_code_execute_first.yml diff --git a/tests/fixtures/cassettes/test_job_with_cmmn_terminate.yml b/external-worker/tests/fixtures/cassettes/test_job_with_cmmn_terminate.yml similarity index 100% rename from tests/fixtures/cassettes/test_job_with_cmmn_terminate.yml rename to external-worker/tests/fixtures/cassettes/test_job_with_cmmn_terminate.yml diff --git a/tests/fixtures/cassettes/test_list_jobs.yml b/external-worker/tests/fixtures/cassettes/test_list_jobs.yml similarity index 100% rename from tests/fixtures/cassettes/test_list_jobs.yml rename to external-worker/tests/fixtures/cassettes/test_list_jobs.yml diff --git a/tests/fixtures/cassettes/test_list_jobs_customize_session.yml b/external-worker/tests/fixtures/cassettes/test_list_jobs_customize_session.yml similarity index 100% rename from tests/fixtures/cassettes/test_list_jobs_customize_session.yml rename to external-worker/tests/fixtures/cassettes/test_list_jobs_customize_session.yml diff --git a/tests/fixtures/cassettes/test_list_jobs_invalid_auth.yml b/external-worker/tests/fixtures/cassettes/test_list_jobs_invalid_auth.yml similarity index 100% rename from tests/fixtures/cassettes/test_list_jobs_invalid_auth.yml rename to external-worker/tests/fixtures/cassettes/test_list_jobs_invalid_auth.yml diff --git a/tests/fixtures/cassettes/test_subscribe_with_bpmn_error.yml b/external-worker/tests/fixtures/cassettes/test_subscribe_with_bpmn_error.yml similarity index 95% rename from tests/fixtures/cassettes/test_subscribe_with_bpmn_error.yml rename to external-worker/tests/fixtures/cassettes/test_subscribe_with_bpmn_error.yml index 4ff5326..0a30bcb 100644 --- a/tests/fixtures/cassettes/test_subscribe_with_bpmn_error.yml +++ b/external-worker/tests/fixtures/cassettes/test_subscribe_with_bpmn_error.yml @@ -118,8 +118,6 @@ interactions: - no-cache Set-Cookie: - XSRF-TOKEN=7315a015-53c4-4c28-ba52-4b070af2e92d; Path=/ - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -169,8 +167,6 @@ interactions: - no-cache Set-Cookie: - XSRF-TOKEN=43804e08-2ad4-4474-9aab-f1705f53da99; Path=/ - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -224,8 +220,6 @@ interactions: - no-cache Set-Cookie: - XSRF-TOKEN=0c1488a0-a248-4d13-9c37-d802fb441c1f; Path=/ - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -280,8 +274,6 @@ interactions: - timeout=60 Pragma: - no-cache - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -383,8 +375,6 @@ interactions: - timeout=60 Pragma: - no-cache - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -481,8 +471,6 @@ interactions: - no-cache Set-Cookie: - XSRF-TOKEN=1f64bb78-a592-4dfc-af73-38f79444b198; Path=/ - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -533,8 +521,6 @@ interactions: - no-cache Set-Cookie: - XSRF-TOKEN=9a7c42f5-b841-4b82-8b3c-b867e57abe63; Path=/ - Transfer-Encoding: - - chunked X-Content-Type-Options: - nosniff X-Frame-Options: @@ -592,4 +578,59 @@ interactions: status: code: 204 message: '' +- request: + body: '{"topic": "myTopic", "lockDuration": "PT1M", "numberOfTasks": 1, "numberOfRetries": + 5, "workerId": "test-worker", "scopeType": null}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + Content-Length: + - '132' + Content-Type: + - application/json + Cookie: + - XSRF-TOKEN=94293f77-1ef7-4280-8e87-c6e3aec5682f + User-Agent: + - python-requests/2.32.3 + method: POST + uri: http://localhost:8090/external-job-api/acquire/jobs + response: + body: + string: '[]' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Sat, 07 Sep 2024 09:03:21 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '2' + status: + code: 200 + message: '' version: 1 diff --git a/tests/fixtures/processes/externalWorkerProcess.bpmn b/external-worker/tests/fixtures/processes/externalWorkerProcess.bpmn similarity index 100% rename from tests/fixtures/processes/externalWorkerProcess.bpmn rename to external-worker/tests/fixtures/processes/externalWorkerProcess.bpmn diff --git a/tests/fixtures/processes/withBoundaryEvent.bpmn b/external-worker/tests/fixtures/processes/withBoundaryEvent.bpmn similarity index 100% rename from tests/fixtures/processes/withBoundaryEvent.bpmn rename to external-worker/tests/fixtures/processes/withBoundaryEvent.bpmn diff --git a/tests/test_init.py b/external-worker/tests/test_init.py similarity index 100% rename from tests/test_init.py rename to external-worker/tests/test_init.py diff --git a/tests/test_restclient.py b/external-worker/tests/test_restclient.py similarity index 100% rename from tests/test_restclient.py rename to external-worker/tests/test_restclient.py diff --git a/tests/vcr.py b/external-worker/tests/vcr.py similarity index 100% rename from tests/vcr.py rename to external-worker/tests/vcr.py diff --git a/robocorp/README.md b/robocorp/README.md new file mode 100644 index 0000000..7cb219b --- /dev/null +++ b/robocorp/README.md @@ -0,0 +1,74 @@ +from typing import Dict# Flowable Robocorp Client + +[License: +![license](https://img.shields.io/hexpm/l/plug.svg)](https://github.com/flowable/flowable-external-client-python/blob/main/LICENSE) + +![Flowable Actions CI](https://github.com/flowable/flowable-external-client-python/actions/workflows/main.yml/badge.svg?branch=main) + +This is a python module to connect a Flowable installation through an external worker with a Robocorp task or action. +This allows to execute a Robocorp task/action from a business process with BPMN or a case diagram CMMN. +The application is started with a task/action definition from Robocorp and connects to an external worker topic. +When using the Robocorp task in Flowable, it is possible to specify a task/action name. +In the task, the specific task/action specified will be executed and the result (only for actions) will be sent back to the process. +It is required that the variables in the diagram match exactly the variables inside the robocorp task/action. + +## Installation + +To install the Flowable Robocorp client, execute the following command: + +``` +pip install flowable.robocorp-client +``` + +## Sample + +The following [diagram BPMN](docs/robocorpExample.bpmn) illustrates a basic usage of the Robocorp task: + +![Simple BPMN diagram with a start event, a robocorp task and an end event](docs/robocorpExample.png) + +The following example `get-weather-forecast.py` can be used as a Robocorp action: +```python +from robocorp.actions import action + +@action +def get_weather_forecast(city: str, days: int, scale: str = "celsius") -> dict[str, str]: + """ + Returns weather conditions forecast for a given city. + + Args: + city (str): Target city to get the weather conditions for + days: How many day forecast to return + scale (str): Temperature scale to use, should be one of "celsius" or "fahrenheit" + + Returns: + object: The requested weather conditions forecast with the temperature as one element + """ + + # your implementation goes here... + + return { + "temperature": "the weather will be nice" + } +``` + +The Robocorp worker can be started with the following command: +```sh +python -m flowable.robocorp_client --flowable-token myTopic action get-weather-forecast.py +``` + +You can request `` at the [Flowable Trial](https://trial.flowable.com/work/) by clicking at the bottom left on your user and go to Settings. +Once in the settings, choose "Access Token" and press the button "New token". +After providing name and the validity, the token is generated and can be set. + +Once the module is started and a process instance is created, it will automatically execute the Robocorp action and returns the result to Flowable. +The Python module will keep running until stopped to process potential additional jobs. + +## Authentication + +Next to the cloud authentication, it's possible to authenticate against other hosts. +The hostname can be specified with `--flowable-host`. + +For the authentication there are two possibilities: + +1. Providing a bearer token with the `--flowable-token ` attribute. +2. Providing basic authentication with `--flowable-username ` and `--flowable-password ` diff --git a/robocorp/docs/robocorpExample.bpmn b/robocorp/docs/robocorpExample.bpmn new file mode 100644 index 0000000..b9d47e1 --- /dev/null +++ b/robocorp/docs/robocorpExample.bpmn @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/robocorp/docs/robocorpExample.png b/robocorp/docs/robocorpExample.png new file mode 100644 index 0000000..c329be3 Binary files /dev/null and b/robocorp/docs/robocorpExample.png differ diff --git a/robocorp/flowable/.gitignore b/robocorp/flowable/.gitignore new file mode 100644 index 0000000..a5fedf3 --- /dev/null +++ b/robocorp/flowable/.gitignore @@ -0,0 +1 @@ +external_worker_client \ No newline at end of file diff --git a/robocorp/flowable/__init__.py b/robocorp/flowable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/robocorp/flowable/robocorp_client/__init__.py b/robocorp/flowable/robocorp_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/robocorp/flowable/robocorp_client/__main__.py b/robocorp/flowable/robocorp_client/__main__.py new file mode 100644 index 0000000..41aa158 --- /dev/null +++ b/robocorp/flowable/robocorp_client/__main__.py @@ -0,0 +1,37 @@ +import argparse + +from requests.auth import HTTPBasicAuth + +from flowable.external_worker_client import ExternalWorkerClient +from flowable.external_worker_client.cloud_token import FlowableCloudToken +from flowable.robocorp_client.robocorp_handler import RobocorpActionHandler, RobocorpTaskHandler + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Flowable Robocorp Client") + + parser.add_argument('topic', help='Topic of the Robocorp Action to listen to') + parser.add_argument('mode', type=str, choices=['action', 'task'], help='Type of robocorp action/task') + parser.add_argument('path', type=str, help='The directory or file with the actions/tasks to run.') + parser.add_argument('--flowable-host', type=str, default='https://trial.flowable.com', help='URL of Flowable Work') + parser.add_argument('--flowable-token', type=str, help='Bearer Token, can be used for example with the Flowable Trial') + parser.add_argument('--flowable-username', type=str, help='Username for Flowable Work when using Basic Authentication') + parser.add_argument('--flowable-password', type=str, help='Password for Flowable Work when using Basic Authentication') + + args = parser.parse_args() + + topic = args.topic + robocorp_action_file = args.path + auth = None + if args.flowable_token: + auth = FlowableCloudToken(args.flowable_token) + elif args.flowable_username and args.flowable_password: + auth = HTTPBasicAuth(args.flowable_username, args.flowable_password) + + print('---> Start consuming for topic "' + topic + '"') + client = ExternalWorkerClient(args.flowable_host, auth=auth) + + if args.mode == 'action': + robocorp_job_handler = RobocorpActionHandler(robocorp_action_file) + else: + robocorp_job_handler = RobocorpTaskHandler(robocorp_action_file) + subscription = client.subscribe(topic, robocorp_job_handler.handle_task) diff --git a/robocorp/flowable/robocorp_client/call_robocorp.py b/robocorp/flowable/robocorp_client/call_robocorp.py new file mode 100644 index 0000000..39a5000 --- /dev/null +++ b/robocorp/flowable/robocorp_client/call_robocorp.py @@ -0,0 +1,12 @@ +import subprocess +import sys + + +def call_robocorp(args, mod_name='robocorp.actions'): + call_args = [sys.executable, "-m", mod_name] + call_args.extend(args) + try: + return subprocess.run(call_args, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + print("ERROR: failed to call robocorp", exc.returncode, exc.output) + diff --git a/robocorp/flowable/robocorp_client/robocorp_handler.py b/robocorp/flowable/robocorp_client/robocorp_handler.py new file mode 100644 index 0000000..1102b16 --- /dev/null +++ b/robocorp/flowable/robocorp_client/robocorp_handler.py @@ -0,0 +1,102 @@ +import ast +import json +import shlex + +from flowable.external_worker_client import ExternalWorkerAcquireJobResponse, WorkerResultBuilder +from flowable.robocorp_client.call_robocorp import call_robocorp + + +def extract_action_and_parameters(job): + action = None + params = [] + for variable in job.variables: + if variable.name == '__robocorpTaskName': + action = variable.value + else: + if variable.value is not None: + params.append('--' + shlex.quote(variable.name) + '=' + shlex.quote(variable.value.__str__())) + return action, params + + +def extract_result(results): + result_item = None + lines = results.strip().splitlines() + for line in lines: + parsed = json.loads(line) + if parsed['message_type'] == 'R': + result_item = parsed + result = None + if result_item is not None: + result = result_item['value'] + return result + + +def create_output_dir(job): + return 'output/' + job.id + '-' + (job.scope_id or job.process_instance_id) + '-' + job.element_id + + +def add_variables_to_result(work_result, result): + json_result = ast.literal_eval(result) + + if isinstance(json_result, dict): + for key in json_result: + value = json_result[key] + if isinstance(value, str): + work_result.variable_string(key, value) + elif isinstance(value, int): + work_result.variable_int(key, value) + elif isinstance(value, float): + work_result.variable_float(key, value) + elif isinstance(value, bool): + work_result.variable_boolean(key, value) + elif isinstance(value, object): + work_result.variable_json(key, value) + else: + work_result.variable_string(key, str(value)) + else: + work_result.variable_json('result', json_result) + return work_result + + +class RobocorpActionHandler: + def __init__(self, robocorp_action_file: str): + self.robocorp_action_file = robocorp_action_file + + def handle_task(self, job: ExternalWorkerAcquireJobResponse, worker_result_builder: WorkerResultBuilder): + action, params = extract_action_and_parameters(job) + if action is None: + return worker_result_builder.failure().error_message('failed to find robocorp action name') + + output_dir = create_output_dir(job) + robocorp_args = ['run', self.robocorp_action_file, '-a' + action.__str__(), '--log-output-to-stdout=json', '-o' + output_dir, '--'] + robocorp_args.extend(params) + print('---> Execute job "' + job.id + '"', robocorp_args) + results = call_robocorp(robocorp_args) + if results.returncode != 0: + print('---> Job execution failed for "' + job.id + '". Output saved to ' + output_dir) + return worker_result_builder.failure().error_message('failed with status code ' + str(results.returncode)).error_details(results.stderr + results.stdout) + result = extract_result(results.stdout) + print('---> Job execution done for "' + job.id + '" with result ' + result + '". Output saved to ' + output_dir, robocorp_args) + work_result = worker_result_builder.success() + return add_variables_to_result(work_result, result) + + +class RobocorpTaskHandler: + def __init__(self, robocorp_action_file: str): + self.robocorp_action_file = robocorp_action_file + + def handle_task(self, job: ExternalWorkerAcquireJobResponse, worker_result_builder: WorkerResultBuilder): + action, params = extract_action_and_parameters(job) + if action is None: + return worker_result_builder.failure().error_message('failed to find robocorp action name') + + output_dir = create_output_dir(job) + robocorp_args = ['run', self.robocorp_action_file, '-t' + action.__str__(), '-o' + output_dir, '--'] + robocorp_args.extend(params) + print('---> Execute job "' + job.id + '"', robocorp_args) + results = call_robocorp(robocorp_args, mod_name='robocorp.tasks') + if results.returncode != 0: + print('---> Job execution failed for "' + job.id + '". Output saved to ' + output_dir) + return worker_result_builder.failure().error_message('failed with status code ' + str(results.returncode)).error_details(results.stderr + results.stdout) + print('---> Job execution done for "' + job.id + '". Output saved to ' + output_dir, robocorp_args) + return worker_result_builder.success() diff --git a/robocorp/setup.py b/robocorp/setup.py new file mode 100644 index 0000000..9f21e17 --- /dev/null +++ b/robocorp/setup.py @@ -0,0 +1,22 @@ +# read the contents of your README file +from pathlib import Path + +from setuptools import setup + +this_directory = Path(__file__).parent +long_description = (this_directory / "README.md").read_text() + +setup( + name='flowable.robocorp-client', + packages=['flowable', 'flowable.robocorp_client'], + version='1.0.1rc1', + description='Flowable client to be used with Robocorp. This client connects to a Flowable instance via external worker and executes robocorp tasks or actions.', + long_description=long_description, + long_description_content_type='text/markdown', + author='Flowable', + license='Apache License, Version 2.0', + install_requires=['flowable.external-worker-client>=1.0.1rc1', 'robocorp-actions>=0.2.1', 'robocorp-tasks>=3.1.1', 'robocorp-truststore>=0.9.1'], + extras_require={ + 'testing': ['pytest', 'vcrpy', 'flowable.external-worker-client>=1.0.1rc1'] + }, +) diff --git a/robocorp/tests/__init__.py b/robocorp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/robocorp/tests/basic_test.py b/robocorp/tests/basic_test.py new file mode 100644 index 0000000..1b414f4 --- /dev/null +++ b/robocorp/tests/basic_test.py @@ -0,0 +1,20 @@ +import unittest +from pathlib import Path + +from requests.auth import HTTPBasicAuth + +from tests.bpmn_utils import deploy_process, get_process_definition_id, delete_deployment + +base_url = "http://localhost:8090/flowable-work" +auth = HTTPBasicAuth("admin", "test") + + +class BasicTest(unittest.TestCase): + + def deploy_process(self, fileToDeploy='externalWorkerProcess.bpmn'): + self._process_deployment_id = deploy_process(base_url, auth, str(Path(__file__).parent.absolute()) + '/fixtures/processes/' + fileToDeploy) + self._process_definition_id = get_process_definition_id(base_url, auth, self._process_deployment_id) + + def remove_deployment(self): + if hasattr(self, "_process_deployment_id"): + delete_deployment(base_url, auth, self._process_deployment_id) diff --git a/robocorp/tests/bpmn_utils.py b/robocorp/tests/bpmn_utils.py new file mode 100644 index 0000000..9e855d3 --- /dev/null +++ b/robocorp/tests/bpmn_utils.py @@ -0,0 +1,55 @@ +import requests +from requests.auth import AuthBase + + +def deploy_process(base_url: str, auth: AuthBase, file: str) -> str: + files = {'file': open(file, 'rb')} + deployment_result = requests.post(base_url + '/process-api/repository/deployments', auth=auth, files=files) + assert deployment_result.status_code == 201 + return deployment_result.json().get("id") + + +def delete_deployment(base_url: str, auth: AuthBase, process_deployment_id: str) -> None: + delete_deployment_result = requests.delete(base_url + "/process-api/repository/deployments/" + process_deployment_id, auth=auth) + assert delete_deployment_result.status_code == 204 + + +def get_process_definition_id(base_url: str, auth: AuthBase, process_deployment_id: str) -> None: + definition_search = requests.get(base_url + "/process-api/repository/process-definitions?deploymentId=" + process_deployment_id, auth=auth) + assert definition_search.status_code == 200 + data = definition_search.json().get("data") + assert len(data) == 1 + return data[0].get("id") + + +def start_process(base_url: str, auth: AuthBase, process_definition_id: str, variables: list[object] = []) -> str: + start_result = requests.post( + base_url + "/process-api/runtime/process-instances", + auth=auth, + json={"processDefinitionId": process_definition_id, "variables": variables}, + headers={"Content-Type": "application/json"} + ) + assert start_result.status_code == 201 + return start_result.json().get("id") + + +def terminate_process(base_url: str, auth: AuthBase, process_instance_id: str) -> None: + delete_result = requests.delete(base_url + "/process-api/runtime/process-instances/" + process_instance_id, auth=auth) + assert delete_result.status_code == 204 or delete_result.status_code == 404 + + +def get_process_variable(base_url: str, auth: AuthBase, process_instance_id: str, variable_name: str) -> dict | None: + variable_result = requests.get(base_url + "/process-api/history/historic-variable-instances?processInstanceId=" + process_instance_id + "&variableName=" + variable_name, auth=auth) + assert variable_result.status_code == 200 + data = variable_result.json().get("data") + if len(data) == 1: + return data[0].get("variable") + else: + return None + + +def executed_activity_ids(base_url: str, auth: AuthBase, process_instance_id: str) -> list[str]: + activity_instances = requests.get(base_url + "/process-api/history/historic-activity-instances?processInstanceId=" + process_instance_id, auth=auth) + assert activity_instances.status_code == 200 + data = activity_instances.json().get("data") + return sorted(list(map(lambda o: o.get("activityId"), data))) diff --git a/robocorp/tests/fixtures/cassettes/test_with_custom_action.yml b/robocorp/tests/fixtures/cassettes/test_with_custom_action.yml new file mode 100644 index 0000000..80ed24a --- /dev/null +++ b/robocorp/tests/fixtures/cassettes/test_with_custom_action.yml @@ -0,0 +1,618 @@ +interactions: +- request: + body: "--7d6d74e28e95ea697b80fb2e866f3861\r\nContent-Disposition: form-data; name=\"file\"; + filename=\"robocorpExample.bpmn\"\r\n\r\n\n\n + \ \n \n + \ \n \n + \ \n + \ \n \n + \ \n \n \n \n + \ \n + \ \n \n + \ \n \n \n \n + \ \n + \ \n \n + \ \n \n \n + \ \n \n + \ \n \n \n \n + \ \n \n \n \n + \ \n \n + \ \n \n \n + \ \n + \ \n + \ \n + \ \n \n \n \n \n \n \n + \ \n \n + \ \n \n + \ \n \n + \ \n \n + \ \n \n\r\n--7d6d74e28e95ea697b80fb2e866f3861--\r\n" + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + Content-Length: + - '4594' + Content-Type: + - multipart/form-data; boundary=7d6d74e28e95ea697b80fb2e866f3861 + User-Agent: + - python-requests/2.32.3 + method: POST + uri: http://localhost:8090/flowable-work/process-api/repository/deployments + response: + body: + string: '{"id":"PRC-d7476565-7789-11ef-8757-fe6a9bab3681","name":"robocorpExample","deploymentTime":"2024-09-20T21:52:17.778+02:00","category":null,"parentDeploymentId":"PRC-d7476565-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/process-api/repository/deployments/PRC-d7476565-7789-11ef-8757-fe6a9bab3681","tenantId":""}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:17 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=62423fb1-697e-413c-9b3d-3f24f0e9780b; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '337' + status: + code: 201 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/process-api/repository/process-definitions?deploymentId=PRC-d7476565-7789-11ef-8757-fe6a9bab3681 + response: + body: + string: '{"data":[{"id":"PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/process-api/repository/process-definitions/PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","key":"robocorpExample","version":3,"name":"Robocorp + Example","description":null,"tenantId":"","deploymentId":"PRC-d7476565-7789-11ef-8757-fe6a9bab3681","deploymentUrl":"http://localhost:8090/flowable-work/process-api/repository/deployments/PRC-d7476565-7789-11ef-8757-fe6a9bab3681","resource":"http://localhost:8090/flowable-work/process-api/repository/deployments/PRC-d7476565-7789-11ef-8757-fe6a9bab3681/resources/robocorpExample.bpmn","diagramResource":null,"category":"http://flowable.org/test","graphicalNotationDefined":true,"suspended":false,"startFormDefined":false}],"total":1,"start":0,"sort":"name","order":"asc","size":1}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:17 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=73278ac4-c116-42b6-bee9-73ed4296f913; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '856' + status: + code: 200 + message: '' +- request: + body: '{"processDefinitionId": "PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681", + "variables": [{"name": "city", "type": "string", "value": "Zurich"}, {"name": + "days", "type": "integer", "value": 3}]}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + Content-Length: + - '204' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.3 + method: POST + uri: http://localhost:8090/flowable-work/process-api/runtime/process-instances + response: + body: + string: '{"id":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/process-api/runtime/process-instances/PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","name":null,"businessKey":null,"businessStatus":null,"suspended":false,"ended":false,"processDefinitionId":"PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","processDefinitionUrl":"http://localhost:8090/flowable-work/process-api/repository/process-definitions/PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","processDefinitionName":"Robocorp + Example","processDefinitionDescription":null,"activityId":null,"startUserId":"admin","startTime":"2024-09-20T21:52:18.044+02:00","superProcessInstanceId":null,"variables":[{"name":"city","type":"string","value":"Zurich","scope":"local"},{"name":"initiator","type":"string","value":"admin","scope":"local"},{"name":"days","type":"integer","value":3,"scope":"local"}],"callbackId":null,"callbackType":null,"referenceId":null,"referenceType":null,"propagatedStageInstanceId":null,"tenantId":"","completed":false}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:17 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=051033f2-6205-488e-84e9-aa4140c00722; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '1046' + status: + code: 201 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/external-job-api/jobs?processInstanceId=PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681 + response: + body: + string: '{"data":[{"id":"JOB-d7707145-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681","correlationId":"d7707144-7789-11ef-8757-fe6a9bab3681","processInstanceId":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","processDefinitionId":"PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","executionId":"PRC-d770231e-7789-11ef-8757-fe6a9bab3681","scopeId":null,"subScopeId":null,"scopeDefinitionId":null,"scopeType":null,"elementId":"bpmnTask_1","elementName":"Get + Weather Forecast","retries":3,"exceptionMessage":null,"dueDate":null,"createTime":"2024-09-20T19:52:18.047Z","tenantId":"","lockOwner":null,"lockExpirationTime":null}],"total":1,"start":0,"sort":"id","order":"asc","size":1}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:17 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=8d8dec72-f0fc-4452-a82c-a18f107e41c9; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '765' + status: + code: 200 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681?i=0 + response: + body: + string: '{"id":"JOB-d7707145-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681","correlationId":"d7707144-7789-11ef-8757-fe6a9bab3681","processInstanceId":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","processDefinitionId":"PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","executionId":"PRC-d770231e-7789-11ef-8757-fe6a9bab3681","scopeId":null,"subScopeId":null,"scopeDefinitionId":null,"scopeType":null,"elementId":"bpmnTask_1","elementName":"Get + Weather Forecast","retries":3,"exceptionMessage":null,"dueDate":null,"createTime":"2024-09-20T19:52:18.047Z","tenantId":"","lockOwner":null,"lockExpirationTime":null}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:17 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=29769a42-9c6e-476b-8e5c-924e7cdf37fd; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '699' + status: + code: 200 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681?i=1 + response: + body: + string: '{"id":"JOB-d7707145-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681","correlationId":"d7707144-7789-11ef-8757-fe6a9bab3681","processInstanceId":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","processDefinitionId":"PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","executionId":"PRC-d770231e-7789-11ef-8757-fe6a9bab3681","scopeId":null,"subScopeId":null,"scopeDefinitionId":null,"scopeType":null,"elementId":"bpmnTask_1","elementName":"Get + Weather Forecast","retries":3,"exceptionMessage":null,"dueDate":null,"createTime":"2024-09-20T19:52:18.047Z","tenantId":"","lockOwner":"lelia.local-44317","lockExpirationTime":"2024-09-20T19:53:18.540Z"}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:17 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=d9451b5a-7346-4344-8983-65b324b94404; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '736' + status: + code: 200 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681?i=2 + response: + body: + string: '{"id":"JOB-d7707145-7789-11ef-8757-fe6a9bab3681","url":"http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681","correlationId":"d7707144-7789-11ef-8757-fe6a9bab3681","processInstanceId":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","processDefinitionId":"PRC-robocorpExample:3:d749d667-7789-11ef-8757-fe6a9bab3681","executionId":"PRC-d770231e-7789-11ef-8757-fe6a9bab3681","scopeId":null,"subScopeId":null,"scopeDefinitionId":null,"scopeType":null,"elementId":"bpmnTask_1","elementName":"Get + Weather Forecast","retries":3,"exceptionMessage":null,"dueDate":null,"createTime":"2024-09-20T19:52:18.047Z","tenantId":"","lockOwner":"lelia.local-44317","lockExpirationTime":"2024-09-20T19:53:18.540Z"}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:18 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=3045f0c1-f8f9-4375-9400-d8cf7d519b57; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '736' + status: + code: 200 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/external-job-api/jobs/JOB-d7707145-7789-11ef-8757-fe6a9bab3681?i=3 + response: + body: + string: '{"message":"Not found","exception":"Could not find external worker + job with id ''JOB-d7707145-7789-11ef-8757-fe6a9bab3681''."}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:18 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=ebf40149-a726-467b-b666-a2ee794e55b5; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '124' + status: + code: 404 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.3 + method: GET + uri: http://localhost:8090/flowable-work/process-api/history/historic-variable-instances?processInstanceId=PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681&variableName=temperature + response: + body: + string: '{"data":[{"id":"VAR-d81f9d09-7789-11ef-8757-fe6a9bab3681","processInstanceId":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","processInstanceUrl":"http://localhost:8090/flowable-work/process-api/history/historic-process-instances/PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","taskId":null,"executionId":"PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681","variable":{"name":"temperature","type":"integer","value":18,"scope":"global"}}],"total":1,"start":0,"sort":"variableName","order":"asc","size":1}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:18 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=118c41ad-05fe-4c95-8282-b11ef9433cfc; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '485' + status: + code: 200 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.32.3 + method: DELETE + uri: http://localhost:8090/flowable-work/process-api/runtime/process-instances/PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681 + response: + body: + string: '{"message":"Not found","exception":"Could not find a process instance + with id ''PRC-d76ffc09-7789-11ef-8757-fe6a9bab3681''."}' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Content-Type: + - application/json;charset=UTF-8 + Date: + - Fri, 20 Sep 2024 19:52:18 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=544145b7-2c6a-4f55-861d-1aecc58aea32; Path=/flowable-work + Transfer-Encoding: + - chunked + Vary: + - origin,access-control-request-method,access-control-request-headers,accept-encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + content-length: + - '123' + status: + code: 404 + message: '' +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Basic YWRtaW46dGVzdA== + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.32.3 + method: DELETE + uri: http://localhost:8090/flowable-work/process-api/repository/deployments/PRC-d7476565-7789-11ef-8757-fe6a9bab3681 + response: + body: + string: '' + headers: + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Connection: + - keep-alive + Date: + - Fri, 20 Sep 2024 19:52:18 GMT + Expires: + - '0' + Keep-Alive: + - timeout=60 + Pragma: + - no-cache + Set-Cookie: + - XSRF-TOKEN=f2d2e2e7-ad28-407c-9759-9d471b06d820; Path=/flowable-work + Vary: + - Origin + - Access-Control-Request-Method + - Access-Control-Request-Headers + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - '0' + status: + code: 204 + message: '' +version: 1 diff --git a/robocorp/tests/fixtures/processes/robocorpExample.bpmn b/robocorp/tests/fixtures/processes/robocorpExample.bpmn new file mode 100644 index 0000000..b9d47e1 --- /dev/null +++ b/robocorp/tests/fixtures/processes/robocorpExample.bpmn @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/robocorp/tests/robocorp_action_weather.py b/robocorp/tests/robocorp_action_weather.py new file mode 100644 index 0000000..3aa2835 --- /dev/null +++ b/robocorp/tests/robocorp_action_weather.py @@ -0,0 +1,8 @@ +from robocorp.actions import action + +@action +def get_weather_forecast(city: str, days: int, scale: str = "celsius") -> dict[str, int]: + # Just a random implementation which multiplies the length of the city by the days + return { + "temperature": days * len(city) + } \ No newline at end of file diff --git a/robocorp/tests/test_robocorp_client.py b/robocorp/tests/test_robocorp_client.py new file mode 100644 index 0000000..0a95ca1 --- /dev/null +++ b/robocorp/tests/test_robocorp_client.py @@ -0,0 +1,57 @@ +import os +import subprocess +import sys +from time import sleep + +import requests + +from tests.basic_test import BasicTest, base_url, auth +from tests.bpmn_utils import start_process, terminate_process, get_process_variable + +from tests.vcr import my_vcr + + +class TestRestClient(BasicTest): + @my_vcr.use_cassette + def test_with_custom_action(self): + self.deploy_process('robocorpExample.bpmn') + process_instance_id = start_process(base_url, auth, self._process_definition_id, [ + {'name': 'city', 'type': 'string', 'value': 'Zurich'}, + {'name': 'days', 'type': 'integer', 'value': 3} + ]) + try: + call_args = [sys.executable, "-m", 'flowable.robocorp_client', + '--flowable-host', base_url, + '--flowable-username', auth.username, + '--flowable-password', auth.password, + 'myTopic', + 'action', + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'robocorp_action_weather.py') + ] + print(" ".join(call_args)) + + r = requests.get(base_url + '/external-job-api/jobs?processInstanceId=' + process_instance_id, auth=auth) + result = r.json() + job = result.get('data')[0].get('id') + + print('start subprocess with parameters', call_args) + process = subprocess.Popen(call_args, text=True) + i = 0 + while i < 10 and job is not None: + # we are adding the i that vcr is not assuming it's the same request + r = requests.get(base_url + '/external-job-api/jobs/' + job + '?i=' + str(i), auth=auth) + if r.status_code == 200: + print('waiting for job to complete', job) + sleep(0.2) + else: + job = None + i += 1 + + process.terminate() + + temperature = get_process_variable(base_url, auth, process_instance_id, 'temperature') + self.assertIsNotNone(temperature) + self.assertEqual(temperature['value'], 18) + finally: + terminate_process(base_url, auth, process_instance_id) + self.remove_deployment() diff --git a/robocorp/tests/vcr.py b/robocorp/tests/vcr.py new file mode 100644 index 0000000..dbf8235 --- /dev/null +++ b/robocorp/tests/vcr.py @@ -0,0 +1,12 @@ +from pathlib import Path + +import vcr +from vcr import VCR + +my_vcr = vcr.VCR( + cassette_library_dir=str(Path(__file__).parent.absolute()) + '/fixtures/cassettes', + path_transformer=VCR.ensure_suffix('.yml'), + # see https://vcrpy.readthedocs.io/en/latest/usage.html for further details; e.g. 'once' (for just at the beginning) or 'all' (for always) + record_mode='new_episodes', + decode_compressed_response=True, +)