diff --git a/CHANGELOG.md b/CHANGELOG.md index 366d5fb..ca053cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ public API, a minor version adds to it, and a patch version fixes it. - `feature_flags.targeting_context()`, `feature_flags.lint_rule()` and `feature_flags.test_rule()`: the facts a targeting rule can read, the check the API runs before accepting one, and a dry run of a rule against a customer or an instance. +- `feature_flags.update(..., slug=...)` renames a flag. + +### Fixed + +- `feature_flags.update` sends the flag's slug. Without it, Kaiten blanked the slug and the flag + could no longer be reached. ## [1.0.0] - 2026-10-01 diff --git a/src/kaitencloud/_async/resources/feature_flags.py b/src/kaitencloud/_async/resources/feature_flags.py index ffa5a32..08df060 100644 --- a/src/kaitencloud/_async/resources/feature_flags.py +++ b/src/kaitencloud/_async/resources/feature_flags.py @@ -103,11 +103,16 @@ async def update( description: str | None = None, metadata: Mapping[str, Any] | None = None, event_name: str = "", + slug: str | None = None, ) -> None: """Replace a feature flag's definition. A full replacement: the variants, rules and metadata sent become the flag's whole definition. See :meth:`create` for what each field means. + + Args: + slug: A new slug, to rename the flag; applications evaluating it by its old key + stop finding it. Omitted, the flag keeps ``feature_flag_slug``. """ body = _flag( name=name, @@ -120,6 +125,9 @@ async def update( metadata=metadata, event_name=event_name, ) + # The slug is always sent. Kaiten writes the body's slug as the flag's own, so a + # body without one would blank it rather than keep it. + body["slug"] = slug or feature_flag_slug await self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body) async def targeting_context(self) -> TargetingContext: diff --git a/src/kaitencloud/_sync/resources/feature_flags.py b/src/kaitencloud/_sync/resources/feature_flags.py index b65a54e..b463a94 100644 --- a/src/kaitencloud/_sync/resources/feature_flags.py +++ b/src/kaitencloud/_sync/resources/feature_flags.py @@ -105,11 +105,16 @@ def update( description: str | None = None, metadata: Mapping[str, Any] | None = None, event_name: str = "", + slug: str | None = None, ) -> None: """Replace a feature flag's definition. A full replacement: the variants, rules and metadata sent become the flag's whole definition. See :meth:`create` for what each field means. + + Args: + slug: A new slug, to rename the flag; applications evaluating it by its old key + stop finding it. Omitted, the flag keeps ``feature_flag_slug``. """ body = _flag( name=name, @@ -122,6 +127,9 @@ def update( metadata=metadata, event_name=event_name, ) + # The slug is always sent. Kaiten writes the body's slug as the flag's own, so a + # body without one would blank it rather than keep it. + body["slug"] = slug or feature_flag_slug self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body) def targeting_context(self) -> TargetingContext: diff --git a/tests/test_contract.py b/tests/test_contract.py index 44ca9b6..6331982 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -302,7 +302,7 @@ class Case: variants=VARIANTS, default_variant=targeting.fixed("off"), ), - forbidden=frozenset({"slug"}), + required=frozenset({"slug"}), ), Case("delete-feature-flag", lambda c: c.feature_flags.delete("new-checkout")), Case("get-openfeature-manifest", lambda c: c.feature_flags.manifest()), diff --git a/tests/test_resources.py b/tests/test_resources.py index 132b80d..6446389 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -182,9 +182,31 @@ def test_a_feature_flag_needs_only_the_essentials(make_client: Callable[..., Any "description": None, "metadata": {}, "event_name": "", + "slug": "new-checkout", } +def test_a_feature_flag_update_keeps_its_slug_unless_renamed( + make_client: Callable[..., Any], +) -> None: + # Kaiten writes the body's slug as the flag's own: sending none blanked it. + client, recorder = make_client(respond(204)) + essentials = { + "name": "New checkout", + "type": "boolean", + "variants": [{"name": "on", "value": True}], + "default_variant": "on", + } + + client.feature_flags.update("new-checkout", **essentials) + client.feature_flags.update("new-checkout", **essentials, slug="checkout-v2") + + first, second = recorder + assert body(first)["slug"] == "new-checkout" + assert body(second)["slug"] == "checkout-v2" + assert second.url.path.endswith("/feature-flags/new-checkout") + + def test_usage_defaults_to_append(make_client: Callable[..., Any]) -> None: client, recorder = make_client(respond(200, USAGE)) client.instances.report_usage("acme-production", "seats", 3)