soprano-sdk 0.1.100__py3-none-any.whl → 0.2.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.
- soprano_sdk/authenticators/__init__.py +0 -0
- soprano_sdk/authenticators/mfa.py +153 -0
- soprano_sdk/core/constants.py +17 -0
- soprano_sdk/core/engine.py +52 -4
- soprano_sdk/core/state.py +9 -1
- soprano_sdk/nodes/base.py +5 -0
- soprano_sdk/nodes/call_api.py +651 -0
- soprano_sdk/nodes/call_function.py +22 -2
- soprano_sdk/nodes/collect_input.py +3 -0
- soprano_sdk/tools.py +0 -2
- soprano_sdk/validation/schema.py +22 -1
- soprano_sdk/validation/validator.py +38 -1
- {soprano_sdk-0.1.100.dist-info → soprano_sdk-0.2.0.dist-info}/METADATA +1 -1
- {soprano_sdk-0.1.100.dist-info → soprano_sdk-0.2.0.dist-info}/RECORD +16 -13
- {soprano_sdk-0.1.100.dist-info → soprano_sdk-0.2.0.dist-info}/WHEEL +0 -0
- {soprano_sdk-0.1.100.dist-info → soprano_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from typing import TypedDict, Literal, NotRequired
|
|
3
|
+
from soprano_sdk.core.constants import MFARestAuthorizerEnv
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MFAChallenge(TypedDict):
|
|
7
|
+
value: str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MFAState(TypedDict):
|
|
11
|
+
challengeType: Literal['OTP', 'dob']
|
|
12
|
+
post_payload: dict[str, str]
|
|
13
|
+
otpValue: NotRequired[str]
|
|
14
|
+
status: Literal['IN_PROGRESS', 'COMPLETED', 'ERRORED', 'FAILED'] | None
|
|
15
|
+
message: str
|
|
16
|
+
retry_count: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_response(response: requests.Response):
|
|
21
|
+
if response.ok:
|
|
22
|
+
return response.json(), None
|
|
23
|
+
else:
|
|
24
|
+
return None, response.json()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_path(base_url: str, path: str):
|
|
28
|
+
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def enforce_mfa_if_required(state: dict):
|
|
32
|
+
_mfa : MFAState = state['_mfa']
|
|
33
|
+
if _mfa['status'] == 'COMPLETED':
|
|
34
|
+
return True
|
|
35
|
+
generate_token_response = requests.post(
|
|
36
|
+
build_path(
|
|
37
|
+
base_url=MFARestAuthorizerEnv.GENERATE_TOKEN_BASE_URL.get_from_env(),
|
|
38
|
+
path=MFARestAuthorizerEnv.GENERATE_TOKEN_PATH.get_from_env()
|
|
39
|
+
), json=_mfa['post_payload'], timeout=30, headers={"Authorization": f"Bearer {state['bearer_token']}"}
|
|
40
|
+
)
|
|
41
|
+
_, error = get_response(generate_token_response)
|
|
42
|
+
|
|
43
|
+
challenge_type = error['additionalData']['challengeType']
|
|
44
|
+
_mfa['challengeType'] = challenge_type
|
|
45
|
+
_mfa['status'] = 'IN_PROGRESS'
|
|
46
|
+
_mfa['retry_count'] = 0
|
|
47
|
+
_mfa['message'] = f"Please enter the {challenge_type}"
|
|
48
|
+
if delivery_methods := error['additionalData'].get(f"{challenge_type.lower()}SentTo"):
|
|
49
|
+
_mfa['message'] += f" sent via {','.join(delivery_methods)}"
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mfa_validate_user_input(**state: dict):
|
|
54
|
+
_mfa : MFAState = state['_mfa']
|
|
55
|
+
input_field_name = state['_active_input_field']
|
|
56
|
+
if not state[input_field_name]:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
post_payload = _mfa['post_payload']
|
|
60
|
+
challenge_field_name = f"{_mfa['challengeType'].lower()}Challenge"
|
|
61
|
+
post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
|
|
62
|
+
validate_token_response = requests.post(
|
|
63
|
+
build_path(
|
|
64
|
+
base_url=MFARestAuthorizerEnv.VALIDATE_TOKEN_BASE_URL.get_from_env(),
|
|
65
|
+
path=MFARestAuthorizerEnv.VALIDATE_TOKEN_PATH.get_from_env()
|
|
66
|
+
), json=post_payload, timeout=30, headers={"Authorization": f"Bearer {state['bearer_token']}"}
|
|
67
|
+
)
|
|
68
|
+
_mfa['retry_count'] += 1
|
|
69
|
+
response, error = get_response(validate_token_response)
|
|
70
|
+
if error:
|
|
71
|
+
if _mfa['retry_count'] == 1:
|
|
72
|
+
_mfa['status'] = 'ERRORED'
|
|
73
|
+
_mfa['message'] = (
|
|
74
|
+
f"You Have Entered Invalid {_mfa['challengeType']}. {_mfa['message']}"
|
|
75
|
+
)
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
if response and 'token' in response:
|
|
79
|
+
token = response['token']
|
|
80
|
+
post_payload['token'] = token
|
|
81
|
+
|
|
82
|
+
authorize = requests.post(
|
|
83
|
+
build_path(
|
|
84
|
+
base_url=MFARestAuthorizerEnv.AUTHORIZE_TOKEN_BASE_URL.get_from_env(),
|
|
85
|
+
path=MFARestAuthorizerEnv.AUTHORIZE_TOKEN_PATH.get_from_env()
|
|
86
|
+
),
|
|
87
|
+
json=post_payload,
|
|
88
|
+
timeout=30,
|
|
89
|
+
headers={"Authorization": f"Bearer {state['bearer_token']}"}
|
|
90
|
+
)
|
|
91
|
+
if authorize.status_code == 204:
|
|
92
|
+
_mfa['status'] = 'COMPLETED'
|
|
93
|
+
return True
|
|
94
|
+
else:
|
|
95
|
+
_mfa['status'] = 'FAILED'
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MFANodeConfig:
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def get_call_function_template(cls, source_node: str, next_node: str, mfa: dict):
|
|
103
|
+
return dict(
|
|
104
|
+
id=f"{source_node}_mfa_start",
|
|
105
|
+
action="call_function",
|
|
106
|
+
function="conversational_sop.authenticators.mfa.enforce_mfa_if_required",
|
|
107
|
+
output=f"{source_node}_mfa_start",
|
|
108
|
+
mfa=mfa,
|
|
109
|
+
transitions=[
|
|
110
|
+
dict(
|
|
111
|
+
condition=True,
|
|
112
|
+
next=source_node,
|
|
113
|
+
),
|
|
114
|
+
dict(
|
|
115
|
+
condition=False,
|
|
116
|
+
next=next_node,
|
|
117
|
+
),
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def get_validate_user_input(cls, source_node: str, next_node: str, model_name: str):
|
|
123
|
+
input_field_name = f"{source_node}_mfa_input"
|
|
124
|
+
return dict(
|
|
125
|
+
id=f"{source_node}_mfa_validate",
|
|
126
|
+
action="collect_input_with_agent",
|
|
127
|
+
description="Collect Input for MFA value",
|
|
128
|
+
field=input_field_name,
|
|
129
|
+
max_attempts=3,
|
|
130
|
+
validator="conversational_sop.authenticators.mfa.mfa_validate_user_input",
|
|
131
|
+
agent=dict(
|
|
132
|
+
name="MFA Input Data Collector",
|
|
133
|
+
model=model_name,
|
|
134
|
+
initial_message="{{_mfa.message}}",
|
|
135
|
+
instructions="""
|
|
136
|
+
You are an authentication value extractor. Your job is to identify and extract MFA codes from user input.
|
|
137
|
+
|
|
138
|
+
**Task:**
|
|
139
|
+
- Read the user's message
|
|
140
|
+
- Extract ONLY the OTP code value
|
|
141
|
+
- Output in the exact format shown below
|
|
142
|
+
|
|
143
|
+
**Output Format:**
|
|
144
|
+
MFA_CAPTURED:
|
|
145
|
+
|
|
146
|
+
"""),
|
|
147
|
+
transitions=[
|
|
148
|
+
dict(
|
|
149
|
+
pattern="MFA_CAPTURED:",
|
|
150
|
+
next=next_node
|
|
151
|
+
)
|
|
152
|
+
]
|
|
153
|
+
)
|
soprano_sdk/core/constants.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from enum import Enum
|
|
2
3
|
|
|
3
4
|
|
|
@@ -57,3 +58,19 @@ DEFAULT_TIMEOUT = 300
|
|
|
57
58
|
|
|
58
59
|
MAX_ATTEMPTS_MESSAGE = "I'm having trouble understanding your {field}. Please contact customer service for assistance."
|
|
59
60
|
WORKFLOW_COMPLETE_MESSAGE = "Workflow completed."
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MFARestAuthorizerEnv(Enum):
|
|
64
|
+
GENERATE_TOKEN_BASE_URL = 'GENERATE_TOKEN_BASE_URL'
|
|
65
|
+
GENERATE_TOKEN_PATH = 'GENERATE_TOKEN_PATH'
|
|
66
|
+
VALIDATE_TOKEN_BASE_URL = 'VALIDATE_TOKEN_BASE_URL'
|
|
67
|
+
VALIDATE_TOKEN_PATH = 'VALIDATE_TOKEN_PATH'
|
|
68
|
+
AUTHORIZE_TOKEN_BASE_URL = 'AUTHORIZE_TOKEN_BASE_URL'
|
|
69
|
+
AUTHORIZE_TOKEN_PATH = 'AUTHORIZE_TOKEN_PATH'
|
|
70
|
+
|
|
71
|
+
API_TIMEOUT = 'API_TIMEOUT'
|
|
72
|
+
|
|
73
|
+
def get_from_env(self):
|
|
74
|
+
if self == MFARestAuthorizerEnv.API_TIMEOUT:
|
|
75
|
+
return int(os.getenv(self.value, '30'))
|
|
76
|
+
return os.getenv(self.value)
|
soprano_sdk/core/engine.py
CHANGED
|
@@ -15,9 +15,10 @@ from ..utils.function import FunctionRepository
|
|
|
15
15
|
from ..utils.logger import logger
|
|
16
16
|
from ..utils.tool import ToolRepository
|
|
17
17
|
from ..validation import validate_workflow
|
|
18
|
-
|
|
18
|
+
from soprano_sdk.authenticators.mfa import MFANodeConfig
|
|
19
19
|
|
|
20
20
|
class WorkflowEngine:
|
|
21
|
+
|
|
21
22
|
def __init__(self, yaml_path: str, configs: dict):
|
|
22
23
|
self.yaml_path = yaml_path
|
|
23
24
|
self.configs = configs or {}
|
|
@@ -33,14 +34,16 @@ class WorkflowEngine:
|
|
|
33
34
|
self.workflow_name = self.config['name']
|
|
34
35
|
self.workflow_description = self.config['description']
|
|
35
36
|
self.workflow_version = self.config['version']
|
|
36
|
-
self.
|
|
37
|
-
self.steps = self.
|
|
37
|
+
self.mfa_node_indexes: list[int] = []
|
|
38
|
+
self.steps: list = self.load_steps()
|
|
39
|
+
self.step_map = {step['id']: step for step in self.steps}
|
|
40
|
+
self.data_fields = self.load_data()
|
|
41
|
+
|
|
38
42
|
self.outcomes = self.config['outcomes']
|
|
39
43
|
self.metadata = self.config.get('metadata', {})
|
|
40
44
|
|
|
41
45
|
self.StateType = create_state_model(self.data_fields)
|
|
42
46
|
|
|
43
|
-
self.step_map = {step['id']: step for step in self.steps}
|
|
44
47
|
self.outcome_map = {outcome['id']: outcome for outcome in self.outcomes}
|
|
45
48
|
|
|
46
49
|
self.function_repository = FunctionRepository()
|
|
@@ -196,6 +199,51 @@ class WorkflowEngine:
|
|
|
196
199
|
return tool_config.get('usage_policy')
|
|
197
200
|
|
|
198
201
|
|
|
202
|
+
def load_steps(self):
|
|
203
|
+
prepared_steps: list = []
|
|
204
|
+
previous_step: dict | None = None
|
|
205
|
+
for step in self.config['steps']:
|
|
206
|
+
step_id = step['id']
|
|
207
|
+
|
|
208
|
+
if mfa_config := step.get('mfa'):
|
|
209
|
+
mfa_data_collector = MFANodeConfig.get_validate_user_input(
|
|
210
|
+
next_node=step_id, model_name=mfa_config['model'],
|
|
211
|
+
source_node=step_id
|
|
212
|
+
)
|
|
213
|
+
mfa_start = MFANodeConfig.get_call_function_template(
|
|
214
|
+
source_node=step_id,
|
|
215
|
+
next_node=mfa_data_collector['id'],
|
|
216
|
+
mfa=mfa_config
|
|
217
|
+
)
|
|
218
|
+
prepared_steps.append(mfa_start)
|
|
219
|
+
prepared_steps.append(mfa_data_collector)
|
|
220
|
+
self.mfa_node_indexes.append(len(prepared_steps) - 1)
|
|
221
|
+
|
|
222
|
+
if previous_step and previous_step.get('transitions'):
|
|
223
|
+
for transition in previous_step.get('transitions'):
|
|
224
|
+
if transition.get('next') == step_id:
|
|
225
|
+
transition['next'] = mfa_start['id']
|
|
226
|
+
|
|
227
|
+
del step['mfa']
|
|
228
|
+
|
|
229
|
+
prepared_steps.append(step)
|
|
230
|
+
previous_step = step
|
|
231
|
+
return prepared_steps
|
|
232
|
+
|
|
233
|
+
def load_data(self):
|
|
234
|
+
data: list = self.config['data']
|
|
235
|
+
for mfa_node_index in self.mfa_node_indexes:
|
|
236
|
+
data.append(
|
|
237
|
+
dict(
|
|
238
|
+
name=f'{self.steps[mfa_node_index]['field']}',
|
|
239
|
+
type='text',
|
|
240
|
+
description='Input Recieved from the user during MFA'
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return data
|
|
245
|
+
|
|
246
|
+
|
|
199
247
|
def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
|
|
200
248
|
"""
|
|
201
249
|
Load a workflow from YAML configuration.
|
soprano_sdk/core/state.py
CHANGED
|
@@ -47,6 +47,8 @@ def create_state_model(data_fields: List[dict]):
|
|
|
47
47
|
fields['_node_field_map'] = Annotated[Dict[str, str], replace]
|
|
48
48
|
fields['_computed_fields'] = Annotated[List[str], replace]
|
|
49
49
|
fields['error'] = Annotated[Optional[Dict[str, str]], replace]
|
|
50
|
+
fields['_mfa'] = Annotated[Optional[Dict[str, str]], replace]
|
|
51
|
+
fields['mfa_input'] = Annotated[Optional[Dict[str, str]], replace]
|
|
50
52
|
|
|
51
53
|
return types.new_class('WorkflowState', (TypedDict,), {}, lambda ns: ns.update({'__annotations__': fields}))
|
|
52
54
|
|
|
@@ -61,7 +63,13 @@ def initialize_state(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
61
63
|
'_node_execution_order': [],
|
|
62
64
|
'_node_field_map': {},
|
|
63
65
|
'_computed_fields': [],
|
|
64
|
-
'error': None
|
|
66
|
+
'error': None,
|
|
67
|
+
'_mfa': {
|
|
68
|
+
'retry_count': 0,
|
|
69
|
+
'challengeType': None,
|
|
70
|
+
'status': None,
|
|
71
|
+
'message': None,
|
|
72
|
+
}
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
for field_name, default_value in fields_to_initialize.items():
|
soprano_sdk/nodes/base.py
CHANGED
|
@@ -18,10 +18,15 @@ class ActionStrategy(ABC):
|
|
|
18
18
|
def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
19
19
|
pass
|
|
20
20
|
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
pass
|
|
24
|
+
|
|
21
25
|
def get_node_function(self) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
|
|
22
26
|
def node_fn(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
27
|
logger.info(f"Executing node: {self.step_id} (action: {self.action})")
|
|
24
28
|
try:
|
|
29
|
+
self.pre_execute(state)
|
|
25
30
|
result = self.execute(state)
|
|
26
31
|
logger.info(f"Node {self.step_id} completed successfully")
|
|
27
32
|
logger.info(f"Result: {result}")
|
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
# # api_response_handler.py
|
|
2
|
+
# import re
|
|
3
|
+
# import os
|
|
4
|
+
# import json
|
|
5
|
+
# import time
|
|
6
|
+
# from typing import Any, Dict, List, Optional, Union, Callable
|
|
7
|
+
# from dataclasses import dataclass
|
|
8
|
+
# from enum import Enum
|
|
9
|
+
# import requests
|
|
10
|
+
# from requests.adapters import HTTPAdapter
|
|
11
|
+
# from urllib3.util.retry import Retry
|
|
12
|
+
# import importlib
|
|
13
|
+
# import operator
|
|
14
|
+
|
|
15
|
+
# class ErrorSeverity(Enum):
|
|
16
|
+
# """Error severity levels"""
|
|
17
|
+
|
|
18
|
+
# INFO = "info"
|
|
19
|
+
# WARNING = "warning"
|
|
20
|
+
# ERROR = "error"
|
|
21
|
+
# CRITICAL = "critical"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# @dataclass
|
|
25
|
+
# class APIError:
|
|
26
|
+
# """Structured API error"""
|
|
27
|
+
|
|
28
|
+
# step_id: str
|
|
29
|
+
# status_code: int
|
|
30
|
+
# error_code: Optional[str] = None
|
|
31
|
+
# message: str = ""
|
|
32
|
+
# severity: ErrorSeverity = ErrorSeverity.ERROR
|
|
33
|
+
# response_data: Optional[Dict] = None
|
|
34
|
+
# retry_after: Optional[int] = None
|
|
35
|
+
|
|
36
|
+
# def to_dict(self) -> Dict[str, Any]:
|
|
37
|
+
# return {
|
|
38
|
+
# "error": True,
|
|
39
|
+
# "step_id": self.step_id,
|
|
40
|
+
# "status_code": self.status_code,
|
|
41
|
+
# "error_code": self.error_code,
|
|
42
|
+
# "message": self.message,
|
|
43
|
+
# "severity": self.severity.value,
|
|
44
|
+
# "response_data": self.response_data,
|
|
45
|
+
# "retry_after": self.retry_after,
|
|
46
|
+
# }
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# class ValueResolver:
|
|
50
|
+
# """Resolves references and variables in values"""
|
|
51
|
+
|
|
52
|
+
# @staticmethod
|
|
53
|
+
# def resolve(value: Any, state: Dict[str, Any]) -> Any:
|
|
54
|
+
# """Resolve a value with state context"""
|
|
55
|
+
# if not isinstance(value, str):
|
|
56
|
+
# return value
|
|
57
|
+
|
|
58
|
+
# # Handle ${env.VAR_NAME}
|
|
59
|
+
# value = ValueResolver._resolve_env_vars(value)
|
|
60
|
+
|
|
61
|
+
# # Handle ${timestamp}
|
|
62
|
+
# if "${timestamp}" in value:
|
|
63
|
+
# value = value.replace("${timestamp}", str(int(time.time())))
|
|
64
|
+
|
|
65
|
+
# # Handle {state.variable} or {variable.nested.path}
|
|
66
|
+
# value = ValueResolver._resolve_state_references(value, state)
|
|
67
|
+
|
|
68
|
+
# return value
|
|
69
|
+
|
|
70
|
+
# @staticmethod
|
|
71
|
+
# def _resolve_env_vars(value: str) -> str:
|
|
72
|
+
# """Resolve environment variables"""
|
|
73
|
+
# env_pattern = r"\$\{env\.([^}]+)\}"
|
|
74
|
+
# matches = re.findall(env_pattern, value)
|
|
75
|
+
# for match in matches:
|
|
76
|
+
# env_value = os.environ.get(match, "")
|
|
77
|
+
# value = value.replace(f"${{env.{match}}}", env_value)
|
|
78
|
+
# return value
|
|
79
|
+
|
|
80
|
+
# @staticmethod
|
|
81
|
+
# def _resolve_state_references(value: str, state: Dict[str, Any]) -> str:
|
|
82
|
+
# """Resolve state references like {username} or {user.profile.name}"""
|
|
83
|
+
# pattern = r"\{([^}]+)\}"
|
|
84
|
+
# matches = re.findall(pattern, value)
|
|
85
|
+
|
|
86
|
+
# for match in matches:
|
|
87
|
+
# resolved = ValueResolver._extract_nested_value(state, match)
|
|
88
|
+
# if resolved is not None:
|
|
89
|
+
# value = value.replace(f"{{{match}}}", str(resolved))
|
|
90
|
+
|
|
91
|
+
# return value
|
|
92
|
+
|
|
93
|
+
# @staticmethod
|
|
94
|
+
# def _extract_nested_value(data: Any, path: str) -> Any:
|
|
95
|
+
# """Extract value from nested structure using dot notation"""
|
|
96
|
+
# parts = path.split(".")
|
|
97
|
+
# result = data
|
|
98
|
+
|
|
99
|
+
# for part in parts:
|
|
100
|
+
# if isinstance(result, dict):
|
|
101
|
+
# result = result.get(part)
|
|
102
|
+
# elif isinstance(result, list) and part.isdigit():
|
|
103
|
+
# try:
|
|
104
|
+
# result = result[int(part)]
|
|
105
|
+
# except (IndexError, ValueError):
|
|
106
|
+
# return None
|
|
107
|
+
# else:
|
|
108
|
+
# return None
|
|
109
|
+
|
|
110
|
+
# if result is None:
|
|
111
|
+
# return None
|
|
112
|
+
|
|
113
|
+
# return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# class ConditionEvaluator:
|
|
117
|
+
# OPERATORS = {
|
|
118
|
+
# "==": operator.eq,
|
|
119
|
+
# "!=": operator.ne,
|
|
120
|
+
# ">": operator.gt,
|
|
121
|
+
# "<": operator.lt,
|
|
122
|
+
# ">=": operator.ge,
|
|
123
|
+
# "<=": operator.le,
|
|
124
|
+
# "contains": lambda a, b: b in str(a) if a is not None else False,
|
|
125
|
+
# "not_contains": lambda a, b: b not in str(a) if a is not None else True,
|
|
126
|
+
# "in": lambda a, b: a in b if isinstance(b, (list, tuple)) else False,
|
|
127
|
+
# "not_in": lambda a, b: a not in b if isinstance(b, (list, tuple)) else True,
|
|
128
|
+
# "starts_with": (
|
|
129
|
+
# lambda a, b: str(a).startswith(str(b))
|
|
130
|
+
# if a is not None else False
|
|
131
|
+
# ),
|
|
132
|
+
# "ends_with": lambda a, b: str(a).endswith(str(b)) if a is not None else False,
|
|
133
|
+
# "is_null": lambda a, b: a is None,
|
|
134
|
+
# "is_not_null": lambda a, b: a is not None,
|
|
135
|
+
# "is_empty": lambda a, b: not a if isinstance(a, (list, dict, str)) else False,
|
|
136
|
+
# "is_not_empty": (
|
|
137
|
+
# lambda a, b: bool(a)
|
|
138
|
+
# if isinstance(a, (list, dict, str)) else False
|
|
139
|
+
# ),
|
|
140
|
+
# }
|
|
141
|
+
|
|
142
|
+
# @staticmethod
|
|
143
|
+
# def evaluate(
|
|
144
|
+
# condition: Dict[str, Any], response: Dict[str, Any], state: Dict[str, Any]) -> bool:
|
|
145
|
+
|
|
146
|
+
# if "status_code" in condition:
|
|
147
|
+
# expected = condition["status_code"]
|
|
148
|
+
# actual = response.get("status")
|
|
149
|
+
|
|
150
|
+
# if isinstance(expected, list):
|
|
151
|
+
# if actual not in expected:
|
|
152
|
+
# return False
|
|
153
|
+
# elif actual != expected:
|
|
154
|
+
# return False
|
|
155
|
+
|
|
156
|
+
# if "field" in condition:
|
|
157
|
+
# field_path = condition["field"]
|
|
158
|
+
# operator = condition.get("operator", "==")
|
|
159
|
+
# expected_value = condition.get("value")
|
|
160
|
+
|
|
161
|
+
# actual_value = ValueResolver._extract_nested_value(response, field_path)
|
|
162
|
+
|
|
163
|
+
# if isinstance(expected_value, str):
|
|
164
|
+
# expected_value = ValueResolver.resolve(expected_value, state)
|
|
165
|
+
|
|
166
|
+
# if operator not in ConditionEvaluator.OPERATORS:
|
|
167
|
+
# raise ValueError(f"Unknown operator: {operator}")
|
|
168
|
+
|
|
169
|
+
# return ConditionEvaluator.OPERATORS[operator](actual_value, expected_value)
|
|
170
|
+
|
|
171
|
+
# if "and" in condition:
|
|
172
|
+
# return all(
|
|
173
|
+
# ConditionEvaluator.evaluate(sub_cond, response, state)
|
|
174
|
+
# for sub_cond in condition["and"]
|
|
175
|
+
# )
|
|
176
|
+
|
|
177
|
+
# if "or" in condition:
|
|
178
|
+
# return any(
|
|
179
|
+
# ConditionEvaluator.evaluate(sub_cond, response, state)
|
|
180
|
+
# for sub_cond in condition["or"]
|
|
181
|
+
# )
|
|
182
|
+
|
|
183
|
+
# return True
|
|
184
|
+
|
|
185
|
+
# @staticmethod
|
|
186
|
+
# def evaluate_all(
|
|
187
|
+
# conditions: List[Dict[str, Any]],
|
|
188
|
+
# response: Dict[str, Any],
|
|
189
|
+
# state: Dict[str, Any],
|
|
190
|
+
# ) -> Optional[Dict[str, Any]]:
|
|
191
|
+
# """Find first matching condition from a list"""
|
|
192
|
+
# for condition in conditions:
|
|
193
|
+
# # Skip default conditions for now
|
|
194
|
+
# if condition.get("default"):
|
|
195
|
+
# continue
|
|
196
|
+
|
|
197
|
+
# if ConditionEvaluator.evaluate(condition, response, state):
|
|
198
|
+
# return condition
|
|
199
|
+
|
|
200
|
+
# # Return default if no match
|
|
201
|
+
# for condition in conditions:
|
|
202
|
+
# if condition.get("default"):
|
|
203
|
+
# return condition
|
|
204
|
+
|
|
205
|
+
# return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# class ResponseMapper:
|
|
209
|
+
# """Maps response data to state variables"""
|
|
210
|
+
|
|
211
|
+
# @staticmethod
|
|
212
|
+
# def apply_mappings(
|
|
213
|
+
# response: Dict[str, Any], mappings: List[Dict[str, Any]], state: Dict[str, Any]
|
|
214
|
+
# ) -> None:
|
|
215
|
+
# """Apply response mappings to state"""
|
|
216
|
+
# for mapping in mappings:
|
|
217
|
+
# from_path = mapping.get("from", "")
|
|
218
|
+
# to_field = mapping.get("to", "")
|
|
219
|
+
# transform = mapping.get("transform") # Optional transformation function
|
|
220
|
+
# default = mapping.get("default") # Default value if extraction fails
|
|
221
|
+
|
|
222
|
+
# # Extract value
|
|
223
|
+
# value = ValueResolver._extract_nested_value(response, from_path)
|
|
224
|
+
|
|
225
|
+
# # Apply default if needed
|
|
226
|
+
# if value is None and default is not None:
|
|
227
|
+
# value = default
|
|
228
|
+
|
|
229
|
+
# # Apply transformation if specified
|
|
230
|
+
# if value is not None and transform:
|
|
231
|
+
# value = ResponseMapper._apply_transform(value, transform, state)
|
|
232
|
+
|
|
233
|
+
# # Store in state
|
|
234
|
+
# if to_field:
|
|
235
|
+
# state[to_field] = value
|
|
236
|
+
|
|
237
|
+
# @staticmethod
|
|
238
|
+
# def _apply_transform(
|
|
239
|
+
# value: Any, transform: Union[str, Dict], state: Dict[str, Any]
|
|
240
|
+
# ) -> Any:
|
|
241
|
+
# """Apply transformation to a value"""
|
|
242
|
+
# if isinstance(transform, str):
|
|
243
|
+
# # Simple transformations
|
|
244
|
+
# if transform == "lowercase":
|
|
245
|
+
# return str(value).lower()
|
|
246
|
+
# elif transform == "uppercase":
|
|
247
|
+
# return str(value).upper()
|
|
248
|
+
# elif transform == "trim":
|
|
249
|
+
# return str(value).strip()
|
|
250
|
+
# elif transform == "int":
|
|
251
|
+
# return int(value)
|
|
252
|
+
# elif transform == "float":
|
|
253
|
+
# return float(value)
|
|
254
|
+
# elif transform == "bool":
|
|
255
|
+
# return bool(value)
|
|
256
|
+
# elif transform == "json":
|
|
257
|
+
# return json.loads(value) if isinstance(value, str) else value
|
|
258
|
+
|
|
259
|
+
# elif isinstance(transform, dict):
|
|
260
|
+
# # Function-based transformation
|
|
261
|
+
# if "function" in transform:
|
|
262
|
+
# func_path = transform["function"]
|
|
263
|
+
# func_args = transform.get("args", {})
|
|
264
|
+
|
|
265
|
+
# # Resolve arguments
|
|
266
|
+
# resolved_args = {
|
|
267
|
+
# k: ValueResolver.resolve(v, state) for k, v in func_args.items()
|
|
268
|
+
# }
|
|
269
|
+
# resolved_args["value"] = value
|
|
270
|
+
|
|
271
|
+
# # Execute transformation function
|
|
272
|
+
# return FunctionExecutor.execute_transform(func_path, resolved_args)
|
|
273
|
+
|
|
274
|
+
# return value
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# class ErrorHandler:
|
|
278
|
+
# """Handles API errors with configurable responses"""
|
|
279
|
+
|
|
280
|
+
# @staticmethod
|
|
281
|
+
# def handle_error(
|
|
282
|
+
# step_id: str,
|
|
283
|
+
# response: Dict[str, Any],
|
|
284
|
+
# error_config: Dict[str, Any],
|
|
285
|
+
# state: Dict[str, Any],
|
|
286
|
+
# ) -> APIError:
|
|
287
|
+
# """Handle an error based on configuration"""
|
|
288
|
+
# status_code = response.get("status", 0)
|
|
289
|
+
|
|
290
|
+
# message = error_config.get("message", "An error occurred")
|
|
291
|
+
# message = ValueResolver.resolve(message, {**state, "response": response})
|
|
292
|
+
|
|
293
|
+
# error_code_path = error_config.get("error_code_field", "error_code")
|
|
294
|
+
# error_code = ValueResolver._extract_nested_value(
|
|
295
|
+
# response, f"data.{error_code_path}"
|
|
296
|
+
# )
|
|
297
|
+
|
|
298
|
+
# severity_str = error_config.get("severity", "error")
|
|
299
|
+
# severity = ErrorSeverity(severity_str)
|
|
300
|
+
|
|
301
|
+
# retry_after = error_config.get("retry_after")
|
|
302
|
+
# if retry_after:
|
|
303
|
+
# retry_after = ValueResolver.resolve(retry_after, state)
|
|
304
|
+
|
|
305
|
+
# return APIError(
|
|
306
|
+
# step_id=step_id,
|
|
307
|
+
# status_code=status_code,
|
|
308
|
+
# error_code=error_code,
|
|
309
|
+
# message=message,
|
|
310
|
+
# severity=severity,
|
|
311
|
+
# response_data=response.get("data"),
|
|
312
|
+
# retry_after=retry_after,
|
|
313
|
+
# )
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# class PostProcessor:
|
|
317
|
+
# """Handles post-processing of API responses"""
|
|
318
|
+
|
|
319
|
+
# @staticmethod
|
|
320
|
+
# def process(
|
|
321
|
+
# response: Dict[str, Any], post_config: Dict[str, Any], state: Dict[str, Any]
|
|
322
|
+
# ) -> Any:
|
|
323
|
+
# """Execute post-processing on response"""
|
|
324
|
+
# function_path = post_config.get("function")
|
|
325
|
+
# inputs = post_config.get("inputs", {})
|
|
326
|
+
# output_field = post_config.get("output")
|
|
327
|
+
|
|
328
|
+
# resolved_inputs = {"response": response}
|
|
329
|
+
# for key, value in inputs.items():
|
|
330
|
+
# resolved_inputs[key] = ValueResolver.resolve(value, state)
|
|
331
|
+
|
|
332
|
+
# result = FunctionExecutor.execute(function_path, resolved_inputs)
|
|
333
|
+
|
|
334
|
+
# if output_field:
|
|
335
|
+
# state[output_field] = result
|
|
336
|
+
|
|
337
|
+
# return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# class FunctionExecutor:
|
|
341
|
+
# """Executes functions from module paths"""
|
|
342
|
+
|
|
343
|
+
# @staticmethod
|
|
344
|
+
# def execute(function_path: str, inputs: Dict[str, Any]) -> Any:
|
|
345
|
+
# """Execute a function given its module path"""
|
|
346
|
+
# try:
|
|
347
|
+
# # Parse module and function name
|
|
348
|
+
# parts = function_path.rsplit(".", 1)
|
|
349
|
+
# if len(parts) != 2:
|
|
350
|
+
# raise ValueError(f"Invalid function path: {function_path}")
|
|
351
|
+
|
|
352
|
+
# module_name, function_name = parts
|
|
353
|
+
|
|
354
|
+
# module = importlib.import_module(module_name)
|
|
355
|
+
# function = getattr(module, function_name)
|
|
356
|
+
|
|
357
|
+
# return function(**inputs)
|
|
358
|
+
|
|
359
|
+
# except Exception as e:
|
|
360
|
+
# raise Exception(f"Failed to execute function {function_path}: {str(e)}")
|
|
361
|
+
|
|
362
|
+
# @staticmethod
|
|
363
|
+
# def execute_transform(function_path: str, inputs: Dict[str, Any]) -> Any:
|
|
364
|
+
# """Execute a transformation function"""
|
|
365
|
+
# return FunctionExecutor.execute(function_path, inputs)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# class APIClient:
|
|
369
|
+
# """Handles API requests with comprehensive error handling"""
|
|
370
|
+
|
|
371
|
+
# def __init__(self, config: Dict[str, Any]):
|
|
372
|
+
# self.name = config.get("name")
|
|
373
|
+
# self.base_url = ValueResolver._resolve_env_vars(config.get("base_url", ""))
|
|
374
|
+
# self.headers = self._resolve_headers(config.get("headers", {}))
|
|
375
|
+
# self.timeout = config.get("timeout", 30)
|
|
376
|
+
# self.retry_config = config.get("retry", {})
|
|
377
|
+
|
|
378
|
+
# self.session = self._create_session()
|
|
379
|
+
|
|
380
|
+
# def _resolve_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
381
|
+
# """Resolve all header values"""
|
|
382
|
+
# return {k: ValueResolver._resolve_env_vars(v) for k, v in headers.items()}
|
|
383
|
+
|
|
384
|
+
# def _create_session(self) -> requests.Session:
|
|
385
|
+
# """Create requests session with retry logic"""
|
|
386
|
+
# session = requests.Session()
|
|
387
|
+
|
|
388
|
+
# if self.retry_config:
|
|
389
|
+
# retry_strategy = Retry(
|
|
390
|
+
# total=self.retry_config.get("max_attempts", 3),
|
|
391
|
+
# backoff_factor=1 if self.retry_config.get("backoff") == "exponential" else 0,
|
|
392
|
+
# status_forcelist=self.retry_config.get("for_status_codes", [429, 500, 502, 503, 504]),
|
|
393
|
+
# )
|
|
394
|
+
# adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
395
|
+
# session.mount("http://", adapter)
|
|
396
|
+
# session.mount("https://", adapter)
|
|
397
|
+
|
|
398
|
+
# return session
|
|
399
|
+
|
|
400
|
+
# def execute(
|
|
401
|
+
# self,
|
|
402
|
+
# request_config: Dict[str, Any],
|
|
403
|
+
# state: Dict[str, Any],
|
|
404
|
+
# step_id: str = "api_call",
|
|
405
|
+
# ) -> Dict[str, Any]:
|
|
406
|
+
# """Execute API request with full error handling"""
|
|
407
|
+
|
|
408
|
+
# method = request_config.get("method", "GET").upper()
|
|
409
|
+
# path = self._prepare_path(request_config, state)
|
|
410
|
+
# url = f"{self.base_url}{path}"
|
|
411
|
+
|
|
412
|
+
# query_params = self._prepare_params(
|
|
413
|
+
# request_config.get("query_params", {}), state
|
|
414
|
+
# )
|
|
415
|
+
# headers = self._prepare_headers(request_config, state)
|
|
416
|
+
# body = self._prepare_body(request_config, state)
|
|
417
|
+
|
|
418
|
+
# try:
|
|
419
|
+
# response = self.session.request(
|
|
420
|
+
# method=method,
|
|
421
|
+
# url=url,
|
|
422
|
+
# headers=headers,
|
|
423
|
+
# params=query_params,
|
|
424
|
+
# json=body if request_config.get("body_type", "json") == "json" else None,
|
|
425
|
+
# data=body if request_config.get("body_type") != "json" else None,
|
|
426
|
+
# timeout=self.timeout,
|
|
427
|
+
# )
|
|
428
|
+
|
|
429
|
+
# try:
|
|
430
|
+
# response_data = response.json()
|
|
431
|
+
# except:
|
|
432
|
+
# response_data = {"raw": response.text}
|
|
433
|
+
|
|
434
|
+
# return {
|
|
435
|
+
# "status": response.status_code,
|
|
436
|
+
# "data": response_data,
|
|
437
|
+
# "headers": dict(response.headers),
|
|
438
|
+
# "success": response.ok,
|
|
439
|
+
# "url": url,
|
|
440
|
+
# "method": method,
|
|
441
|
+
# }
|
|
442
|
+
|
|
443
|
+
# except requests.exceptions.Timeout:
|
|
444
|
+
# return {
|
|
445
|
+
# "status": 0,
|
|
446
|
+
# "error": "Request timeout",
|
|
447
|
+
# "error_type": "timeout",
|
|
448
|
+
# "success": False,
|
|
449
|
+
# }
|
|
450
|
+
# except requests.exceptions.ConnectionError:
|
|
451
|
+
# return {
|
|
452
|
+
# "status": 0,
|
|
453
|
+
# "error": "Connection error",
|
|
454
|
+
# "error_type": "connection",
|
|
455
|
+
# "success": False,
|
|
456
|
+
# }
|
|
457
|
+
# except requests.exceptions.RequestException as e:
|
|
458
|
+
# return {
|
|
459
|
+
# "status": 0,
|
|
460
|
+
# "error": str(e),
|
|
461
|
+
# "error_type": "request",
|
|
462
|
+
# "success": False,
|
|
463
|
+
# }
|
|
464
|
+
|
|
465
|
+
# def _prepare_path(
|
|
466
|
+
# self, request_config: Dict[str, Any], state: Dict[str, Any]
|
|
467
|
+
# ) -> str:
|
|
468
|
+
# """Prepare URL path with variable substitution"""
|
|
469
|
+
# path = request_config.get("path", "")
|
|
470
|
+
# path_params = request_config.get("path_params", {})
|
|
471
|
+
|
|
472
|
+
# for key, value in path_params.items():
|
|
473
|
+
# resolved_value = ValueResolver.resolve(value, state)
|
|
474
|
+
# path = path.replace(f"{{{key}}}", str(resolved_value))
|
|
475
|
+
|
|
476
|
+
# return path
|
|
477
|
+
|
|
478
|
+
# def _prepare_params(
|
|
479
|
+
# self, params: Dict[str, Any], state: Dict[str, Any]
|
|
480
|
+
# ) -> Dict[str, Any]:
|
|
481
|
+
# """Prepare query parameters"""
|
|
482
|
+
# return {k: ValueResolver.resolve(v, state) for k, v in params.items()}
|
|
483
|
+
|
|
484
|
+
# def _prepare_headers(
|
|
485
|
+
# self, request_config: Dict[str, Any], state: Dict[str, Any]
|
|
486
|
+
# ) -> Dict[str, str]:
|
|
487
|
+
# """Prepare request headers"""
|
|
488
|
+
# headers = {**self.headers}
|
|
489
|
+
|
|
490
|
+
# if "headers" in request_config:
|
|
491
|
+
# for key, value in request_config["headers"].items():
|
|
492
|
+
# headers[key] = ValueResolver.resolve(value, state)
|
|
493
|
+
|
|
494
|
+
# return headers
|
|
495
|
+
|
|
496
|
+
# def _prepare_body(
|
|
497
|
+
# self, request_config: Dict[str, Any], state: Dict[str, Any]
|
|
498
|
+
# ) -> Optional[Dict[str, Any]]:
|
|
499
|
+
# """Prepare request body"""
|
|
500
|
+
# if "body" not in request_config:
|
|
501
|
+
# return None
|
|
502
|
+
|
|
503
|
+
# body = request_config["body"]
|
|
504
|
+
# return self._resolve_dict(body, state)
|
|
505
|
+
|
|
506
|
+
# def _resolve_dict(
|
|
507
|
+
# self, data: Dict[str, Any], state: Dict[str, Any]
|
|
508
|
+
# ) -> Dict[str, Any]:
|
|
509
|
+
# """Recursively resolve dictionary values"""
|
|
510
|
+
# result = {}
|
|
511
|
+
# for key, value in data.items():
|
|
512
|
+
# if isinstance(value, dict):
|
|
513
|
+
# result[key] = self._resolve_dict(value, state)
|
|
514
|
+
# elif isinstance(value, list):
|
|
515
|
+
# result[key] = [
|
|
516
|
+
# self._resolve_dict(item, state)
|
|
517
|
+
# if isinstance(item, dict)
|
|
518
|
+
# else ValueResolver.resolve(item, state)
|
|
519
|
+
# for item in value
|
|
520
|
+
# ]
|
|
521
|
+
# else:
|
|
522
|
+
# result[key] = ValueResolver.resolve(value, state)
|
|
523
|
+
# return result
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# class APIResponseHandler:
|
|
527
|
+
# """Main handler for API responses with error handling and post-processing"""
|
|
528
|
+
|
|
529
|
+
# def __init__(self, step_id: str, api_client: APIClient):
|
|
530
|
+
# self.step_id = step_id
|
|
531
|
+
# self.api_client = api_client
|
|
532
|
+
|
|
533
|
+
# def execute_with_handling(
|
|
534
|
+
# self,
|
|
535
|
+
# request_config: Dict[str, Any],
|
|
536
|
+
# response_config: Dict[str, Any],
|
|
537
|
+
# state: Dict[str, Any],
|
|
538
|
+
# ) -> Dict[str, Any]:
|
|
539
|
+
# """Execute API call with full response handling"""
|
|
540
|
+
|
|
541
|
+
# response = self.api_client.execute(request_config, state, self.step_id)
|
|
542
|
+
|
|
543
|
+
# error_handlers = response_config.get("error_handlers", [])
|
|
544
|
+
# error = self._check_errors(response, error_handlers, state)
|
|
545
|
+
|
|
546
|
+
# if error:
|
|
547
|
+
# return error.to_dict()
|
|
548
|
+
|
|
549
|
+
# mappings = response_config.get("mappings", [])
|
|
550
|
+
# if mappings:
|
|
551
|
+
# ResponseMapper.apply_mappings(response, mappings, state)
|
|
552
|
+
|
|
553
|
+
# if "post_process" in response_config:
|
|
554
|
+
# post_result = PostProcessor.process(
|
|
555
|
+
# response, response_config["post_process"], state
|
|
556
|
+
# )
|
|
557
|
+
|
|
558
|
+
# conditions = response_config.get("conditions", [])
|
|
559
|
+
# if conditions:
|
|
560
|
+
# matching_condition = ConditionEvaluator.evaluate_all(
|
|
561
|
+
# conditions, response, state
|
|
562
|
+
# )
|
|
563
|
+
|
|
564
|
+
# if matching_condition:
|
|
565
|
+
# if "mappings" in matching_condition:
|
|
566
|
+
# ResponseMapper.apply_mappings(
|
|
567
|
+
# response, matching_condition["mappings"], state
|
|
568
|
+
# )
|
|
569
|
+
|
|
570
|
+
# if "next" in matching_condition:
|
|
571
|
+
# state["_next_step"] = matching_condition["next"]
|
|
572
|
+
|
|
573
|
+
# if "action" in matching_condition:
|
|
574
|
+
# state["_action"] = matching_condition["action"]
|
|
575
|
+
|
|
576
|
+
# return {"success": True, "response": response, "state": state}
|
|
577
|
+
|
|
578
|
+
# def _check_errors(
|
|
579
|
+
# self,
|
|
580
|
+
# response: Dict[str, Any],
|
|
581
|
+
# error_handlers: List[Dict[str, Any]],
|
|
582
|
+
# state: Dict[str, Any],
|
|
583
|
+
# ) -> Optional[APIError]:
|
|
584
|
+
# for error_config in error_handlers:
|
|
585
|
+
# # Evaluate error condition
|
|
586
|
+
# if ConditionEvaluator.evaluate(error_config, response, state):
|
|
587
|
+
# return ErrorHandler.handle_error(
|
|
588
|
+
# self.step_id, response, error_config, state
|
|
589
|
+
# )
|
|
590
|
+
|
|
591
|
+
# return None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# # Example usage
|
|
595
|
+
# if __name__ == "__main__":
|
|
596
|
+
# # Example configuration
|
|
597
|
+
# api_config = {
|
|
598
|
+
# "name": "user_service",
|
|
599
|
+
# "base_url": "https://api.example.com",
|
|
600
|
+
# "headers": {
|
|
601
|
+
# "Authorization": "Bearer ${env.API_TOKEN}",
|
|
602
|
+
# "Content-Type": "application/json",
|
|
603
|
+
# },
|
|
604
|
+
# "timeout": 30,
|
|
605
|
+
# }
|
|
606
|
+
|
|
607
|
+
# request_config = {
|
|
608
|
+
# "method": "GET",
|
|
609
|
+
# "path": "/users/{user_id}",
|
|
610
|
+
# "path_params": {"user_id": "{user_id}"},
|
|
611
|
+
# }
|
|
612
|
+
|
|
613
|
+
# response_config = {
|
|
614
|
+
# "mappings": [
|
|
615
|
+
# {"from": "data.name", "to": "user_name"},
|
|
616
|
+
# {"from": "data.email", "to": "user_email"},
|
|
617
|
+
# ],
|
|
618
|
+
# "error_handlers": [
|
|
619
|
+
# {
|
|
620
|
+
# "status_code": 404,
|
|
621
|
+
# "field": "data.error_code",
|
|
622
|
+
# "operator": "==",
|
|
623
|
+
# "value": "ERR_NOT_FOUND",
|
|
624
|
+
# "message": "Sorry, we could not find the user you requested",
|
|
625
|
+
# "severity": "error",
|
|
626
|
+
# },
|
|
627
|
+
# {
|
|
628
|
+
# "status_code": 200,
|
|
629
|
+
# "field": "data.error_data",
|
|
630
|
+
# "operator": "==",
|
|
631
|
+
# "value": "ERR_NOT_FOUND",
|
|
632
|
+
# "message": "Sorry, we could not find the request",
|
|
633
|
+
# "severity": "warning",
|
|
634
|
+
# },
|
|
635
|
+
# ],
|
|
636
|
+
# "post_process": {
|
|
637
|
+
# "function": "response_processors.format_user_data",
|
|
638
|
+
# "inputs": {"user_id": "{user_id}"},
|
|
639
|
+
# "output": "formatted_user",
|
|
640
|
+
# },
|
|
641
|
+
# }
|
|
642
|
+
|
|
643
|
+
# # Initialize
|
|
644
|
+
# state = {"user_id": "12345"}
|
|
645
|
+
# api_client = APIClient(api_config)
|
|
646
|
+
# handler = APIResponseHandler("fetch_user", api_client)
|
|
647
|
+
|
|
648
|
+
# # Execute
|
|
649
|
+
# result = handler.execute_with_handling(request_config, response_config, state)
|
|
650
|
+
|
|
651
|
+
# print(json.dumps(result, indent=2))
|
|
@@ -3,9 +3,23 @@ from typing import Dict, Any
|
|
|
3
3
|
from .base import ActionStrategy
|
|
4
4
|
from ..core.state import set_state_value, get_state_value
|
|
5
5
|
from ..utils.logger import logger
|
|
6
|
+
from jinja2 import Environment
|
|
7
|
+
import uuid
|
|
8
|
+
from ..core.constants import WorkflowKeys
|
|
6
9
|
from ..utils.template import get_nested_value
|
|
7
10
|
|
|
8
11
|
|
|
12
|
+
def compile_values(template_loader, state: dict, values: Any):
|
|
13
|
+
if isinstance(values, dict):
|
|
14
|
+
return {k: compile_values(template_loader, state, v) for k, v in values.items()}
|
|
15
|
+
elif isinstance(values, list):
|
|
16
|
+
return [compile_values(template_loader, state, value) for value in values]
|
|
17
|
+
elif isinstance(values, str):
|
|
18
|
+
return template_loader.from_string(values).render(state)
|
|
19
|
+
else:
|
|
20
|
+
return values
|
|
21
|
+
|
|
22
|
+
|
|
9
23
|
class CallFunctionStrategy(ActionStrategy):
|
|
10
24
|
def __init__(self, step_config: Dict[str, Any], engine_context: Any):
|
|
11
25
|
super().__init__(step_config, engine_context)
|
|
@@ -21,6 +35,14 @@ class CallFunctionStrategy(ActionStrategy):
|
|
|
21
35
|
if not self.output_field:
|
|
22
36
|
raise RuntimeError(f"Step '{self.step_id}' missing required 'output' property")
|
|
23
37
|
|
|
38
|
+
def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
39
|
+
if 'mfa' in self.step_config:
|
|
40
|
+
state['_mfa'] = state.get('_mfa', {})
|
|
41
|
+
state['_mfa']['post_payload'] = dict(transactionId=str(uuid.uuid4()))
|
|
42
|
+
template_loader = self.engine_context.get_config_value("template_loader", Environment())
|
|
43
|
+
for k, v in self.step_config['mfa']['payload'].items():
|
|
44
|
+
state['_mfa']['post_payload'][k] = compile_values(template_loader, state, v)
|
|
45
|
+
|
|
24
46
|
def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
25
47
|
from ..utils.tracing import trace_node_execution
|
|
26
48
|
|
|
@@ -58,8 +80,6 @@ class CallFunctionStrategy(ActionStrategy):
|
|
|
58
80
|
|
|
59
81
|
if self.output_field:
|
|
60
82
|
set_state_value(state, self.output_field, result)
|
|
61
|
-
|
|
62
|
-
from ..core.constants import WorkflowKeys
|
|
63
83
|
computed_fields = get_state_value(state, WorkflowKeys.COMPUTED_FIELDS, [])
|
|
64
84
|
if self.output_field not in computed_fields:
|
|
65
85
|
computed_fields.append(self.output_field)
|
|
@@ -114,6 +114,9 @@ class CollectInputStrategy(ActionStrategy):
|
|
|
114
114
|
def _conversation_key(self) -> str:
|
|
115
115
|
return f'{self.field}_conversation'
|
|
116
116
|
|
|
117
|
+
def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
118
|
+
state['_active_input_field'] = self.step_config.get('field')
|
|
119
|
+
|
|
117
120
|
@property
|
|
118
121
|
def _formatted_field_name(self) -> str:
|
|
119
122
|
return self.field.replace('_', ' ').title()
|
soprano_sdk/tools.py
CHANGED
soprano_sdk/validation/schema.py
CHANGED
|
@@ -226,7 +226,28 @@ WORKFLOW_SCHEMA = {
|
|
|
226
226
|
"minimum": 1,
|
|
227
227
|
"maximum": 600,
|
|
228
228
|
"description": "Timeout in seconds"
|
|
229
|
-
}
|
|
229
|
+
},
|
|
230
|
+
"mfa": {
|
|
231
|
+
"type": "object",
|
|
232
|
+
"description": "Multi-factor authentication configuration",
|
|
233
|
+
"required": ["type", "model", "payload"],
|
|
234
|
+
"properties": {
|
|
235
|
+
"model": {
|
|
236
|
+
"type": "string",
|
|
237
|
+
"description": "Path to the model which will be used to parse the MFA input from the user"
|
|
238
|
+
},
|
|
239
|
+
"type": {
|
|
240
|
+
"type": "string",
|
|
241
|
+
"enum": ["REST"],
|
|
242
|
+
"description": "API type for MFA"
|
|
243
|
+
},
|
|
244
|
+
"payload": {
|
|
245
|
+
"type": "object",
|
|
246
|
+
"description": "MFA payload data that is posted to the RESTAPI, Apart from the properties provided transactionId is sent by the framework in the post payload as an additional property, transactionId is the same throughout the MFA process",
|
|
247
|
+
"additionalProperties": True
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
230
251
|
}
|
|
231
252
|
}
|
|
232
253
|
},
|
|
@@ -3,6 +3,7 @@ from typing import List, Set
|
|
|
3
3
|
import jsonschema
|
|
4
4
|
|
|
5
5
|
from .schema import WORKFLOW_SCHEMA
|
|
6
|
+
from soprano_sdk.core.constants import MFARestAuthorizerEnv
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class ValidationResult:
|
|
@@ -64,7 +65,7 @@ class WorkflowValidator:
|
|
|
64
65
|
if not step_id:
|
|
65
66
|
self.errors.append(f"Step at index {i} is missing 'id' field")
|
|
66
67
|
continue
|
|
67
|
-
|
|
68
|
+
self._validate_authorizer(step)
|
|
68
69
|
if step_id in step_ids:
|
|
69
70
|
self.errors.append(f"Duplicate step ID: '{step_id}'")
|
|
70
71
|
step_ids.add(step_id)
|
|
@@ -108,9 +109,37 @@ class WorkflowValidator:
|
|
|
108
109
|
f"Step '{step_id}' transition {i} references unknown target: '{next_target}'"
|
|
109
110
|
)
|
|
110
111
|
|
|
112
|
+
def _validate_authorizer(self, step):
|
|
113
|
+
|
|
114
|
+
def _validate_rest_fields():
|
|
115
|
+
if mfa_authorizer['type'] == 'REST':
|
|
116
|
+
for field in MFARestAuthorizerEnv:
|
|
117
|
+
is_present = field.get_from_env()
|
|
118
|
+
if not is_present:
|
|
119
|
+
self.errors.append(f"`{field.value}` needs to be set as Environment for REST MFA")
|
|
120
|
+
else:
|
|
121
|
+
self.errors.append(f"step({step['id']}) -> mfa -> type is unsupported")
|
|
122
|
+
|
|
123
|
+
mfa_authorizer = step.get("mfa", None)
|
|
124
|
+
if mfa_authorizer is None:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if mfa_authorizer and step['action'] != 'call_function':
|
|
128
|
+
self.errors.append(
|
|
129
|
+
f"MFA is enabled in step({step['id']}). MFA is supported only for `call_function` nodes"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
model = mfa_authorizer.get('model')
|
|
133
|
+
if not model:
|
|
134
|
+
self.errors.append(f"step({step['id']}) -> mfa -> model is missing")
|
|
135
|
+
|
|
136
|
+
_validate_rest_fields()
|
|
137
|
+
|
|
138
|
+
|
|
111
139
|
def _validate_data_fields(self):
|
|
112
140
|
data_fields = {field.get('name') for field in self.config.get('data', [])}
|
|
113
141
|
steps = self.config.get('steps', [])
|
|
142
|
+
has_mfa = False
|
|
114
143
|
|
|
115
144
|
for step in steps:
|
|
116
145
|
step_id = step.get('id', 'unknown')
|
|
@@ -135,6 +164,9 @@ class WorkflowValidator:
|
|
|
135
164
|
output = step.get('output')
|
|
136
165
|
function = step.get('function')
|
|
137
166
|
|
|
167
|
+
if step.get('mfa'):
|
|
168
|
+
has_mfa = True
|
|
169
|
+
|
|
138
170
|
if not function:
|
|
139
171
|
self.errors.append(f"Step '{step_id}' is missing 'function' property")
|
|
140
172
|
|
|
@@ -145,6 +177,11 @@ class WorkflowValidator:
|
|
|
145
177
|
f"Step '{step_id}' output field '{output}' not defined in data fields"
|
|
146
178
|
)
|
|
147
179
|
|
|
180
|
+
if has_mfa and 'bearer_token' not in data_fields:
|
|
181
|
+
self.errors.append(
|
|
182
|
+
"`bearer_token` must be defined in `data` section: for MFA authorization"
|
|
183
|
+
)
|
|
184
|
+
|
|
148
185
|
def _validate_function_references(self):
|
|
149
186
|
steps = self.config.get('steps', [])
|
|
150
187
|
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
soprano_sdk/__init__.py,sha256=y3c4i7Q7SAPS2Tee7V0TzWdhgMxBWfDJJ98eqD1HxGI,188
|
|
2
2
|
soprano_sdk/engine.py,sha256=EFK91iTHjp72otLN6Kg-yeLye2J3CAKN0QH4FI2taL8,14838
|
|
3
|
-
soprano_sdk/tools.py,sha256=
|
|
3
|
+
soprano_sdk/tools.py,sha256=bjJzapnQp31iLDx8jHp_0X2FcS2kY8uqf6hsgD3uDx4,7442
|
|
4
4
|
soprano_sdk/agents/__init__.py,sha256=Yzbtv6iP_ABRgZo0IUjy9vDofEvLFbOjuABw758176A,636
|
|
5
5
|
soprano_sdk/agents/adaptor.py,sha256=Cm02YKFclrESu-Qq4CTknCgU7KaA7Z_2FspnQDkEVfU,3214
|
|
6
6
|
soprano_sdk/agents/factory.py,sha256=Aucfz4rZVKCXMAQtbGAqp1JR8aYwa66mokRmKkKGhYA,6699
|
|
7
7
|
soprano_sdk/agents/structured_output.py,sha256=7DSVzfMPsZAqBwI3v6XL15qG5Gh4jJ-qddcVPaa3gdc,3326
|
|
8
|
+
soprano_sdk/authenticators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
soprano_sdk/authenticators/mfa.py,sha256=bSUiwN2xH9kJlP4C_YTIOIXhmYb2QObAthL9C6vM238,5248
|
|
8
10
|
soprano_sdk/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
soprano_sdk/core/constants.py,sha256
|
|
10
|
-
soprano_sdk/core/engine.py,sha256=
|
|
11
|
+
soprano_sdk/core/constants.py,sha256=-Ij4bbSDsyxQ_NZ5NiusJeepWhoe13WGuzN3LlnXqWY,2017
|
|
12
|
+
soprano_sdk/core/engine.py,sha256=w1ofIGmBaXBa4UxWwYzRjIdJiCFC6QXqivuo3eESW5M,10085
|
|
11
13
|
soprano_sdk/core/rollback_strategies.py,sha256=NjDTtBCZlqyDql5PSwI9SMDLK7_BNlTxbW_cq_5gV0g,7783
|
|
12
|
-
soprano_sdk/core/state.py,sha256=
|
|
14
|
+
soprano_sdk/core/state.py,sha256=ENf81OOe4C2IEYGWUcaBwJ3O1P6VRg4DVirOXFXjioA,2899
|
|
13
15
|
soprano_sdk/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
soprano_sdk/nodes/base.py,sha256=
|
|
15
|
-
soprano_sdk/nodes/
|
|
16
|
-
soprano_sdk/nodes/
|
|
16
|
+
soprano_sdk/nodes/base.py,sha256=idFyOGGPnjsASYnrOF_NIh7eFcSuJqw61EoVN_WCTaU,2360
|
|
17
|
+
soprano_sdk/nodes/call_api.py,sha256=9ApkuKMU8en5pQ7SK0yB8eF6KXY_bYV2Q3DkthibizU,22655
|
|
18
|
+
soprano_sdk/nodes/call_function.py,sha256=Quys2Rf2JitV1fnTpnEIpIoCCOQDzhWBriRXO59UqqI,5469
|
|
19
|
+
soprano_sdk/nodes/collect_input.py,sha256=pR4PrQfnufAZmCpN_1T14zBp3rg8BkSWGP7eMSa3pZE,23140
|
|
17
20
|
soprano_sdk/nodes/factory.py,sha256=l-Gysfgnao-o2dphhnbjjxcH3ojZanZNYN3CBH9dDbA,1624
|
|
18
21
|
soprano_sdk/routing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
22
|
soprano_sdk/routing/router.py,sha256=SrNciTIXXdC9bAbbO5bX7PN9mlRbITjr4RZdNm4jEVA,3450
|
|
@@ -24,9 +27,9 @@ soprano_sdk/utils/template.py,sha256=MG_B9TMx1ShpnSGo7s7TO-VfQzuFByuRNhJTvZ668kM
|
|
|
24
27
|
soprano_sdk/utils/tool.py,sha256=hWN826HIKmLdswLCTURLH8hWlb2WU0MB8nIUErbpB-8,1877
|
|
25
28
|
soprano_sdk/utils/tracing.py,sha256=gSHeBDLe-MbAZ9rkzpCoGFveeMdR9KLaA6tteB0IWjk,1991
|
|
26
29
|
soprano_sdk/validation/__init__.py,sha256=ImChmO86jYHU90xzTttto2-LmOUOmvY_ibOQaLRz5BA,262
|
|
27
|
-
soprano_sdk/validation/schema.py,sha256=
|
|
28
|
-
soprano_sdk/validation/validator.py,sha256=
|
|
29
|
-
soprano_sdk-0.
|
|
30
|
-
soprano_sdk-0.
|
|
31
|
-
soprano_sdk-0.
|
|
32
|
-
soprano_sdk-0.
|
|
30
|
+
soprano_sdk/validation/schema.py,sha256=R6bm4Pzgx8p08o77FN9FFSKTLTKOCf7WuThrR6rV2FI,14578
|
|
31
|
+
soprano_sdk/validation/validator.py,sha256=mUTweaQyDaPqHWQDlsE9AFwnB3_MQAEOBrj1hwKQK68,7888
|
|
32
|
+
soprano_sdk-0.2.0.dist-info/METADATA,sha256=ovCyWtkIXMfuAvs3-5UttlzCEn7r57ZhUNMfm9C6L2o,11297
|
|
33
|
+
soprano_sdk-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
34
|
+
soprano_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
|
|
35
|
+
soprano_sdk-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|