diff --git a/docs/cli/cli-destinations.md b/docs/cli/cli-destinations.md index 299e4a55..d990ae84 100644 --- a/docs/cli/cli-destinations.md +++ b/docs/cli/cli-destinations.md @@ -31,12 +31,19 @@ The `list` command supports filtering on a variety of fields. You can discover a requesting user, false to exclude them. * `--can-write`: Set to true to include only destinations the user can modify, false to exclude them. +* `--is-default`: Set to true to include only the default destination, + false to exclude it. For example, issue the following command to list destinations that are not archived and you as the user have permission to modify: ```sh planet destinations list --archived false --can-write true ``` +To list only the default destination (if one is set): +```sh +planet destinations list --is-default true +``` + ### Modify Destinations The CLI conveniently moves all modify actions to first class commands on the destination. The supported actions are archive, unarchive, rename, and update credentials. To discover all update actions run `planet destinations --help`. @@ -47,6 +54,31 @@ Credential updating might be done if credentials expire or need to be rotated. F planet destinations update s3 my-destination-id --access-key-id NEW_ACCESS_KEY --secret-access-key NEW_SECRET_KEY ``` +### Manage Default Destinations +Default destinations are globally available to all members of an organization. An organization can have zero or one default destination at any time. Managing default destinations (setting, unsetting) is restricted to organization administrators and destination owners. + +#### Set a Default Destination +To set a destination as the default for your organization: +```sh +planet destinations default set my-destination-id +``` + +#### Get the Current Default Destination +To retrieve the current default destination (if one is set): +```sh +planet destinations default get +``` + +If no default destination is set, this command will return an error indicating that no default destination is configured. + +#### Unset the Default Destination +To remove the current default destination: +```sh +planet destinations default unset +``` + +This command returns no content on success (HTTP 204). Only organization administrators and destination owners can unset the default destination. + ## Using destinations in Subscriptions API After creating a destination, it can be used as the delivery location for subscriptions. Use the destination reference in the delivery block instead of credentials. diff --git a/planet/cli/destinations.py b/planet/cli/destinations.py index 139378a9..ed3a2513 100644 --- a/planet/cli/destinations.py +++ b/planet/cli/destinations.py @@ -36,12 +36,18 @@ async def _patch_destination(ctx, destination_id, data, pretty): raise ClickException(f"Failed to patch destination: {e}") -async def _list_destinations(ctx, archived, is_owner, can_write, pretty): +async def _list_destinations(ctx, + archived, + is_owner, + can_write, + is_default, + pretty): async with destinations_client(ctx) as cl: try: response = await cl.list_destinations(archived, is_owner, - can_write) + can_write, + is_default) echo_json(response, pretty) except Exception as e: raise ClickException(f"Failed to list destinations: {e}") @@ -65,6 +71,33 @@ async def _create_destination(ctx, data, pretty): raise ClickException(f"Failed to create destination: {e}") +async def _set_default_destination(ctx, destination_id, pretty): + async with destinations_client(ctx) as cl: + try: + response = await cl.set_default_destination(destination_id) + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to set default destination: {e}") + + +async def _unset_default_destination(ctx, pretty): + async with destinations_client(ctx) as cl: + try: + response = await cl.unset_default_destination() + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to unset default destination: {e}") + + +async def _get_default_destination(ctx, pretty): + async with destinations_client(ctx) as cl: + try: + response = await cl.get_default_destination() + echo_json(response, pretty) + except Exception as e: + raise ClickException(f"Failed to get default destination: {e}") + + @command(destinations, name="list") @click.option('--archived', type=bool, @@ -82,7 +115,17 @@ async def _create_destination(ctx, data, pretty): default=None, help="""Set to true to include only destinations the user can modify, false to exclude them.""") -async def list_destinations(ctx, archived, is_owner, can_write, pretty): +@click.option('--is-default', + type=bool, + default=None, + help="""Set to true to include only the default destination, + false to exclude it.""") +async def list_destinations(ctx, + archived, + is_owner, + can_write, + is_default, + pretty): """ List destinations with optional filters @@ -93,7 +136,12 @@ async def list_destinations(ctx, archived, is_owner, can_write, pretty): planet destinations list --archived false --is-owner true --can-write true """ - await _list_destinations(ctx, archived, is_owner, can_write, pretty) + await _list_destinations(ctx, + archived, + is_owner, + can_write, + is_default, + pretty) @command(destinations, name="get") @@ -590,3 +638,57 @@ async def update_s3_compatible(ctx, data["parameters"]["use_path_style"] = True await _patch_destination(ctx, destination_id, data, pretty) + + +@destinations.group() +def default(): + """Commands for interacting with default destinations.""" + pass + + +@command(default, name="set") +@click.argument("destination_id") +async def set_default_destination(ctx, destination_id, pretty): + """ + Set a default destination. + + This command sets a specified destination as the default for the organization. Default destinations + are globally available to all members of an organization. An organization can have zero or one default + destination at any time. Ability to set a default destination is restricted to organization + administrators and destination owners. + + Example: + + planet destinations default set my-destination-id + """ + await _set_default_destination(ctx, destination_id, pretty) + + +@command(default, name="unset") +async def unset_default_destination(ctx, pretty): + """ + Unset the current default destination. + + This command unsets the current default destination. Ability to unset a default destination is restricted + to organization administrators and destination owners. Returns None (HTTP 204, No Content) on success. + + Example: + + planet destinations default unset + """ + await _unset_default_destination(ctx, pretty) + + +@command(default, name="get") +async def get_default_destination(ctx, pretty): + """ + Get the current default destination. + + This command gets the current default destination for the organization, if one is set. The default + destination is globally available to all members of an organization. + + Example: + + planet destinations default get + """ + await _get_default_destination(ctx, pretty) diff --git a/planet/clients/destinations.py b/planet/clients/destinations.py index 92d2a37b..1d1f0dad 100644 --- a/planet/clients/destinations.py +++ b/planet/clients/destinations.py @@ -26,6 +26,8 @@ T = TypeVar("T") +DEFAULT_DESTINATION_REF = "pl:destinations/default" + class DestinationsClient(_BaseClient): """Asynchronous Destinations API client. @@ -58,7 +60,8 @@ def __init__(self, async def list_destinations(self, archived: Optional[bool] = None, is_owner: Optional[bool] = None, - can_write: Optional[bool] = None) -> Dict: + can_write: Optional[bool] = None, + is_default: Optional[bool] = None) -> Dict: """ List all destinations. By default, all non-archived destinations in the requesting user's org are returned. @@ -66,6 +69,7 @@ async def list_destinations(self, archived (bool): If True, include archived destinations. is_owner (bool): If True, include only destinations owned by the requesting user. can_write (bool): If True, include only destinations the requesting user can modify. + is_default (bool): If True, include only the default destination. Returns: dict: A dictionary containing the list of destinations inside the 'destinations' key. @@ -81,6 +85,8 @@ async def list_destinations(self, params["is_owner"] = is_owner if can_write is not None: params["can_write"] = can_write + if is_default is not None: + params["is_default"] = is_default try: response = await self._session.request(method='GET', @@ -174,3 +180,75 @@ async def create_destination(self, request: Dict[str, Any]) -> Dict: else: dest = response.json() return dest + + async def set_default_destination(self, destination_id: str) -> Dict: + """ + Set an existing destination as the default destination. Default destinations are globally available + to all members of an organization. An organization can have zero or one default destination at any time. + Ability to set a default destination is restricted to organization administrators and destination owners. + + Args: + destination_id (str): The ID of the destination to set as default. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/default' + request = {"destination_id": destination_id} + try: + response = await self._session.request(method='PUT', + url=url, + json=request) + except APIError: + raise + except ClientError: # pragma: no cover + raise + else: + return response.json() + + async def unset_default_destination(self) -> None: + """ + Unset the current default destination. Ability to unset a default destination is restricted to + organization administrators and destination owners. Returns None (HTTP 204, No Content) on success. + + Returns: + None + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/default' + try: + await self._session.request(method='DELETE', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + + async def get_default_destination(self) -> Dict: + """ + Get the current default destination. The default destination is globally available to all members of an + organization. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + url = f'{self._base_url}/default' + try: + response = await self._session.request(method='GET', url=url) + except APIError: + raise + except ClientError: # pragma: no cover + raise + else: + dest = response.json() + return dest diff --git a/planet/order_request.py b/planet/order_request.py index 31a600c8..512fb0c7 100644 --- a/planet/order_request.py +++ b/planet/order_request.py @@ -18,6 +18,7 @@ from typing import Any, Dict, List, Mapping, Optional, Union from . import geojson, specs +from .clients.destinations import DEFAULT_DESTINATION_REF from .exceptions import ClientError LOGGER = logging.getLogger(__name__) @@ -392,6 +393,36 @@ def s3_compatible(endpoint: str, return {'s3_compatible': parameters} +def destination(destination_ref: str, + path_prefix: Optional[str] = None) -> dict: + """Destinations API configuration. + Parameters: + destination_ref: Reference to an existing Destinations API + destination. + path_prefix: Path prefix for deliveries. + """ + cloud_details: Dict[str, Any] = {'ref': destination_ref} + + if path_prefix: + cloud_details['path_prefix'] = path_prefix + + return {'destination': cloud_details} + + +def default_destination(path_prefix: Optional[str] = None) -> dict: + """Default Destinations API configuration. + + Parameters: + path_prefix: Path prefix for deliveries. + """ + parameters: Dict[str, Any] = {'ref': DEFAULT_DESTINATION_REF} + + if path_prefix: + parameters['path_prefix'] = path_prefix + + return {'destination': parameters} + + def _tool(name: str, parameters: dict) -> dict: """Create the API spec representation of a tool. diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 72b36e80..ae5dbf19 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -19,6 +19,7 @@ from typing_extensions import Literal from . import geojson, specs +from .clients.destinations import DEFAULT_DESTINATION_REF from .exceptions import ClientError NOTIFICATIONS_TOPICS = ('delivery.success', @@ -528,6 +529,36 @@ def s3_compatible(endpoint: str, return _delivery('s3_compatible', parameters) +def destination(destination_ref: str, + path_prefix: Optional[str] = None) -> dict: + """Specify a Destinations API destination by its ref. + + Parameters: + destination_ref: The ID of the destination to deliver to. + path_prefix: Path prefix for deliveries. + """ + parameters: Dict[str, Any] = {"ref": destination_ref} + + if path_prefix: + parameters['path_prefix'] = path_prefix + + return _delivery('destination', parameters) + + +def default_destination(path_prefix: Optional[str] = None) -> dict: + """Specify the organization's default Destinations API destination. + + Parameters: + path_prefix: Path prefix for deliveries. + """ + parameters: Dict[str, Any] = {"ref": DEFAULT_DESTINATION_REF} + + if path_prefix: + parameters['path_prefix'] = path_prefix + + return _delivery('destination', parameters) + + def notifications(url: str, topics: List[str]) -> dict: """Specify a subscriptions API notification. diff --git a/planet/sync/destinations.py b/planet/sync/destinations.py index 6024dd71..a95b2f97 100644 --- a/planet/sync/destinations.py +++ b/planet/sync/destinations.py @@ -34,7 +34,8 @@ def __init__(self, session: Session, base_url: Optional[str] = None): def list_destinations(self, archived: Optional[bool] = None, is_owner: Optional[bool] = None, - can_write: Optional[bool] = None) -> Dict: + can_write: Optional[bool] = None, + is_default: Optional[bool] = None) -> Dict: """ List all destinations. By default, all non-archived destinations in the requesting user's org are returned. @@ -42,6 +43,7 @@ def list_destinations(self, archived (bool): If True, include archived destinations. is_owner (bool): If True, include only destinations owned by the requesting user. can_write (bool): If True, include only destinations the requesting user can modify. + is_default (bool): If True, include only the default destination. Returns: dict: A dictionary containing the list of destinations inside the 'destinations' key. @@ -51,7 +53,10 @@ def list_destinations(self, ClientError: If there is an issue with the client request. """ return self._client._call_sync( - self._client.list_destinations(archived, is_owner, can_write)) + self._client.list_destinations(archived, + is_owner, + can_write, + is_default)) def get_destination(self, destination_id: str) -> Dict: """ @@ -105,3 +110,51 @@ def create_destination(self, request: Dict[str, Any]) -> Dict: """ return self._client._call_sync( self._client.create_destination(request)) + + def set_default_destination(self, destination_id: str) -> Dict: + """ + Set an existing destination as the default destination. Default destinations are globally available + to all members of an organization. An organization can have zero or one default destination at any time. + Ability to set a default destination is restricted to organization administrators and destination owners. + + Args: + destination_id (str): The ID of the destination to set as default. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + return self._client._call_sync( + self._client.set_default_destination(destination_id)) + + def unset_default_destination(self) -> None: + """ + Unset the current default destination. Ability to unset a default destination is restricted to + organization administrators and destination owners. Returns None (HTTP 204, No Content) on success. + + Returns: + None + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + return self._client._call_sync( + self._client.unset_default_destination()) + + def get_default_destination(self) -> Dict: + """ + Get the current default destination. The default destination is globally available to all members of an + organization. + + Returns: + dict: A dictionary containing the default destination details. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + """ + return self._client._call_sync(self._client.get_default_destination()) diff --git a/tests/integration/test_destinations_api.py b/tests/integration/test_destinations_api.py index e480bc53..af702b22 100644 --- a/tests/integration/test_destinations_api.py +++ b/tests/integration/test_destinations_api.py @@ -179,3 +179,88 @@ async def test_get_destination_not_found(): await cl_async.get_destination(id) with pytest.raises(Exception): cl_sync.get_destination(id) + + +@respx.mock +async def test_set_default_destination(): + id = DEST_1["id"] + url = f"{TEST_URL}/default" + mock_response(url, DEST_1, method="put") + + def assertf(resp): + assert resp == DEST_1 + + assertf(await cl_async.set_default_destination(id)) + assertf(cl_sync.set_default_destination(id)) + + +@respx.mock +async def test_set_default_destination_bad_request(): + id = "invalid_dest_id" + url = f"{TEST_URL}/default" + mock_response( + url, { + "code": 400, "message": "Bad Request: Invalid destination ID" + }, + method="put", + status_code=HTTPStatus.BAD_REQUEST) + + with pytest.raises(Exception): + await cl_async.set_default_destination(id) + with pytest.raises(Exception): + cl_sync.set_default_destination(id) + + +@respx.mock +async def test_get_default_destination(): + url = f"{TEST_URL}/default" + mock_response(url, DEST_1) + + def assertf(resp): + assert resp == DEST_1 + + assertf(await cl_async.get_default_destination()) + assertf(cl_sync.get_default_destination()) + + +@respx.mock +async def test_get_default_destination_not_found(): + url = f"{TEST_URL}/default" + mock_response(url, { + "code": 404, "message": "No default destination configured" + }, + status_code=HTTPStatus.NOT_FOUND) + + with pytest.raises(Exception): + await cl_async.get_default_destination() + with pytest.raises(Exception): + cl_sync.get_default_destination() + + +@respx.mock +async def test_unset_default_destination(): + url = f"{TEST_URL}/default" + mock_response(url, + None, + method="delete", + status_code=HTTPStatus.NO_CONTENT) + + # unset_default_destination returns None + assert await cl_async.unset_default_destination() is None + assert cl_sync.unset_default_destination() is None + + +@respx.mock +async def test_unset_default_destination_unauthorized(): + url = f"{TEST_URL}/default" + mock_response( + url, { + "code": 401, "message": "Unauthorized: Insufficient permissions" + }, + method="delete", + status_code=HTTPStatus.UNAUTHORIZED) + + with pytest.raises(Exception): + await cl_async.unset_default_destination() + with pytest.raises(Exception): + cl_sync.unset_default_destination() diff --git a/tests/integration/test_destinations_cli.py b/tests/integration/test_destinations_cli.py index 50f80623..f975989b 100644 --- a/tests/integration/test_destinations_cli.py +++ b/tests/integration/test_destinations_cli.py @@ -180,6 +180,9 @@ def test_destinations_cli_list(invoke): result = invoke(['list', '--can-write', 'true']) assert result.exit_code == 0 + result = invoke(['list', '--is-default', 'true']) + assert result.exit_code == 0 + result = invoke(['list', '--archived', 'false']) assert result.exit_code == 0 @@ -189,6 +192,9 @@ def test_destinations_cli_list(invoke): result = invoke(['list', '--can-write', 'false']) assert result.exit_code == 0 + result = invoke(['list', '--is-default', 'false']) + assert result.exit_code == 0 + result = invoke(['list', '--archived', 'false', '--is-owner', 'true']) assert result.exit_code == 0 @@ -251,3 +257,72 @@ def test_destinations_cli_update(invoke): '--use-path-style' ]) assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_set(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.put(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['default', 'set', 'fake-dest-id']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_set_bad_request(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.put(url).return_value = httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "code": 400, "message": "Bad Request: Invalid destination ID" + }) + + result = invoke(['default', 'set', 'invalid-dest-id']) + assert result.exit_code != 0 + assert "Failed to set default destination" in result.output + + +@respx.mock +def test_destinations_cli_default_get(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['default', 'get']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_get_not_found(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.get(url).return_value = httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "code": 404, "message": "No default destination configured" + }) + + result = invoke(['default', 'get']) + assert result.exit_code != 0 + assert "Failed to get default destination" in result.output + + +@respx.mock +def test_destinations_cli_default_unset(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.delete(url).return_value = httpx.Response(HTTPStatus.NO_CONTENT) + + result = invoke(['default', 'unset']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_default_unset_unauthorized(invoke): + url = f"{TEST_DESTINATIONS_URL}/default" + respx.delete(url).return_value = httpx.Response( + HTTPStatus.UNAUTHORIZED, + json={ + "code": 401, "message": "Unauthorized: Insufficient permissions" + }) + + result = invoke(['default', 'unset']) + assert result.exit_code != 0 + assert "Failed to unset default destination" in result.output diff --git a/tests/unit/test_order_request.py b/tests/unit/test_order_request.py index b602fe98..c963aa17 100644 --- a/tests/unit/test_order_request.py +++ b/tests/unit/test_order_request.py @@ -469,3 +469,40 @@ def test_fallback_bundle_invalid(bundle, fallback_bundle): bundle, "PSScene", fallback_bundle=fallback_bundle) + + +def test_destination(): + dest_config = order_request.destination('my-dest-ref') + + expected = {'destination': {'ref': 'my-dest-ref'}} + assert dest_config == expected + + +def test_destination_path_prefix(): + dest_config = order_request.destination('my-dest-ref', + path_prefix='my/prefix') + + expected = { + 'destination': { + 'ref': 'my-dest-ref', 'path_prefix': 'my/prefix' + } + } + assert dest_config == expected + + +def test_default_destination(): + dest_config = order_request.default_destination() + + expected = {'destination': {'ref': 'pl:destinations/default'}} + assert dest_config == expected + + +def test_default_destination_path_prefix(): + dest_config = order_request.default_destination(path_prefix='my/prefix') + + expected = { + 'destination': { + 'ref': 'pl:destinations/default', 'path_prefix': 'my/prefix' + } + } + assert dest_config == expected diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py index 858cc6c2..d7661e11 100644 --- a/tests/unit/test_subscription_request.py +++ b/tests/unit/test_subscription_request.py @@ -634,3 +634,43 @@ def test_cloud_filter_tool_success(): } assert res == expected + + +def test_destination_success(): + res = subscription_request.destination(destination_ref='my-dest-ref') + + assert res == {"type": "destination", "parameters": {"ref": "my-dest-ref"}} + + +def test_destination_path_prefix_success(): + res = subscription_request.destination(destination_ref='my-dest-ref', + path_prefix='my/prefix') + + assert res == { + "type": "destination", + "parameters": { + "ref": "my-dest-ref", "path_prefix": "my/prefix" + } + } + + +def test_default_destination_success(): + res = subscription_request.default_destination() + + assert res == { + "type": "destination", + "parameters": { + "ref": "pl:destinations/default" + } + } + + +def test_default_destination_path_prefix_success(): + res = subscription_request.default_destination(path_prefix='my/prefix') + + assert res == { + "type": "destination", + "parameters": { + "ref": "pl:destinations/default", "path_prefix": "my/prefix" + } + }