diff --git a/changelog.d/20231208_162645_sirosen_add_api_gcs.md b/changelog.d/20231208_162645_sirosen_add_api_gcs.md new file mode 100644 index 000000000..48a4b44a3 --- /dev/null +++ b/changelog.d/20231208_162645_sirosen_add_api_gcs.md @@ -0,0 +1,4 @@ +### Enhancements + +* Add `globus api gcs $ENDPOINT_ID` as a command for directly interacting with + the GCS Manager API diff --git a/src/globus_cli/commands/api.py b/src/globus_cli/commands/api.py index d4491faf7..d2ac0cef8 100644 --- a/src/globus_cli/commands/api.py +++ b/src/globus_cli/commands/api.py @@ -3,6 +3,7 @@ import json import sys import typing as t +import uuid from collections import defaultdict import click @@ -11,7 +12,7 @@ from globus_cli import termio, version from globus_cli.login_manager import LoginManager, is_client_login from globus_cli.login_manager.scopes import CLI_SCOPE_REQUIREMENTS -from globus_cli.parsing import command, group, mutex_option_group +from globus_cli.parsing import command, endpoint_id_arg, group, mutex_option_group from globus_cli.termio import display from globus_cli.types import ServiceNameLiteral @@ -20,6 +21,8 @@ else: from typing_extensions import Literal +C = t.TypeVar("C", bound=t.Union[t.Callable, click.Command]) + class QueryParamType(click.ParamType): def get_metavar(self, param: click.Parameter) -> str: @@ -165,40 +168,19 @@ def _get_url(service_name: str) -> str: "search": "https://search.api.globus.org/", "transfer": "https://transfer.api.globus.org/v0.10/", "timer": "https://timer.automate.globus.org/", + "gcs": "https://$GCS_MANAGER/", }[service_name] -@group("api") -def api_command() -> None: - """Make API calls to Globus services""" - - -# note: this must be written as a separate call and not inlined into the loop body -# this ensures that it acts as a closure over 'service_name' -def build_command(service_name: ServiceNameLiteral) -> click.Command: - @command( - service_name, - help=f"""\ -Make API calls to Globus {service_name.title()} - -The arguments are an HTTP method name and a path within the service to which the request -should be made. The path will be joined with the known service URL. -For example, a call of - - globus api {service_name} GET /foo/bar - -sends a 'GET' request to '{_get_url(service_name)}foo/bar' -""", - ) - @LoginManager.requires_login(service_name) - @click.argument( +def _service_command_params(cmd: C) -> C: + cmd = click.argument("path")(cmd) + cmd = click.argument( "method", type=click.Choice( ("HEAD", "GET", "PUT", "POST", "PATCH", "DELETE"), case_sensitive=False ), - ) - @click.argument("path") - @click.option( + )(cmd) + cmd = click.option( "--query-param", "-Q", type=QueryParamType(), @@ -207,8 +189,8 @@ def build_command(service_name: ServiceNameLiteral) -> click.Command: "A query parameter, given as 'key=value'. " "Use this option multiple times to pass multiple query parameters." ), - ) - @click.option( + )(cmd) + cmd = click.option( "--content-type", type=click.Choice(("json", "form", "text", "none", "auto")), default="auto", @@ -218,8 +200,8 @@ def build_command(service_name: ServiceNameLiteral) -> click.Command: "the request body, while the other names refer to common data encodings. " "Any explicit Content-Type header set via '--header' will override this" ), - ) - @click.option( + )(cmd) + cmd = click.option( "--header", "-H", type=HeaderParamType(), @@ -228,22 +210,22 @@ def build_command(service_name: ServiceNameLiteral) -> click.Command: "A header, specified as 'Key: Value'. " "Use this option multiple times to pass multiple headers." ), - ) - @click.option("--body", help="A request body to include, as text") - @click.option( + )(cmd) + cmd = click.option("--body", help="A request body to include, as text")(cmd) + cmd = click.option( "--body-file", type=click.File("r"), help="A request body to include, as a file. Mutually exclusive with --body", - ) - @click.option( + )(cmd) + cmd = click.option( "--allow-errors", is_flag=True, help=( "Allow error responses (4xx and 5xx) to be displayed " "without triggering normal error handling" ), - ) - @click.option( + )(cmd) + cmd = click.option( "--allow-redirects", "--location", "-L", @@ -252,9 +234,11 @@ def build_command(service_name: ServiceNameLiteral) -> click.Command: "If the server responds with a redirect (a 3xx response with a Location " "header), follow the redirect. By default, redirects are not followed." ), - ) - @click.option("--no-retry", is_flag=True, help="Disable built-in request retries") - @click.option( + )(cmd) + cmd = click.option( + "--no-retry", is_flag=True, help="Disable built-in request retries" + )(cmd) + cmd = click.option( "--scope-string", type=str, multiple=True, @@ -263,102 +247,212 @@ def build_command(service_name: ServiceNameLiteral) -> click.Command: "At present, only supported for confidential-client based authorization. " "Pass this option multiple times to specify multiple scopes." ), - ) - @mutex_option_group("--body", "--body-file") - def service_command( - login_manager: LoginManager, - *, - method: Literal["HEAD", "GET", "PUT", "POST", "PATCH", "DELETE"], - path: str, - query_param: tuple[tuple[str, str], ...], - header: tuple[tuple[str, str], ...], - body: str | None, - body_file: t.TextIO | None, - content_type: Literal["json", "form", "text", "none", "auto"], - allow_errors: bool, - allow_redirects: bool, - no_retry: bool, - scope_string: tuple[str, ...], - ) -> None: - # the overall flow of this command will be as follows: - # - prepare a client - # - prepare parameters for the request - # - Groups-only - strip copied-and-pasted paths with `/v2/` that will fail - # - send the request capturing any error raised - # - process the response - # - on success or error with --allow-errors, print - # - on error without --allow-errors, reraise - - if scope_string: - if not is_client_login(): - raise click.UsageError( - "Scope requirements (--scope-string) are currently only " - "supported for confidential-client authorized calls." + )(cmd) + cmd = mutex_option_group("--body", "--body-file")(cmd) + return cmd + + +def _execute_service_command( + client: globus_sdk.BaseClient, + *, + method: Literal["HEAD", "GET", "PUT", "POST", "PATCH", "DELETE"], + path: str, + query_param: tuple[tuple[str, str], ...], + header: tuple[tuple[str, str], ...], + body: str | None, + body_file: t.TextIO | None, + content_type: Literal["json", "form", "text", "none", "auto"], + allow_errors: bool, + allow_redirects: bool, + no_retry: bool, +) -> None: + # this execution method picks up after authentication logic, + # which may vary per-service, is encoded in a client + # + # the overall flow of a command after that is as follows: + # - prepare parameters for the request + # - Groups-only - strip copied-and-pasted paths with `/v2/` that will fail + # - send the request capturing any error raised + # - process the response + # - on success or error with --allow-errors, print + # - on error without --allow-errors, reraise + + client.app_name = version.app_name + " raw-api-command" + if no_retry: + client.transport.max_retries = 0 + + # Prepare Query Params + query_params_d = defaultdict(list) + for param_name, param_value in query_param: + query_params_d[param_name].append(param_value) + + # Prepare Request Body + # the value in 'body' will be passed in the request + # it is intentional that if neither `--body` nor `--body-file` is given, + # then `body=None` + if body_file: + body = body_file.read() + + # Prepare Headers + # order of evaluation here matters + # first we process any Content-Type directive, especially for the default case + # of --content-type=auto + # after that, apply any manually provided headers, ensuring that they have + # higher precedence + # + # this also makes the behavior well-defined if a user passes + # + # --content-type=json -H "Content-Type: application/octet-stream" + # + # the explicit header wins and this is intentional and internally documented + headers_d = {} + if content_type != "none": + detected_content_type = detect_content_type(content_type, body) + if detected_content_type is not None: + headers_d["Content-Type"] = detected_content_type + for header_name, header_value in header: + headers_d[header_name] = header_value + + # Strip `/v2` from Groups paths, which are auto-added by `GroupsClient`. + if isinstance(client, globus_sdk.GroupsClient) and path.startswith("/v2"): + path = path[3:] + + # try sending and handle any error + try: + res = client.request( + method.upper(), + path, + query_params=query_params_d, + data=body, + headers=headers_d, + allow_redirects=allow_redirects, + ) + except globus_sdk.GlobusAPIError as e: + if not allow_errors: + raise + # we're in the allow-errors case, so print the HTTP response + print_error_or_response(e) + else: + print_error_or_response(res) + + +def _handle_scope_string( + login_manager: LoginManager, + resource_server: str, + scope_string: tuple[str, ...], +) -> None: + if not is_client_login(): + raise click.UsageError( + "Scope requirements (--scope-string) are currently only " + "supported for confidential-client authorized calls." + ) + login_manager.add_requirement(resource_server, scope_string) + + +@group("api") +def api_command() -> None: + """Make API calls to Globus services""" + + +# note: this must be written as a separate call and not inlined into the loop body +# this ensures that it acts as a closure over 'service_name' +def build_command(service_name: ServiceNameLiteral | Literal["gcs"]) -> click.Command: + helptext = f"""\ +Make API calls to Globus {service_name.title()} + +The arguments are an HTTP method name and a path within the service to which the request +should be made. The path will be joined with the known service URL. +For example, a call of + + globus api {service_name} GET /foo/bar + +sends a 'GET' request to '{_get_url(service_name)}foo/bar' +""" + + if service_name != "gcs": + + @command(service_name, help=helptext) + @LoginManager.requires_login(service_name) + @_service_command_params + def service_command( + login_manager: LoginManager, + *, + method: Literal["HEAD", "GET", "PUT", "POST", "PATCH", "DELETE"], + path: str, + query_param: tuple[tuple[str, str], ...], + header: tuple[tuple[str, str], ...], + body: str | None, + body_file: t.TextIO | None, + content_type: Literal["json", "form", "text", "none", "auto"], + allow_errors: bool, + allow_redirects: bool, + no_retry: bool, + scope_string: tuple[str, ...], + ) -> None: + if scope_string: + _handle_scope_string( + login_manager, _get_resource_server(service_name), scope_string ) - resource_server = _get_resource_server(service_name) - login_manager.add_requirement(resource_server, scope_string) - - client = _get_client(login_manager, service_name) - client.app_name = version.app_name + " raw-api-command" - if no_retry: - client.transport.max_retries = 0 - - # Prepare Query Params - query_params_d = defaultdict(list) - for param_name, param_value in query_param: - query_params_d[param_name].append(param_value) - - # Prepare Request Body - # the value in 'body' will be passed in the request - # it is intentional that if neither `--body` nor `--body-file` is given, - # then `body=None` - if body_file: - body = body_file.read() - - # Prepare Headers - # order of evaluation here matters - # first we process any Content-Type directive, especially for the default case - # of --content-type=auto - # after that, apply any manually provided headers, ensuring that they have - # higher precedence - # - # this also makes the behavior well-defined if a user passes - # - # --content-type=json -H "Content-Type: application/octet-stream" - # - # the explicit header wins and this is intentional and internally documented - headers_d = {} - if content_type != "none": - detected_content_type = detect_content_type(content_type, body) - if detected_content_type is not None: - headers_d["Content-Type"] = detected_content_type - for header_name, header_value in header: - headers_d[header_name] = header_value - - # Strip `/v2` from Groups paths, which are auto-added by `GroupsClient`. - if service_name == "groups" and path.startswith("/v2"): - path = path[3:] - - # try sending and handle any error - try: - res = client.request( - method.upper(), - path, - query_params=query_params_d, - data=body, - headers=headers_d, + + client = _get_client(login_manager, service_name) + return _execute_service_command( + client, + method=method, + path=path, + query_param=query_param, + header=header, + body=body, + body_file=body_file, + content_type=content_type, + allow_errors=allow_errors, allow_redirects=allow_redirects, + no_retry=no_retry, + ) + + else: + + @command("gcs", help=helptext) + @LoginManager.requires_login("auth", "transfer") + @endpoint_id_arg + @_service_command_params + def service_command( + login_manager: LoginManager, + *, + endpoint_id: uuid.UUID, + method: Literal["HEAD", "GET", "PUT", "POST", "PATCH", "DELETE"], + path: str, + query_param: tuple[tuple[str, str], ...], + header: tuple[tuple[str, str], ...], + body: str | None, + body_file: t.TextIO | None, + content_type: Literal["json", "form", "text", "none", "auto"], + allow_errors: bool, + allow_redirects: bool, + no_retry: bool, + scope_string: tuple[str, ...], + ) -> None: + if scope_string: + _handle_scope_string(login_manager, str(endpoint_id), scope_string) + + client = login_manager.get_gcs_client(endpoint_id=endpoint_id) + return _execute_service_command( + client, + method=method, + path=path, + query_param=query_param, + header=header, + body=body, + body_file=body_file, + content_type=content_type, + allow_errors=allow_errors, + allow_redirects=allow_redirects, + no_retry=no_retry, ) - except globus_sdk.GlobusAPIError as e: - if not allow_errors: - raise - # we're in the allow-errors case, so print the HTTP response - print_error_or_response(e) - else: - print_error_or_response(res) return t.cast(click.Command, service_command) -for service_name in CLI_SCOPE_REQUIREMENTS: - api_command.add_command(build_command(service_name)) +for service_name_ in CLI_SCOPE_REQUIREMENTS: + api_command.add_command(build_command(service_name_)) + +api_command.add_command(build_command("gcs")) diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 2ff8c2b50..92784123e 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -1,4 +1,5 @@ import urllib.parse +import uuid import pytest import responses @@ -26,10 +27,10 @@ def _register_stub_transfer_response(): @pytest.mark.parametrize( - "service_name", ["auth", "flows", "groups", "search", "timer", "transfer"] + "service_name", ["gcs", "auth", "flows", "groups", "search", "timer", "transfer"] ) @pytest.mark.parametrize("is_error_response", (False, True)) -def test_api_command_get(run_line, service_name, is_error_response): +def test_api_command_get(run_line, service_name, add_gcs_login, is_error_response): load_response( RegisteredResponse( service=service_name, @@ -38,9 +39,31 @@ def test_api_command_get(run_line, service_name, is_error_response): json={"foo": "bar"}, ) ) + service_args = [service_name] + + if service_name == "gcs": + endpoint_id = str(uuid.uuid1()) + load_response( + RegisteredResponse( + service="transfer", + path=f"/endpoint/{endpoint_id}", + # this data contains the GCS server hostname which is baked into + # globus_sdk._testing, so there's some "magical" data coordination at + # play here + json={ + "DATA": [ + {"DATA_TYPE": "server", "hostname": "abc.xyz.data.globus.org"} + ], + "gcs_manager_url": "https://abc.xyz.data.globus.org", + "entity_type": "GCSv5_endpoint", + }, + ) + ) + service_args.append(endpoint_id) + add_gcs_login(endpoint_id) result = run_line( - ["globus", "api", service_name, "get", "/foo"] + ["globus", "api", *service_args, "get", "/foo"] + (["--no-retry", "--allow-errors"] if is_error_response else []) ) assert result.output == '{"foo": "bar"}\n'