diff --git a/src/fastapi_cloud_cli/api/client.py b/src/fastapi_cloud_cli/api/client.py index 821a169..55955ac 100644 --- a/src/fastapi_cloud_cli/api/client.py +++ b/src/fastapi_cloud_cli/api/client.py @@ -160,6 +160,10 @@ def create_custom_domain( return CustomDomain.model_validate(response.json()) + def remove_custom_domain(self, *, app_id: str, domain_id: str) -> None: + response = self.delete(f"/apps/{app_id}/custom-domains/{domain_id}") + response.raise_for_status() + @attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT) def stream_build_logs( self, deployment_id: str, *, follow: bool = True diff --git a/src/fastapi_cloud_cli/commands/domains/__init__.py b/src/fastapi_cloud_cli/commands/domains/__init__.py index aec5210..7e96643 100644 --- a/src/fastapi_cloud_cli/commands/domains/__init__.py +++ b/src/fastapi_cloud_cli/commands/domains/__init__.py @@ -3,6 +3,7 @@ from fastapi_cloud_cli.commands.domains.add import add_domain from fastapi_cloud_cli.commands.domains.get import get_domain from fastapi_cloud_cli.commands.domains.list import list_domains +from fastapi_cloud_cli.commands.domains.remove import remove_domain domains_app = typer.Typer( no_args_is_help=True, @@ -11,5 +12,6 @@ domains_app.command("add")(add_domain) domains_app.command("get")(get_domain) domains_app.command("list")(list_domains) +domains_app.command("remove")(remove_domain) __all__ = ["domains_app"] diff --git a/src/fastapi_cloud_cli/commands/domains/remove.py b/src/fastapi_cloud_cli/commands/domains/remove.py new file mode 100644 index 0000000..3046564 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/domains/remove.py @@ -0,0 +1,181 @@ +from typing import Annotated, Any + +import typer +from pydantic import BaseModel +from rich_toolkit import RichToolkit + +from fastapi_cloud_cli.api import APIClient, CustomDomain +from fastapi_cloud_cli.commands.domains._shared import ( + _find_custom_domain, + _select_custom_domain, +) +from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail +from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.cli import get_rich_toolkit +from fastapi_cloud_cli.utils.execution import JsonOutputOption + + +class CustomDomainRemoveOutput(BaseModel): + app_id: str + domain_id: str + name: str + removed: bool = True + + +def _render_custom_domain_remove_output( + data: CustomDomainRemoveOutput, + toolkit: RichToolkit, +) -> None: + toolkit.print(f"Removed [bold]{data.name}[/bold]", emoji="🐔") + + +def _print_removal_warning(toolkit: RichToolkit, domain: CustomDomain) -> None: + toolkit.print( + f"FastAPI Cloud resources for [bold]{domain.name}[/bold] will be removed. " + "DNS records at your provider will not be changed.", + emoji="⚠️", + ) + + +def remove_domain( + domain: Annotated[ + str | None, + typer.Argument( + help="Hostname or ID of the custom domain to remove.", + ), + ] = None, + app_id: Annotated[ + str | None, + typer.Option( + "--app-id", + help="ID of the app that owns the custom domain.", + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm removal without prompting.", + ), + ] = False, + json_output: JsonOutputOption = False, +) -> Any: + """ + Remove a custom domain from an app. + + DNS records at your provider are not changed. + """ + identity = Identity() + + with get_rich_toolkit(json_output=json_output) as toolkit: + if not identity.is_logged_in(): + toolkit.fail( + "not_logged_in", + "No credentials found.", + hint="Run `fastapi cloud login`.", + ) + + app_id = resolve_app_id_or_fail(toolkit, app_id=app_id) + + if toolkit.mode == "json": + if domain is None: + toolkit.fail( + "missing_required_input", + "Custom domain is required.", + hint="Pass DOMAIN to choose a custom domain.", + ) + if not yes: + toolkit.fail( + "missing_required_input", + "Removal confirmation is required.", + hint="Pass --yes to confirm removal.", + ) + + with APIClient() as client: + with ( + toolkit.progress( + title="Fetching custom domains", + transient=True, + ) as progress, + client.handle_http_errors( + progress, + default_message=( + "Error fetching custom domains. Please try again later." + ), + not_found_message="App not found.", + toolkit=toolkit, + ), + ): + domains = client.get_custom_domains(app_id=app_id).data + + toolkit.print_title("custom domains") + toolkit.print_line() + + selected_domain: CustomDomain | None + if domain is None: + if not domains: + toolkit.print("No custom domains found.", bullet=False) + return + + selected_domain = _select_custom_domain( + toolkit, + domains, + prompt="Select the custom domain to remove:", + ) + toolkit.print_line() + else: + selected_domain = _find_custom_domain(domains, domain) + if selected_domain is None: + toolkit.fail( + "not_found", + f"Custom domain {domain} not found.", + hint=( + "Run `fastapi cloud domains list` to see available " + "custom domains." + ), + ) + + assert selected_domain is not None + _print_removal_warning(toolkit, selected_domain) + + if not yes: + toolkit.print_line() + should_remove = toolkit.confirm( + f"Remove [bold]{selected_domain.name}[/bold]?", + default=False, + bullet=False, + ) + if not should_remove: + toolkit.print_line() + toolkit.print("Removal cancelled.", bullet=False) + raise typer.Exit(0) + + toolkit.print_line() + with ( + toolkit.progress( + title="Removing custom domain", + transient=True, + ) as progress, + client.handle_http_errors( + progress, + default_message=( + "Error removing custom domain. Please try again later." + ), + not_found_message="Custom domain not found.", + toolkit=toolkit, + ), + ): + client.remove_custom_domain( + app_id=app_id, + domain_id=selected_domain.id, + ) + + toolkit.success( + CustomDomainRemoveOutput( + app_id=app_id, + domain_id=selected_domain.id, + name=selected_domain.name, + ), + render_output=_render_custom_domain_remove_output, + ) diff --git a/tests/domains/test_cli.py b/tests/domains/test_cli.py index b10a7c8..08d412f 100644 --- a/tests/domains/test_cli.py +++ b/tests/domains/test_cli.py @@ -58,8 +58,9 @@ def custom_domain(**overrides: Any) -> dict[str, Any]: ["list"], ["get", "api.example.com"], ["add", "api.example.com", "--standard"], + ["remove", "api.example.com", "--yes"], ], - ids=["list", "get", "add"], + ids=["list", "get", "add", "remove"], ) def test_domains_commands_require_user_session( command: list[str], @@ -839,3 +840,196 @@ def test_domains_add_surfaces_entitlement_limit( "hint": None, } } + + +def test_domains_remove_json_requires_domain(logged_in_cli: None) -> None: + result = runner.invoke( + app, + ["domains", "remove", "--yes", "--app-id", APP_ID, "--json"], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "Custom domain is required.", + "hint": "Pass DOMAIN to choose a custom domain.", + } + } + + +def test_domains_remove_json_requires_confirmation(logged_in_cli: None) -> None: + result = runner.invoke( + app, + ["domains", "remove", "api.example.com", "--app-id", APP_ID, "--json"], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "Removal confirmation is required.", + "hint": "Pass --yes to confirm removal.", + } + } + + +@pytest.mark.respx +def test_domains_remove_resolves_name_and_returns_json( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + respx_mock.delete(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}").mock( + return_value=Response(200, json={"message": "Custom domain deleted"}) + ) + + result = runner.invoke( + app, + [ + "domains", + "remove", + " API.Example.COM. ", + "--yes", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "app_id": APP_ID, + "domain_id": DOMAIN_ID, + "name": "api.example.com", + "removed": True, + } + } + + +@pytest.mark.respx +def test_domains_remove_warns_and_confirms( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + respx_mock.delete(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}").mock( + return_value=Response(200, json={"message": "Custom domain deleted"}) + ) + + with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): + result = runner.invoke( + app, + ["domains", "remove", domain["name"], "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert ( + "FastAPI Cloud resources for api.example.com will be removed" in result.output + ) + assert "DNS records at" in result.output + assert "will not be changed" in result.output + assert "Remove api.example.com?" in result.output + assert "Removed api.example.com" in result.output + + +@pytest.mark.respx +def test_domains_remove_can_select_domain( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + respx_mock.delete(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}").mock( + return_value=Response(200, json={"message": "Custom domain deleted"}) + ) + + with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): + result = runner.invoke( + app, + ["domains", "remove", "--yes", "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert "Select the custom domain to remove:" in result.output + assert "Removed api.example.com" in result.output + + +@pytest.mark.respx +def test_domains_remove_can_be_cancelled( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + + with patch( + "rich_toolkit.container.getchar", + side_effect=[Keys.RIGHT_ARROW, Keys.ENTER], + ): + result = runner.invoke( + app, + ["domains", "remove", domain["name"], "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert "Removal cancelled." in result.output + + +@pytest.mark.respx +def test_domains_remove_reports_not_found_before_delete( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [custom_domain()], "count": 1}) + ) + + result = runner.invoke( + app, + [ + "domains", + "remove", + "missing.example.com", + "--yes", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"] == { + "code": "not_found", + "message": "Custom domain missing.example.com not found.", + "hint": "Run `fastapi cloud domains list` to see available custom domains.", + } + + +@pytest.mark.respx +def test_domains_remove_selector_handles_empty_collection( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [], "count": 0}) + ) + + result = runner.invoke( + app, + ["domains", "remove", "--yes", "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert "No custom domains found." in result.output