operaton-external-task-client-python3 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/__init__.py +0 -0
- examples/bpmn_error_example.py +75 -0
- examples/correlate_message.py +11 -0
- examples/event_subprocess_example.py +50 -0
- examples/examples_auth_basic/__init__.py +0 -0
- examples/examples_auth_basic/fetch_and_execute.py +31 -0
- examples/examples_auth_basic/get_process_instance.py +12 -0
- examples/examples_auth_basic/start_process.py +15 -0
- examples/examples_auth_basic/task_handler_example.py +44 -0
- examples/fetch_and_execute.py +30 -0
- examples/get_process_instance.py +12 -0
- examples/retry_task_example.py +58 -0
- examples/start_process.py +14 -0
- examples/task_handler_example.py +44 -0
- examples/tasks_example.py +36 -0
- operaton/__init__.py +0 -0
- operaton/client/__init__.py +0 -0
- operaton/client/async_external_task_client.py +171 -0
- operaton/client/engine_client.py +180 -0
- operaton/client/external_task_client.py +166 -0
- operaton/client/tests/__init__.py +0 -0
- operaton/client/tests/test_async_external_task_client.py +128 -0
- operaton/client/tests/test_async_external_task_client_auth.py +42 -0
- operaton/client/tests/test_async_external_task_client_bearer.py +43 -0
- operaton/client/tests/test_engine_client.py +228 -0
- operaton/client/tests/test_engine_client_auth.py +231 -0
- operaton/client/tests/test_engine_client_bearer.py +237 -0
- operaton/client/tests/test_external_task_client.py +17 -0
- operaton/client/tests/test_external_task_client_auth.py +19 -0
- operaton/client/tests/test_external_task_client_bearer.py +24 -0
- operaton/external_task/__init__.py +0 -0
- operaton/external_task/async_external_task_executor.py +91 -0
- operaton/external_task/async_external_task_worker.py +181 -0
- operaton/external_task/external_task.py +173 -0
- operaton/external_task/external_task_executor.py +88 -0
- operaton/external_task/external_task_worker.py +92 -0
- operaton/external_task/tests/__init__.py +0 -0
- operaton/external_task/tests/test_async_external_task_executor.py +139 -0
- operaton/external_task/tests/test_async_external_task_worker.py +129 -0
- operaton/external_task/tests/test_external_task.py +106 -0
- operaton/external_task/tests/test_external_task_executor.py +200 -0
- operaton/external_task/tests/test_external_task_worker.py +147 -0
- operaton/process_definition/__init__.py +0 -0
- operaton/process_definition/process_definition_client.py +123 -0
- operaton/process_definition/tests/__init__.py +0 -0
- operaton/process_definition/tests/test_process_definition_client.py +181 -0
- operaton/utils/__init__.py +0 -0
- operaton/utils/auth_basic.py +28 -0
- operaton/utils/auth_bearer.py +28 -0
- operaton/utils/log_utils.py +31 -0
- operaton/utils/response_utils.py +35 -0
- operaton/utils/tests/test_auth_basic.py +30 -0
- operaton/utils/tests/test_auth_bearer.py +27 -0
- operaton/utils/tests/test_response_utils.py +43 -0
- operaton/utils/tests/test_utils.py +21 -0
- operaton/utils/utils.py +14 -0
- operaton/variables/__init__.py +0 -0
- operaton/variables/properties.py +27 -0
- operaton/variables/tests/test_properties.py +20 -0
- operaton/variables/tests/test_variables.py +60 -0
- operaton/variables/variables.py +45 -0
- operaton_external_task_client_python3-1.0.0.dist-info/METADATA +258 -0
- operaton_external_task_client_python3-1.0.0.dist-info/RECORD +66 -0
- operaton_external_task_client_python3-1.0.0.dist-info/WHEEL +5 -0
- operaton_external_task_client_python3-1.0.0.dist-info/licenses/LICENSE +201 -0
- operaton_external_task_client_python3-1.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from operaton.utils.response_utils import raise_exception_if_not_ok
|
|
8
|
+
from operaton.utils.utils import join
|
|
9
|
+
from operaton.utils.auth_basic import AuthBasic
|
|
10
|
+
from operaton.utils.auth_bearer import AuthBearer
|
|
11
|
+
from operaton.variables.variables import Variables
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
ENGINE_LOCAL_BASE_URL = "http://localhost:8080/engine-rest"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EngineClient:
|
|
19
|
+
|
|
20
|
+
def __init__(self, engine_base_url=ENGINE_LOCAL_BASE_URL, config=None):
|
|
21
|
+
config = config if config is not None else {}
|
|
22
|
+
self.config = config.copy()
|
|
23
|
+
self.engine_base_url = engine_base_url
|
|
24
|
+
|
|
25
|
+
def get_start_process_instance_url(self, process_key, tenant_id=None):
|
|
26
|
+
if tenant_id:
|
|
27
|
+
return f"{self.engine_base_url}/process-definition/key/{process_key}/tenant-id/{tenant_id}/start"
|
|
28
|
+
return f"{self.engine_base_url}/process-definition/key/{process_key}/start"
|
|
29
|
+
|
|
30
|
+
def start_process(self, process_key, variables, tenant_id=None, business_key=None):
|
|
31
|
+
"""
|
|
32
|
+
Start a process instance with the process_key and variables passed.
|
|
33
|
+
:param process_key: Mandatory
|
|
34
|
+
:param variables: Mandatory - can be empty dict
|
|
35
|
+
:param tenant_id: Optional
|
|
36
|
+
:param business_key: Optional
|
|
37
|
+
:return: response json
|
|
38
|
+
"""
|
|
39
|
+
url = self.get_start_process_instance_url(process_key, tenant_id)
|
|
40
|
+
body = {
|
|
41
|
+
"variables": Variables.format(variables)
|
|
42
|
+
}
|
|
43
|
+
if business_key:
|
|
44
|
+
body["businessKey"] = business_key
|
|
45
|
+
|
|
46
|
+
response = requests.post(url, headers=self._get_headers(), json=body)
|
|
47
|
+
raise_exception_if_not_ok(response)
|
|
48
|
+
return response.json()
|
|
49
|
+
|
|
50
|
+
def get_process_instance(self, process_key=None, variables=frozenset([]), tenant_ids=frozenset([])):
|
|
51
|
+
url = f"{self.engine_base_url}/process-instance"
|
|
52
|
+
url_params = self.__get_process_instance_url_params(process_key, tenant_ids, variables)
|
|
53
|
+
response = requests.get(url, headers=self._get_headers(), params=url_params)
|
|
54
|
+
raise_exception_if_not_ok(response)
|
|
55
|
+
return response.json()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def __get_process_instance_url_params(process_key, tenant_ids, variables):
|
|
59
|
+
url_params = {}
|
|
60
|
+
if process_key:
|
|
61
|
+
url_params["processDefinitionKey"] = process_key
|
|
62
|
+
var_filter = join(variables, ',')
|
|
63
|
+
if var_filter:
|
|
64
|
+
url_params["variables"] = var_filter
|
|
65
|
+
tenant_ids_filter = join(tenant_ids, ',')
|
|
66
|
+
if tenant_ids_filter:
|
|
67
|
+
url_params["tenantIdIn"] = tenant_ids_filter
|
|
68
|
+
return url_params
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def auth_basic(self) -> dict:
|
|
72
|
+
if not self.config.get("auth_basic") or not isinstance(self.config.get("auth_basic"), dict):
|
|
73
|
+
return {}
|
|
74
|
+
token = AuthBasic(**self.config.get("auth_basic").copy()).token
|
|
75
|
+
return {"Authorization": token}
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def auth_bearer(self) -> dict:
|
|
79
|
+
if not self.config.get("auth_bearer") or not isinstance(self.config.get("auth_bearer"), dict):
|
|
80
|
+
return {}
|
|
81
|
+
token = AuthBearer(access_token=self.config["auth_bearer"]).access_token
|
|
82
|
+
return {"Authorization": token}
|
|
83
|
+
|
|
84
|
+
def _get_headers(self):
|
|
85
|
+
headers = {
|
|
86
|
+
"Content-Type": "application/json"
|
|
87
|
+
}
|
|
88
|
+
if self.auth_basic:
|
|
89
|
+
headers.update(self.auth_basic)
|
|
90
|
+
if self.auth_bearer:
|
|
91
|
+
headers.update(self.auth_bearer)
|
|
92
|
+
return headers
|
|
93
|
+
|
|
94
|
+
def correlate_message(self, message_name, process_instance_id=None, tenant_id=None, business_key=None,
|
|
95
|
+
process_variables=None):
|
|
96
|
+
"""
|
|
97
|
+
Correlates a message to the process engine to either trigger a message start event or
|
|
98
|
+
an intermediate message catching event.
|
|
99
|
+
:param message_name:
|
|
100
|
+
:param process_instance_id:
|
|
101
|
+
:param tenant_id:
|
|
102
|
+
:param business_key:
|
|
103
|
+
:param process_variables:
|
|
104
|
+
:return: response json
|
|
105
|
+
"""
|
|
106
|
+
url = f"{self.engine_base_url}/message"
|
|
107
|
+
body = {
|
|
108
|
+
"messageName": message_name,
|
|
109
|
+
"resultEnabled": True,
|
|
110
|
+
"processVariables": Variables.format(process_variables) if process_variables else None,
|
|
111
|
+
"processInstanceId": process_instance_id,
|
|
112
|
+
"tenantId": tenant_id,
|
|
113
|
+
"withoutTenantId": not tenant_id,
|
|
114
|
+
"businessKey": business_key,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if process_instance_id:
|
|
118
|
+
body.pop("tenantId")
|
|
119
|
+
body.pop("withoutTenantId")
|
|
120
|
+
|
|
121
|
+
body = {k: v for k, v in body.items() if v is not None}
|
|
122
|
+
|
|
123
|
+
response = requests.post(url, headers=self._get_headers(), json=body)
|
|
124
|
+
raise_exception_if_not_ok(response)
|
|
125
|
+
return response.json()
|
|
126
|
+
|
|
127
|
+
def get_jobs(self,
|
|
128
|
+
offset: int,
|
|
129
|
+
limit: int,
|
|
130
|
+
tenant_ids=None,
|
|
131
|
+
with_failure=None,
|
|
132
|
+
process_instance_id=None,
|
|
133
|
+
task_name=None,
|
|
134
|
+
sort_by="jobDueDate",
|
|
135
|
+
sort_order="desc"):
|
|
136
|
+
# offset starts with zero
|
|
137
|
+
# sort_order can be "asc" or "desc
|
|
138
|
+
|
|
139
|
+
url = f"{self.engine_base_url}/job"
|
|
140
|
+
params = {
|
|
141
|
+
"firstResult": offset,
|
|
142
|
+
"maxResults": limit,
|
|
143
|
+
"sortBy": sort_by,
|
|
144
|
+
"sortOrder": sort_order,
|
|
145
|
+
}
|
|
146
|
+
if process_instance_id:
|
|
147
|
+
params["processInstanceId"] = process_instance_id
|
|
148
|
+
if task_name:
|
|
149
|
+
params["failedActivityId"] = task_name
|
|
150
|
+
if with_failure:
|
|
151
|
+
params["withException"] = "true"
|
|
152
|
+
if tenant_ids:
|
|
153
|
+
params["tenantIdIn"] = ','.join(tenant_ids)
|
|
154
|
+
response = requests.get(url, params=params, headers=self._get_headers())
|
|
155
|
+
raise_exception_if_not_ok(response)
|
|
156
|
+
return response.json()
|
|
157
|
+
|
|
158
|
+
def set_job_retry(self, job_id, retries=1):
|
|
159
|
+
url = f"{self.engine_base_url}/job/{job_id}/retries"
|
|
160
|
+
body = {"retries": retries}
|
|
161
|
+
|
|
162
|
+
response = requests.put(url, headers=self._get_headers(), json=body)
|
|
163
|
+
raise_exception_if_not_ok(response)
|
|
164
|
+
return response.status_code == HTTPStatus.NO_CONTENT
|
|
165
|
+
|
|
166
|
+
def get_process_instance_variable(self, process_instance_id, variable_name, with_meta=False):
|
|
167
|
+
url = f"{self.engine_base_url}/process-instance/{process_instance_id}/variables/{variable_name}"
|
|
168
|
+
response = requests.get(url, headers=self._get_headers())
|
|
169
|
+
raise_exception_if_not_ok(response)
|
|
170
|
+
resp_json = response.json()
|
|
171
|
+
|
|
172
|
+
url_with_data = f"{url}/data"
|
|
173
|
+
response = requests.get(url_with_data, headers=self._get_headers())
|
|
174
|
+
raise_exception_if_not_ok(response)
|
|
175
|
+
|
|
176
|
+
decoded_value = base64.encodebytes(response.content).decode("utf-8")
|
|
177
|
+
|
|
178
|
+
if with_meta:
|
|
179
|
+
return dict(resp_json, value=decoded_value)
|
|
180
|
+
return decoded_value
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
|
|
7
|
+
from operaton.utils.log_utils import log_with_context
|
|
8
|
+
from operaton.utils.response_utils import raise_exception_if_not_ok
|
|
9
|
+
from operaton.utils.utils import str_to_list
|
|
10
|
+
from operaton.utils.auth_basic import AuthBasic, obfuscate_password
|
|
11
|
+
from operaton.utils.auth_bearer import AuthBearer
|
|
12
|
+
from operaton.variables.variables import Variables
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExternalTaskClient:
|
|
18
|
+
default_config = {
|
|
19
|
+
"maxTasks": 1,
|
|
20
|
+
"lockDuration": 300000, # in milliseconds
|
|
21
|
+
"asyncResponseTimeout": 30000,
|
|
22
|
+
"retries": 3,
|
|
23
|
+
"retryTimeout": 300000,
|
|
24
|
+
"httpTimeoutMillis": 30000,
|
|
25
|
+
"timeoutDeltaMillis": 5000,
|
|
26
|
+
"includeExtensionProperties": True, # enables Camunda Extension Properties
|
|
27
|
+
"deserializeValues": True, # deserialize values when fetch a task by default
|
|
28
|
+
"usePriority": False,
|
|
29
|
+
"sorting": None
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def __init__(self, worker_id, engine_base_url=ENGINE_LOCAL_BASE_URL, config=None):
|
|
33
|
+
config = config if config is not None else {}
|
|
34
|
+
self.worker_id = worker_id
|
|
35
|
+
self.external_task_base_url = engine_base_url + "/external-task"
|
|
36
|
+
self.config = type(self).default_config.copy()
|
|
37
|
+
self.config.update(config)
|
|
38
|
+
self.is_debug = config.get('isDebug', False)
|
|
39
|
+
self.http_timeout_seconds = self.config.get('httpTimeoutMillis') / 1000
|
|
40
|
+
self._log_with_context(f"Created External Task client with config: {obfuscate_password(self.config)}")
|
|
41
|
+
|
|
42
|
+
def get_fetch_and_lock_url(self):
|
|
43
|
+
return f"{self.external_task_base_url}/fetchAndLock"
|
|
44
|
+
|
|
45
|
+
def fetch_and_lock(self, topic_names, process_variables=None, variables=None):
|
|
46
|
+
url = self.get_fetch_and_lock_url()
|
|
47
|
+
body = {
|
|
48
|
+
"workerId": str(self.worker_id), # convert to string to make it JSON serializable
|
|
49
|
+
"maxTasks": self.config["maxTasks"],
|
|
50
|
+
"topics": self._get_topics(topic_names, process_variables, variables),
|
|
51
|
+
"asyncResponseTimeout": self.config["asyncResponseTimeout"],
|
|
52
|
+
"usePriority": self.config["usePriority"],
|
|
53
|
+
"sorting": self.config["sorting"]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if self.is_debug:
|
|
57
|
+
self._log_with_context(f"trying to fetch and lock with request payload: {body}")
|
|
58
|
+
http_timeout_seconds = self.__get_fetch_and_lock_http_timeout_seconds()
|
|
59
|
+
response = requests.post(url, headers=self._get_headers(), json=body, timeout=http_timeout_seconds)
|
|
60
|
+
raise_exception_if_not_ok(response)
|
|
61
|
+
|
|
62
|
+
resp_json = response.json()
|
|
63
|
+
if self.is_debug:
|
|
64
|
+
self._log_with_context(f"fetch and lock response json: {resp_json} for request: {body}")
|
|
65
|
+
return response.json()
|
|
66
|
+
|
|
67
|
+
def __get_fetch_and_lock_http_timeout_seconds(self):
|
|
68
|
+
# use HTTP timeout slightly more than async Response / long polling timeout
|
|
69
|
+
return (self.config["timeoutDeltaMillis"] + self.config["asyncResponseTimeout"]) / 1000
|
|
70
|
+
|
|
71
|
+
def _get_topics(self, topic_names, process_variables, variables):
|
|
72
|
+
topics = []
|
|
73
|
+
for topic in str_to_list(topic_names):
|
|
74
|
+
topics.append({
|
|
75
|
+
"topicName": topic,
|
|
76
|
+
"lockDuration": self.config["lockDuration"],
|
|
77
|
+
"processVariables": process_variables if process_variables else {},
|
|
78
|
+
# enables Camunda Extension Properties
|
|
79
|
+
"includeExtensionProperties": self.config.get("includeExtensionProperties") or False,
|
|
80
|
+
"deserializeValues": self.config["deserializeValues"],
|
|
81
|
+
"variables": variables
|
|
82
|
+
})
|
|
83
|
+
return topics
|
|
84
|
+
|
|
85
|
+
def complete(self, task_id, global_variables, local_variables=None):
|
|
86
|
+
url = self.get_task_complete_url(task_id)
|
|
87
|
+
|
|
88
|
+
body = {
|
|
89
|
+
"workerId": self.worker_id,
|
|
90
|
+
"variables": Variables.format(global_variables),
|
|
91
|
+
"localVariables": Variables.format(local_variables)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
response = requests.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds)
|
|
95
|
+
raise_exception_if_not_ok(response)
|
|
96
|
+
return response.status_code == HTTPStatus.NO_CONTENT
|
|
97
|
+
|
|
98
|
+
def get_task_complete_url(self, task_id):
|
|
99
|
+
return f"{self.external_task_base_url}/{task_id}/complete"
|
|
100
|
+
|
|
101
|
+
def failure(self, task_id, error_message, error_details, retries, retry_timeout):
|
|
102
|
+
url = self.get_task_failure_url(task_id)
|
|
103
|
+
logger.info(f"setting retries to: {retries} for task: {task_id}")
|
|
104
|
+
body = {
|
|
105
|
+
"workerId": self.worker_id,
|
|
106
|
+
"errorMessage": error_message,
|
|
107
|
+
"retries": retries,
|
|
108
|
+
"retryTimeout": retry_timeout,
|
|
109
|
+
}
|
|
110
|
+
if error_details:
|
|
111
|
+
body["errorDetails"] = error_details
|
|
112
|
+
|
|
113
|
+
response = requests.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds)
|
|
114
|
+
raise_exception_if_not_ok(response)
|
|
115
|
+
return response.status_code == HTTPStatus.NO_CONTENT
|
|
116
|
+
|
|
117
|
+
def get_task_failure_url(self, task_id):
|
|
118
|
+
return f"{self.external_task_base_url}/{task_id}/failure"
|
|
119
|
+
|
|
120
|
+
def bpmn_failure(self, task_id, error_code, error_message, variables=None):
|
|
121
|
+
url = self.get_task_bpmn_error_url(task_id)
|
|
122
|
+
|
|
123
|
+
body = {
|
|
124
|
+
"workerId": self.worker_id,
|
|
125
|
+
"errorCode": error_code,
|
|
126
|
+
"errorMessage": error_message,
|
|
127
|
+
"variables": Variables.format(variables),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if self.is_debug:
|
|
131
|
+
self._log_with_context(f"trying to report bpmn error with request payload: {body}")
|
|
132
|
+
|
|
133
|
+
resp = requests.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
return resp.status_code == HTTPStatus.NO_CONTENT
|
|
136
|
+
|
|
137
|
+
def get_task_bpmn_error_url(self, task_id):
|
|
138
|
+
return f"{self.external_task_base_url}/{task_id}/bpmnError"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def auth_basic(self) -> dict:
|
|
142
|
+
if not self.config.get("auth_basic") or not isinstance(self.config.get("auth_basic"), dict):
|
|
143
|
+
return {}
|
|
144
|
+
token = AuthBasic(**self.config.get("auth_basic").copy()).token
|
|
145
|
+
return {"Authorization": token}
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def auth_bearer(self) -> dict:
|
|
149
|
+
if not self.config.get("auth_bearer") or not isinstance(self.config.get("auth_bearer"), dict):
|
|
150
|
+
return {}
|
|
151
|
+
token = AuthBearer(access_token=self.config["auth_bearer"]).access_token
|
|
152
|
+
return {"Authorization": token}
|
|
153
|
+
|
|
154
|
+
def _get_headers(self):
|
|
155
|
+
headers = {
|
|
156
|
+
"Content-Type": "application/json"
|
|
157
|
+
}
|
|
158
|
+
if self.auth_basic:
|
|
159
|
+
headers.update(self.auth_basic)
|
|
160
|
+
if self.auth_bearer:
|
|
161
|
+
headers.update(self.auth_bearer)
|
|
162
|
+
return headers
|
|
163
|
+
|
|
164
|
+
def _log_with_context(self, msg, log_level='info', **kwargs):
|
|
165
|
+
context = {"WORKER_ID": self.worker_id}
|
|
166
|
+
log_with_context(msg, context=context, log_level=log_level, **kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
from unittest.mock import patch, AsyncMock
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
# Adjust the import based on your actual module path
|
|
8
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient, ENGINE_LOCAL_BASE_URL
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncExternalTaskClientTest(unittest.IsolatedAsyncioTestCase):
|
|
12
|
+
"""
|
|
13
|
+
Tests for async_external_task_client.py
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def setUp(self):
|
|
17
|
+
# Common setup if needed
|
|
18
|
+
self.default_worker_id = 1
|
|
19
|
+
self.default_engine_url = ENGINE_LOCAL_BASE_URL
|
|
20
|
+
|
|
21
|
+
async def test_creation_with_no_debug_config(self):
|
|
22
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {})
|
|
23
|
+
self.assertFalse(client.is_debug)
|
|
24
|
+
self.assertFalse(client.config.get("isDebug"))
|
|
25
|
+
# Check default_config merges:
|
|
26
|
+
self.assertEqual(client.config["maxConcurrentTasks"], 10)
|
|
27
|
+
self.assertEqual(client.config["lockDuration"], 300000)
|
|
28
|
+
|
|
29
|
+
async def test_creation_with_debug_config(self):
|
|
30
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {"isDebug": True})
|
|
31
|
+
self.assertTrue(client.is_debug)
|
|
32
|
+
self.assertTrue(client.config.get("isDebug"))
|
|
33
|
+
|
|
34
|
+
@patch("httpx.AsyncClient.post")
|
|
35
|
+
async def test_fetch_and_lock_success(self, mock_post):
|
|
36
|
+
# Provide actual JSON as bytes
|
|
37
|
+
content = b'[{"id": "someExternalTaskId", "topicName": "topicA"}]'
|
|
38
|
+
mock_post.return_value = httpx.Response(
|
|
39
|
+
status_code=200,
|
|
40
|
+
request=httpx.Request("POST", "http://example.com"),
|
|
41
|
+
content=content
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {})
|
|
45
|
+
# Perform call
|
|
46
|
+
tasks = await client.fetch_and_lock("topicA")
|
|
47
|
+
|
|
48
|
+
# Assertions
|
|
49
|
+
expected_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/fetchAndLock"
|
|
50
|
+
self.assertEqual([{"id": "someExternalTaskId", "topicName": "topicA"}], tasks)
|
|
51
|
+
mock_post.assert_awaited_once() # Check post was awaited exactly once
|
|
52
|
+
args, kwargs = mock_post.call_args
|
|
53
|
+
self.assertEqual(expected_url, args[0], "Expected correct fetchAndLock endpoint URL")
|
|
54
|
+
# You could also check the payload or headers here:
|
|
55
|
+
self.assertIn("json", kwargs)
|
|
56
|
+
self.assertEqual(kwargs["json"]["workerId"], "1") # str(worker_id)
|
|
57
|
+
|
|
58
|
+
@patch("httpx.AsyncClient.post")
|
|
59
|
+
async def test_fetch_and_lock_server_error(self, mock_post):
|
|
60
|
+
# Create a real httpx.Response with status=500
|
|
61
|
+
server_err_resp = httpx.Response(
|
|
62
|
+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
63
|
+
request=httpx.Request("POST", "http://example.com/external-task/fetchAndLock"),
|
|
64
|
+
content=b"Internal Server Error"
|
|
65
|
+
)
|
|
66
|
+
# Each call to mock_post() returns this real response object
|
|
67
|
+
mock_post.return_value = server_err_resp
|
|
68
|
+
|
|
69
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {})
|
|
70
|
+
|
|
71
|
+
# Now we expect an exception
|
|
72
|
+
with self.assertRaises(httpx.HTTPStatusError) as ctx:
|
|
73
|
+
await client.fetch_and_lock("topicA")
|
|
74
|
+
|
|
75
|
+
# Optional: confirm the error message
|
|
76
|
+
self.assertIn("500 Internal Server Error", str(ctx.exception))
|
|
77
|
+
|
|
78
|
+
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
|
79
|
+
async def test_complete_success(self, mock_post):
|
|
80
|
+
mock_post.return_value.status_code = HTTPStatus.NO_CONTENT
|
|
81
|
+
|
|
82
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {})
|
|
83
|
+
result = await client.complete("myTaskId", {"globalVar": 1})
|
|
84
|
+
|
|
85
|
+
self.assertTrue(result)
|
|
86
|
+
mock_post.assert_awaited_once()
|
|
87
|
+
complete_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/myTaskId/complete"
|
|
88
|
+
self.assertEqual(complete_url, mock_post.call_args[0][0])
|
|
89
|
+
|
|
90
|
+
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
|
91
|
+
async def test_failure_with_error_details(self, mock_post):
|
|
92
|
+
mock_post.return_value.status_code = HTTPStatus.NO_CONTENT
|
|
93
|
+
|
|
94
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {})
|
|
95
|
+
result = await client.failure(
|
|
96
|
+
task_id="myTaskId",
|
|
97
|
+
error_message="some error",
|
|
98
|
+
error_details="stacktrace info",
|
|
99
|
+
retries=3,
|
|
100
|
+
retry_timeout=10000
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self.assertTrue(result)
|
|
104
|
+
mock_post.assert_awaited_once()
|
|
105
|
+
failure_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/myTaskId/failure"
|
|
106
|
+
self.assertEqual(failure_url, mock_post.call_args[0][0])
|
|
107
|
+
self.assertIn("errorDetails", mock_post.call_args[1]["json"])
|
|
108
|
+
|
|
109
|
+
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
|
110
|
+
async def test_bpmn_failure_success(self, mock_post):
|
|
111
|
+
mock_post.return_value.status_code = HTTPStatus.NO_CONTENT
|
|
112
|
+
|
|
113
|
+
client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {"isDebug": True})
|
|
114
|
+
result = await client.bpmn_failure(
|
|
115
|
+
task_id="myTaskId",
|
|
116
|
+
error_code="BPMN_ERROR",
|
|
117
|
+
error_message="an example BPMN error",
|
|
118
|
+
variables={"foo": "bar"}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.assertTrue(result)
|
|
122
|
+
mock_post.assert_awaited_once()
|
|
123
|
+
bpmn_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/myTaskId/bpmnError"
|
|
124
|
+
args, kwargs = mock_post.call_args
|
|
125
|
+
self.assertEqual(bpmn_url, args[0])
|
|
126
|
+
self.assertEqual(kwargs["json"]["errorCode"], "BPMN_ERROR")
|
|
127
|
+
self.assertTrue(client.is_debug) # Confirm the debug flag is set
|
|
128
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient
|
|
6
|
+
from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncExternalTaskClientAuthTest(unittest.IsolatedAsyncioTestCase):
|
|
10
|
+
async def test_auth_basic_fetch_and_lock_no_debug(self):
|
|
11
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
|
12
|
+
mock_post.return_value.status_code = HTTPStatus.OK
|
|
13
|
+
mock_post.return_value.json.return_value = []
|
|
14
|
+
|
|
15
|
+
client = AsyncExternalTaskClient(
|
|
16
|
+
1,
|
|
17
|
+
ENGINE_LOCAL_BASE_URL,
|
|
18
|
+
{"auth_basic": {"username": "demo", "password": "demo"}}
|
|
19
|
+
)
|
|
20
|
+
await client.fetch_and_lock("someTopic")
|
|
21
|
+
|
|
22
|
+
# Confirm "Authorization" header is present
|
|
23
|
+
headers_used = mock_post.call_args[1]["headers"]
|
|
24
|
+
self.assertIn("Authorization", headers_used)
|
|
25
|
+
self.assertTrue(headers_used["Authorization"].startswith("Basic "))
|
|
26
|
+
|
|
27
|
+
async def test_auth_basic_fetch_and_lock_with_debug(self):
|
|
28
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
|
29
|
+
mock_post.return_value.status_code = HTTPStatus.OK
|
|
30
|
+
mock_post.return_value.json.return_value = []
|
|
31
|
+
|
|
32
|
+
client = AsyncExternalTaskClient(
|
|
33
|
+
1,
|
|
34
|
+
ENGINE_LOCAL_BASE_URL,
|
|
35
|
+
{"auth_basic": {"username": "demo", "password": "demo"}, "isDebug": True}
|
|
36
|
+
)
|
|
37
|
+
await client.fetch_and_lock("someTopic")
|
|
38
|
+
|
|
39
|
+
# Confirm "Authorization" header is present
|
|
40
|
+
headers_used = mock_post.call_args[1]["headers"]
|
|
41
|
+
self.assertIn("Authorization", headers_used)
|
|
42
|
+
self.assertTrue(headers_used["Authorization"].startswith("Basic "))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
from unittest.mock import patch, AsyncMock
|
|
4
|
+
|
|
5
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient
|
|
6
|
+
from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncExternalTaskClientAuthTest(unittest.IsolatedAsyncioTestCase):
|
|
10
|
+
|
|
11
|
+
async def test_auth_bearer_fetch_and_lock_no_debug(self):
|
|
12
|
+
token = "some.super.long.jwt"
|
|
13
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
|
14
|
+
mock_post.return_value.status_code = HTTPStatus.OK
|
|
15
|
+
mock_post.return_value.json.return_value = []
|
|
16
|
+
|
|
17
|
+
client = AsyncExternalTaskClient(
|
|
18
|
+
1,
|
|
19
|
+
ENGINE_LOCAL_BASE_URL,
|
|
20
|
+
{"auth_bearer": {"access_token": token}}
|
|
21
|
+
)
|
|
22
|
+
await client.fetch_and_lock("someTopic")
|
|
23
|
+
|
|
24
|
+
headers_used = mock_post.call_args[1]["headers"]
|
|
25
|
+
self.assertIn("Authorization", headers_used)
|
|
26
|
+
self.assertEqual(f"Bearer {token}", headers_used["Authorization"])
|
|
27
|
+
|
|
28
|
+
async def test_auth_bearer_fetch_and_lock_with_debug(self):
|
|
29
|
+
token = "some.super.long.jwt"
|
|
30
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
|
31
|
+
mock_post.return_value.status_code = HTTPStatus.OK
|
|
32
|
+
mock_post.return_value.json.return_value = []
|
|
33
|
+
|
|
34
|
+
client = AsyncExternalTaskClient(
|
|
35
|
+
1,
|
|
36
|
+
ENGINE_LOCAL_BASE_URL,
|
|
37
|
+
{"auth_bearer": {"access_token": token}, "isDebug": True}
|
|
38
|
+
)
|
|
39
|
+
await client.fetch_and_lock("someTopic")
|
|
40
|
+
|
|
41
|
+
headers_used = mock_post.call_args[1]["headers"]
|
|
42
|
+
self.assertIn("Authorization", headers_used)
|
|
43
|
+
self.assertEqual(f"Bearer {token}", headers_used["Authorization"])
|