diff --git a/README.md b/README.md index 4be253377..8fcce21f7 100644 --- a/README.md +++ b/README.md @@ -1096,6 +1096,62 @@ exported_theme = descope_client.mgmt.flow.export_theme() imported_theme = descope_client.mgmt.flow.import_flow( theme={} ) + +# Run a flow with the given flow id and options. +# You can use the FlowRunOptions class or pass a dict directly. +from descope import FlowRunOptions + +# Using FlowRunOptions class +result = descope_client.mgmt.flow.run_flow( + flow_id="my-flow-id", + options=FlowRunOptions( + input={"key": "value"}, + preview=True, + tenant="tenant-id", + ), +) + +# Or using a dict +result = descope_client.mgmt.flow.run_flow( + flow_id="my-flow-id", + options={ + "input": {"key": "value"}, + "preview": True, + "tenant": "tenant-id", + }, +) + +# Run a flow asynchronously for long-running flows. +# This returns immediately with an execution ID that can be used to check the result later. +from descope import FlowRunOptions + +# Start an async flow run +async_result = descope_client.mgmt.flow.run_flow_async( + flow_id="my-flow-id", + options=FlowRunOptions( + flow_input={"key": "value"}, + preview=True, + tenant="tenant-id", + ), +) +execution_id = async_result["executionId"] + +# Or using a dict +async_result = descope_client.mgmt.flow.run_flow_async( + flow_id="my-flow-id", + options={ + "input": {"key": "value"}, + "preview": True, + "tenant": "tenant-id", + }, +) +execution_id = async_result["executionId"] + +# Check the result of an async flow execution +# Poll this endpoint until the flow completes +result = descope_client.mgmt.flow.get_flow_async_result( + execution_id=execution_id, +) ``` ### Query SSO Groups diff --git a/descope/__init__.py b/descope/__init__.py index 29e3f4171..91666a775 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -26,6 +26,7 @@ DescoperRBAC, DescoperRole, DescoperTagRole, + FlowRunOptions, MgmtKeyProjectRole, MgmtKeyReBac, MgmtKeyStatus, diff --git a/descope/management/common.py b/descope/management/common.py index 577b783c3..cd290f439 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -209,6 +209,9 @@ class MgmtV1: flow_delete_path = "/v1/mgmt/flow/delete" flow_import_path = "/v1/mgmt/flow/import" flow_export_path = "/v1/mgmt/flow/export" + flow_run_path = "/v1/mgmt/flow/run" + flow_async_run_path = "/v1/mgmt/flow/async/run" + flow_async_result_path = "/v1/mgmt/flow/async/result" # theme theme_import_path = "/v1/mgmt/theme/import" @@ -283,6 +286,42 @@ def __init__( self.refresh_duration = refresh_duration +class FlowRunOptions: + """ + Options for running a flow. + """ + + def __init__( + self, + flow_input: Optional[Dict[str, Any]] = None, + preview: Optional[bool] = None, + tenant: Optional[str] = None, + ): + self.flow_input = flow_input + self.preview = preview + self.tenant = tenant + + def to_dict(self) -> Dict[str, Any]: + result: Dict[str, Any] = {} + if self.flow_input is not None: + result["input"] = self.flow_input + if self.preview is not None: + result["preview"] = self.preview + if self.tenant is not None: + result["tenant"] = self.tenant + return result + + @staticmethod + def from_dict(options: Optional[dict]) -> Optional["FlowRunOptions"]: + if options is None: + return None + return FlowRunOptions( + flow_input=options.get("input"), + preview=options.get("preview"), + tenant=options.get("tenant"), + ) + + class MgmtLoginOptions: def __init__( self, diff --git a/descope/management/flow.py b/descope/management/flow.py index d7e2a5fe5..7b76fb6db 100644 --- a/descope/management/flow.py +++ b/descope/management/flow.py @@ -1,7 +1,7 @@ -from typing import List +from typing import List, Optional, Union from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 +from descope.management.common import FlowRunOptions, MgmtV1 class Flow(HTTPBase): @@ -146,3 +146,97 @@ def import_theme( }, ) return response.json() + + def run_flow( + self, + flow_id: str, + options: Optional[Union[FlowRunOptions, dict]] = None, + ) -> dict: + """ + Run a flow with the given flow id and options. + + Args: + flow_id (str): the flow id to run. + options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: + - input: optional input data to pass to the flow. + - preview: optional flag to run the flow in preview mode. + - tenant: optional tenant ID to run the flow for. + + Return value (dict): + Return dict with the flow execution result. + + Raise: + AuthException: raised if run operation fails + """ + body: dict = {"flowId": flow_id} + + if options is not None: + if isinstance(options, dict): + options = FlowRunOptions.from_dict(options) + if options is not None: + body.update(options.to_dict()) + + response = self._http.post( + MgmtV1.flow_run_path, + body=body, + ) + return response.json() + + def run_flow_async( + self, + flow_id: str, + options: Optional[Union[FlowRunOptions, dict]] = None, + ) -> dict: + """ + Run a flow asynchronously with the given flow id and options. + + Args: + flow_id (str): the flow id to run. + options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: + - input: optional input data to pass to the flow. + - preview: optional flag to run the flow in preview mode. + - tenant: optional tenant ID to run the flow for. + + Return value (dict): + Return dict with the async flow execution result. + use the get_flow_async_result() method with this result's executionId + to get the actual flow's result. + + Raise: + AuthException: raised if run operation fails + """ + body: dict = {"flowId": flow_id} + + if options is not None: + if isinstance(options, dict): + options = FlowRunOptions.from_dict(options) + if options is not None: + body.update(options.to_dict()) + + response = self._http.post( + MgmtV1.flow_async_run_path, + body=body, + ) + return response.json() + + def get_flow_async_result( + self, + execution_id: str, + ) -> dict: + """ + Get the result of an async flow execution. + + Args: + execution_id (str): the execution id returned from run_flow_async. + + Return value (dict): + Return dict with the async flow execution result. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.flow_async_result_path, + body={"executionId": execution_id}, + ) + return response.json() diff --git a/tests/management/test_flow.py b/tests/management/test_flow.py index 4a527a5b1..68b6660e9 100644 --- a/tests/management/test_flow.py +++ b/tests/management/test_flow.py @@ -2,7 +2,7 @@ from descope import AuthException, DescopeClient from descope.common import DEFAULT_TIMEOUT_SECONDS -from descope.management.common import MgmtV1 +from descope.management.common import MgmtV1, FlowRunOptions from .. import common @@ -234,3 +234,235 @@ def test_import_theme(self): verify=True, timeout=DEFAULT_TIMEOUT_SECONDS, ) + + def test_run_flow(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed run flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.flow.run_flow, + "test-flow", + ) + + # Test success run flow with no options + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone(client.mgmt.flow.run_flow("test-flow")) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={"flowId": "test-flow"}, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test success run flow with dict options + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + client.mgmt.flow.run_flow( + "test-flow", + {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}, + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "flowId": "test-flow", + "input": {"key": "value"}, + "preview": True, + "tenant": "tenant-id", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test success run flow with FlowRunOptions object + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + options = FlowRunOptions( + flow_input={"key": "value"}, + preview=True, + tenant="tenant-id", + ) + self.assertIsNotNone(client.mgmt.flow.run_flow("test-flow", options)) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "flowId": "test-flow", + "input": {"key": "value"}, + "preview": True, + "tenant": "tenant-id", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_flow_run_options_from_dict(self): + # Test from_dict with None returns None + self.assertIsNone(FlowRunOptions.from_dict(None)) + + # Test from_dict with valid dict + options = FlowRunOptions.from_dict( + {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"} + ) + self.assertIsNotNone(options) + self.assertEqual(options.flow_input, {"key": "value"}) + self.assertEqual(options.preview, True) + self.assertEqual(options.tenant, "tenant-id") + + def test_run_flow_async(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed run flow async + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.flow.run_flow_async, + "test-flow", + ) + + # Test success run flow async with no options + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone(client.mgmt.flow.run_flow_async("test-flow")) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={"flowId": "test-flow"}, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test success run flow async with dict options + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + client.mgmt.flow.run_flow_async( + "test-flow", + {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}, + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "flowId": "test-flow", + "input": {"key": "value"}, + "preview": True, + "tenant": "tenant-id", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test success run flow async with FlowRunOptions object + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + options = FlowRunOptions( + flow_input={"key": "value"}, + preview=True, + tenant="tenant-id", + ) + self.assertIsNotNone(client.mgmt.flow.run_flow_async("test-flow", options)) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "flowId": "test-flow", + "input": {"key": "value"}, + "preview": True, + "tenant": "tenant-id", + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_get_flow_async_result(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed get flow async result + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.flow.get_flow_async_result, + "execution-123", + ) + + # Test success get flow async result + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + client.mgmt.flow.get_flow_async_result("execution-123") + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_result_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={"executionId": "execution-123"}, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + )