From 284b6fe61ebf50bb0721721c5eeb6261ff05b1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Brunat?= Date: Fri, 21 Aug 2026 12:07:25 +0200 Subject: [PATCH 1/2] fix: reject a domain whose fqdn is only slashes Domain.from_api_response() validated the raw fqdn and stripped the trailing slashes afterwards, so a payload such as {"fqdn": "/"} passed validation and produced Domain(domain=""). Validate the stripped value instead, and raise the same InvalidResponseError as a missing field so the caller has one case to handle. Reported by a user who read model="Domain" as a schema mapping; it is only the label used in error messages, and reading "fqdn" from the raw payload is correct. --- src/clever_cloud/models.py | 6 +++++- tests/test_models.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/clever_cloud/models.py b/src/clever_cloud/models.py index 5b0163a..4450bc3 100644 --- a/src/clever_cloud/models.py +++ b/src/clever_cloud/models.py @@ -176,8 +176,12 @@ class Domain: @classmethod def from_api_response(cls, data: Any, *, is_primary: bool = False) -> Self: data = _mapping(data, model="Domain") + fqdn = _require_str(data, "fqdn", model="Domain").rstrip("/") + if not fqdn: + msg = "Domain: missing or invalid required field 'fqdn'" + raise InvalidResponseError(msg) return cls( - domain=_require_str(data, "fqdn", model="Domain").rstrip("/"), + domain=fqdn, is_primary=is_primary, ) diff --git a/tests/test_models.py b/tests/test_models.py index 8f9f781..0ae0793 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -128,6 +128,11 @@ def test_missing_fqdn_is_rejected(self) -> None: with pytest.raises(InvalidResponseError, match="'fqdn'"): Domain.from_api_response({}) + @pytest.mark.parametrize("fqdn", ["/", "///"]) + def test_slash_only_fqdn_is_rejected(self, fqdn: str) -> None: + with pytest.raises(InvalidResponseError, match="'fqdn'"): + Domain.from_api_response({"fqdn": fqdn}) + class TestTcpRedirection: def test_parses_namespace_and_port(self) -> None: From 3e76d8ae79bba8926f8d31b8c338866845bee43c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Brunat?= Date: Fri, 21 Aug 2026 12:07:31 +0200 Subject: [PATCH 2/2] feat: add create_domain() to attach a domain to an application PUT /v2/organisations/{ownerId}/applications/{appId}/vhosts/{domain}. The SDK could list domains and read the primary one, but not attach a new one. The name is stripped of its trailing slash before being percent-encoded, so it round-trips with Domain.domain and a path suffix such as "example.com/api" stays part of the vhost name instead of changing which endpoint the request reaches. Some deployments answer with an empty body, so the returned Domain falls back to the requested name. --- README.md | 1 + src/clever_cloud/client.py | 42 ++++++++++++++++++++++++++++++++++ tests/test_client.py | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/README.md b/README.md index eb0b6f9..8110246 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ features: - Create application - Redeploy application - Create TCP redirection +- Add a domain to an application - List domains - Get primary domain - Custom CA bundle and mTLS client certificate support diff --git a/src/clever_cloud/client.py b/src/clever_cloud/client.py index db19a0b..c5e9fe2 100644 --- a/src/clever_cloud/client.py +++ b/src/clever_cloud/client.py @@ -631,6 +631,48 @@ async def create_tcp_redirection( ) return TcpRedirection.from_api_response(data) + async def create_domain( + self, + owner_id: str, + app_id: str, + *, + domain: str, + ) -> Domain: + """Attach a domain (vhost) to an application. + + Args: + owner_id: Organisation or user ID that owns the application + app_id: Application ID to attach the domain to + domain: Fully-qualified domain name, e.g. ``"app.example.com"``. + The platform also accepts a wildcard (``"*.example.com"``) and + a path suffix (``"example.com/api"``). A trailing slash is + ignored, so the value round-trips with :attr:`Domain.domain`. + + Returns: + The domain now attached to the application. This endpoint answers + with an empty body on some deployments; the returned value is then + built from the requested name. + + Raises: + ValueError: If ``domain`` is empty. + NotFoundError: If the organisation or the application does not + exist. + HttpError: If the domain is invalid, or is already attached to + another application. + """ + owner = encode_path_segment(owner_id, name="owner_id") + app = encode_path_segment(app_id, name="app_id") + # Stripped before encoding: a trailing slash would otherwise survive as + # %2F and reach the API as part of the name. + fqdn = domain.rstrip("/") if isinstance(domain, str) else domain + vhost = encode_path_segment(fqdn, name="domain") + data = await self._request( + "PUT", f"/v2/organisations/{owner}/applications/{app}/vhosts/{vhost}" + ) + if data is None: + return Domain(domain=fqdn, is_primary=False) + return Domain.from_api_response(data) + async def list_domains(self, owner_id: str, app_id: str) -> list[Domain]: """List all domains (vhosts) for an application. diff --git a/tests/test_client.py b/tests/test_client.py index a6619f3..b765029 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -533,6 +533,53 @@ def test_no_deprecation_warning_when_creating_the_transport( class TestEndpoints: + async def test_create_domain( + self, + make_client: Callable[..., CleverCloudClient], + record_requests: tuple[list[httpx.Request], Callable[..., object]], + ) -> None: + seen, factory = record_requests + client = make_client(factory(200, {"fqdn": "app.example.test"})) + async with client: + domain = await client.create_domain( + "orga_1", "app_1", domain="app.example.test" + ) + assert domain.domain == "app.example.test" + assert domain.is_primary is False + assert seen[0].method == "PUT" + assert seen[0].url.path.endswith("/vhosts/app.example.test") + + async def test_create_domain_accepts_an_empty_body( + self, make_client: Callable[..., CleverCloudClient] + ) -> None: + """Some deployments answer the PUT with no content.""" + client = make_client(lambda r: httpx.Response(200)) + async with client: + domain = await client.create_domain( + "orga_1", "app_1", domain="app.example.test/" + ) + assert domain.domain == "app.example.test" + + async def test_create_domain_encodes_a_path_suffix( + self, + make_client: Callable[..., CleverCloudClient], + record_requests: tuple[list[httpx.Request], Callable[..., object]], + ) -> None: + """A path suffix belongs to the vhost name, not to the request route.""" + seen, factory = record_requests + client = make_client(factory(200)) + async with client: + await client.create_domain("orga_1", "app_1", domain="example.test/api") + assert seen[0].url.raw_path.endswith(b"/vhosts/example.test%2Fapi") + + async def test_create_domain_rejects_an_empty_name( + self, make_client: Callable[..., CleverCloudClient] + ) -> None: + client = make_client(lambda r: httpx.Response(200)) + async with client: + with pytest.raises(ValueError, match="domain"): + await client.create_domain("orga_1", "app_1", domain="/") + async def test_list_domains( self, make_client: Callable[..., CleverCloudClient] ) -> None: