From a5e51a8a3c30feb85abdc89ef2d62c6922b6eb0c Mon Sep 17 00:00:00 2001 From: m1yag1 <8730430+m1yag1@users.noreply.github.com> Date: Tue, 12 May 2026 11:55:56 -0500 Subject: [PATCH] Add list command for registered APIs --- ...6_8730430+m1yag1_sc_49700_add_list_gras.md | 3 + .../commands/flows/registered_api/__init__.py | 1 + .../commands/flows/registered_api/list.py | 92 ++++++++++ tests/functional/flows/conftest.py | 148 ++++++++++++++++ .../flows/test_list_registered_apis.py | 167 ++++++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 changelog.d/20260512_133746_8730430+m1yag1_sc_49700_add_list_gras.md create mode 100644 src/globus_cli/commands/flows/registered_api/list.py create mode 100644 tests/functional/flows/test_list_registered_apis.py diff --git a/changelog.d/20260512_133746_8730430+m1yag1_sc_49700_add_list_gras.md b/changelog.d/20260512_133746_8730430+m1yag1_sc_49700_add_list_gras.md new file mode 100644 index 00000000..9d65a8c3 --- /dev/null +++ b/changelog.d/20260512_133746_8730430+m1yag1_sc_49700_add_list_gras.md @@ -0,0 +1,3 @@ +### Enhancements + +* Added `globus flows registered-api list` command to list registered APIs diff --git a/src/globus_cli/commands/flows/registered_api/__init__.py b/src/globus_cli/commands/flows/registered_api/__init__.py index 395389ed..3f8517b6 100644 --- a/src/globus_cli/commands/flows/registered_api/__init__.py +++ b/src/globus_cli/commands/flows/registered_api/__init__.py @@ -5,6 +5,7 @@ "registered-api", lazy_subcommands={ "show": (".show", "show_command"), + "list": (".list", "list_command"), }, ) def registered_api_command() -> None: diff --git a/src/globus_cli/commands/flows/registered_api/list.py b/src/globus_cli/commands/flows/registered_api/list.py new file mode 100644 index 00000000..cec8d412 --- /dev/null +++ b/src/globus_cli/commands/flows/registered_api/list.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import typing as t + +import click +import globus_sdk +from globus_sdk.paging import Paginator + +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import ColonDelimitedChoiceTuple, command +from globus_cli.termio import Field, display, formatters +from globus_cli.utils import PagingWrapper + +ROLE_TYPES = ("owner", "administrator", "viewer") +ORDER_BY_FIELDS = ("id", "name", "created_timestamp", "updated_timestamp") + + +@command("list") +@click.option( + "--filter-role", + "filter_roles", + type=click.Choice(ROLE_TYPES), + help="Filter results by the registered API's role type associated with the caller", + multiple=True, +) +@click.option( + "--orderby", + default=("created_timestamp:DESC",), + show_default=True, + type=ColonDelimitedChoiceTuple( + choices=tuple( + f"{field}:{order}" for field in ORDER_BY_FIELDS for order in ("ASC", "DESC") + ), + case_sensitive=False, + ), + multiple=True, + metavar=f"[{'|'.join(ORDER_BY_FIELDS)}]:[ASC|DESC]", + help=""" + Sort results by the given field and ordering. + ASC for ascending, DESC for descending. + + This option can be specified multiple times to sort by multiple fields. + """, +) +@click.option( + "--limit", + default=25, + show_default=True, + metavar="N", + type=click.IntRange(1), + help="The maximum number of results to return.", +) +@LoginManager.requires_login("flows") +def list_command( + login_manager: LoginManager, + *, + filter_roles: tuple[t.Literal["owner", "administrator", "viewer"], ...], + orderby: tuple[ + tuple[ + t.Literal["id", "name", "created_timestamp", "updated_timestamp"], + t.Literal["ASC", "DESC"], + ], + ..., + ], + limit: int, +) -> None: + """ + List registered APIs. + """ + flows_client = login_manager.get_flows_client() + paginator = Paginator.wrap(flows_client.list_registered_apis) + api_iterator = PagingWrapper( + paginator( + filter_roles=filter_roles or globus_sdk.MISSING, + orderby=",".join(f"{field} {order}" for field, order in orderby), + ).items(), + json_conversion_key="registered_apis", + limit=limit, + ) + + fields = [ + Field("Registered API ID", "id"), + Field("Name", "name"), + Field("Created At", "created_timestamp", formatter=formatters.Date), + Field("Updated At", "updated_timestamp", formatter=formatters.Date), + ] + + display( + api_iterator, + fields=fields, + json_converter=api_iterator.json_converter, + ) diff --git a/tests/functional/flows/conftest.py b/tests/functional/flows/conftest.py index a08b0d49..54093b6a 100644 --- a/tests/functional/flows/conftest.py +++ b/tests/functional/flows/conftest.py @@ -302,3 +302,151 @@ def _load_identities_for_registered_api( return pool return _load_identities_for_registered_api + + +def generate_registered_api_summary( + n: int, *, name: str | None = None +) -> dict[str, t.Any]: + """Generate a summary registered API object for list responses.""" + api_id = str(uuid.UUID(int=n)) + base_time = datetime.datetime.fromisoformat("2023-12-31T18:00:00") + created_timestamp = base_time + datetime.timedelta(days=n) + updated_timestamp = created_timestamp + datetime.timedelta(hours=n) + + return { + "id": api_id, + "name": name or f"registered-api-{n}", + "description": f"Test registered API number {n}", + "created_timestamp": created_timestamp.isoformat(), + "updated_timestamp": updated_timestamp.isoformat(), + } + + +@pytest.fixture(autouse=True, scope="session") +def setup_registered_apis_list_responses() -> None: + register_response_set( + "cli.registered_apis_list", + { + "default": { + "service": "flows", + "path": "/registered_apis", + "json": { + "registered_apis": [ + generate_registered_api_summary(1), + generate_registered_api_summary(0), + ], + "limit": 25, + "has_next_page": False, + "marker": None, + }, + "match": [ + matchers.query_param_matcher({"orderby": "created_timestamp DESC"}) + ], + }, + }, + ) + + register_response_set( + "cli.registered_apis_list_filtered", + { + "default": { + "service": "flows", + "path": "/registered_apis", + "json": { + "registered_apis": [ + generate_registered_api_summary(2), + generate_registered_api_summary(1), + generate_registered_api_summary(0), + ], + "limit": 25, + "has_next_page": False, + "marker": None, + }, + }, + }, + ) + + register_response_set( + "registered_apis_list_paginated", + { + "page0": { + "service": "flows", + "path": "/registered_apis", + "json": { + "registered_apis": [ + generate_registered_api_summary(i) for i in range(10) + ], + "limit": 10, + "has_next_page": True, + "marker": "fake_marker_0", + }, + "match": [ + matchers.query_param_matcher({"orderby": "created_timestamp DESC"}) + ], + }, + "page1": { + "service": "flows", + "path": "/registered_apis", + "json": { + "registered_apis": [ + generate_registered_api_summary(i) for i in range(10, 20) + ], + "limit": 10, + "has_next_page": True, + "marker": "fake_marker_1", + }, + "match": [ + matchers.query_param_matcher( + {"orderby": "created_timestamp DESC", "marker": "fake_marker_0"} + ) + ], + }, + "page2": { + "service": "flows", + "path": "/registered_apis", + "json": { + "registered_apis": [ + generate_registered_api_summary(i) for i in range(20, 25) + ], + "limit": 5, + "has_next_page": False, + "marker": None, + }, + "match": [ + matchers.query_param_matcher( + {"orderby": "created_timestamp DESC", "marker": "fake_marker_1"} + ) + ], + }, + }, + metadata={ + "num_pages": 3, + "expect_markers": ["fake_marker_0", "fake_marker_1", None], + "total_items": 25, + }, + ) + + register_response_set( + "registered_apis_list_orderby_name_asc", + { + "name_asc": { + "service": "flows", + "path": "/registered_apis", + "json": { + "registered_apis": [ + # chr(65) = 'A', so this is A-Z + generate_registered_api_summary( + i, name=f"{chr(65 + i)}-Sorted-API" + ) + for i in range(26) + ], + "limit": 100, + "has_next_page": False, + }, + "match": [matchers.query_param_matcher({"orderby": "name ASC"})], + }, + }, + metadata={ + "total_items": 26, + }, + ) diff --git a/tests/functional/flows/test_list_registered_apis.py b/tests/functional/flows/test_list_registered_apis.py new file mode 100644 index 00000000..93694fd5 --- /dev/null +++ b/tests/functional/flows/test_list_registered_apis.py @@ -0,0 +1,167 @@ +import json +import urllib.parse +import uuid + +from globus_sdk.testing import RegisteredResponse, get_last_request, load_response_set + + +def test_list_registered_apis(run_line): + load_response_set("cli.registered_apis_list") + + expected = ( + "Registered API ID | Name | Created At | Updated At \n" # noqa: E501 + "-------------------------------------+------------------+---------------------+--------------------\n" # noqa: E501 + "00000000-0000-0000-0000-000000000001 | registered-api-1 | 2024-01-01 18:00:00 | 2024-01-01 19:00:00\n" # noqa: E501 + "00000000-0000-0000-0000-000000000000 | registered-api-0 | 2023-12-31 18:00:00 | 2023-12-31 18:00:00\n" # noqa: E501 + ) + + result = run_line("globus flows registered-api list") + assert result.output == expected + + # confirm that none of the filtering parameters were sent to the API + # and that the default orderby was sent + last_req = get_last_request() + parsed_url = urllib.parse.urlparse(last_req.url) + parsed_params = urllib.parse.parse_qs(parsed_url.query) + assert "filter_roles" not in parsed_params + assert "orderby" in parsed_params + assert parsed_params["orderby"] == ["created_timestamp DESC"] + + +def test_list_registered_apis_json(run_line): + load_response_set("cli.registered_apis_list") + + result = run_line("globus flows registered-api list -F json") + json.loads(result.output) + + +def test_list_registered_apis_filter_role_single(run_line): + load_response_set("cli.registered_apis_list_filtered") + + expected = ( + "Registered API ID | Name | Created At | Updated At \n" # noqa: E501 + "-------------------------------------+------------------+---------------------+--------------------\n" # noqa: E501 + "00000000-0000-0000-0000-000000000002 | registered-api-2 | 2024-01-02 18:00:00 | 2024-01-02 20:00:00\n" # noqa: E501 + "00000000-0000-0000-0000-000000000001 | registered-api-1 | 2024-01-01 18:00:00 | 2024-01-01 19:00:00\n" # noqa: E501 + "00000000-0000-0000-0000-000000000000 | registered-api-0 | 2023-12-31 18:00:00 | 2023-12-31 18:00:00\n" # noqa: E501 + ) + + result = run_line("globus flows registered-api list --filter-role owner") + assert result.output == expected + + # verify the filter_roles parameter was sent + last_req = get_last_request() + parsed_url = urllib.parse.urlparse(last_req.url) + parsed_params = urllib.parse.parse_qs(parsed_url.query) + assert "filter_roles" in parsed_params + assert parsed_params["filter_roles"] == ["owner"] + + +def test_list_registered_apis_filter_role_multiple(run_line): + load_response_set("cli.registered_apis_list_filtered") + + expected = ( + "Registered API ID | Name | Created At | Updated At \n" # noqa: E501 + "-------------------------------------+------------------+---------------------+--------------------\n" # noqa: E501 + "00000000-0000-0000-0000-000000000002 | registered-api-2 | 2024-01-02 18:00:00 | 2024-01-02 20:00:00\n" # noqa: E501 + "00000000-0000-0000-0000-000000000001 | registered-api-1 | 2024-01-01 18:00:00 | 2024-01-01 19:00:00\n" # noqa: E501 + "00000000-0000-0000-0000-000000000000 | registered-api-0 | 2023-12-31 18:00:00 | 2023-12-31 18:00:00\n" # noqa: E501 + ) + + result = run_line( + [ + "globus", + "flows", + "registered-api", + "list", + "--filter-role", + "owner", + "--filter-role", + "administrator", + ] + ) + assert result.output == expected + + # verify both filter_roles parameters were sent as comma-separated values + last_req = get_last_request() + parsed_url = urllib.parse.urlparse(last_req.url) + parsed_params = urllib.parse.parse_qs(parsed_url.query) + assert "filter_roles" in parsed_params + assert parsed_params["filter_roles"] == ["owner,administrator"] + + +def test_list_registered_apis_invalid_filter_role(run_line): + load_response_set("cli.registered_apis_list") + + run_line( + [ + "globus", + "flows", + "registered-api", + "list", + "--filter-role", + "this-certainly-isnt-a-valid-role", + ], + assert_exit_code=2, + ) + + +def test_list_registered_apis_paginated_response(run_line): + meta = load_response_set("registered_apis_list_paginated").metadata + total_items = meta["total_items"] + + result = run_line("globus flows registered-api list --limit 1000") + output_lines = result.output.split("\n")[:-1] # trim the final newline/empty str + # 2 header lines + total_items data lines + assert len(output_lines) == total_items + 2 + + for i, line in enumerate(output_lines[2:]): + row = line.split(" | ") + assert row[0] == str(uuid.UUID(int=i)) + # rstrip because this column may be right-padded to align + assert row[1].rstrip() == f"registered-api-{i}" + + +def test_list_registered_apis_sorted(run_line): + meta = load_response_set("registered_apis_list_orderby_name_asc").metadata + total_items = meta["total_items"] + + result = run_line( + [ + "globus", + "flows", + "registered-api", + "list", + "--limit", + "100", + "--orderby", + "name:asc", + ] + ) + # trim the final newline/empty str and the header lines + output_lines = result.output.split("\n")[2:-1] + assert len(output_lines) == total_items + + names_in_order = [] + for i, line in enumerate(output_lines): + row = line.split(" | ") + assert row[0] == str(uuid.UUID(int=i)) + names_in_order.append(row[1].strip()) + + assert names_in_order == sorted(names_in_order) + + +def test_list_registered_apis_empty_list(run_line): + RegisteredResponse( + service="flows", + path="/registered_apis", + json={"registered_apis": [], "has_next_page": False, "marker": None}, + ).add() + + expected = ( + "Registered API ID | Name | Created At | Updated At\n" + "------------------+------+------------+-----------\n" + ) + + result = run_line("globus flows registered-api list") + assert result.output == expected