diff --git a/src/fastapi_cloud_cli/api/_errors.py b/src/fastapi_cloud_cli/api/_errors.py index a9cec5e5..25e67f30 100644 --- a/src/fastapi_cloud_cli/api/_errors.py +++ b/src/fastapi_cloud_cli/api/_errors.py @@ -44,10 +44,18 @@ def _get_response_error_message(response: httpx.Response) -> str | None: return None # pragma: no cover detail = data.get("detail") - if not isinstance(detail, str): - return None # pragma: no cover + if isinstance(detail, str): + return detail + + if ( + isinstance(detail, list) + and detail + and isinstance(detail[0], dict) + and isinstance(message := detail[0].get("msg"), str) + ): + return message.removeprefix("Value error, ") - return detail + return None # pragma: no cover def handle_http_error( @@ -61,9 +69,9 @@ def handle_http_error( if isinstance(error, httpx.HTTPStatusError): status_code = error.response.status_code - # Handle validation errors from Pydantic models, this should make it easier to debug :) if status_code == 422: - logger.debug(error.response.json()) # pragma: no cover + logger.debug(error.response.json()) + message = _get_response_error_message(error.response) elif status_code == 400: message = _get_response_error_message(error.response) @@ -103,7 +111,7 @@ def get_http_error_code(error: httpx.HTTPError) -> ErrorCode: if isinstance(error, httpx.HTTPStatusError): status_code = error.response.status_code - if status_code in {400, 409}: + if status_code in {400, 409, 422}: return "invalid_input" if status_code == 401: diff --git a/src/fastapi_cloud_cli/api/client.py b/src/fastapi_cloud_cli/api/client.py index f5b04351..821a1695 100644 --- a/src/fastapi_cloud_cli/api/client.py +++ b/src/fastapi_cloud_cli/api/client.py @@ -28,6 +28,7 @@ AppLogEntry, BuildLogAdapter, BuildLogLine, + CustomDomain, CustomDomainsAPIResponse, DeploymentStatus, ) @@ -141,6 +142,24 @@ def get_custom_domains(self, *, app_id: str) -> CustomDomainsAPIResponse: return CustomDomainsAPIResponse.model_validate(response.json()) + def create_custom_domain( + self, + *, + app_id: str, + name: str, + is_using_pre_validation: bool, + ) -> CustomDomain: + response = self.post( + f"/apps/{app_id}/custom-domains", + json={ + "name": name, + "is_using_pre_validation": is_using_pre_validation, + }, + ) + response.raise_for_status() + + return CustomDomain.model_validate(response.json()) + @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 29898eae..aec5210d 100644 --- a/src/fastapi_cloud_cli/commands/domains/__init__.py +++ b/src/fastapi_cloud_cli/commands/domains/__init__.py @@ -1,5 +1,6 @@ import typer +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 @@ -7,6 +8,7 @@ no_args_is_help=True, help="Manage the custom domains of your app.", ) +domains_app.command("add")(add_domain) domains_app.command("get")(get_domain) domains_app.command("list")(list_domains) diff --git a/src/fastapi_cloud_cli/commands/domains/add.py b/src/fastapi_cloud_cli/commands/domains/add.py new file mode 100644 index 00000000..7ae9f5f6 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/domains/add.py @@ -0,0 +1,194 @@ +from typing import Annotated, Any + +import typer +from pydantic import BaseModel, Field +from rich_toolkit import RichToolkit +from rich_toolkit.menu import Option + +from fastapi_cloud_cli.api import APIClient, CustomDomain +from fastapi_cloud_cli.commands.domains._shared import _normalize_domain_name +from fastapi_cloud_cli.commands.domains.rendering import render_custom_domain_setup +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 FastAPIRichToolkit, get_rich_toolkit +from fastapi_cloud_cli.utils.execution import JsonOutputOption + + +class CustomDomainAddOutput(BaseModel): + app_id: str + domain: CustomDomain + show_title: Annotated[bool, Field(exclude=True)] = True + + +def _resolve_domain_name( + toolkit: FastAPIRichToolkit, + *, + domain: str | None, +) -> str: + if domain is None: + if toolkit.mode == "json": + toolkit.fail( + "missing_required_input", + "Custom domain is required.", + hint="Pass DOMAIN to choose the hostname to add.", + ) + + domain = toolkit.input( + "What domain do you want to add?", + emoji="🌐", + bullet=False, + ) + toolkit.print_line() + + return _normalize_domain_name(domain) + + +def _resolve_pre_validation( + toolkit: FastAPIRichToolkit, + *, + domain: str, + standard: bool, + zero_downtime: bool, +) -> bool: + if standard: + return False + if zero_downtime: + return True + if toolkit.mode == "json": + toolkit.fail( + "missing_required_input", + "Custom domain setup mode is required.", + hint="Pass either --standard or --zero-downtime.", + ) + + toolkit.print(f"Is {domain} already serving traffic?") + toolkit.print_line() + + return toolkit.ask( + "", + options=[ + Option( + { + "name": "No — set up a new or unused domain", + "value": False, + } + ), + Option( + { + "name": "Yes — migrate it without downtime", + "value": True, + } + ), + ], + ) + + +def _render_custom_domain_add_output( + data: CustomDomainAddOutput, + toolkit: RichToolkit, +) -> None: + if data.show_title: + toolkit.print_title("custom domains") + toolkit.print_line() + + toolkit.print(f"Added [bold]{data.domain.name}[/bold]", emoji="🐔") + render_custom_domain_setup(data.domain, toolkit) + + +def add_domain( + domain: Annotated[ + str | None, + typer.Argument( + help="Hostname of the custom domain to add.", + ), + ] = None, + app_id: Annotated[ + str | None, + typer.Option( + "--app-id", + help="ID of the app to which the custom domain should be added.", + ), + ] = None, + standard: Annotated[ + bool, + typer.Option( + "--standard", + help="Set up a new or unused domain.", + ), + ] = False, + zero_downtime: Annotated[ + bool, + typer.Option( + "--zero-downtime", + help="Migrate an already-live domain without downtime.", + ), + ] = False, + json_output: JsonOutputOption = False, +) -> Any: + """ + Add a custom domain to an app. + """ + 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 standard and zero_downtime: + toolkit.fail( + "invalid_input", + "Setup modes are mutually exclusive.", + hint="Pass either --standard or --zero-downtime.", + ) + + prompts_user = domain is None or not (standard or zero_downtime) + if prompts_user and toolkit.mode != "json": + toolkit.print_title("custom domains") + toolkit.print_line() + + domain = _resolve_domain_name(toolkit, domain=domain) + is_using_pre_validation = _resolve_pre_validation( + toolkit, + domain=domain, + standard=standard, + zero_downtime=zero_downtime, + ) + + if prompts_user: + toolkit.print_line() + + with APIClient() as client: + with ( + toolkit.progress( + title="Adding custom domain", + transient=True, + ) as progress, + client.handle_http_errors( + progress, + default_message=( + "Error adding custom domain. Please try again later." + ), + not_found_message="App not found.", + toolkit=toolkit, + ), + ): + created_domain = client.create_custom_domain( + app_id=app_id, + name=domain, + is_using_pre_validation=is_using_pre_validation, + ) + + toolkit.success( + CustomDomainAddOutput( + app_id=app_id, + domain=created_domain, + show_title=not prompts_user, + ), + render_output=_render_custom_domain_add_output, + ) diff --git a/src/fastapi_cloud_cli/commands/domains/rendering.py b/src/fastapi_cloud_cli/commands/domains/rendering.py index cbb8f893..812ca890 100644 --- a/src/fastapi_cloud_cli/commands/domains/rendering.py +++ b/src/fastapi_cloud_cli/commands/domains/rendering.py @@ -1,6 +1,5 @@ from dataclasses import dataclass -from rich.console import RenderableType from rich.table import Table from rich.text import Text from rich_toolkit import RichToolkit @@ -13,10 +12,8 @@ from fastapi_cloud_cli.commands.domains._setup import ( ATTENTION_STATUSES, SetupStep, - StepStatus, get_setup_steps, ) -from fastapi_cloud_cli.utils.cli import get_details_table from fastapi_cloud_cli.utils.dates import format_last_updated @@ -25,6 +22,7 @@ class DomainStatusMetadata: label: str title: str description: str + emoji: str = "⏳" DOMAIN_STATUS: dict[CustomDomainStatus, DomainStatusMetadata] = { @@ -51,6 +49,7 @@ class DomainStatusMetadata: "The DNS records were found but don't match the expected values. " "Double-check your provider settings." ), + emoji="⚠️", ), CustomDomainStatus.internal_dcv_timeout: DomainStatusMetadata( label="Domain verification timed out", @@ -59,6 +58,7 @@ class DomainStatusMetadata: "We couldn't verify the domain in time, it's possible the DNS changes " "may still be propagating. Please restart the domain verification process." ), + emoji="⚠️", ), CustomDomainStatus.internal_dcv_revoked: DomainStatusMetadata( label="Domain verification revoked", @@ -67,6 +67,7 @@ class DomainStatusMetadata: "Domain ownership could no longer be verified. This may happen if DNS " "records were removed or changed." ), + emoji="⚠️", ), CustomDomainStatus.external_dcv_pending: DomainStatusMetadata( label="Setting up domain", @@ -96,6 +97,7 @@ class DomainStatusMetadata: "This domain has been restricted and domain verification cannot proceed. " "Please contact support for more information." ), + emoji="⚠️", ), CustomDomainStatus.external_dcv_timeout: DomainStatusMetadata( label="Domain setup timed out", @@ -104,6 +106,7 @@ class DomainStatusMetadata: "The domain setup took too long to complete. This is often caused by slow " "DNS propagation. Please restart the domain verification process." ), + emoji="⚠️", ), CustomDomainStatus.origin_setup_pending: DomainStatusMetadata( label="Validating", @@ -128,6 +131,7 @@ class DomainStatusMetadata: "The DNS records were found but don't match the expected values. " "Double-check your DNS records." ), + emoji="⚠️", ), CustomDomainStatus.origin_setup_timeout: DomainStatusMetadata( label="Timeout", @@ -136,6 +140,7 @@ class DomainStatusMetadata: "The domain setup couldn't be validated in time. Please restart the " "domain setup process." ), + emoji="⚠️", ), CustomDomainStatus.origin_setup_success: DomainStatusMetadata( label="Live", @@ -144,6 +149,7 @@ class DomainStatusMetadata: "Your domain is fully configured, secured with TLS, and set up to route " "traffic to your app." ), + emoji="✅", ), CustomDomainStatus.origin_setup_removed: DomainStatusMetadata( label="Removed", @@ -152,6 +158,7 @@ class DomainStatusMetadata: "The required DNS records were removed after being valid. Your domain is " "no longer active and needs to be set up again." ), + emoji="⚠️", ), } @@ -188,16 +195,31 @@ def get_custom_domains_table(domains: list[CustomDomain]) -> Table: return table -STEP_LABELS: dict[StepStatus, str] = { - "verified": "Verified", - "in_progress": "In progress", - "attention": "Needs attention", - "locked": "Locked", - "failed": "Action needed", +STEP_NUMBER_EMOJIS = { + 1: "1️⃣", + 2: "2️⃣", + 3: "3️⃣", } def _get_dns_records_table(records: list[CustomDomainRecord]) -> Table: + if len(records) == 1: + record = records[0] + generating = Text("Generating; check again shortly", style="dim italic") + table = Table.grid(padding=(0, 2), pad_edge=False) + table.add_column(style="dim", no_wrap=True) + table.add_column(overflow="fold") + table.add_row("type", record.type) + table.add_row( + "name", + Text(record.name) if record.name is not None else generating, + ) + table.add_row( + "value", + Text(record.value) if record.value is not None else generating, + ) + return table + table = Table.grid(padding=(0, 2), pad_edge=False) table.add_column("Type", no_wrap=True) table.add_column("Name", overflow="fold") @@ -219,50 +241,95 @@ def _get_dns_records_table(records: list[CustomDomainRecord]) -> Table: return table +def _get_concise_step_description( + step: SetupStep, + records: list[CustomDomainRecord], +) -> str: + if not records: + return step.description + + records_label = ( + f"this {records[0].type} record" if len(records) == 1 else "these records" + ) + if step.id == "combined": + return ( + "[bold]To verify ownership and route traffic[/bold], add " + f"{records_label} at your DNS provider:" + ) + return f"Add {records_label} at your DNS provider:" + + def _render_setup_step( step: SetupStep, toolkit: RichToolkit, *, number: int, + show_number: bool, ) -> None: - toolkit.print( - f"[bold]{number} {step.title}[/bold] [dim]{STEP_LABELS[step.status]}[/dim]", - bullet=False, - ) - toolkit.print(step.description, bullet=False) + if step.status == "verified": + toolkit.print(f"[bold]{step.title}[/bold]", emoji="✅") + return records = step.records if step.status != "locked" else [] + hint_emoji = "" if show_number else "💡" + + if show_number: + toolkit.print( + f"[bold]{step.title}[/bold]", + emoji=STEP_NUMBER_EMOJIS[number], + ) + + if step.status == "locked": + return + + toolkit.print(_get_concise_step_description(step, records)) + if not records: return toolkit.print_line() - toolkit.print(_get_dns_records_table(records), bullet=False) + toolkit.print(_get_dns_records_table(records)) - if any( + has_trailing_dot = any( record.type == "CNAME" and (record.value or "").endswith(".") for record in records - ): + ) + show_cloudflare_warning = any(record.type in {"CNAME", "A"} for record in records) + + if has_trailing_dot or show_cloudflare_warning: toolkit.print_line() + + if has_trailing_dot: toolkit.print( - "Copy CNAME values as shown. If your DNS provider rejects the trailing " - "dot, remove it and try again.", - emoji="💡", + "Copy as shown; remove the trailing dot only if your provider rejects it.", + emoji=hint_emoji, ) + hint_emoji = "" - if step.status != "verified" and any( - record.type in {"CNAME", "A"} for record in records - ): - toolkit.print_line() + if show_cloudflare_warning: toolkit.print( - "Using Cloudflare? Set these records to DNS only (gray cloud), not " - "Proxied (orange cloud).", - emoji="💡", + "Cloudflare: use DNS only (gray cloud).", + emoji=hint_emoji, ) -def _get_next_action(domain: CustomDomain) -> str: - if domain.setup_successful: - return "No action needed. Your domain is live." +def render_custom_domain_setup( + domain: CustomDomain, + toolkit: RichToolkit, +) -> None: + steps = get_setup_steps(domain) + show_number = len(steps) > 1 + for number, step in enumerate(steps, start=1): + toolkit.print_line() + _render_setup_step( + step, + toolkit, + number=number, + show_number=show_number, + ) + + +def _get_next_action(domain: CustomDomain) -> str | None: if domain.setup_failed: return ( "Correct the DNS records, then run " @@ -270,41 +337,28 @@ def _get_next_action(domain: CustomDomain) -> str: ) if domain.status in ATTENTION_STATUSES: return "Correct the DNS records shown. We'll check again automatically." - return ( - "Wait for automatic verification. DNS changes can take up to 48 hours " - "to propagate." - ) + return None def render_custom_domain_details( domain: CustomDomain, toolkit: RichToolkit, ) -> None: - metadata = DOMAIN_STATUS[domain.status] - rows: list[tuple[str, RenderableType]] = [ - ("hostname", domain.name), - ("status", metadata.label), - ("raw status", domain.status.value), - ("setup mode", get_setup_mode_label(domain)), - ("id", domain.id), - ("created", format_last_updated(domain.created_at)), - ("updated", format_last_updated(domain.updated_at)), - ("setup started", format_last_updated(domain.setup_started_at)), - ("last checked", format_last_updated(domain.setup_checked_at)), - ] if domain.setup_successful: url = f"https://{domain.name}" - rows.insert(1, ("url", Text(url, style=f"link {url}"))) + toolkit.print(Text(url, style=f"bold link {url}"), emoji="🌐") + toolkit.print_line() + toolkit.print("Your domain is live.", emoji="✅") + return + metadata = DOMAIN_STATUS[domain.status] toolkit.print(Text(domain.name, style="bold"), emoji="🌐") toolkit.print_line() - toolkit.print(get_details_table(rows)) - toolkit.print_line() - toolkit.print(f"[bold]{metadata.title}[/bold]\n{metadata.description}") - - for number, step in enumerate(get_setup_steps(domain), start=1): + toolkit.print( + f"[bold]{metadata.title}[/bold]\n{metadata.description}", + emoji=metadata.emoji, + ) + render_custom_domain_setup(domain, toolkit) + if next_action := _get_next_action(domain): toolkit.print_line() - _render_setup_step(step, toolkit, number=number) - - toolkit.print_line() - toolkit.print(_get_next_action(domain), emoji="⏳") + toolkit.print(next_action) diff --git a/tests/domains/test_cli.py b/tests/domains/test_cli.py index 0664a7f6..b10a7c8d 100644 --- a/tests/domains/test_cli.py +++ b/tests/domains/test_cli.py @@ -10,12 +10,9 @@ from httpx import Response from typer.testing import CliRunner -from fastapi_cloud_cli.api import CustomDomain, CustomDomainStatus +from fastapi_cloud_cli.api import CustomDomainStatus from fastapi_cloud_cli.cli import cloud_app as app -from fastapi_cloud_cli.commands.domains.rendering import ( - DOMAIN_STATUS, - _get_next_action, -) +from fastapi_cloud_cli.commands.domains.rendering import DOMAIN_STATUS from tests.conftest import ConfiguredApp from tests.utils import Keys, changing_dir @@ -57,8 +54,12 @@ def custom_domain(**overrides: Any) -> dict[str, Any]: @pytest.mark.parametrize( "command", - [["list"], ["get", "api.example.com"]], - ids=["list", "get"], + [ + ["list"], + ["get", "api.example.com"], + ["add", "api.example.com", "--standard"], + ], + ids=["list", "get", "add"], ) def test_domains_commands_require_user_session( command: list[str], @@ -191,8 +192,9 @@ def test_domain_status_metadata_is_exhaustive() -> None: assert set(DOMAIN_STATUS) == set(CustomDomainStatus) +@pytest.mark.respx @pytest.mark.parametrize( - ("overrides", "expected"), + ("overrides", "expected_output"), [ ( { @@ -201,23 +203,35 @@ def test_domain_status_metadata_is_exhaustive() -> None: "setup_failed": True, }, ( - "Correct the DNS records, then run " - "`fastapi cloud domains restart api.example.com`." + "Correct the DNS records, then run `fastapi cloud domains restart", + "api.example.com`.", ), ), ( {"status": "internal_dcv_invalid"}, - "Correct the DNS records shown. We'll check again automatically.", + ("Correct the DNS records shown. We'll check again automatically.",), ), ], ) -def test_domain_next_action_when_user_intervention_is_required( +def test_domains_get_renders_next_action_when_user_intervention_is_required( overrides: dict[str, Any], - expected: str, + expected_output: tuple[str, ...], + logged_in_cli: None, + respx_mock: respx.MockRouter, ) -> None: - domain = CustomDomain.model_validate(custom_domain(**overrides)) + domain = custom_domain(**overrides) + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) - assert _get_next_action(domain) == expected + result = runner.invoke( + app, + ["domains", "get", domain["name"], "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + for text in expected_output: + assert text in result.output def test_domains_get_json_requires_domain(logged_in_cli: None) -> None: @@ -283,35 +297,51 @@ def test_domains_get_prompts_with_selector_and_renders_details( 🌐 api.example.com - hostname api.example.com - status Pending - raw status internal_dcv_pending - setup mode Standard - id 00000000-0000-4000-8000-000000000003 - created 4 days ago - updated 4 days ago - setup started 4 days ago - last checked 4 days ago - - Waiting domain verification + ⏳ Waiting domain verification We are checking your DNS configuration to confirm domain ownership. This usually takes a few minutes. - 1 Verify ownership and route traffic In progress - Add the record below. We'll verify ownership, issue your TLS certificate, and - route traffic automatically. + To verify ownership and route traffic, add this CNAME record at your DNS + provider: + + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + + 💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud). + """ + ) + + +@pytest.mark.respx +def test_domains_get_renders_description_before_dns_records_are_available( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain(dns_records=[]) + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + + result = runner.invoke( + app, + ["domains", "get", domain["name"], "--app-id", APP_ID], + ) - Type Name Value - CNAME api 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( + """ + custom domains - 💡 Copy CNAME values as shown. If your DNS provider rejects the trailing dot, - remove it and try again. + 🌐 api.example.com - 💡 Using Cloudflare? Set these records to DNS only (gray cloud), not Proxied - (orange cloud). + ⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. - ⏳ Wait for automatic verification. DNS changes can take up to 48 hours to - propagate. + Add the record below. We'll verify ownership, issue your TLS certificate, + and route traffic automatically. """ ) @@ -371,17 +401,19 @@ def test_domains_get_selector_handles_empty_collection( @pytest.mark.respx @pytest.mark.parametrize( - ("status", "shows_certificate", "shows_traffic"), + ("status", "shows_ownership", "shows_certificate", "shows_traffic", "done_steps"), [ - ("internal_dcv_pending", False, False), - ("external_dcv_pending", True, False), - ("origin_setup_pending", True, True), + ("internal_dcv_pending", True, False, False, 0), + ("external_dcv_pending", False, True, False, 1), + ("origin_setup_pending", False, False, True, 2), ], ) def test_domains_get_gates_zero_downtime_records_by_phase( status: str, + shows_ownership: bool, shows_certificate: bool, shows_traffic: bool, + done_steps: int, logged_in_cli: None, respx_mock: respx.MockRouter, ) -> None: @@ -400,10 +432,12 @@ def test_domains_get_gates_zero_downtime_records_by_phase( ) assert result.exit_code == 0 - assert "ownership-value" in result.output + assert ("ownership-value" in result.output) is shows_ownership assert ("certificate-value" in result.output) is shows_certificate assert ("Generating; check again shortly" in result.output) is shows_certificate + assert ("Type" in result.output) is shows_certificate assert ("traffic-value" in result.output) is shows_traffic + assert result.output.count("✅") == done_steps @pytest.mark.respx @@ -431,32 +465,377 @@ def test_domains_get_shows_url_and_no_action_when_live( """ custom domains - 🌐 api.example.com + 🌐 https://api.example.com - hostname api.example.com - url https://api.example.com - status Live - raw status origin_setup_success - setup mode Standard - id 00000000-0000-4000-8000-000000000003 - created 4 days ago - updated 4 days ago - setup started 4 days ago - last checked 4 days ago + ✅ Your domain is live. + """ + ) - Live - Your domain is fully configured, secured with TLS, and set up to route - traffic to your app. - 1 Verify ownership and route traffic Verified - Your domain is live on FastAPI Cloud. +def test_domains_add_json_requires_domain(logged_in_cli: None) -> None: + result = runner.invoke( + app, + ["domains", "add", "--standard", "--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 the hostname to add.", + } + } + - Type Name Value - CNAME api 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. +def test_domains_add_json_requires_setup_mode(logged_in_cli: None) -> None: + result = runner.invoke( + app, + ["domains", "add", "api.example.com", "--app-id", APP_ID, "--json"], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "Custom domain setup mode is required.", + "hint": "Pass either --standard or --zero-downtime.", + } + } + + +def test_domains_add_rejects_both_setup_modes(logged_in_cli: None) -> None: + result = runner.invoke( + app, + [ + "domains", + "add", + "api.example.com", + "--standard", + "--zero-downtime", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": "Setup modes are mutually exclusive.", + "hint": "Pass either --standard or --zero-downtime.", + } + } + + +@pytest.mark.respx +def test_domains_add_surfaces_backend_hostname_validation( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + message = "Domain name must have at least two labels (e.g., example.com)" + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={"name": "localhost", "is_using_pre_validation": False}, + ).mock( + return_value=Response( + 422, + json={ + "detail": [ + { + "type": "value_error", + "loc": ["body", "name"], + "msg": f"Value error, {message}", + "input": "localhost", + } + ] + }, + ) + ) + + result = runner.invoke( + app, + [ + "domains", + "add", + " LOCALHOST. ", + "--standard", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": message, + "hint": None, + } + } + + +@pytest.mark.respx +@pytest.mark.parametrize( + ("flag", "is_using_pre_validation"), + [("--standard", False), ("--zero-downtime", True)], +) +def test_domains_add_posts_setup_mode_and_returns_json( + flag: str, + is_using_pre_validation: bool, + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain( + name="api.example.com", + is_using_pre_validation=is_using_pre_validation, + ) + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={ + "name": "api.example.com", + "is_using_pre_validation": is_using_pre_validation, + }, + ).mock(return_value=Response(201, json=domain)) + + result = runner.invoke( + app, + [ + "domains", + "add", + " API.Example.COM. ", + flag, + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == {"data": {"app_id": APP_ID, "domain": domain}} - 💡 Copy CNAME values as shown. If your DNS provider rejects the trailing dot, - remove it and try again. - ⏳ No action needed. Your domain is live. +@pytest.mark.respx +def test_domains_add_with_arguments_renders_result( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={"name": "api.example.com", "is_using_pre_validation": False}, + ).mock(return_value=Response(201, json=domain)) + + result = runner.invoke( + app, + [ + "domains", + "add", + "api.example.com", + "--standard", + "--app-id", + APP_ID, + ], + ) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( + """ + custom domains + + 🐔 Added api.example.com + + To verify ownership and route traffic, add this CNAME record at your DNS + provider: + + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + + 💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud). + """ + ) + + +@pytest.mark.respx +def test_domains_add_standard_wizard( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain() + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={"name": "api.example.com", "is_using_pre_validation": False}, + ).mock(return_value=Response(201, json=domain)) + keys = ["api.example.com", Keys.ENTER, Keys.ENTER] + + with patch("rich_toolkit.container.getchar", side_effect=keys): + result = runner.invoke(app, ["domains", "add", "--app-id", APP_ID]) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( + """ + custom domains + + 🌐 What domain do you want to add? + + + 🌐 What domain do you want to add? + api.example.com + + 🌐 What domain do you want to add? api.example.com + + Is api.example.com already serving traffic? + + ● No — set up a new or unused domain + ○ Yes — migrate it without downtime + + No — set up a new or unused domain + + 🐔 Added api.example.com + + To verify ownership and route traffic, add this CNAME record at your DNS + provider: + + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + + 💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud). + """ + ) + + +@pytest.mark.respx +def test_domains_add_zero_downtime_wizard_hides_traffic_records( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain( + is_using_pre_validation=True, + dns_records=PREVALIDATION_RECORDS, + ) + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={"name": "api.example.com", "is_using_pre_validation": True}, + ).mock(return_value=Response(201, json=domain)) + keys = ["api.example.com", Keys.ENTER, Keys.DOWN_ARROW, Keys.ENTER] + + with patch("rich_toolkit.container.getchar", side_effect=keys): + result = runner.invoke(app, ["domains", "add", "--app-id", APP_ID]) + + assert result.exit_code == 0 + assert _normalize_output(result.output) == _normalize_output( """ + custom domains + + 🌐 What domain do you want to add? + + + 🌐 What domain do you want to add? + api.example.com + + 🌐 What domain do you want to add? api.example.com + + Is api.example.com already serving traffic? + + ● No — set up a new or unused domain + ○ Yes — migrate it without downtime + + ○ No — set up a new or unused domain + ● Yes — migrate it without downtime + + Yes — migrate it without downtime + + 🐔 Added api.example.com + + 1️⃣ Prove ownership + Add this TXT record at your DNS provider: + + type TXT + name _fc-dcv.api + value ownership-value + + 2️⃣ Secure your domain + + 3️⃣ Switch traffic + """ + ) + + +@pytest.mark.respx +def test_domains_add_surfaces_duplicate_as_invalid_input( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={"name": "api.example.com", "is_using_pre_validation": False}, + ).mock( + return_value=Response( + 400, + json={ + "detail": "A custom domain with this name already exists for the app" + }, + ) ) + + result = runner.invoke( + app, + [ + "domains", + "add", + "api.example.com", + "--standard", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": "A custom domain with this name already exists for the app", + "hint": None, + } + } + + +@pytest.mark.respx +def test_domains_add_surfaces_entitlement_limit( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + message = "Custom domain limit reached (1). Upgrade your plan to add more domains." + respx_mock.post( + f"/apps/{APP_ID}/custom-domains", + json={"name": "api.example.com", "is_using_pre_validation": False}, + ).mock(return_value=Response(403, json={"detail": message})) + + result = runner.invoke( + app, + [ + "domains", + "add", + "api.example.com", + "--standard", + "--app-id", + APP_ID, + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "permission_denied", + "message": message, + "hint": None, + } + }