diff --git a/changelog.d/20260803_114540_kurtmckee_web_inputs_sc_50208.rst b/changelog.d/20260803_114540_kurtmckee_web_inputs_sc_50208.rst new file mode 100644 index 000000000..965f43a66 --- /dev/null +++ b/changelog.d/20260803_114540_kurtmckee_web_inputs_sc_50208.rst @@ -0,0 +1,7 @@ +Added +----- + +- Add Web Input-related methods to the ``FlowsClient`` class. (:pr:`NUMBER`) + + The new methods are: ``list_web_inputs``, ``get_web_input``, + and ``respond_to_web_input``. diff --git a/src/globus_sdk/services/flows/__init__.py b/src/globus_sdk/services/flows/__init__.py index ca39a5bcb..76c7aa684 100644 --- a/src/globus_sdk/services/flows/__init__.py +++ b/src/globus_sdk/services/flows/__init__.py @@ -6,6 +6,7 @@ IterableRegisteredAPIsResponse, IterableRunLogsResponse, IterableRunsResponse, + IterableWebInputsResponse, ) __all__ = ( @@ -15,6 +16,7 @@ "IterableRegisteredAPIsResponse", "IterableRunLogsResponse", "IterableRunsResponse", + "IterableWebInputsResponse", "SpecificFlowClient", "RunActivityNotificationPolicy", ) diff --git a/src/globus_sdk/services/flows/client.py b/src/globus_sdk/services/flows/client.py index 620ec2394..c9e718596 100644 --- a/src/globus_sdk/services/flows/client.py +++ b/src/globus_sdk/services/flows/client.py @@ -27,6 +27,7 @@ IterableRegisteredAPIsResponse, IterableRunLogsResponse, IterableRunsResponse, + IterableWebInputsResponse, ) if sys.version_info >= (3, 11): @@ -983,6 +984,181 @@ def list_registered_apis( self.get("/registered_apis", query_params=query_params) ) + @paging.has_paginator( + paging.NullableMarkerPaginator, items_key="web_input_summaries" + ) + def list_web_inputs( + self, + *, + filter_roles: ( + t.Literal["viewer", "respondent"] + | t.Iterable[t.Literal["viewer", "respondent"]] + | MissingType + ) = MISSING, + filter_states: ( + t.Literal["open", "closed"] + | t.Iterable[t.Literal["open", "closed"]] + | MissingType + ) = MISSING, + filter_flow_ids: ( + t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType + ) = MISSING, + filter_run_ids: ( + t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType + ) = MISSING, + orderby: str | t.Iterable[str] | MissingType = MISSING, + per_page: int | MissingType = MISSING, + marker: str | MissingType = MISSING, + query_params: dict[str, t.Any] | None = None, + ) -> IterableWebInputsResponse: + """ + List web inputs. + + :param filter_roles: + Filter web inputs to only include those the user has the given role for. + :param filter_states: + Filter web inputs to only include those in the given state(s). + :param filter_flow_ids: + Filter web inputs to only include those associated with the given flow IDs. + :param filter_run_ids: + Filter web inputs to only include those associated with the given run IDs. + :param orderby: + A criterion for ordering web inputs in the listing. Known criteria include + ``created_timestamp``, ``edited_timestamp``, and ``closed_timestamp``. + An optional sort order can be provided (either ``ASC`` or ``DESC``) + and must be separated by a space. + For example: ``"created_timestamp DESC"``. + :param per_page: + The number of results to return per page. + :param marker: + A marker for pagination. Provided by the server on a previous request. + :param query_params: + Any additional parameters to be passed through as query params. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + from globus_sdk import FlowsClient + + flows = FlowsClient(...) + for web_input in flows.list_web_inputs(filter_states="open"): + print(f"Title: {web_input['title']}") + print(f"Status: {web_input['status']}") + + .. tab-item:: Paginated Usage + + .. paginatedusage:: list_web_inputs + + .. tab-item:: Example Response Data + + .. expandtestfixture:: flows.list_web_inputs + + .. tab-item:: API Info + + .. extdoclink:: List Web Inputs + :service: flows + :ref: Web-Inputs/paths/~1web_inputs/get + """ + query_params = { + "filter_roles": commajoin(filter_roles), + "filter_states": commajoin(filter_states), + "filter_flow_ids": commajoin(filter_flow_ids), + "filter_run_ids": commajoin(filter_run_ids), + # if `orderby` is an iterable (e.g., generator expression), it gets + # converted to a list in this step + "orderby": commajoin(orderby), + "per_page": per_page, + "marker": marker, + **(query_params or {}), + } + return IterableWebInputsResponse( + self.get("/web_inputs", query_params=query_params) + ) + + def get_web_input( + self, + web_input_id: uuid.UUID | str, + ) -> GlobusHTTPResponse: + """ + Get a web input by ID. + + Returns data about the web input if the current user has any role on it + (``viewer`` or ``respondent``). If the web input's flow has an associated + authentication policy that the caller's session does not satisfy, the + service may instead respond with a GARE (Globus Auth Requirements Error) + requiring reauthentication. + + :param web_input_id: The ID of the web input to fetch + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + from globus_sdk import FlowsClient + + flows = FlowsClient(...) + flows.get_web_input("11111111-2222-3333-4444-555555555555") + + .. tab-item:: Example Response Data + + .. expandtestfixture:: flows.get_web_input + + .. tab-item:: API Info + + .. extdoclink:: Get Web Input + :service: flows + :ref: Web-Inputs/paths/~1web_inputs~1{web_input_id}/get + """ + return self.get(f"/web_inputs/{web_input_id}") + + def respond_to_web_input( + self, + web_input_id: uuid.UUID | str, + value: t.Any, + ) -> GlobusHTTPResponse: + """ + Submit a response to a web input. + + The caller must have the ``respondent`` role on the web input. + + If the web input is a ``selection``-type web input, + ``value`` must be the ``option_id`` of one of the web input's options. + + :param web_input_id: The ID of the web input to respond to + :param value: The response value + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + from globus_sdk import FlowsClient + + flows = FlowsClient(...) + flows.respond_to_web_input( + "11111111-2222-3333-4444-555555555555", + value="22222222-3333-4444-5555-666666666666", + ) + + .. tab-item:: Example Response Data + + .. expandtestfixture:: flows.respond_to_web_input + + .. tab-item:: API Info + + .. extdoclink:: Respond to Web Input + :service: flows + :ref: Web-Inputs/paths/~1web_inputs~1{web_input_id}~1respond/post + """ + data = {"response": {"value": value}} + return self.post(f"/web_inputs/{web_input_id}/respond", data=data) + class SpecificFlowClient(client.BaseClient): r""" diff --git a/src/globus_sdk/services/flows/response.py b/src/globus_sdk/services/flows/response.py index 07214ac9a..c976d5155 100644 --- a/src/globus_sdk/services/flows/response.py +++ b/src/globus_sdk/services/flows/response.py @@ -23,6 +23,34 @@ class IterableFlowsResponse(response.IterableResponse): default_iter_key = "flows" +class IterableWebInputsResponse(response.IterableResponse): + """ + An iterable response containing a "web_input_summaries" array of web input + summaries. + + This response type is returned by :meth:`FlowsClient.list_web_inputs` and + provides iteration over individual web input summary objects from a single page + of results. + + When iterated over, yields individual web input summary dictionaries, where each + summary typically contains: + + - ``id``: UUID of the web input + - ``status``: Current status of the web input (``"open"`` or ``"closed"``) + - ``user_roles``: The roles (``"viewer"``, ``"respondent"``) the caller has on + the web input + - ``input_type``: The type of the web input (e.g. ``"selection"``) + - ``title``: Display title of the web input + - ``flow``: The associated flow's ``id`` and ``title`` + - ``run``: The associated run's ``id`` and ``label`` + - ``created_timestamp``: Timestamp of web input creation + - ``edited_timestamp``: Timestamp of last edit + - ``closed_timestamp``: Timestamp the web input was closed, if applicable + """ + + default_iter_key = "web_input_summaries" + + class IterableRunsResponse(response.IterableResponse): """ An iterable response containing a "runs" array of flow run records. diff --git a/src/globus_sdk/testing/data/flows/get_web_input.py b/src/globus_sdk/testing/data/flows/get_web_input.py new file mode 100644 index 000000000..337c8345f --- /dev/null +++ b/src/globus_sdk/testing/data/flows/get_web_input.py @@ -0,0 +1,180 @@ +from copy import deepcopy + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +from ._common import ( + GROUP, + TWO_HOP_TRANSFER_FLOW_DOC, + TWO_HOP_TRANSFER_FLOW_ID, + TWO_HOP_TRANSFER_RUN, + TWO_HOP_TRANSFER_RUN_ID, + USER1, + USER2, +) + +WEB_INPUT_ID = "e35d1f92-3e2a-4c1c-8f36-7ce4f00f9d2c" +AUTHENTICATION_POLICY_ID = "6f2c1d7e-3b4a-4a5e-9f0f-8f2a1b7c4d9e" + +OPTION_ID_APPROVE = "8f14e45f-ceea-467e-add1-6a6b672efe31" +OPTION_ID_REJECT = "c9f0f895-fb98-4a4b-b845-4ded0c6b6d3c" + +# Mirrors the JSON Schema shape generated by +# globus_flows.engines.execution.lifecycle.web_inputs.schemas.generate_schema_from_options +WEB_INPUT_INPUT_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Please select an option", + "type": "string", + "oneOf": [ + { + "const": OPTION_ID_APPROVE, + "title": "Approve", + "description": "Proceed with the production deployment.", + }, + { + "const": OPTION_ID_REJECT, + "title": "Reject", + "description": "Halt the deployment.", + }, + ], +} + +WEB_INPUT_DOC = { + "id": WEB_INPUT_ID, + "status": "open", + "user_roles": ["viewer", "respondent"], + "input_type": "selection", + "input_schema": WEB_INPUT_INPUT_SCHEMA, + "context": { + "title": "Approve deployment to production?", + "presentation_style": "text", + "text": "The nightly build has passed all tests and is ready to deploy.", + }, + "options": [ + { + "option_id": OPTION_ID_APPROVE, + "label": "Approve", + "description": "Proceed with the production deployment.", + }, + { + "option_id": OPTION_ID_REJECT, + "label": "Reject", + "description": "Halt the deployment.", + }, + ], + "roles": { + "viewer_urns": [GROUP], + "respondent_urns": [USER2], + }, + "creator_urn": USER1, + "flow": { + "id": TWO_HOP_TRANSFER_FLOW_ID, + "title": TWO_HOP_TRANSFER_FLOW_DOC["title"], + }, + "run": { + "id": TWO_HOP_TRANSFER_RUN_ID, + "label": TWO_HOP_TRANSFER_RUN["label"], + }, + "created_timestamp": "2026-08-01T10:30:00+00:00", + "edited_timestamp": "2026-08-01T10:30:00+00:00", + "closed_timestamp": None, +} + +WEB_INPUT_DOC_CLOSED = deepcopy(WEB_INPUT_DOC) +WEB_INPUT_DOC_CLOSED["status"] = "closed" +WEB_INPUT_DOC_CLOSED["closed_timestamp"] = "2026-08-02T09:12:00+00:00" + +# `WEB_INPUT_DOC`'s context is "text" presentation style; this variant covers +# "table" presentation style. +WEB_INPUT_DOC_TABLE_CONTEXT = deepcopy(WEB_INPUT_DOC) +WEB_INPUT_DOC_TABLE_CONTEXT["context"] = { + "title": "Approve deployment to production?", + "presentation_style": "table", + "rows": [ + {"field": "Environment", "value": "production"}, + {"field": "Version", "value": "v2.4.1"}, + {"field": "Requested By", "value": "pete@kreb.star"}, + ], +} + +NOT_FOUND_RESPONSE = { + "error": { + "code": "NOT_FOUND", + "detail": f"No Web Input exists with id value {WEB_INPUT_ID}", + } +} + +# Raised by `authorize_get_web_input` when the caller has a role on the web input +# but the associated flow's authentication policy is not satisfied by their session. +WEB_INPUT_SUMMARY_FOR_GARE = { + "id": WEB_INPUT_ID, + "status": "open", + "user_roles": ["respondent"], + "input_type": "selection", + "title": "Approve deployment to production?", + "flow": { + "id": TWO_HOP_TRANSFER_FLOW_ID, + "title": TWO_HOP_TRANSFER_FLOW_DOC["title"], + }, + "run": { + "id": TWO_HOP_TRANSFER_RUN_ID, + "label": TWO_HOP_TRANSFER_RUN["label"], + }, + "created_timestamp": "2026-08-01T10:30:00+00:00", + "edited_timestamp": "2026-08-01T10:30:00+00:00", + "closed_timestamp": None, +} +AUTH_POLICY_REQUIRED_RESPONSE = { + "web_input_summary": WEB_INPUT_SUMMARY_FOR_GARE, + "error": { + "code": "AUTHENTICATION_POLICY_REQUIRED", + "detail": ( + "None of the identities in session meet the requirements " + "for the authentication policy associated with this Web Input's flow. " + "Reauthenticate as an identity that has permission " + "to view the Web Input " + "and can meet the requirements for the attached authentication policy." + ), + }, + "code": "AuthenticationPolicyRequired", + "authorization_parameters": { + "session_required_policies": [AUTHENTICATION_POLICY_ID], + "session_message": ( + "Globus Flows detected an unsatisfied session policy for this web input." + ), + }, +} + +RESPONSES = ResponseSet( + metadata={ + "web_input_id": WEB_INPUT_ID, + "flow_id": TWO_HOP_TRANSFER_FLOW_ID, + "run_id": TWO_HOP_TRANSFER_RUN_ID, + }, + default=RegisteredResponse( + service="flows", + path=f"/web_inputs/{WEB_INPUT_ID}", + json=WEB_INPUT_DOC, + ), + closed=RegisteredResponse( + service="flows", + path=f"/web_inputs/{WEB_INPUT_ID}", + json=WEB_INPUT_DOC_CLOSED, + ), + table_context=RegisteredResponse( + service="flows", + path=f"/web_inputs/{WEB_INPUT_ID}", + json=WEB_INPUT_DOC_TABLE_CONTEXT, + ), + not_found=RegisteredResponse( + service="flows", + path=f"/web_inputs/{WEB_INPUT_ID}", + status=404, + json=NOT_FOUND_RESPONSE, + ), + auth_policy_required=RegisteredResponse( + service="flows", + path=f"/web_inputs/{WEB_INPUT_ID}", + status=403, + json=AUTH_POLICY_REQUIRED_RESPONSE, + ), +) diff --git a/src/globus_sdk/testing/data/flows/list_web_inputs.py b/src/globus_sdk/testing/data/flows/list_web_inputs.py new file mode 100644 index 000000000..af30a9eae --- /dev/null +++ b/src/globus_sdk/testing/data/flows/list_web_inputs.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import typing as t +import uuid + +from responses import matchers + +from globus_sdk.testing.models import RegisteredResponse, ResponseList, ResponseSet + +from ._common import TWO_HOP_TRANSFER_FLOW_ID, TWO_HOP_TRANSFER_RUN_ID + +FIRST_WEB_INPUT_ID = "e35d1f92-3e2a-4c1c-8f36-7ce4f00f9d2c" + + +def generate_web_input_summary( + web_input_id: str | uuid.UUID, n: int = 0 +) -> dict[str, t.Any]: + base_time = "2026-08-01T10:30:00+00:00" + + return { + "id": str(web_input_id), + "status": "open", + "user_roles": ["viewer", "respondent"], + "input_type": "selection", + "title": f"Approve deployment #{n}?", + "flow": { + "id": TWO_HOP_TRANSFER_FLOW_ID, + "title": "Multi Step Transfer", + }, + "run": { + "id": TWO_HOP_TRANSFER_RUN_ID, + "label": "Transfer all of these files!", + }, + "created_timestamp": base_time, + "edited_timestamp": base_time, + "closed_timestamp": None, + } + + +FIRST_WEB_INPUT_SUMMARY = generate_web_input_summary(FIRST_WEB_INPUT_ID) + +RESPONSES = ResponseSet( + metadata={ + "first_web_input_id": FIRST_WEB_INPUT_ID, + "flow_id": TWO_HOP_TRANSFER_FLOW_ID, + "run_id": TWO_HOP_TRANSFER_RUN_ID, + }, + default=RegisteredResponse( + service="flows", + path="/web_inputs", + json={ + "web_input_summaries": [FIRST_WEB_INPUT_SUMMARY], + "marker": None, + }, + ), + empty=RegisteredResponse( + service="flows", + path="/web_inputs", + json={ + "web_input_summaries": [], + "marker": None, + }, + ), + paginated=ResponseList( + RegisteredResponse( + service="flows", + path="/web_inputs", + json={ + "web_input_summaries": [ + generate_web_input_summary(uuid.UUID(int=i), i) for i in range(20) + ], + "marker": "fake_marker_0", + }, + ), + RegisteredResponse( + service="flows", + path="/web_inputs", + json={ + "web_input_summaries": [ + generate_web_input_summary(uuid.UUID(int=i), i) + for i in range(20, 40) + ], + "marker": "fake_marker_1", + }, + # `strict_match=False` so this matches regardless of what other query + # params (orderby, filter_roles, etc.) a given caller also sends. + match=[ + matchers.query_param_matcher( + {"marker": "fake_marker_0"}, strict_match=False + ) + ], + ), + RegisteredResponse( + service="flows", + path="/web_inputs", + json={ + "web_input_summaries": [ + generate_web_input_summary(uuid.UUID(int=i), i) + for i in range(40, 60) + ], + "marker": None, + }, + match=[ + matchers.query_param_matcher( + {"marker": "fake_marker_1"}, strict_match=False + ) + ], + ), + metadata={ + "num_pages": 3, + "expect_markers": ["fake_marker_0", "fake_marker_1", None], + "total_items": 60, + }, + ), +) diff --git a/src/globus_sdk/testing/data/flows/respond_to_web_input.py b/src/globus_sdk/testing/data/flows/respond_to_web_input.py new file mode 100644 index 000000000..ddbb2e402 --- /dev/null +++ b/src/globus_sdk/testing/data/flows/respond_to_web_input.py @@ -0,0 +1,129 @@ +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +from ._common import TWO_HOP_TRANSFER_FLOW_ID, TWO_HOP_TRANSFER_RUN_ID + +WEB_INPUT_ID = "3d9a4e7b-1c2f-4b8a-9e6d-5f7a8b6c2d1e" +AUTHENTICATION_POLICY_ID = "6f2c1d7e-3b4a-4a5e-9f0f-8f2a1b7c4d9e" +OPTION_ID_APPROVE = "8f14e45f-ceea-467e-add1-6a6b672efe31" + +RESPOND_OK_RESPONSE = {"status": "ok"} + +NOT_FOUND_RESPONSE = { + "error": { + "code": "NOT_FOUND", + "detail": f"No Web Input exists with id value {WEB_INPUT_ID}", + } +} + +CLOSED_RESPONSE = { + "error": { + "code": "STATE_CONFLICT", + "detail": f"Web Input {WEB_INPUT_ID} is already closed.", + } +} + +# Raised by `_WebInputResponseController.authorize` when the caller has a viewer +# role, but not the respondent role required to submit a response. +FORBIDDEN_RESPONSE = { + "error": { + "code": "FORBIDDEN", + "detail": f"User does not have respondent role on web input {WEB_INPUT_ID}", + } +} + +# Raised when the caller has the respondent role but fails the associated flow's +# authentication policy. +WEB_INPUT_SUMMARY_FOR_GARE = { + "id": WEB_INPUT_ID, + "status": "open", + "user_roles": ["respondent"], + "input_type": "selection", + "title": "Approve deployment to production?", + "flow": { + "id": TWO_HOP_TRANSFER_FLOW_ID, + "title": "Multi Step Transfer", + }, + "run": { + "id": TWO_HOP_TRANSFER_RUN_ID, + "label": "Transfer all of these files!", + }, + "created_timestamp": "2026-08-01T10:30:00+00:00", + "edited_timestamp": "2026-08-01T10:30:00+00:00", + "closed_timestamp": None, +} +AUTH_POLICY_REQUIRED_RESPONSE = { + "web_input_summary": WEB_INPUT_SUMMARY_FOR_GARE, + "error": { + "code": "AUTHENTICATION_POLICY_REQUIRED", + "detail": ( + "None of the identities in session for this authentication policy " + "have the respondent role on this web input. Reauthenticate as an " + "identity that has this permission and can meet the requirements " + "for the attached authentication policy." + ), + }, + "code": "AuthenticationPolicyRequired", + "authorization_parameters": { + "session_required_policies": [AUTHENTICATION_POLICY_ID], + "session_message": ( + "Globus Flows detected an unsatisfied session policy for this web input." + ), + }, +} + +# Raised by `_WebInputSelectionResponseController.process_response` when `value` +# does not match one of the web input's registered `option_id`s. +INVALID_OPTION_RESPONSE = { + "error": { + "code": "UNPROCESSABLE_ENTITY", + "detail": "'not-a-real-option' is not a registered option id for this input.", + } +} + +RESPONSES = ResponseSet( + metadata={ + "web_input_id": WEB_INPUT_ID, + "option_id": OPTION_ID_APPROVE, + }, + default=RegisteredResponse( + service="flows", + method="POST", + path=f"/web_inputs/{WEB_INPUT_ID}/respond", + json=RESPOND_OK_RESPONSE, + ), + not_found=RegisteredResponse( + service="flows", + method="POST", + path=f"/web_inputs/{WEB_INPUT_ID}/respond", + status=404, + json=NOT_FOUND_RESPONSE, + ), + closed=RegisteredResponse( + service="flows", + method="POST", + path=f"/web_inputs/{WEB_INPUT_ID}/respond", + status=409, + json=CLOSED_RESPONSE, + ), + forbidden=RegisteredResponse( + service="flows", + method="POST", + path=f"/web_inputs/{WEB_INPUT_ID}/respond", + status=403, + json=FORBIDDEN_RESPONSE, + ), + auth_policy_required=RegisteredResponse( + service="flows", + method="POST", + path=f"/web_inputs/{WEB_INPUT_ID}/respond", + status=403, + json=AUTH_POLICY_REQUIRED_RESPONSE, + ), + invalid_option=RegisteredResponse( + service="flows", + method="POST", + path=f"/web_inputs/{WEB_INPUT_ID}/respond", + status=422, + json=INVALID_OPTION_RESPONSE, + ), +) diff --git a/tests/functional/services/flows/test_get_web_input.py b/tests/functional/services/flows/test_get_web_input.py new file mode 100644 index 000000000..12fb23813 --- /dev/null +++ b/tests/functional/services/flows/test_get_web_input.py @@ -0,0 +1,27 @@ +import uuid + +import pytest + +from globus_sdk.testing import get_last_request, load_response + + +@pytest.mark.parametrize("use_uuid", [False, True]) +def test_get_web_input(flows_client, use_uuid): + loaded_response = load_response(flows_client.get_web_input) + json, meta = loaded_response.json, loaded_response.metadata + + web_input_id_str = meta["web_input_id"] + web_input_id = uuid.UUID(web_input_id_str) if use_uuid else web_input_id_str + + res = flows_client.get_web_input(web_input_id) + + assert res.http_status == 200 + assert res["id"] == web_input_id_str + assert res["status"] == json["status"] + assert res["input_type"] == json["input_type"] + assert res["flow"]["id"] == meta["flow_id"] + assert res["run"]["id"] == meta["run_id"] + + req = get_last_request() + assert req.body is None + assert f"/web_inputs/{web_input_id_str}" in req.url diff --git a/tests/functional/services/flows/test_list_web_inputs.py b/tests/functional/services/flows/test_list_web_inputs.py new file mode 100644 index 000000000..f523b6562 --- /dev/null +++ b/tests/functional/services/flows/test_list_web_inputs.py @@ -0,0 +1,61 @@ +import urllib.parse + +import pytest + +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response + + +@pytest.mark.parametrize("filter_states", [MISSING, "open"]) +@pytest.mark.parametrize("filter_roles", [MISSING, "viewer"]) +@pytest.mark.parametrize("orderby", [MISSING, "created_timestamp ASC"]) +def test_list_web_inputs_simple(flows_client, filter_states, filter_roles, orderby): + meta = load_response(flows_client.list_web_inputs).metadata + + add_kwargs = {} + if filter_states is not MISSING: + add_kwargs["filter_states"] = filter_states + if filter_roles is not MISSING: + add_kwargs["filter_roles"] = filter_roles + if orderby is not MISSING: + add_kwargs["orderby"] = orderby + + res = flows_client.list_web_inputs(**add_kwargs) + + assert res.http_status == 200 + # dict-like indexing + assert meta["first_web_input_id"] == res["web_input_summaries"][0]["id"] + # list conversion (using __iter__) and indexing + assert meta["first_web_input_id"] == list(res)[0]["id"] + + req = get_last_request() + assert req.body is None + parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) + expect_query_params = { + k: [v] + for k, v in ( + ("filter_states", filter_states), + ("filter_roles", filter_roles), + ("orderby", orderby), + ) + if v is not MISSING + } + assert parsed_qs == expect_query_params + + +@pytest.mark.parametrize("by_pages", [True, False]) +def test_list_web_inputs_paginated(flows_client, by_pages): + meta = load_response(flows_client.list_web_inputs, case="paginated").metadata + total_items = meta["total_items"] + num_pages = meta["num_pages"] + expect_markers = meta["expect_markers"] + + res = flows_client.paginated.list_web_inputs() + if by_pages: + pages = list(res) + assert len(pages) == num_pages + for i, page in enumerate(pages): + assert page["marker"] == expect_markers[i] + else: + items = list(res.items()) + assert len(items) == total_items diff --git a/tests/functional/services/flows/test_respond_to_web_input.py b/tests/functional/services/flows/test_respond_to_web_input.py new file mode 100644 index 000000000..06be0a7a4 --- /dev/null +++ b/tests/functional/services/flows/test_respond_to_web_input.py @@ -0,0 +1,24 @@ +import uuid + +import pytest + +from globus_sdk.testing import get_last_request, load_response +from tests.common import fast_json + + +@pytest.mark.parametrize("use_uuid", [False, True]) +def test_respond_to_web_input(flows_client, use_uuid): + meta = load_response(flows_client.respond_to_web_input).metadata + + web_input_id_str = meta["web_input_id"] + web_input_id = uuid.UUID(web_input_id_str) if use_uuid else web_input_id_str + + res = flows_client.respond_to_web_input(web_input_id, value=meta["option_id"]) + + assert res.http_status == 200 + assert res["status"] == "ok" + + req = get_last_request() + assert f"/web_inputs/{web_input_id_str}/respond" in req.url + sent_payload = fast_json.loads(req.body) + assert sent_payload == {"response": {"value": meta["option_id"]}}