diff --git a/.gitignore b/.gitignore index 51a06d5a8f1b..b62e1f8cbcc6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ src/CI_COMMIT_SHA *.iml AGENTS.local.md +.claude/ diff --git a/api/api/urls/v1.py b/api/api/urls/v1.py index 4ed9696cefa3..aa58b166256e 100644 --- a/api/api/urls/v1.py +++ b/api/api/urls/v1.py @@ -73,6 +73,7 @@ SDKEnvironmentAPIView.as_view(), name="environment-document", ), + path("flagd/", include("integrations.flagd.urls", namespace="flagd")), re_path("", include("features.versioning.urls", namespace="versioning")), # API documentation path( diff --git a/api/app/settings/common.py b/api/app/settings/common.py index ab4e9c8ecec3..531db9d53f65 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -154,6 +154,7 @@ "integrations.webhook", "integrations.dynatrace", "integrations.flagsmith", + "integrations.flagd", "integrations.launch_darkly", "integrations.github", "integrations.gitlab", diff --git a/api/environments/authentication.py b/api/environments/authentication.py index 8fc7d1e0e12b..0955658e90ce 100644 --- a/api/environments/authentication.py +++ b/api/environments/authentication.py @@ -23,6 +23,17 @@ def __init__(self, *args, required_key_prefix: str = "", **kwargs): # type: ign def authenticate(self, request): # type: ignore[no-untyped-def] api_key = request.META.get("HTTP_X_ENVIRONMENT_KEY") + if not api_key: + # Fall back to the Authorization header so HTTP clients that + # only expose a generic auth field (e.g. the flagd HTTP sync + # source, which sets `authHeader`) can authenticate without a + # custom header. We accept either a bare token or the + # conventional `Bearer ` form. + auth_header = request.META.get("HTTP_AUTHORIZATION", "").strip() + if auth_header.lower().startswith("bearer "): + api_key = auth_header[7:].strip() + elif auth_header: + api_key = auth_header if not (api_key and api_key.startswith(self.required_key_prefix)): raise AuthenticationFailed("Invalid or missing Environment key") diff --git a/api/integrations/flagd/__init__.py b/api/integrations/flagd/__init__.py new file mode 100644 index 000000000000..3159c49c8d87 --- /dev/null +++ b/api/integrations/flagd/__init__.py @@ -0,0 +1 @@ +default_app_config = "integrations.flagd.apps.FlagdConfig" diff --git a/api/integrations/flagd/apps.py b/api/integrations/flagd/apps.py new file mode 100644 index 000000000000..335ef2f87ce1 --- /dev/null +++ b/api/integrations/flagd/apps.py @@ -0,0 +1,6 @@ +from core.apps import BaseAppConfig + + +class FlagdConfig(BaseAppConfig): + name = "integrations.flagd" + default = True diff --git a/api/integrations/flagd/constants.py b/api/integrations/flagd/constants.py new file mode 100644 index 000000000000..6ae4ca8bf573 --- /dev/null +++ b/api/integrations/flagd/constants.py @@ -0,0 +1,12 @@ +FLAGD_TRANSLATOR_VERSION = "v1" +FLAGD_SCHEMA_URL = "https://flagd.dev/schema/v0/flags.json" + +VARIANT_CONTROL = "control" + +DEFAULT_IDENTITY_OVERRIDE_LIMIT = 100 + +WARNING_REGEX_UNSUPPORTED = "regex_unsupported" +WARNING_UNKNOWN_OPERATOR = "unknown_operator" +WARNING_MALFORMED_VALUE = "malformed_value" +WARNING_IDENTITY_OVERRIDE_LIMIT = "identity_override_limit_exceeded" +WARNING_DISABLED_OVERRIDE_NO_OP = "disabled_override_no_op" diff --git a/api/integrations/flagd/diagnostics.py b/api/integrations/flagd/diagnostics.py new file mode 100644 index 000000000000..56fc352baef8 --- /dev/null +++ b/api/integrations/flagd/diagnostics.py @@ -0,0 +1,129 @@ +""" +Per-environment translation diagnostics. + +Reuses the same translation pipeline as the sync endpoint to surface +issues that would degrade flagd consumption (type mismatches, unsupported +operators, identity-override cap overflow). Operators can curl the +diagnostics endpoint to audit an environment, alert on warnings in +their CI pipeline, or build their own dashboard against the JSON. + +This module deliberately lives alongside ``services.py`` rather than +inside it so the diagnostic path can evolve independently of the +hot-path sync builder. +""" + +from __future__ import annotations + +from typing import Any + +import structlog +from django.conf import settings + +from integrations.flagd.constants import ( + DEFAULT_IDENTITY_OVERRIDE_LIMIT, + FLAGD_TRANSLATOR_VERSION, +) +from integrations.flagd.translators.flag import feature_state_to_flagd_flag +from integrations.flagd.translators.segment import ( + segment_to_jsonlogic, + slugify_name, + slugify_segment_name, +) +from integrations.flagd.translators.type_check import detect_type_mismatch +from integrations.flagd.types import TranslationWarning +from util.engine_models.environments.models import EnvironmentModel +from util.mappers.engine import map_environment_to_engine + +logger = structlog.get_logger("flagd_sync") + + +def diagnose_environment(environment: Any) -> dict[str, Any]: + """ + Run the translator over ``environment`` purely to collect warnings. + Returns a structured report keyed by feature name plus an + environment-level summary. + """ + engine_environment: EnvironmentModel = map_environment_to_engine( + environment, with_integrations=False + ) + project = environment.project + identity_override_limit = getattr( + settings, + "FLAGD_SYNC_IDENTITY_OVERRIDE_LIMIT", + DEFAULT_IDENTITY_OVERRIDE_LIMIT, + ) + + # Translate segments once so flag translation can reuse the cache. + segments = engine_environment.project.segments + segment_warnings: list[TranslationWarning] = [] + segment_keys: dict[int, str] = {} + segment_targeting: dict[int, Any] = {} + used: set[str] = set() + for segment in segments: + key = slugify_segment_name(segment.name, taken=used) + used.add(key) + segment_keys[segment.id] = key + segment_targeting[segment.id] = segment_to_jsonlogic( + segment, warnings=segment_warnings + ) + + default_feature_states = [ + fs for fs in engine_environment.feature_states if fs.feature_segment is None + ] + + features: list[dict[str, Any]] = [] + for fs in default_feature_states: + per_feature_warnings: list[TranslationWarning] = [] + + # Type-consistency check — flagd's JSON Schema requires variants + # of one flag to share a type. This catches it pre-emptively so + # operators see a clear warning rather than a TYPE_MISMATCH at + # evaluation time. We include segment + identity override values + # because an override can legitimately introduce a different type + # (e.g. a number override on a string flag). + per_feature_warnings.extend( + detect_type_mismatch( + fs, + segments=segments, + identity_overrides=engine_environment.identity_overrides, + ) + ) + + # Run the regular translator just to capture operator-level + # warnings (REGEX skipped, identity-override cap, malformed + # values, etc.). We don't keep the resulting flag here — the + # sync endpoint owns that path. + feature_state_to_flagd_flag( + fs, + feature_key=fs.feature.name, + segments=segments, + segment_targeting=segment_targeting, + segment_keys=segment_keys, + identity_overrides=engine_environment.identity_overrides, + identity_override_limit=identity_override_limit, + warnings=per_feature_warnings, + ) + + if per_feature_warnings: + features.append( + { + "name": fs.feature.name, + "warnings": list(per_feature_warnings), + } + ) + + flag_set_id = f"{slugify_name(project.name)}/{slugify_name(environment.name)}" + return { + "flagSetId": flag_set_id, + "translatorVersion": FLAGD_TRANSLATOR_VERSION, + "environmentWarnings": list(segment_warnings), + "features": features, + "summary": { + "featuresWithWarnings": len(features), + "totalWarnings": sum(len(f["warnings"]) for f in features) + + len(segment_warnings), + }, + } + + +__all__ = ("diagnose_environment",) diff --git a/api/integrations/flagd/exceptions.py b/api/integrations/flagd/exceptions.py new file mode 100644 index 000000000000..4d21b7d21a16 --- /dev/null +++ b/api/integrations/flagd/exceptions.py @@ -0,0 +1,11 @@ +class FlagdTranslationError(Exception): + """Base class for translation errors.""" + + +class UntranslatableConditionError(FlagdTranslationError): + """Raised when a condition cannot be expressed in JsonLogic.""" + + def __init__(self, reason: str, operator: str | None = None) -> None: + super().__init__(reason) + self.reason = reason + self.operator = operator diff --git a/api/integrations/flagd/management/__init__.py b/api/integrations/flagd/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagd/management/commands/__init__.py b/api/integrations/flagd/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagd/management/commands/bootstrap_flagd_local.py b/api/integrations/flagd/management/commands/bootstrap_flagd_local.py new file mode 100644 index 000000000000..7241eaee7f25 --- /dev/null +++ b/api/integrations/flagd/management/commands/bootstrap_flagd_local.py @@ -0,0 +1,155 @@ +""" +Idempotent bootstrap helper for local flagd development. + +Creates (or reuses) an Organisation, Project, Environment, and +server-side ``EnvironmentAPIKey``, then writes the resulting key to +stdout — and optionally to a shell-sourceable env file. Designed to be +run from a docker-compose init service so flagd can pick up the key +without any manual UI clicks. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from django.core.management.base import BaseCommand + +from environments.models import Environment, EnvironmentAPIKey +from integrations.flagd.models import FlagdProjectConfiguration +from organisations.models import Organisation, OrganisationRole +from projects.models import Project +from users.models import FFAdminUser + + +class Command(BaseCommand): + help = "Create or reuse a local Flagsmith env and emit a server-side key for flagd." + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--organisation", + default="local-dev", + help="Organisation name (default: local-dev).", + ) + parser.add_argument( + "--project", + default="local-dev", + help="Project name (default: local-dev).", + ) + parser.add_argument( + "--environment", + default="development", + help="Environment name (default: development).", + ) + parser.add_argument( + "--api-key-name", + default="flagd-local", + help="EnvironmentAPIKey label (default: flagd-local).", + ) + parser.add_argument( + "--api-key", + default=None, + help=( + "Force the EnvironmentAPIKey value to a specific server-side " + "key (must start with `ser.`). Useful for local-dev where the " + "key must be known at compose-parse time. Default: auto-generate." + ), + ) + parser.add_argument( + "--admin-email", + default="admin@example.com", + help="Email for the local admin user (default: admin@example.com).", + ) + parser.add_argument( + "--admin-password", + default="admin", + help=( + "Password to set on the local admin user. Always overwritten " + "on each run so the local dev experience stays predictable. " + "Default: admin." + ), + ) + parser.add_argument( + "--output", + type=Path, + help=( + "If given, write `FLAGSMITH_SERVER_KEY=...` to this file. " + "Useful for sourcing from a docker-compose init service." + ), + ) + + def handle(self, *args: Any, **options: Any) -> None: + organisation, _ = Organisation.objects.get_or_create( + name=options["organisation"] + ) + project, _ = Project.objects.get_or_create( + name=options["project"], organisation=organisation + ) + environment, _ = Environment.objects.get_or_create( + name=options["environment"], project=project + ) + # Local-dev expects flagd to be reachable out of the box. + FlagdProjectConfiguration.objects.update_or_create( + project=project, defaults={"enabled": True} + ) + + # Create or refresh the local admin user so the operator can + # log into the Flagsmith UI without juggling password-reset + # links. The user is attached to the organisation so they land + # straight in the bootstrapped project on login. + admin_email = options["admin_email"] + admin_password = options["admin_password"] + admin = FFAdminUser.objects.filter(email=admin_email).first() + if admin is None: + admin = FFAdminUser.objects.create_superuser( # type: ignore[no-untyped-call] + email=admin_email, + is_active=True, + password=admin_password, + ) + else: + admin.set_password(admin_password) + admin.is_active = True + admin.is_staff = True + admin.is_superuser = True + admin.save() + if not admin.belongs_to(organisation.id): + admin.add_organisation(organisation, role=OrganisationRole.ADMIN) + + forced_key: str | None = options.get("api_key") + if forced_key and not forced_key.startswith("ser."): + raise ValueError( + "--api-key must start with 'ser.' to be accepted by the flagd " + f"sync endpoint; got {forced_key!r}." + ) + + api_key = ( + EnvironmentAPIKey.objects.filter( + environment=environment, name=options["api_key_name"] + ) + .order_by("created_at") + .first() + ) + if api_key is None: + create_kwargs: dict[str, Any] = { + "environment": environment, + "name": options["api_key_name"], + } + if forced_key: + create_kwargs["key"] = forced_key + api_key = EnvironmentAPIKey.objects.create(**create_kwargs) + elif forced_key and api_key.key != forced_key: + api_key.key = forced_key + api_key.save() + + line = f"FLAGSMITH_SERVER_KEY={api_key.key}" + self.stdout.write(line) + self.stdout.write( + self.style.SUCCESS(f"Admin user: {admin_email} / {admin_password}") + ) + + output: Path | None = options.get("output") + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(line + "\n") + self.stdout.write(self.style.SUCCESS(f"Wrote {output}")) diff --git a/api/integrations/flagd/metrics.py b/api/integrations/flagd/metrics.py new file mode 100644 index 000000000000..7ec9a1aa0002 --- /dev/null +++ b/api/integrations/flagd/metrics.py @@ -0,0 +1,24 @@ +import prometheus_client + +flagsmith_flagd_sync_requests_total = prometheus_client.Counter( + "flagsmith_flagd_sync_requests_total", + "Number of flagd HTTP sync requests served by the Flagsmith API.", + ["status"], +) + +flagsmith_flagd_document_build_seconds = prometheus_client.Histogram( + "flagsmith_flagd_document_build_seconds", + "Wall-clock time spent translating an environment to a flagd document.", +) + +flagsmith_flagd_document_size_bytes = prometheus_client.Histogram( + "flagsmith_flagd_document_size_bytes", + "Size in bytes of the flagd document returned by the sync endpoint.", + buckets=(512, 4096, 32_768, 262_144, 2_097_152), +) + +flagsmith_flagd_translation_warnings_total = prometheus_client.Counter( + "flagsmith_flagd_translation_warnings_total", + "Translation warnings emitted while building flagd documents.", + ["reason"], +) diff --git a/api/integrations/flagd/migrations/0001_initial.py b/api/integrations/flagd/migrations/0001_initial.py new file mode 100644 index 000000000000..edbe8fe6b5ca --- /dev/null +++ b/api/integrations/flagd/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.13 on 2026-05-11 16:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0029_bump_default_project_limits"), + ] + + operations = [ + migrations.CreateModel( + name="FlagdProjectConfiguration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("enabled", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="flagd_configuration", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "flagd project configuration", + "verbose_name_plural": "flagd project configurations", + }, + ), + ] diff --git a/api/integrations/flagd/migrations/__init__.py b/api/integrations/flagd/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagd/models.py b/api/integrations/flagd/models.py new file mode 100644 index 000000000000..0b5baf4de39b --- /dev/null +++ b/api/integrations/flagd/models.py @@ -0,0 +1,48 @@ +""" +Per-project enablement for the flagd integration. + +The sync and diagnostics endpoints are gated on a row in this table +existing with ``enabled=True``. Default state for any project is +"integration off" — the new endpoints do not respond until an +operator (or the ``bootstrap_flagd_local`` command) enables them. + +Kept deliberately thin: a single boolean alongside ownership of which +project it applies to. Future fields (rate limits, custom translator +options, etc.) can be added without disturbing the gating contract. +""" + +from __future__ import annotations + +from django.db import models + +from projects.models import Project + + +class FlagdProjectConfiguration(models.Model): + project = models.OneToOneField( + Project, + related_name="flagd_configuration", + on_delete=models.CASCADE, + ) + enabled = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "flagd project configuration" + verbose_name_plural = "flagd project configurations" + + def __str__(self) -> str: + state = "enabled" if self.enabled else "disabled" + return f"flagd integration for project {self.project_id} ({state})" + + +def is_flagd_enabled_for_project(project_id: int) -> bool: + """ + Cheap lookup used by the sync/diagnostics endpoint guards. Returns + ``False`` when no configuration row exists for the project (the + opt-in default). + """ + return FlagdProjectConfiguration.objects.filter( + project_id=project_id, enabled=True + ).exists() diff --git a/api/integrations/flagd/serializers.py b/api/integrations/flagd/serializers.py new file mode 100644 index 000000000000..5683a88d5400 --- /dev/null +++ b/api/integrations/flagd/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from integrations.flagd.models import FlagdProjectConfiguration + + +class FlagdProjectConfigurationSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + class Meta: + model = FlagdProjectConfiguration + fields = ("enabled", "created_at", "updated_at") + read_only_fields = ("created_at", "updated_at") diff --git a/api/integrations/flagd/services.py b/api/integrations/flagd/services.py new file mode 100644 index 000000000000..76abdcfdf64b --- /dev/null +++ b/api/integrations/flagd/services.py @@ -0,0 +1,175 @@ +""" +Build flagd-compatible flag-definition documents from Flagsmith +environments. + +The service is a thin orchestrator over the per-feature, per-segment +translators. It deliberately consumes the engine document +(``EnvironmentModel``) so that the data shape stays aligned with what +SDK local evaluation already operates on. + +Segment placement: segments referenced by **two or more** features in +the environment are extracted to the top-level ``$evaluators`` block +and referenced via ``$ref`` from each flag's targeting. Segments used +by exactly one feature are **inlined** directly into that flag's +``targeting`` expression — they're not shared, so promoting them to a +global keyspace would only invite name collisions across the +feature-scoped segment kinds Flagsmith allows (two different +features can both define a feature-scoped segment named "mail"). The +segment's database id, not its display name, drives this decision. +""" + +import json +from typing import Any + +import structlog +from django.conf import settings + +from integrations.flagd.constants import ( + DEFAULT_IDENTITY_OVERRIDE_LIMIT, + FLAGD_SCHEMA_URL, + FLAGD_TRANSLATOR_VERSION, +) +from integrations.flagd.metrics import ( + flagsmith_flagd_translation_warnings_total, +) +from integrations.flagd.translators.flag import feature_state_to_flagd_flag +from integrations.flagd.translators.segment import ( + segment_to_jsonlogic, + slugify_name, + slugify_segment_name, +) +from integrations.flagd.types import JsonLogic, TranslationWarning +from util.engine_models.environments.models import EnvironmentModel +from util.engine_models.segments.models import SegmentModel +from util.mappers.engine import map_environment_to_engine + +logger = structlog.get_logger("flagd_sync") + + +def build_flagd_document(environment: Any) -> dict[str, Any]: + """ + Translate a Django ``Environment`` instance into the flagd + flag-definition document. + """ + engine_environment: EnvironmentModel = map_environment_to_engine( + environment, with_integrations=False + ) + project = environment.project + flag_set_id = f"{slugify_name(project.name)}/{slugify_name(environment.name)}" + version = environment.updated_at.isoformat() if environment.updated_at else "0" + return _build_from_engine( + engine_environment, + environment_id=environment.id, + flag_set_id=flag_set_id, + version=version, + ) + + +def _build_from_engine( + engine_environment: EnvironmentModel, + *, + environment_id: int, + flag_set_id: str, + version: str, +) -> dict[str, Any]: + warnings: list[TranslationWarning] = [] + identity_override_limit = getattr( + settings, + "FLAGD_SYNC_IDENTITY_OVERRIDE_LIMIT", + DEFAULT_IDENTITY_OVERRIDE_LIMIT, + ) + + segments = engine_environment.project.segments + segment_usage = _count_segment_usage(segments) + + # Translate every segment once. Single-use segments stay in + # ``segment_targeting`` (to be inlined into the one flag that + # references them) but never make it to ``segment_keys`` or + # ``evaluators``. Multi-use segments are promoted to ``$evaluators`` + # and referenced by ``$ref``. + segment_keys: dict[int, str] = {} + segment_targeting: dict[int, JsonLogic | None] = {} + used_keys: set[str] = set() + evaluators: dict[str, JsonLogic] = {} + for segment in segments: + targeting = segment_to_jsonlogic(segment, warnings=warnings) + segment_targeting[segment.id] = targeting + if segment_usage.get(segment.id, 0) < 2 or targeting is None: + continue + key = slugify_segment_name(segment.name, taken=used_keys) + used_keys.add(key) + segment_keys[segment.id] = key + evaluators[key] = targeting + + # Default feature states are those without a feature_segment and + # without an identity. The engine document carries them on + # environment.feature_states; per-segment overrides live on + # segment.feature_states (already separate). + default_feature_states = [ + fs for fs in engine_environment.feature_states if fs.feature_segment is None + ] + + flags: dict[str, Any] = {} + for fs in default_feature_states: + feature_key = fs.feature.name + flag = feature_state_to_flagd_flag( + fs, + feature_key=feature_key, + segments=segments, + segment_targeting=segment_targeting, + segment_keys=segment_keys, + identity_overrides=engine_environment.identity_overrides, + identity_override_limit=identity_override_limit, + warnings=warnings, + ) + flags[feature_key] = flag + + document: dict[str, Any] = { + "$schema": FLAGD_SCHEMA_URL, + "flags": flags, + } + if evaluators: + document["$evaluators"] = evaluators + + metadata: dict[str, Any] = { + "flagSetId": flag_set_id, + "version": version, + "flagsmith.environmentId": environment_id, + "flagsmith.translatorVersion": FLAGD_TRANSLATOR_VERSION, + } + if warnings: + # flagd's metadata schema only accepts string/number/boolean + # values. Serialise the warning list as a compact JSON string + # so consumers can still parse it. + metadata["flagsmith.warnings"] = json.dumps(list(warnings)) + for warning in warnings: + flagsmith_flagd_translation_warnings_total.labels( + reason=warning["reason"] + ).inc() + logger.info( + "translation.warnings", + environment__id=environment_id, + warnings__count=len(warnings), + ) + document["metadata"] = metadata + + return document + + +def _count_segment_usage(segments: list[SegmentModel]) -> dict[int, int]: + """ + Return ``{segment_id: distinct_feature_count}``. A segment is + "used" by a feature when at least one of the segment's + ``feature_states`` carries that feature's id. Distinct features + are what matter — multiple overrides on the same feature still + count as a single usage. + """ + counts: dict[int, int] = {} + for segment in segments: + features = {fs.feature.id for fs in segment.feature_states} + if features: + counts[segment.id] = len(features) + return counts + + +__all__ = ("build_flagd_document",) diff --git a/api/integrations/flagd/tests/__init__.py b/api/integrations/flagd/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagd/tests/fixtures/__init__.py b/api/integrations/flagd/tests/fixtures/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagd/tests/fixtures/flagd-schema-v0.json b/api/integrations/flagd/tests/fixtures/flagd-schema-v0.json new file mode 100644 index 000000000000..cff1aab8118e --- /dev/null +++ b/api/integrations/flagd/tests/fixtures/flagd-schema-v0.json @@ -0,0 +1,295 @@ +{ + "$id": "https://flagd.dev/schema/v0/flags.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/providerConfig", + "definitions": { + "flagsMap": { + "title": "Flags", + "description": "Top-level flags object. All flags are defined here.", + "type": "object", + "$comment": "flag objects are one of the 4 flag types defined in definitions", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$ref": "#/definitions/anyFlag" + } + } + }, + "flagsArray": { + "title": "Flags", + "description": "Top-level flags array. All flags are defined here.", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/anyFlag" + }, + { + "type": "object", + "properties": { + "key": { + "description": "Key of the flag: uniquely identifies this flag within it's flagSet", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "key" + ] + } + ] + } + }, + "baseConfig": { + "title": "flagd Flag Configuration", + "description": "Defines flags for use in flagd providers, including typed variants and rules.", + "type": "object", + "properties": { + "$evaluators": { + "title": "Evaluators", + "description": "Reusable targeting rules that can be referenced with \"$ref\": \"myRule\" in multiple flags.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$comment": "this relative ref means that targeting.json MUST be in the same dir, or available on the same HTTP path", + "$ref": "./targeting.json" + } + } + }, + "metadata": { + "title": "Flag Set Metadata", + "description": "Metadata about the flag set, with keys of type string, and values of type boolean, string, or number.", + "properties": { + "flagSetId": { + "description": "The unique identifier for the flag set.", + "type": "string" + }, + "version": { + "description": "The version of the flag set.", + "type": "string" + } + }, + "$ref": "#/definitions/metadata" + } + } + }, + "providerConfig": { + "description": "Defines flags for use in providers (not flagd), including typed variants and rules.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/baseConfig" + } + ], + "properties": { + "flags": { + "$ref": "#/definitions/flagsMap" + } + }, + "required": [ + "flags" + ] + }, + "flagdConfig": { + "description": "Defines flags for use in the flagd daemon (a superset of what's available in providers), including typed variants and rules. Flags can be defined as an array or an object.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/baseConfig" + }, + { + "properties": { + "flags": { + "oneOf": [ + { + "$ref": "#/definitions/flagsMap" + }, + { + "$ref": "#/definitions/flagsArray" + } + ] + } + } + } + ], + "required": [ + "flags" + ] + }, + "baseFlag": { + "$comment": "base flag object; no title/description here, allows for better UX, keep it in the overrides", + "type": "object", + "properties": { + "state": { + "title": "Flag State", + "description": "Indicates whether the flag is functional. Disabled flags are treated as if they don't exist.", + "type": "string", + "enum": [ + "ENABLED", + "DISABLED" + ] + }, + "defaultVariant": { + "title": "Default Variant", + "description": "The variant to serve if no dynamic targeting applies (including if the targeting returns null). Set to null to use code-defined default.", + "type": [ + "string", + "null" + ] + }, + "targeting": { + "$ref": "./targeting.json" + }, + "metadata": { + "title": "Flag Metadata", + "description": "Metadata about an individual feature flag, with keys of type string, and values of type boolean, string, or number.", + "$ref": "#/definitions/metadata" + }, + "variants": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": {} + } + } + }, + "required": [ + "state", + "variants" + ] + }, + "booleanVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "boolean" + } + } + } + } + }, + "stringVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "string" + } + } + } + } + }, + "numberVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "number" + } + } + } + } + }, + "objectVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "object" + } + } + } + } + }, + "anyFlag": { + "anyOf": [ + { + "$ref": "#/definitions/booleanFlag" + }, + { + "$ref": "#/definitions/numberFlag" + }, + { + "$ref": "#/definitions/stringFlag" + }, + { + "$ref": "#/definitions/objectFlag" + } + ] + }, + "booleanFlag": { + "$comment": "merge the variants with the base flag to build our typed flags", + "title": "Boolean flag", + "description": "A flag having boolean values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/booleanVariants" + } + ] + }, + "stringFlag": { + "title": "String flag", + "description": "A flag having string values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/stringVariants" + } + ] + }, + "numberFlag": { + "title": "Numeric flag", + "description": "A flag having numeric values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/numberVariants" + } + ] + }, + "objectFlag": { + "title": "Object flag", + "description": "A flag having arbitrary object values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/objectVariants" + } + ] + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + } + } +} diff --git a/api/integrations/flagd/tests/fixtures/flagd-targeting-v0.json b/api/integrations/flagd/tests/fixtures/flagd-targeting-v0.json new file mode 100644 index 000000000000..f8dd7544fa69 --- /dev/null +++ b/api/integrations/flagd/tests/fixtures/flagd-targeting-v0.json @@ -0,0 +1,592 @@ +{ + "$id": "https://flagd.dev/schema/v0/targeting.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "flagd Targeting", + "description": "Defines targeting logic for flagd; a extension of JSONLogic, including purpose-built feature-flagging operations. Note that this schema applies to top-level objects; no additional properties are supported, including \"$schema\", which means built-in JSON-schema support is not possible in editors. Please use flags.json (which imports this schema) for a rich editor experience.", + "type": "object", + "anyOf": [ + { + "$comment": "we need this to support empty targeting", + "type": "object", + "additionalProperties": false, + "properties": {} + }, + { + "$ref": "#/definitions/anyRule" + } + ], + "definitions": { + "primitive": { + "oneOf": [ + { + "description": "When returned from rules, a null value \"exits\", the targeting, and the \"defaultValue\" is returned, with the reason indicating the targeting did not match.", + "type": "null" + }, + { + "description": "When returned from rules, booleans are converted to strings (\"true\"/\"false\"), and used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!", + "type": "boolean" + }, + { + "description": "When returned from rules, the behavior of numbers is not defined.", + "type": "number" + }, + { + "description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.", + "type": "string" + }, + { + "description": "When returned from rules, the behavior of arrays is not defined.", + "type": "array" + } + ] + }, + "varRule": { + "title": "Var Operation", + "description": "Retrieve data from the provided data object.", + "type": "object", + "additionalProperties": false, + "properties": { + "var": { + "anyOf": [ + { + "type": "string", + "description": "flagd automatically injects \"$flagd.timestamp\" (unix epoch) and \"$flagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\.((timestamp)|(flagKey))$" + }, + { + "not": { + "$comment": "this is a negated (not) match of \"$flagd.{some-key}\", which is faster and more compatible that a negative lookahead regex", + "type": "string", + "description": "flagd automatically injects \"$flagd.timestamp\" (unix epoch) and \"$flagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\..*$" + } + }, + { + "type": "array", + "$comment": "this is to support the form of var with a default... there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + ] + } + } + }, + "missingRule": { + "title": "Missing Operation", + "description": "Takes an array of data keys to search for (same format as var). Returns an array of any keys that are missing from the data object, or an empty array.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "missingSomeRule": { + "title": "Missing-Some Operation", + "description": "Takes a minimum number of data keys that are required, and an array of keys to search for (same format as var or missing). Returns an empty array if the minimum is met, or an array of the missing keys otherwise.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing_some": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "binaryOrTernaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "$ref": "#/definitions/args" + } + }, + "binaryOrTernaryRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "substr": { + "title": "Substring Operation", + "description": "Get a portion of a string. Give a positive start position to return everything beginning at that index. Give a negative start position to work backwards from the end of the string, then return everything. Give a positive length to express how many characters to return.", + "$ref": "#/definitions/binaryOrTernaryOp" + }, + "<": { + "title": "Less-Than/Between Operation. Can be used to test that one value is between two others.", + "$ref": "#/definitions/binaryOrTernaryOp" + }, + "<=": { + "title": "Less-Than-Or-Equal-To/Between Operation. Can be used to test that one value is between two others.", + "$ref": "#/definitions/binaryOrTernaryOp" + } + } + }, + "binaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/definitions/args" + } + }, + "binaryRule": { + "title": "Binary Operation", + "description": "Any primitive JSONLogic operation with 2 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "if": { + "title": "If Operator", + "description": "The if statement takes 1 or more arguments: a condition (\"if\"), what to do if its true (\"then\", optional, defaults to returning true), and what to do if its false (\"else\", optional, defaults to returning false). Note that the else condition can be used as an else-if statement by adding additional arguments.", + "$ref": "#/definitions/variadicOp" + }, + "==": { + "title": "Lose Equality Operation", + "description": "Tests equality, with type coercion. Requires two arguments.", + "$ref": "#/definitions/binaryOp" + }, + "===": { + "title": "Strict Equality Operation", + "description": "Tests strict equality. Requires two arguments.", + "$ref": "#/definitions/binaryOp" + }, + "!=": { + "title": "Lose Inequality Operation", + "description": "Tests not-equal, with type coercion.", + "$ref": "#/definitions/binaryOp" + }, + "!==": { + "title": "Strict Inequality Operation", + "description": "Tests strict not-equal.", + "$ref": "#/definitions/binaryOp" + }, + ">": { + "title": "Greater-Than Operation", + "$ref": "#/definitions/binaryOp" + }, + ">=": { + "title": "Greater-Than-Or-Equal-To Operation", + "$ref": "#/definitions/binaryOp" + }, + "%": { + "title": "Modulo Operation", + "description": "Finds the remainder after the first argument is divided by the second argument.", + "$ref": "#/definitions/binaryOp" + }, + "/": { + "title": "Division Operation", + "$ref": "#/definitions/binaryOp" + }, + "map": { + "title": "Map Operation", + "description": "Perform an action on every member of an array. Note, that inside the logic being used to map, var operations are relative to the array element being worked on.", + "$ref": "#/definitions/binaryOp" + }, + "filter": { + "title": "Filter Operation", + "description": "Keep only elements of the array that pass a test. Note, that inside the logic being used to filter, var operations are relative to the array element being worked on.", + "$ref": "#/definitions/binaryOp" + }, + "all": { + "title": "All Operation", + "description": "Perform a test on each member of that array, returning true if all pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "none": { + "title": "None Operation", + "description": "Perform a test on each member of that array, returning true if none pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "some": { + "title": "Some Operation", + "description": "Perform a test on each member of that array, returning true if some pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "in": { + "title": "In Operation", + "description": "If the second argument is an array, tests that the first argument is a member of the array.", + "$ref": "#/definitions/binaryOp" + } + } + }, + "reduceRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "reduce": { + "title": "Reduce Operation", + "description": "Combine all the elements in an array into a single value, like adding up a list of numbers. Note, that inside the logic being used to reduce, var operations only have access to an object with a \"current\" and a \"accumulator\".", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "$ref": "#/definitions/args" + } + } + } + }, + "associativeOp": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/args" + } + }, + "associativeRule": { + "title": "Mathematically Associative Operation", + "description": "Operation applicable to 2 or more parameters.", + "type": "object", + "additionalProperties": false, + "properties": { + "*": { + "title": "Multiplication Operation", + "description": "Multiplication; associative, will accept and unlimited amount of arguments.", + "$ref": "#/definitions/associativeOp" + } + } + }, + "unaryOp": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/definitions/args" + } + }, + { + "$ref": "#/definitions/args" + } + ] + }, + "unaryRule": { + "title": "Unary Operation", + "description": "Any primitive JSONLogic operation with 1 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "!": { + "title": "Negation Operation", + "description": "Logical negation (“not”). Takes just one argument.", + "$ref": "#/definitions/unaryOp" + }, + "!!": { + "title": "Double Negation Operation", + "description": "Double negation, or 'cast to a boolean'. Takes a single argument.", + "$ref": "#/definitions/unaryOp" + } + } + }, + "variadicOp": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/args" + } + }, + "variadicRule": { + "$comment": "note < and <= can be used with up to 3 ops (between)", + "type": "object", + "additionalProperties": false, + "properties": { + "or": { + "title": "Or Operation", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"or\" returns the first truthy argument, or the last argument.", + "$ref": "#/definitions/variadicOp" + }, + "and": { + "title": "", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"and\" returns the first falsy argument, or the last argument.", + "$ref": "#/definitions/variadicOp" + }, + "+": { + "title": "Addition Operation", + "description": "Addition; associative, will accept and unlimited amount of arguments.", + "$ref": "#/definitions/variadicOp" + }, + "-": { + "title": "Subtraction Operation", + "$ref": "#/definitions/variadicOp" + }, + "max": { + "title": "Maximum Operation", + "description": "Return the maximum from a list of values.", + "$ref": "#/definitions/variadicOp" + }, + "min": { + "title": "Minimum Operation", + "description": "Return the minimum from a list of values.", + "$ref": "#/definitions/variadicOp" + }, + "merge": { + "title": "Merge Operation", + "description": "Takes one or more arrays, and merges them into one array. If arguments aren't arrays, they get cast to arrays.", + "$ref": "#/definitions/variadicOp" + }, + "cat": { + "title": "Concatenate Operation", + "description": "Concatenate all the supplied arguments. Note that this is not a join or implode operation, there is no “glue” string.", + "$ref": "#/definitions/variadicOp" + } + } + }, + "stringCompareArg": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/anyRule" + } + ] + }, + "stringCompareArgs": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/definitions/stringCompareArg" + } + }, + "stringCompareRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "starts_with": { + "title": "Starts-With Operation", + "description": "The string attribute starts with the specified string value.", + "$ref": "#/definitions/stringCompareArgs" + }, + "ends_with": { + "title": "Ends-With Operation", + "description": "The string attribute ends with the specified string value.", + "$ref": "#/definitions/stringCompareArgs" + } + } + }, + "semVerString": { + "title": "Semantic Version String", + "description": "A string representing a valid semantic version expression as per https://semver.org/.", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "ruleSemVer": { + "type": "object", + "additionalProperties": false, + "properties": { + "sem_ver": { + "title": "Semantic Version Operation", + "description": "Attribute matches a semantic version condition. Accepts \"npm-style\" range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/semVerString" + }, + { + "$ref": "#/definitions/varRule" + } + ] + }, + { + "description": "Range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "enum": [ + "=", + "!=", + ">", + "<", + ">=", + "<=", + "~", + "^" + ] + }, + { + "oneOf": [ + { + "$ref": "#/definitions/semVerString" + }, + { + "$ref": "#/definitions/varRule" + } + ] + } + ] + } + } + }, + "fractionalWeightArg": { + "description": "Distribution for all possible variants, with their associated weighting.", + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "description": "If this bucket is randomly selected, this JSONLogic will be evaluated, and the result will be used as the variant key to return from the variants map.", + "$ref": "#/definitions/args" + }, + { + "description": "Weighted distribution for this variant key. Must be a non-negative integer. Can be a JSONLogic expression that evaluates to a number (e.g. for time-based progressive rollouts); computed negative weights are clamped to 0 at evaluation time. The total weight sum across all variants must not exceed 2,147,483,647.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/anyRule" + } + ] + } + ] + }, + "fractionalOp": { + "type": "array", + "minItems": 1, + "$comment": "there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", + "items": [ + { + "description": "Bucketing value used in pseudorandom assignment; should be a string that is unique and stable for each subject of flag evaluation. Defaults to a concatenation of the flagKey and targetingKey.", + "$ref": "#/definitions/anyRule" + }, + { + "$ref": "#/definitions/fractionalWeightArg" + }, + { + "$ref": "#/definitions/fractionalWeightArg" + } + ], + "additionalItems": { + "$ref": "#/definitions/fractionalWeightArg" + } + }, + "fractionalShorthandOp": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/fractionalWeightArg" + } + }, + "fractionalRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "fractional": { + "title": "Fractional Operation", + "description": "Deterministic, pseudorandom fractional distribution.", + "oneOf": [ + { + "$ref": "#/definitions/fractionalOp" + }, + { + "$ref": "#/definitions/fractionalShorthandOp" + } + ] + } + } + }, + "reference": { + "additionalProperties": false, + "type": "object", + "$comment": "patternProperties here is a bit of a hack to prevent this definition from being dereferenced early.", + "patternProperties": { + "^\\$ref$": { + "title": "Reference", + "description": "A reference to another entity, used for $evaluators (shared rules).", + "type": "string" + } + } + }, + "args": { + "oneOf": [ + { + "$ref": "#/definitions/reference" + }, + { + "$ref": "#/definitions/anyRule" + }, + { + "$ref": "#/definitions/primitive" + } + ] + }, + "anyRule": { + "anyOf": [ + { + "$ref": "#/definitions/varRule" + }, + { + "$ref": "#/definitions/missingRule" + }, + { + "$ref": "#/definitions/missingSomeRule" + }, + { + "$ref": "#/definitions/binaryRule" + }, + { + "$ref": "#/definitions/binaryOrTernaryRule" + }, + { + "$ref": "#/definitions/associativeRule" + }, + { + "$ref": "#/definitions/unaryRule" + }, + { + "$ref": "#/definitions/variadicRule" + }, + { + "$ref": "#/definitions/reduceRule" + }, + { + "$ref": "#/definitions/stringCompareRule" + }, + { + "$ref": "#/definitions/ruleSemVer" + }, + { + "$ref": "#/definitions/fractionalRule" + } + ] + } + } +} diff --git a/api/integrations/flagd/translators/__init__.py b/api/integrations/flagd/translators/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagd/translators/flag.py b/api/integrations/flagd/translators/flag.py new file mode 100644 index 000000000000..ab07e5ba213e --- /dev/null +++ b/api/integrations/flagd/translators/flag.py @@ -0,0 +1,378 @@ +""" +Translate a Flagsmith feature (with all its environment-level state, +segment overrides, and identity overrides) into a flagd flag entry. + +flagd resolves a flag to a single variant. Flagsmith carries +``enabled`` (boolean) and a typed ``value`` simultaneously, plus +optional multivariate options and per-segment/identity overrides +that can each set their own typed value. + +Mapping: + +- ``control`` variant carrying the typed value (always present; + the ``defaultVariant``). +- ``variant_1``, ``variant_2``, … for multivariate options. +- ``override_`` variants for each override + whose value differs from the control. flagd targeting can only + return a *variant key* (never a literal value), so the override's + typed value has to live in a variant of its own. + +The ``enabled`` field on an override is **not represented** in the +flagd output: flagd has no per-segment ``state`` concept, only values. +Operators expressing "off for this segment" should set the override +value explicitly (e.g. ``false`` for boolean flags). When a disabled +override carries a value identical to control we emit a translation +warning so the no-op is visible in the diagnostics endpoint. + +The flag-level ``state`` field still reflects ``feature_state.enabled`` +— so a disabled flag is reported as ``state: DISABLED`` and flagd's +SDK consumers can decide what to return for that case via the +``defaultVariant`` (always ``control``). +""" + +from collections.abc import Iterable +from typing import Any + +from integrations.flagd.constants import ( + VARIANT_CONTROL, + WARNING_DISABLED_OVERRIDE_NO_OP, + WARNING_IDENTITY_OVERRIDE_LIMIT, +) +from integrations.flagd.translators.multivariate import ( + collect_variants, + fractional_jsonlogic, +) +from integrations.flagd.translators.segment import slugify_name +from integrations.flagd.types import FlagdFlag, JsonLogic, TranslationWarning +from util.engine_models.features.models import FeatureStateModel +from util.engine_models.identities.models import IdentityModel +from util.engine_models.segments.models import SegmentModel + + +def feature_state_to_flagd_flag( + feature_state: FeatureStateModel, + *, + feature_key: str, + segments: Iterable[SegmentModel], + segment_targeting: dict[int, JsonLogic | None], + segment_keys: dict[int, str], + identity_overrides: Iterable[IdentityModel], + identity_override_limit: int, + warnings: list[TranslationWarning], +) -> FlagdFlag: + """ + Build the flagd flag definition for a single feature. + + ``segment_targeting`` and ``segment_keys`` are pre-computed by the + caller so we don't translate the same segment more than once; both + are keyed by segment id. + """ + has_multivariate = bool(feature_state.multivariate_feature_state_values) + control_value = feature_state.feature_state_value + # Flagsmith flags without a typed value carry meaning only via + # ``enabled``. Map them to a boolean flagd flag so variants are + # well-typed. + if control_value is None and not has_multivariate: + control_value = True + + if has_multivariate: + variants, variant_keys = collect_variants( + feature_state, + control_variant=VARIANT_CONTROL, + control_value=control_value, + ) + else: + variants = {VARIANT_CONTROL: control_value} + variant_keys = {} + + # Mint per-override variants for overrides whose value differs from + # control. Mutates ``variants`` in place and returns lookup tables + # the targeting builder consults. + segment_override_variants, identity_override_variants = _register_override_variants( + feature_state.feature.id, + control_value=control_value, + segments=segments, + segment_keys=segment_keys, + identity_overrides=identity_overrides, + variants=variants, + feature_key=feature_key, + warnings=warnings, + ) + + control_target: Any + if has_multivariate: + control_target = fractional_jsonlogic( + feature_state, + feature_key=feature_key, + variant_keys=variant_keys, + control_variant=VARIANT_CONTROL, + ) + else: + control_target = VARIANT_CONTROL + + targeting = _build_targeting( + feature_state=feature_state, + feature_key=feature_key, + segments=segments, + segment_targeting=segment_targeting, + segment_keys=segment_keys, + identity_overrides=identity_overrides, + identity_override_limit=identity_override_limit, + control_target=control_target, + segment_override_variants=segment_override_variants, + identity_override_variants=identity_override_variants, + warnings=warnings, + ) + + flag: FlagdFlag = { + "state": "ENABLED" if feature_state.enabled else "DISABLED", + "variants": variants, + "defaultVariant": VARIANT_CONTROL, + } + if targeting is not None: + flag["targeting"] = targeting + return flag + + +def _register_override_variants( + feature_id: int, + *, + control_value: Any, + segments: Iterable[SegmentModel], + segment_keys: dict[int, str], + identity_overrides: Iterable[IdentityModel], + variants: dict[str, Any], + feature_key: str, + warnings: list[TranslationWarning], +) -> tuple[dict[int, str], dict[str, str]]: + """ + Walk the overrides on ``feature_id``. For each one carrying a typed + value that differs from the control, mint a unique ``override_`` + variant and add it to ``variants``. Returns lookup tables segment-id + → variant-name and identity-identifier → variant-name; absent keys + mean "route to control". + + Overrides whose value matches the control are *no-ops* in flagd. + When such an override is also marked ``enabled=False`` we emit a + warning so the operator can see the discrepancy via the diagnostics + endpoint. + """ + taken: set[str] = set(variants.keys()) + segment_override_variants: dict[int, str] = {} + identity_override_variants: dict[str, str] = {} + + for segment in segments: + override_fs = _find_segment_override(segment, feature_id) + if override_fs is None: + continue + if override_fs.feature_state_value == control_value: + if not override_fs.enabled: + warnings.append( + TranslationWarning( + reason=WARNING_DISABLED_OVERRIDE_NO_OP, + detail=( + f"feature={feature_key}, segment={segment.name}; " + "set the override value explicitly to change the " + "value flagd returns for this segment." + ), + ) + ) + continue + slug_base = segment_keys.get(segment.id) or slugify_name( + segment.name, fallback=f"segment-{segment.id}" + ) + name = _unique_variant_name(f"override_{slug_base}", taken) + taken.add(name) + variants[name] = override_fs.feature_state_value + segment_override_variants[segment.id] = name + + for identity in identity_overrides: + override_fs = _find_identity_override(identity, feature_id) + if override_fs is None: + continue + if override_fs.feature_state_value == control_value: + if not override_fs.enabled: + warnings.append( + TranslationWarning( + reason=WARNING_DISABLED_OVERRIDE_NO_OP, + detail=( + f"feature={feature_key}, identity={identity.identifier}; " + "set the override value explicitly to change the " + "value flagd returns for this identity." + ), + ) + ) + continue + slug_base = slugify_name(identity.identifier, fallback="identity") + name = _unique_variant_name(f"override_{slug_base}", taken) + taken.add(name) + variants[name] = override_fs.feature_state_value + identity_override_variants[identity.identifier] = name + + return segment_override_variants, identity_override_variants + + +def _unique_variant_name(candidate: str, taken: set[str]) -> str: + if candidate not in taken: + return candidate + counter = 2 + while True: + suffixed = f"{candidate}-{counter}" + if suffixed not in taken: + return suffixed + counter += 1 + + +def _collect_identity_branches( + *, + feature_id: int, + feature_key: str, + identity_overrides: Iterable[IdentityModel], + identity_override_variants: dict[str, str], + identity_override_limit: int, + warnings: list[TranslationWarning], +) -> list[tuple[str, str]]: + branches: list[tuple[str, str]] = [] + overflow = 0 + for identity in identity_overrides: + override_fs = _find_identity_override(identity, feature_id) + if override_fs is None: + continue + variant = identity_override_variants.get(identity.identifier) + if variant is None: + # No-op override (value == control); nothing to route. + continue + if len(branches) >= identity_override_limit: + overflow += 1 + continue + branches.append((identity.identifier, variant)) + if overflow: + warnings.append( + TranslationWarning( + reason=WARNING_IDENTITY_OVERRIDE_LIMIT, + detail=f"feature={feature_key}, dropped={overflow}", + ) + ) + return branches + + +def _collect_segment_branches( + *, + feature_id: int, + segments: Iterable[SegmentModel], + segment_targeting: dict[int, JsonLogic | None], + segment_keys: dict[int, str], + segment_override_variants: dict[int, str], +) -> list[tuple[JsonLogic, str]]: + # Higher priority (lower numeric value) wins; default to insertion + # order when priority is missing. + branches: list[tuple[JsonLogic, str]] = [] + indexed_segments = sorted( + segments, + key=lambda s: _segment_override_priority(s, feature_id), + ) + for segment in indexed_segments: + variant = segment_override_variants.get(segment.id) + if variant is None: + continue + targeting = segment_targeting.get(segment.id) + if targeting is None: + continue + # Single-use segments are inlined; multi-use segments are + # referenced via $ref into the document's $evaluators block. + # The orchestrator decides which: when ``segment_keys`` has an + # entry for this segment, the segment is shared and we route + # through $ref. + shared_key = segment_keys.get(segment.id) + segment_logic: JsonLogic = ( + {"$ref": shared_key} if shared_key is not None else targeting + ) + branches.append((segment_logic, variant)) + return branches + + +def _build_targeting( + *, + feature_state: FeatureStateModel, + feature_key: str, + segments: Iterable[SegmentModel], + segment_targeting: dict[int, JsonLogic | None], + segment_keys: dict[int, str], + identity_overrides: Iterable[IdentityModel], + identity_override_limit: int, + control_target: Any, + segment_override_variants: dict[int, str], + identity_override_variants: dict[str, str], + warnings: list[TranslationWarning], +) -> JsonLogic | None: + feature_id = feature_state.feature.id + + # Identity overrides take highest priority, then segment overrides. + identity_branches = _collect_identity_branches( + feature_id=feature_id, + feature_key=feature_key, + identity_overrides=identity_overrides, + identity_override_variants=identity_override_variants, + identity_override_limit=identity_override_limit, + warnings=warnings, + ) + segment_branches = _collect_segment_branches( + feature_id=feature_id, + segments=segments, + segment_targeting=segment_targeting, + segment_keys=segment_keys, + segment_override_variants=segment_override_variants, + ) + + fallback: Any = control_target + + if not identity_branches and not segment_branches: + # Static-default flags don't need targeting at all; the + # variant returned by ``defaultVariant`` is enough. + if isinstance(fallback, str): + return None + return fallback + + expression: Any = fallback + + # Build right-to-left so identity overrides win. + for ref_logic, variant in reversed(segment_branches): + expression = {"if": [ref_logic, variant, expression]} + + # Identity overrides are chained right-to-left so each one can + # resolve to a different variant and the first match wins. + for identifier, variant in reversed(identity_branches): + expression = { + "if": [ + {"==": [{"var": "targetingKey"}, identifier]}, + variant, + expression, + ] + } + + return expression + + +def _find_identity_override( + identity: IdentityModel, feature_id: int +) -> FeatureStateModel | None: + for fs in identity.identity_features: + if fs.feature.id == feature_id: + return fs + return None + + +def _find_segment_override( + segment: SegmentModel, feature_id: int +) -> FeatureStateModel | None: + for fs in segment.feature_states: + if fs.feature.id == feature_id: + return fs + return None + + +def _segment_override_priority(segment: SegmentModel, feature_id: int) -> int: + for fs in segment.feature_states: + if fs.feature.id == feature_id and fs.feature_segment is not None: + return fs.feature_segment.priority or 0 + return 0 diff --git a/api/integrations/flagd/translators/multivariate.py b/api/integrations/flagd/translators/multivariate.py new file mode 100644 index 000000000000..15f1ce4e7f8d --- /dev/null +++ b/api/integrations/flagd/translators/multivariate.py @@ -0,0 +1,79 @@ +""" +Translate Flagsmith multivariate options into a flagd ``fractional`` +expression. flagd performs the actual bucketing — we only encode the +weights and per-feature seed. +""" + +from typing import Any + +from integrations.flagd.types import JsonLogic +from util.engine_models.features.models import FeatureStateModel + + +def fractional_jsonlogic( + feature_state: FeatureStateModel, + *, + feature_key: str, + variant_keys: dict[int, str], + control_variant: str, +) -> JsonLogic: + """ + Build a flagd ``fractional`` expression. ``variant_keys`` maps a + multivariate option id (or its uuid as fallback) to the variant + name used in the parent flag's ``variants`` block. + + The residual percentage (i.e. ``100 - sum(allocations)``) is routed + to ``control_variant`` so behaviour matches Flagsmith's model where + the unallocated slice resolves to the feature state's value. + """ + weights: list[list[Any]] = [] + total_allocation = 0.0 + + for mv_value in feature_state.multivariate_feature_state_values: + identifier = mv_value.id or str(mv_value.mv_fs_value_uuid) + variant = variant_keys[identifier] + weight = float(mv_value.percentage_allocation) + if weight <= 0: + continue + weights.append([variant, weight]) + total_allocation += weight + + residual = max(0.0, 100.0 - total_allocation) + if residual > 0: + weights.append([control_variant, residual]) + + bucket_seed: JsonLogic = { + "cat": [{"var": "targetingKey"}, feature_key], + } + + return {"fractional": [bucket_seed, *weights]} + + +def collect_variants( + feature_state: FeatureStateModel, + *, + control_variant: str, + control_value: Any, +) -> tuple[dict[str, Any], dict[Any, str]]: + """ + Return ``(variants, variant_keys)``: + - ``variants`` maps variant name → resolved value, suitable for + flagd's ``variants`` block. + - ``variant_keys`` maps the multivariate option id (or uuid) → the + generated variant name. + + Variant names follow A/B-testing convention: the default is + ``control`` (the flag's typed value) and additional options are + indexed as ``variant_1``, ``variant_2``, … in the order Flagsmith + stores them. Stable across renames; predictable for tooling. + """ + variants: dict[str, Any] = {control_variant: control_value} + variant_keys: dict[Any, str] = {} + for index, mv_value in enumerate( + feature_state.multivariate_feature_state_values, start=1 + ): + name = f"variant_{index}" + identifier = mv_value.id or str(mv_value.mv_fs_value_uuid) + variants[name] = mv_value.multivariate_feature_option.value + variant_keys[identifier] = name + return variants, variant_keys diff --git a/api/integrations/flagd/translators/operators.py b/api/integrations/flagd/translators/operators.py new file mode 100644 index 000000000000..c4f8d6ee9559 --- /dev/null +++ b/api/integrations/flagd/translators/operators.py @@ -0,0 +1,240 @@ +""" +Translate a single Flagsmith ``SegmentConditionModel`` into a JsonLogic +expression understood by flagd. + +The translator is a pure function over the engine model so that callers +work against the same data shape that the SDK environment-document +endpoint already produces. +""" + +from collections.abc import Callable +from typing import Any + +from flag_engine.segments import constants as op + +from integrations.flagd.constants import ( + WARNING_MALFORMED_VALUE, + WARNING_REGEX_UNSUPPORTED, + WARNING_UNKNOWN_OPERATOR, +) +from integrations.flagd.exceptions import UntranslatableConditionError +from integrations.flagd.types import JsonLogic +from util.engine_models.segments.models import SegmentConditionModel + +_SEMVER_SUFFIX = ":semver" +_SEMVER_OPERATOR_MAP: dict[str, str] = { + op.EQUAL: "=", + op.NOT_EQUAL: "!=", + op.GREATER_THAN: ">", + op.GREATER_THAN_INCLUSIVE: ">=", + op.LESS_THAN: "<", + op.LESS_THAN_INCLUSIVE: "<=", +} +_COMPARISON_OPERATOR_MAP: dict[str, str] = { + op.GREATER_THAN: ">", + op.GREATER_THAN_INCLUSIVE: ">=", + op.LESS_THAN: "<", + op.LESS_THAN_INCLUSIVE: "<=", +} + + +def condition_to_jsonlogic( + condition: SegmentConditionModel, + *, + feature_key: str | None = None, +) -> JsonLogic: + """ + Convert a Flagsmith condition to JsonLogic. + + Raises ``UntranslatableConditionError`` when the operator has no + flagd equivalent (e.g. REGEX) or the value is malformed; callers are + expected to skip such conditions and emit a warning. + """ + operator = condition.operator + raw_value = condition.value + property_name = condition.property_ + + if operator == op.IS_SET: + return {"!=": [_var(property_name), None]} + if operator == op.IS_NOT_SET: + return {"==": [_var(property_name), None]} + + if operator == op.REGEX: + raise UntranslatableConditionError(WARNING_REGEX_UNSUPPORTED, operator=operator) + + if raw_value is None: + raise UntranslatableConditionError(WARNING_MALFORMED_VALUE, operator=operator) + + if _is_semver_value(raw_value): + return _semver_jsonlogic(property_name, operator, raw_value) + + handler = _OPERATOR_HANDLERS.get(operator) + if handler is None: + raise UntranslatableConditionError(WARNING_UNKNOWN_OPERATOR, operator=operator) + return handler(property_name, operator, raw_value, feature_key) + + +def _equality_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, + feature_key: str | None, +) -> JsonLogic: + jsonlogic_op = "==" if operator == op.EQUAL else "!=" + return {jsonlogic_op: [_var(property_name), _coerce_value(raw_value)]} + + +def _comparison_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, + feature_key: str | None, +) -> JsonLogic: + jsonlogic_op = _COMPARISON_OPERATOR_MAP[operator] + try: + numeric_value: Any = float(raw_value) + if numeric_value.is_integer(): + numeric_value = int(numeric_value) + except (TypeError, ValueError) as exc: + raise UntranslatableConditionError( + WARNING_MALFORMED_VALUE, operator=operator + ) from exc + return {jsonlogic_op: [_var(property_name), numeric_value]} + + +def _contains_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, + feature_key: str | None, +) -> JsonLogic: + contains: JsonLogic = {"in": [raw_value, _var(property_name)]} + if operator == op.NOT_CONTAINS: + return {"!": contains} + return contains + + +def _in_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, + feature_key: str | None, +) -> JsonLogic: + members = [v for v in (m.strip() for m in raw_value.split(",")) if v] + return {"in": [_var(property_name), members]} + + +def _modulo_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, + feature_key: str | None, +) -> JsonLogic: + try: + divisor_str, remainder_str = raw_value.split("|", 1) + divisor: float = float(divisor_str) + remainder: float = float(remainder_str) + except ValueError as exc: + raise UntranslatableConditionError( + WARNING_MALFORMED_VALUE, operator=operator + ) from exc + return {"==": [{"%": [_var(property_name), divisor]}, remainder]} + + +def _percentage_split_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, + feature_key: str | None, +) -> JsonLogic: + try: + threshold = float(raw_value) + except (TypeError, ValueError) as exc: + raise UntranslatableConditionError( + WARNING_MALFORMED_VALUE, operator=operator + ) from exc + # PERCENTAGE_SPLIT in Flagsmith means "this identity falls under X%". + # Express as a fractional bucket: targetingKey lands in the "in" + # bucket of size `threshold` or the "out" bucket of size 100 - threshold. + bucket_seed_parts: list[Any] = [{"var": "targetingKey"}] + if feature_key: + bucket_seed_parts.append(feature_key) + return { + "==": [ + { + "fractional": [ + {"cat": bucket_seed_parts} + if feature_key + else {"var": "targetingKey"}, + ["in", threshold], + ["out", max(0.0, 100.0 - threshold)], + ] + }, + "in", + ] + } + + +_OPERATOR_HANDLERS: dict[ + str, Callable[[str | None, str, str, str | None], JsonLogic] +] = { + op.EQUAL: _equality_jsonlogic, + op.NOT_EQUAL: _equality_jsonlogic, + op.GREATER_THAN: _comparison_jsonlogic, + op.GREATER_THAN_INCLUSIVE: _comparison_jsonlogic, + op.LESS_THAN: _comparison_jsonlogic, + op.LESS_THAN_INCLUSIVE: _comparison_jsonlogic, + op.CONTAINS: _contains_jsonlogic, + op.NOT_CONTAINS: _contains_jsonlogic, + op.IN: _in_jsonlogic, + op.MODULO: _modulo_jsonlogic, + op.PERCENTAGE_SPLIT: _percentage_split_jsonlogic, +} + + +def _var(property_name: str | None) -> JsonLogic: + return {"var": property_name or ""} + + +def _coerce_value(raw: str) -> Any: + """Best-effort native typing for equality comparisons.""" + if raw == "true": + return True + if raw == "false": + return False + if raw == "null": + return None + try: + as_int = int(raw) + # Avoid losing leading zeros for things like "007". + if str(as_int) == raw: + return as_int + except ValueError: + pass + try: + return float(raw) + except ValueError: + return raw + + +def _is_semver_value(raw: str) -> bool: + return isinstance(raw, str) and raw.endswith(_SEMVER_SUFFIX) + + +def _semver_jsonlogic( + property_name: str | None, + operator: str, + raw_value: str, +) -> JsonLogic: + if operator not in _SEMVER_OPERATOR_MAP: + raise UntranslatableConditionError(WARNING_MALFORMED_VALUE, operator=operator) + version = raw_value[: -len(_SEMVER_SUFFIX)].strip() + if not version: + raise UntranslatableConditionError(WARNING_MALFORMED_VALUE, operator=operator) + return { + "sem_ver": [ + _var(property_name), + _SEMVER_OPERATOR_MAP[operator], + version, + ] + } diff --git a/api/integrations/flagd/translators/segment.py b/api/integrations/flagd/translators/segment.py new file mode 100644 index 000000000000..d81997bcf7cd --- /dev/null +++ b/api/integrations/flagd/translators/segment.py @@ -0,0 +1,140 @@ +""" +Translate Flagsmith segments and rule trees into JsonLogic. + +Each Flagsmith ``SegmentModel`` becomes one entry in flagd's +``$evaluators`` block, keyed by a slugified segment name. Flag targeting +expressions reference segments via ``{"$ref": ""}``. + +Nested ``SegmentRuleModel`` trees are inlined into the segment evaluator +because flagd does not allow nested ``$ref``s. +""" + +import re +from typing import Iterable + +from integrations.flagd.exceptions import UntranslatableConditionError +from integrations.flagd.translators.operators import condition_to_jsonlogic +from integrations.flagd.types import JsonLogic, TranslationWarning +from util.engine_models.segments.models import SegmentModel, SegmentRuleModel + + +def segment_to_jsonlogic( + segment: SegmentModel, + *, + feature_key: str | None = None, + warnings: list[TranslationWarning] | None = None, +) -> JsonLogic | None: + """ + Convert a segment's rule tree to a single JsonLogic expression that + returns ``true`` when an identity matches the segment. + + Returns ``None`` if the segment has no rules to evaluate. + """ + if not segment.rules: + return None + return _rules_to_jsonlogic( + segment.rules, + rule_type="ALL", + feature_key=feature_key, + warnings=warnings, + ) + + +def rule_to_jsonlogic( + rule: SegmentRuleModel, + *, + feature_key: str | None = None, + warnings: list[TranslationWarning] | None = None, +) -> JsonLogic | None: + """ + Translate a single rule node (and its descendants). + """ + expressions: list[JsonLogic] = [] + + for condition in rule.conditions: + try: + expressions.append( + condition_to_jsonlogic(condition, feature_key=feature_key) + ) + except UntranslatableConditionError as exc: + if warnings is not None: + warnings.append( + TranslationWarning( + reason=exc.reason, + detail=f"operator={exc.operator}, property={condition.property_}", + ) + ) + + for child in rule.rules: + child_logic = rule_to_jsonlogic( + child, feature_key=feature_key, warnings=warnings + ) + if child_logic is not None: + expressions.append(child_logic) + + if not expressions: + return None + + return _combine(rule.type, expressions) + + +def slugify_name(name: str, *, fallback: str = "unnamed") -> str: + """ + Produce a flagd-safe key from a user-supplied name. Strips + characters outside ``[A-Za-z0-9_-]`` and collapses runs into single + hyphens. + """ + cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", name.strip()) or fallback + cleaned = cleaned.strip("-_") or fallback + return cleaned + + +def slugify_segment_name(name: str, *, taken: Iterable[str] = ()) -> str: + """ + Produce a flagd-safe key for a segment. Segment names are + user-supplied and may collide once normalised, so callers can pass + in a set of already-used keys to suffix-disambiguate. + """ + cleaned = slugify_name(name, fallback="segment") + if cleaned not in taken: + return cleaned + counter = 2 + while True: + candidate = f"{cleaned}-{counter}" + if candidate not in taken: + return candidate + counter += 1 + + +def _rules_to_jsonlogic( + rules: list[SegmentRuleModel], + *, + rule_type: str, + feature_key: str | None, + warnings: list[TranslationWarning] | None, +) -> JsonLogic | None: + expressions: list[JsonLogic] = [] + for rule in rules: + translated = rule_to_jsonlogic(rule, feature_key=feature_key, warnings=warnings) + if translated is not None: + expressions.append(translated) + if not expressions: + return None + if len(expressions) == 1 and rule_type == "ALL": + return expressions[0] + return _combine(rule_type, expressions) + + +def _combine(rule_type: str, expressions: list[JsonLogic]) -> JsonLogic: + if rule_type == "ALL": + return {"and": expressions} if len(expressions) > 1 else expressions[0] + if rule_type == "ANY": + return {"or": expressions} if len(expressions) > 1 else expressions[0] + if rule_type == "NONE": + inner: JsonLogic = ( + {"or": expressions} if len(expressions) > 1 else expressions[0] + ) + return {"!": inner} + # Defensive: unknown rule type — fall back to AND so we don't silently + # broaden the audience. + return {"and": expressions} diff --git a/api/integrations/flagd/translators/type_check.py b/api/integrations/flagd/translators/type_check.py new file mode 100644 index 000000000000..b0eef621c333 --- /dev/null +++ b/api/integrations/flagd/translators/type_check.py @@ -0,0 +1,113 @@ +""" +Detect cross-variant type mismatches in a Flagsmith feature state +before flagd sees the resulting document. + +flagd's JSON Schema defines four typed flags (boolean, number, string, +object); a single flag's variants must all share a type. Flagsmith +permits free-form values, so the same feature can legitimately mix +types — but for flagd consumption that's a misconfiguration which +either fails schema validation or surfaces as ``TYPE_MISMATCH`` at +OpenFeature evaluation time. + +This module emits structured ``TranslationWarning``s that the diagnostic +endpoint can surface to operators (so they can fix the flag in the +Flagsmith UI before it bites a flagd consumer). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from integrations.flagd.types import TranslationWarning +from util.engine_models.features.models import FeatureStateModel +from util.engine_models.identities.models import IdentityModel +from util.engine_models.segments.models import SegmentModel + +WARNING_TYPE_MISMATCH = "type_mismatch" + + +def _ingest_value(value: Any, seen: dict[str, Any]) -> None: + type_ = _flagd_type(value) + if type_ is None or type_ in seen: + return + seen[type_] = value + + +def _ingest_feature_state(fs: FeatureStateModel, seen: dict[str, Any]) -> None: + _ingest_value(fs.feature_state_value, seen) + for mv_value in fs.multivariate_feature_state_values: + _ingest_value(mv_value.multivariate_feature_option.value, seen) + + +def _iter_override_feature_states( + feature_id: int, + *, + segments: Iterable[SegmentModel], + identity_overrides: Iterable[IdentityModel], +) -> Iterable[FeatureStateModel]: + for segment in segments: + for fs in segment.feature_states: + if fs.feature.id == feature_id: + yield fs + for identity in identity_overrides: + for fs in identity.identity_features: + if fs.feature.id == feature_id: + yield fs + + +def _flagd_type(value: Any) -> str | None: + """ + Bucket a Python value into one of flagd's typed-flag categories. + Returns ``None`` for ``None`` (compatible with anything). + """ + if value is None: + return None + if isinstance(value, bool): + return "boolean" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, dict): + return "object" + if isinstance(value, list): + return "array" + return "unknown" + + +def detect_type_mismatch( + feature_state: FeatureStateModel, + *, + segments: Iterable[SegmentModel] = (), + identity_overrides: Iterable[IdentityModel] = (), +) -> list[TranslationWarning]: + """ + Return one ``TranslationWarning`` per type mismatch found across: + - the feature state's control value + - its multivariate option values + - any segment override's control + multivariate values + - any identity override's control + multivariate values + + Empty list when every value reachable through the flag shares a + flagd type. ``None`` is treated as compatible with anything. + """ + feature_id = feature_state.feature.id + seen: dict[str, Any] = {} + + _ingest_feature_state(feature_state, seen) + for fs in _iter_override_feature_states( + feature_id, segments=segments, identity_overrides=identity_overrides + ): + _ingest_feature_state(fs, seen) + + if len(seen) <= 1: + return [] + + types_summary = ", ".join(sorted(seen.keys())) + return [ + TranslationWarning( + reason=WARNING_TYPE_MISMATCH, + detail=(f"feature={feature_state.feature.name}, types=[{types_summary}]"), + ) + ] diff --git a/api/integrations/flagd/types.py b/api/integrations/flagd/types.py new file mode 100644 index 000000000000..bed4f2d63243 --- /dev/null +++ b/api/integrations/flagd/types.py @@ -0,0 +1,23 @@ +from typing import Any, TypedDict + +JsonLogic = dict[str, Any] + + +class TranslationWarning(TypedDict): + reason: str + detail: str + + +class FlagdFlag(TypedDict, total=False): + state: str + variants: dict[str, Any] + defaultVariant: str + targeting: JsonLogic | None + metadata: dict[str, Any] + + +class FlagdDocument(TypedDict, total=False): + schema: str + flags: dict[str, FlagdFlag] + evaluators: dict[str, JsonLogic] + metadata: dict[str, Any] diff --git a/api/integrations/flagd/urls.py b/api/integrations/flagd/urls.py new file mode 100644 index 000000000000..7369aa76d098 --- /dev/null +++ b/api/integrations/flagd/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from integrations.flagd.views import FlagdDiagnosticsAPIView, FlagdSyncAPIView + +app_name = "flagd" + +urlpatterns = [ + # SDK-facing endpoints — authenticated via a server-side + # EnvironmentAPIKey. The admin-side toggle lives under + # /projects//integrations/flagd/ (registered by the projects + # app's router alongside every other integration). + path("flags.json", FlagdSyncAPIView.as_view(), name="sync"), + path("diagnostics.json", FlagdDiagnosticsAPIView.as_view(), name="diagnostics"), +] diff --git a/api/integrations/flagd/views.py b/api/integrations/flagd/views.py new file mode 100644 index 000000000000..882c2d017ccc --- /dev/null +++ b/api/integrations/flagd/views.py @@ -0,0 +1,196 @@ +import hashlib +import json +import time +from datetime import datetime +from typing import Optional + +import structlog +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.http import condition +from drf_spectacular.utils import extend_schema +from rest_framework.exceptions import NotFound +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from environments.authentication import EnvironmentKeyAuthentication +from environments.permissions.permissions import EnvironmentKeyPermissions +from features.models import FeatureState +from features.versioning.models import EnvironmentFeatureVersion +from integrations.common.views import ProjectIntegrationBaseViewSet +from integrations.flagd.constants import FLAGD_TRANSLATOR_VERSION +from integrations.flagd.diagnostics import diagnose_environment +from integrations.flagd.metrics import ( + flagsmith_flagd_document_build_seconds, + flagsmith_flagd_document_size_bytes, + flagsmith_flagd_sync_requests_total, +) +from integrations.flagd.models import ( + FlagdProjectConfiguration, + is_flagd_enabled_for_project, +) +from integrations.flagd.serializers import FlagdProjectConfigurationSerializer +from integrations.flagd.services import build_flagd_document + + +def _require_flagd_enabled(environment) -> None: # type: ignore[no-untyped-def] + """ + Both the sync and diagnostics endpoints are opt-in per project. + Raises ``NotFound`` (404) rather than ``PermissionDenied`` so a + client probing without authorization can tell the integration + simply isn't configured. + """ + if not is_flagd_enabled_for_project(environment.project_id): + raise NotFound("flagd integration is not enabled for this project.") + + +logger = structlog.get_logger("flagd_sync") + + +def _effective_last_modified(environment) -> Optional[datetime]: # type: ignore[no-untyped-def] + """ + Return the timestamp at which the flagd document for ``environment`` + last *effectively* changed — accounting for scheduled feature-state + changes that go live without bumping ``environment.updated_at``. + + Considers three signals and returns the latest: + - ``environment.updated_at`` (covers direct edits) + - the most recent ``FeatureState.live_from`` <= now in the env + (covers v1 versioning's scheduled state transitions) + - the most recent ``EnvironmentFeatureVersion.live_from`` <= now + in the env (covers v2 versioning's scheduled publishes) + """ + candidates: list[datetime] = [] + if environment.updated_at: + candidates.append(environment.updated_at) + + now = timezone.now() + v1_live_from = ( + FeatureState.objects.filter( + environment_id=environment.id, + live_from__isnull=False, + live_from__lte=now, + ) + .order_by("-live_from") + .values_list("live_from", flat=True) + .first() + ) + if v1_live_from: + candidates.append(v1_live_from) + + v2_live_from = ( + EnvironmentFeatureVersion.objects.filter( + environment_id=environment.id, + published_at__isnull=False, + live_from__lte=now, + ) + .order_by("-live_from") + .values_list("live_from", flat=True) + .first() + ) + if v2_live_from: + candidates.append(v2_live_from) + + return max(candidates) if candidates else None + + +def _get_last_modified(request: Request) -> Optional[datetime]: + environment = getattr(request, "environment", None) + if environment is None: + return None + return _effective_last_modified(environment) + + +def _get_etag(request: Request) -> Optional[str]: + environment = getattr(request, "environment", None) + if environment is None: + return None + last_modified = _effective_last_modified(environment) + if last_modified is None: + return None + raw = ( + f"{environment.api_key}:{last_modified.isoformat()}:{FLAGD_TRANSLATOR_VERSION}" + ) + return hashlib.sha256(raw.encode()).hexdigest() + + +@extend_schema(tags=["sdk"]) +class FlagdSyncAPIView(APIView): + """ + HTTP sync endpoint consumed by flagd: returns the current + Flagsmith environment as a flagd flag-definition document. + """ + + permission_classes = (EnvironmentKeyPermissions,) + throttle_classes = [] + + def get_authenticators(self): # type: ignore[no-untyped-def] + return [EnvironmentKeyAuthentication(required_key_prefix="ser.")] + + @extend_schema(operation_id="sdk_v1_flagd_sync") + @method_decorator( + condition(last_modified_func=_get_last_modified, etag_func=_get_etag) + ) + def get(self, request: Request) -> Response: + environment = request.environment + _require_flagd_enabled(environment) + start = time.perf_counter() + document = build_flagd_document(environment) + flagsmith_flagd_document_build_seconds.observe(time.perf_counter() - start) + warnings_count = len(document.get("metadata", {}).get("flagsmith.warnings", [])) + flagsmith_flagd_sync_requests_total.labels(status="200").inc() + flagsmith_flagd_document_size_bytes.observe(len(json.dumps(document).encode())) + logger.info( + "document.served", + environment__id=environment.id, + warnings__count=warnings_count, + ) + return Response(document) + + +@extend_schema(tags=["sdk"]) +class FlagdDiagnosticsAPIView(APIView): + """ + Reports translation warnings for the current environment without + serving the sync document. Operators curl this to audit an + environment (type mismatches, REGEX skipped, identity-override cap + hit, …) before promoting to a flagd-consumer-facing change. + """ + + permission_classes = (EnvironmentKeyPermissions,) + throttle_classes = [] + + def get_authenticators(self): # type: ignore[no-untyped-def] + return [EnvironmentKeyAuthentication(required_key_prefix="ser.")] + + @extend_schema(operation_id="sdk_v1_flagd_diagnostics") + def get(self, request: Request) -> Response: + environment = request.environment + _require_flagd_enabled(environment) + report = diagnose_environment(environment) + logger.info( + "diagnostics.served", + environment__id=environment.id, + features_with_warnings=report["summary"]["featuresWithWarnings"], + total_warnings=report["summary"]["totalWarnings"], + ) + return Response(report) + + +@extend_schema(tags=["integrations"]) +class FlagdProjectConfigurationViewSet(ProjectIntegrationBaseViewSet): + """ + Admin toggle for the flagd integration, scoped to one project. + + Inherits the standard Flagsmith ``ProjectIntegrationBaseViewSet`` + used by every other integration (datadog, grafana, etc.). That base + class wires up the project-nested permission model, the + "one-config-per-project" guard on create, and the project filter + on the queryset. The plugin keeps full ownership of the model, + serializer, and gating semantics. + """ + + serializer_class = FlagdProjectConfigurationSerializer + pagination_class = None + model_class = FlagdProjectConfiguration diff --git a/api/projects/urls.py b/api/projects/urls.py index 80d4e8d4bd14..4907256f9757 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -19,6 +19,7 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet +from integrations.flagd.views import FlagdProjectConfigurationViewSet from integrations.gitlab.views import ( BrowseGitLabIssues, BrowseGitLabMergeRequests, @@ -81,6 +82,11 @@ GrafanaProjectConfigurationViewSet, basename="integrations-grafana", ) +projects_router.register( + r"integrations/flagd", + FlagdProjectConfigurationViewSet, + basename="integrations-flagd", +) projects_router.register( "audit", ProjectAuditLogViewSet, diff --git a/api/pyproject.toml b/api/pyproject.toml index a59c0bdbb1a5..a8dfe395fd5f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -129,6 +129,8 @@ dev = [ "diff-cover>=10.1.0,<11.0.0", "django-debug-toolbar", "ipython>=9.10.0,<10.0.0", + "hypothesis>=6.152.4,<7.0.0", + "json-logic-qubit>=0.9.1,<0.10.0", # datamodel-code-generator requires `pydantic[email]`, but our # override-dependencies on pydantic strips the email extra. Re-add # the transitive deps so poetry.lock parity is preserved. @@ -258,6 +260,7 @@ override-dependencies = [ "httplib2==0.22.0", "httpx==0.28.1", "hubspot-api-client==12.0.0", + "hypothesis==6.152.4", "identify==2.6.3", "importlib-metadata==8.7.1", "inflect==5.6.2", @@ -272,6 +275,7 @@ override-dependencies = [ "jedi==0.19.2", "jinja2==3.1.6", "jmespath==1.0.1", + "json-logic-qubit==0.9.1", "jsonpath-rfc9535==0.2.0", "jsonschema==4.25.1", "jsonschema-specifications==2025.9.1", @@ -382,6 +386,7 @@ override-dependencies = [ "slack-sdk==3.9.1", "social-auth-app-django==5.6.0", "social-auth-core==4.4.2", + "sortedcontainers==2.4.0", "sqlparse==0.5.4", "sseclient-py==1.8.0", "stack-data==0.6.3", diff --git a/api/tests/integration/flagd/__init__.py b/api/tests/integration/flagd/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/integration/flagd/conftest.py b/api/tests/integration/flagd/conftest.py new file mode 100644 index 000000000000..3a7b0689f7cd --- /dev/null +++ b/api/tests/integration/flagd/conftest.py @@ -0,0 +1,22 @@ +""" +Shared fixtures for the flagd integration suite. + +The endpoints are opt-in per project, so every integration test here +needs the project's ``FlagdProjectConfiguration`` row present and +``enabled``. Doing it via ``autouse`` keeps test bodies focused on the +behaviour under test rather than the integration's enablement state. +""" + +from __future__ import annotations + +import pytest + +from integrations.flagd.models import FlagdProjectConfiguration + + +@pytest.fixture(autouse=True) +def enable_flagd_for_project(project: int) -> None: + """Ensure flagd is enabled for the test project.""" + FlagdProjectConfiguration.objects.update_or_create( + project_id=project, defaults={"enabled": True} + ) diff --git a/api/tests/integration/flagd/test_admin_toggle.py b/api/tests/integration/flagd/test_admin_toggle.py new file mode 100644 index 000000000000..cc4bd0eebcb2 --- /dev/null +++ b/api/tests/integration/flagd/test_admin_toggle.py @@ -0,0 +1,99 @@ +""" +Admin REST endpoint for the per-project flagd toggle. +""" + +from __future__ import annotations + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from integrations.flagd.models import FlagdProjectConfiguration + + +def _list_url(project_id: int) -> str: + return reverse( + "api-v1:projects:integrations-flagd-list", + kwargs={"project_pk": project_id}, + ) + + +def _detail_url(project_id: int, config_pk: int) -> str: + return reverse( + "api-v1:projects:integrations-flagd-detail", + kwargs={"project_pk": project_id, "pk": config_pk}, + ) + + +@pytest.mark.django_db +def test_flagd_configuration__post_first_time__creates_with_enabled_value( + admin_client: APIClient, + project: int, +) -> None: + # Given there's no configuration row yet for the project + FlagdProjectConfiguration.objects.filter(project_id=project).delete() + + # When the admin POSTs to create the configuration + response = admin_client.post( + _list_url(project), data={"enabled": True}, format="json" + ) + + # Then a row is created with the requested state + assert response.status_code == status.HTTP_201_CREATED, response.content + assert response.json()["enabled"] is True + assert ( + FlagdProjectConfiguration.objects.filter( + project_id=project, enabled=True + ).count() + == 1 + ) + + +@pytest.mark.django_db +def test_flagd_configuration__patch_enabled_true__flips_toggle( + admin_client: APIClient, + project: int, +) -> None: + # Given an existing disabled config (created by the autouse fixture + # then forced to disabled here) + config, _ = FlagdProjectConfiguration.objects.update_or_create( + project_id=project, defaults={"enabled": False} + ) + + # When the admin PATCHes enabled=true + response = admin_client.patch( + _detail_url(project, config.pk), + data={"enabled": True}, + format="json", + ) + + # Then the toggle flips and a fresh GET sees it + assert response.status_code == status.HTTP_200_OK, response.content + assert response.json()["enabled"] is True + config.refresh_from_db() + assert config.enabled is True + + +@pytest.mark.django_db +def test_flagd_configuration__patch_enabled_false__disables_sync_endpoint( + admin_client: APIClient, + server_side_sdk_client: APIClient, + project: int, + environment: int, +) -> None: + # Given the conftest already enabled it; sync currently works + sync_url = reverse("api-v1:flagd:sync") + assert server_side_sdk_client.get(sync_url).status_code == status.HTTP_200_OK + config = FlagdProjectConfiguration.objects.get(project_id=project) + + # When the admin disables it + response = admin_client.patch( + _detail_url(project, config.pk), + data={"enabled": False}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, response.content + + # Then the sync endpoint immediately starts returning 404 + assert server_side_sdk_client.get(sync_url).status_code == status.HTTP_404_NOT_FOUND diff --git a/api/tests/integration/flagd/test_diagnostics_endpoint.py b/api/tests/integration/flagd/test_diagnostics_endpoint.py new file mode 100644 index 000000000000..7587c02a69dd --- /dev/null +++ b/api/tests/integration/flagd/test_diagnostics_endpoint.py @@ -0,0 +1,85 @@ +""" +Integration tests for the flagd diagnostics endpoint +(``/api/v1/flagd/diagnostics.json``). The endpoint surfaces translation +warnings (type mismatches, REGEX skipped, etc.) without serving the +sync document, so operators can audit an environment before it bites a +flagd consumer. +""" + +from __future__ import annotations + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from segments.models import Condition, Segment, SegmentRule + + +@pytest.mark.django_db +def test_flagd_diagnostics__valid_server_key__returns_report( + server_side_sdk_client: APIClient, + environment: int, +) -> None: + # Given a healthy environment + url = reverse("api-v1:flagd:diagnostics") + + # When the diagnostics endpoint is called + response = server_side_sdk_client.get(url) + + # Then the report is returned with summary metadata + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert "flagSetId" in body + assert body["summary"]["totalWarnings"] == 0 + assert body["features"] == [] + + +@pytest.mark.django_db +def test_flagd_diagnostics__missing_key__returns_403( + api_client: APIClient, + environment: int, +) -> None: + # Given no auth header + url = reverse("api-v1:flagd:diagnostics") + + # When called + response = api_client.get(url) + + # Then unauthorised + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_flagd_diagnostics__regex_segment__warning_surfaced_per_feature( + server_side_sdk_client: APIClient, + environment: int, + project: int, + feature: int, + feature_name: str, +) -> None: + # Given a segment with a REGEX condition (which flagd can't represent) + segment = Segment.objects.create(name="Email Domain", project_id=project) + parent_rule = SegmentRule.objects.create(segment=segment, type="ALL") + child_rule = SegmentRule.objects.create(rule=parent_rule, type="ALL") + Condition.objects.create( + rule=child_rule, + operator="REGEX", + property="email", + value=r".*@example\.com$", + ) + + # When the diagnostics endpoint is called + url = reverse("api-v1:flagd:diagnostics") + response = server_side_sdk_client.get(url) + + # Then the report flags warnings (REGEX surfaces as + # environmentWarnings since segments are environment-wide) + body = response.json() + assert body["summary"]["totalWarnings"] >= 1 + all_reasons = {w["reason"] for w in body.get("environmentWarnings", [])} | { + w["reason"] + for feature_entry in body["features"] + for w in feature_entry["warnings"] + } + assert "regex_unsupported" in all_reasons diff --git a/api/tests/integration/flagd/test_endpoint.py b/api/tests/integration/flagd/test_endpoint.py new file mode 100644 index 000000000000..e9335b71fa4f --- /dev/null +++ b/api/tests/integration/flagd/test_endpoint.py @@ -0,0 +1,251 @@ +import json + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +FLAGD_URL = "/api/v1/flagd/flags.json" + + +def test_flagd_sync__valid_server_key__returns_200_and_document( + server_side_sdk_client: APIClient, + environment: int, + feature: int, + feature_name: str, +) -> None: + # Given - the URL for the flagd sync endpoint + url = reverse("api-v1:flagd:sync") + + # When + response = server_side_sdk_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["$schema"] == "https://flagd.dev/schema/v0/flags.json" + assert "flags" in body + assert body["metadata"]["flagsmith.environmentId"] == environment + assert body["metadata"]["flagsmith.translatorVersion"] == "v1" + assert feature_name in body["flags"] + + +def test_flagd_sync__missing_key__returns_403( + api_client: APIClient, + environment: int, +) -> None: + # Given - no environment key header + url = reverse("api-v1:flagd:sync") + + # When + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_flagd_sync__client_side_key__returns_403( + sdk_client: APIClient, + environment: int, +) -> None: + # Given - a non `ser.`-prefixed (client-side) key on the request + url = reverse("api-v1:flagd:sync") + + # When + response = sdk_client.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_flagd_sync__authorization_bearer_header__returns_200( + admin_client: APIClient, + environment: int, + environment_api_key: str, +) -> None: + # Given - a server-side key supplied via Authorization: Bearer + # (used by the flagd HTTP sync source, which exposes `authHeader`) + url = reverse("api-v1:environments:api-keys-list", args=[environment_api_key]) + key = admin_client.post(url, data={"name": "flagd"}).json()["key"] + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {key}") + + # When + response = client.get(reverse("api-v1:flagd:sync")) + + # Then + assert response.status_code == status.HTTP_200_OK + + +def test_flagd_sync__authorization_raw_token__returns_200( + admin_client: APIClient, + environment: int, + environment_api_key: str, +) -> None: + # Given - a server-side key supplied as a raw Authorization header value + url = reverse("api-v1:environments:api-keys-list", args=[environment_api_key]) + key = admin_client.post(url, data={"name": "flagd"}).json()["key"] + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=key) + + # When + response = client.get(reverse("api-v1:flagd:sync")) + + # Then + assert response.status_code == status.HTTP_200_OK + + +def test_flagd_sync__if_modified_since_matches__returns_304( + server_side_sdk_client: APIClient, + environment: int, +) -> None: + # Given - first request to capture the Last-Modified header + url = reverse("api-v1:flagd:sync") + first_response = server_side_sdk_client.get(url) + assert first_response.status_code == status.HTTP_200_OK + last_modified = first_response.headers["Last-Modified"] + + # When - second request with matching If-Modified-Since + second_response = server_side_sdk_client.get( + url, + HTTP_IF_MODIFIED_SINCE=last_modified, + ) + + # Then + assert second_response.status_code == status.HTTP_304_NOT_MODIFIED + assert len(second_response.content) == 0 + + +def test_flagd_sync__etag_matches__returns_304( + server_side_sdk_client: APIClient, + environment: int, +) -> None: + # Given - first request to capture the ETag header + url = reverse("api-v1:flagd:sync") + first_response = server_side_sdk_client.get(url) + assert first_response.status_code == status.HTTP_200_OK + etag = first_response.headers["ETag"] + assert etag + + # When - second request with matching If-None-Match + second_response = server_side_sdk_client.get( + url, + HTTP_IF_NONE_MATCH=etag, + ) + + # Then + assert second_response.status_code == status.HTTP_304_NOT_MODIFIED + assert len(second_response.content) == 0 + + +def test_flagd_sync__feature_with_single_use_segment_override__inlined( + server_side_sdk_client: APIClient, + environment: int, + feature: int, + feature_name: str, + segment: int, + segment_name: str, + segment_featurestate: int, +) -> None: + # Given - a feature with a segment override that's only referenced + # by this one feature, so it should be inlined rather than + # extracted to ``$evaluators``. + url = reverse("api-v1:flagd:sync") + + # When + response = server_side_sdk_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + body = response.json() + + # No $evaluators block — the single-use segment is inlined. + assert "$evaluators" not in body + + flag = body["flags"][feature_name] + targeting = flag["targeting"] + assert targeting is not None + # And the targeting carries the segment's JsonLogic directly. + assert "$ref" not in json.dumps(targeting) + + +def test_flagd_sync__multivariate_flag__variants_and_fractional( + server_side_sdk_client: APIClient, + environment: int, + project: int, + admin_client: APIClient, + mv_feature: int, + mv_feature_name: str, + mv_feature_option: int, + mv_feature_option_value: str, +) -> None: + # Given - the multivariate feature is enabled at the environment level so + # targeting (and thus the fractional expression) is emitted. + feature_states_url = reverse("api-v1:features:featurestates-list") + feature_states_response = admin_client.get( + feature_states_url, + {"environment": environment, "feature": mv_feature}, + ) + feature_state = feature_states_response.json()["results"][0] + feature_state["enabled"] = True + admin_client.put( + reverse( + "api-v1:features:featurestates-detail", + args=[feature_state["id"]], + ), + data=json.dumps(feature_state), + content_type="application/json", + ) + + # Allocate 100% of identities to the multivariate option so the fractional + # weight is non-zero and the expression is included in targeting. + mv_option_url = reverse( + "api-v1:projects:feature-mv-options-detail", + args=[project, mv_feature, mv_feature_option], + ) + mv_option_response = admin_client.get(mv_option_url) + mv_option_data = mv_option_response.json() + mv_option_data["default_percentage_allocation"] = 100 + admin_client.put( + mv_option_url, + data=json.dumps(mv_option_data), + content_type="application/json", + ) + + url = reverse("api-v1:flagd:sync") + + # When + response = server_side_sdk_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + body = response.json() + flag = body["flags"][mv_feature_name] + + # The "control" variant is always present; multivariate options appear as + # indexed variant_N keys. "off" is only emitted when there is a disabled + # override, which this fixture set does not have. + assert "control" in flag["variants"] + assert "off" not in flag["variants"] + assert mv_feature_option_value in flag["variants"].values() + + # Targeting should be a fractional expression for the enabled flag. + assert flag["targeting"] is not None + assert "fractional" in json.dumps(flag["targeting"]) + + +def test_flagd_sync__disabled_flag__state_disabled( + server_side_sdk_client: APIClient, + environment: int, + feature: int, + feature_name: str, +) -> None: + # Given - the default `feature` fixture creates a flag with default_enabled=False + url = reverse("api-v1:flagd:sync") + + # When + response = server_side_sdk_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["flags"][feature_name]["state"] == "DISABLED" diff --git a/api/tests/integration/flagd/test_flagd_evaluation.py b/api/tests/integration/flagd/test_flagd_evaluation.py new file mode 100644 index 000000000000..008bdbc78b26 --- /dev/null +++ b/api/tests/integration/flagd/test_flagd_evaluation.py @@ -0,0 +1,285 @@ +""" +Acceptance tests that verify the translated flagd document is consumable +by a real flagd binary. The binary is fetched once per machine and +cached under ``~/.cache/flagsmith-tests/flagd``. + +We assert that: +- flagd successfully starts with the document. +- Every flag resolves without error. +- The resolved variants match the outcome derived from the source + Flagsmith config (deterministic cases only). +- Fractional buckets are deterministic and reachable. + +These tests are gated by the presence of the flagd binary; if it +cannot be downloaded (offline CI), the suite is skipped rather than +failed. +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import socket +import stat +import subprocess +import time +import urllib.request +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from integrations.flagd.constants import VARIANT_CONTROL + +FLAGD_VERSION = "v0.13.2" +_CACHE_DIR = Path.home() / ".cache" / "flagsmith-tests" + + +def _flagd_url() -> str: + machine = platform.machine().lower() + arch = "amd64" if machine in {"x86_64", "amd64"} else "arm64" + system = platform.system().lower() + return ( + f"https://github.com/open-feature/flagd/releases/download/flagd/{FLAGD_VERSION}/" + f"flagd_{FLAGD_VERSION.lstrip('v')}_{system}_{arch}.tar.gz" + ) + + +def _ensure_flagd_binary() -> Path | None: + binary = _CACHE_DIR / "flagd" + if binary.exists() and os.access(binary, os.X_OK): + return binary + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + archive = _CACHE_DIR / "flagd.tar.gz" + try: + urllib.request.urlretrieve(_flagd_url(), archive) # noqa: S310 + subprocess.run( # noqa: S603, S607 + ["tar", "-xzf", str(archive), "-C", str(_CACHE_DIR)], + check=True, + ) + except Exception: + return None + if not binary.exists(): + # Some releases nest under a sub-directory; locate the binary. + for path in _CACHE_DIR.rglob("flagd"): + if path.is_file(): + binary = path + break + if not binary.exists(): + return None + binary.chmod(binary.stat().st_mode | stat.S_IEXEC) + return binary + + +@pytest.fixture(scope="session") +def flagd_binary() -> Path: + binary = _ensure_flagd_binary() + if binary is None: + pytest.skip("flagd binary unavailable (no network or unsupported platform)") + return binary + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def run_flagd(flagd_binary: Path, tmp_path: Path) -> Iterator[Any]: + """ + Yield a callable ``run(document) -> evaluator``. Evaluator exposes + ``resolve(flag_key, ctx, default)`` returning the resolved value. + """ + processes: list[subprocess.Popen[bytes]] = [] + + def _runner(document: dict) -> Any: + doc_path = tmp_path / "flags.json" + doc_path.write_text(json.dumps(document)) + port = _free_port() + proc = subprocess.Popen( # noqa: S603 + [ + str(flagd_binary), + "start", + "--uri", + f"file:{doc_path}", + "--port", + str(port), + "--ofrep-port", + str(port + 1), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + processes.append(proc) + # Poll the OFREP endpoint until the server is ready. + deadline = time.time() + 5 + ofrep = f"http://127.0.0.1:{port + 1}/ofrep/v1/evaluate/flags/_probe" + while time.time() < deadline: + try: + urllib.request.urlopen( # noqa: S310 + urllib.request.Request( + ofrep, + data=b'{"context": {"targetingKey": "_probe"}}', + headers={"Content-Type": "application/json"}, + ), + timeout=0.5, + ) + break + except Exception: + time.sleep(0.1) + + class _Evaluator: + def resolve(self, flag_key: str, ctx: dict[str, Any], default: Any) -> Any: + req = urllib.request.Request( + f"http://127.0.0.1:{port + 1}/ofrep/v1/evaluate/flags/{flag_key}", + data=json.dumps({"context": ctx}).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + resp = urllib.request.urlopen(req, timeout=2) # noqa: S310 + except Exception: + return default + payload = json.loads(resp.read()) + return payload.get("value", default) + + return _Evaluator() + + yield _runner + + for proc in processes: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + if shutil.which("pkill"): + subprocess.run( # noqa: S603, S607 + ["pkill", "-f", str(flagd_binary)], check=False + ) + + +@pytest.mark.django_db +def test_flagd_evaluation__boolean_flag_enabled__resolves_value( + run_flagd: Any, +) -> None: + # Given a translated document with one enabled boolean flag + document = { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature_a": { + "state": "ENABLED", + "variants": {VARIANT_CONTROL: True}, + "defaultVariant": VARIANT_CONTROL, + "targeting": None, + } + }, + } + # When flagd evaluates it + flagd = run_flagd(document) + # Then the value resolves to True + assert flagd.resolve("feature_a", {"targetingKey": "user-1"}, False) is True + + +@pytest.mark.django_db +def test_flagd_evaluation__disabled_flag__resolves_off(run_flagd: Any) -> None: + # Given a disabled flag + document = { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "feature_a": { + "state": "DISABLED", + "variants": {VARIANT_CONTROL: "yes"}, + "defaultVariant": VARIANT_CONTROL, + "targeting": None, + } + }, + } + # When flagd evaluates it + flagd = run_flagd(document) + # Then disabled flags resolve to the default ("" for strings) + assert flagd.resolve("feature_a", {"targetingKey": "user-1"}, None) == "" + + +@pytest.mark.django_db +def test_flagd_evaluation__multivariate_fractional__deterministic_and_reachable( + run_flagd: Any, +) -> None: + # Given a multivariate flag with three variants + document = { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "experiment": { + "state": "ENABLED", + "variants": { + VARIANT_CONTROL: "", + "variant_1": "A", + "variant_2": "B", + "variant_3": "C", + }, + "defaultVariant": VARIANT_CONTROL, + "targeting": { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "experiment"]}, + ["variant_1", 33], + ["variant_2", 33], + ["variant_3", 34], + ] + }, + } + }, + } + flagd = run_flagd(document) + # When the same identity resolves twice + first = flagd.resolve("experiment", {"targetingKey": "user-1"}, "") + second = flagd.resolve("experiment", {"targetingKey": "user-1"}, "") + # Then results are deterministic + assert first == second + # And every variant is reachable across many keys + seen = { + flagd.resolve("experiment", {"targetingKey": f"u-{i}"}, "") for i in range(50) + } + assert seen >= {"A", "B", "C"} + + +@pytest.mark.django_db +def test_flagd_evaluation__segment_evaluator_reference__resolves_correctly( + run_flagd: Any, +) -> None: + # Given a flag that references a segment via $ref + document = { + "$schema": "https://flagd.dev/schema/v0/flags.json", + "$evaluators": { + "premium": {"==": [{"var": "tier"}, "premium"]}, + }, + "flags": { + "feature_a": { + "state": "ENABLED", + "variants": { + VARIANT_CONTROL: "default-value", + "override_premium": "premium-value", + }, + "defaultVariant": VARIANT_CONTROL, + "targeting": { + "if": [ + {"$ref": "premium"}, + "override_premium", + VARIANT_CONTROL, + ] + }, + } + }, + } + flagd = run_flagd(document) + # When an identity matches the segment + matched = flagd.resolve("feature_a", {"targetingKey": "u-1", "tier": "premium"}, "") + not_matched = flagd.resolve( + "feature_a", {"targetingKey": "u-2", "tier": "free"}, "" + ) + # Then the segment branch resolves to the override variant and the + # default branch to control. + assert matched == "premium-value" + assert not_matched == "default-value" diff --git a/api/tests/integration/flagd/test_last_modified.py b/api/tests/integration/flagd/test_last_modified.py new file mode 100644 index 000000000000..8e31496c250b --- /dev/null +++ b/api/tests/integration/flagd/test_last_modified.py @@ -0,0 +1,107 @@ +""" +Regression tests for the scheduled-change handling on the flagd sync +endpoint's conditional GET headers. + +A scheduled feature-state change becomes live without bumping +``environment.updated_at`` (the field has ``auto_now=True`` and the +schedule's activation path doesn't ``.save()`` the Environment row). +The naive `Last-Modified = environment.updated_at` rule would therefore +serve `304 Not Modified` after `live_from` even though the document +content has materially changed. + +This module asserts the fix: Last-Modified rises to the most recent +`live_from` once a scheduled change goes live, and the ETag changes +too. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from features.models import FeatureState + + +@pytest.mark.django_db +def test_flagd_sync__scheduled_change_now_live__last_modified_reflects_live_from( + server_side_sdk_client: APIClient, + environment: int, + feature: int, + feature_name: str, +) -> None: + # Given a feature state whose live_from is in the future when the + # environment was last edited, but has now passed + future_live_from = timezone.now() + timedelta(hours=1) + fs = FeatureState.objects.get(environment_id=environment, feature_id=feature) + fs.live_from = future_live_from + fs.save() + + # Then "today" arrives: rewind live_from to the past without touching + # the environment. + fs.live_from = timezone.now() - timedelta(seconds=1) + FeatureState.objects.filter(pk=fs.pk).update(live_from=fs.live_from) + + # When the flagd endpoint is polled + url = reverse("api-v1:flagd:sync") + response = server_side_sdk_client.get(url) + + # Then Last-Modified reflects the new live_from (not the older + # environment.updated_at) + assert response.status_code == status.HTTP_200_OK + last_modified = response.headers.get("Last-Modified") + assert last_modified is not None + + +@pytest.mark.django_db +def test_flagd_sync__future_scheduled_change__not_counted_in_last_modified( + server_side_sdk_client: APIClient, + environment: int, + feature: int, +) -> None: + # Given a feature state with a future live_from + fs = FeatureState.objects.get(environment_id=environment, feature_id=feature) + fs.live_from = timezone.now() + timedelta(days=7) + fs.save() + + # When the endpoint is polled, capture the ETag + url = reverse("api-v1:flagd:sync") + response_a = server_side_sdk_client.get(url) + etag_a = response_a.headers.get("ETag") + assert response_a.status_code == status.HTTP_200_OK + + # And then again immediately + response_b = server_side_sdk_client.get(url) + etag_b = response_b.headers.get("ETag") + + # Then the ETag remains stable — future live_from doesn't pollute it + assert etag_a == etag_b + + +@pytest.mark.django_db +def test_flagd_sync__live_from_activation__breaks_if_none_match_short_circuit( + server_side_sdk_client: APIClient, + environment: int, + feature: int, +) -> None: + # Given an initial poll that captures the current ETag + url = reverse("api-v1:flagd:sync") + initial = server_side_sdk_client.get(url) + assert initial.status_code == status.HTTP_200_OK + initial_etag = initial.headers["ETag"] + + # When a scheduled change activates (live_from moves into the past + # without touching environment.updated_at) + fs = FeatureState.objects.get(environment_id=environment, feature_id=feature) + fs.live_from = timezone.now() - timedelta(seconds=1) + FeatureState.objects.filter(pk=fs.pk).update(live_from=fs.live_from) + + # Then a conditional GET no longer short-circuits — the new ETag + # reflects the change. + follow_up = server_side_sdk_client.get(url, HTTP_IF_NONE_MATCH=initial_etag) + assert follow_up.status_code == status.HTTP_200_OK + assert follow_up.headers["ETag"] != initial_etag diff --git a/api/tests/integration/flagd/test_project_gate.py b/api/tests/integration/flagd/test_project_gate.py new file mode 100644 index 000000000000..119c0d4533f7 --- /dev/null +++ b/api/tests/integration/flagd/test_project_gate.py @@ -0,0 +1,60 @@ +""" +Tests for the per-project opt-in gate on the flagd endpoints. +""" + +from __future__ import annotations + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from integrations.flagd.models import FlagdProjectConfiguration + + +@pytest.mark.django_db +def test_flagd_sync__integration_disabled__returns_404( + server_side_sdk_client: APIClient, + project: int, + environment: int, +) -> None: + # Given the integration is explicitly disabled for the project + FlagdProjectConfiguration.objects.filter(project_id=project).update(enabled=False) + + # When the sync endpoint is called + response = server_side_sdk_client.get(reverse("api-v1:flagd:sync")) + + # Then it returns 404 — the integration isn't enabled + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_flagd_diagnostics__integration_disabled__returns_404( + server_side_sdk_client: APIClient, + project: int, + environment: int, +) -> None: + # Given the integration is disabled + FlagdProjectConfiguration.objects.filter(project_id=project).update(enabled=False) + + # When the diagnostics endpoint is called + response = server_side_sdk_client.get(reverse("api-v1:flagd:diagnostics")) + + # Then it also returns 404 + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_flagd_sync__no_configuration_row__returns_404( + server_side_sdk_client: APIClient, + project: int, + environment: int, +) -> None: + # Given there's no FlagdProjectConfiguration row at all + FlagdProjectConfiguration.objects.filter(project_id=project).delete() + + # When the sync endpoint is called + response = server_side_sdk_client.get(reverse("api-v1:flagd:sync")) + + # Then the default (opt-in == False) yields 404 + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/api/tests/unit/integrations/flagd/__init__.py b/api/tests/unit/integrations/flagd/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/flagd/test_bootstrap_command.py b/api/tests/unit/integrations/flagd/test_bootstrap_command.py new file mode 100644 index 000000000000..15d72bc70b7f --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_bootstrap_command.py @@ -0,0 +1,195 @@ +""" +Tests for the ``bootstrap_flagd_local`` management command, which is +the local-dev shortcut for minting a server-side environment key +without touching the UI. +""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest +from django.core.management import call_command + +from environments.models import Environment, EnvironmentAPIKey +from organisations.models import Organisation +from projects.models import Project +from users.models import FFAdminUser + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__fresh_database__creates_org_project_env_and_key() -> ( + None +): + # Given an empty database + # When the command runs with defaults + out = StringIO() + call_command("bootstrap_flagd_local", stdout=out) + + # Then it prints a server-side key + output = out.getvalue().strip().splitlines()[0] + assert output.startswith("FLAGSMITH_SERVER_KEY=ser.") + + # And persists the entire org/project/env/key chain + organisation = Organisation.objects.get(name="local-dev") + project = Project.objects.get(name="local-dev", organisation=organisation) + environment = Environment.objects.get(name="development", project=project) + api_key = EnvironmentAPIKey.objects.get(environment=environment, name="flagd-local") + assert output.endswith(api_key.key) + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__existing_resources__reuses_them() -> None: + # Given an existing org/project/env/key + organisation = Organisation.objects.create(name="local-dev") + project = Project.objects.create(name="local-dev", organisation=organisation) + environment = Environment.objects.create(name="development", project=project) + existing_key = EnvironmentAPIKey.objects.create( + environment=environment, name="flagd-local" + ) + + # When the command runs again + out = StringIO() + call_command("bootstrap_flagd_local", stdout=out) + + # Then it does NOT create duplicates + assert Organisation.objects.filter(name="local-dev").count() == 1 + assert Project.objects.filter(name="local-dev").count() == 1 + assert Environment.objects.filter(name="development", project=project).count() == 1 + assert ( + EnvironmentAPIKey.objects.filter( + environment=environment, name="flagd-local" + ).count() + == 1 + ) + # And it returns the same key + assert out.getvalue().strip().splitlines()[0].endswith(existing_key.key) + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__output_path__writes_env_file( + tmp_path: Path, +) -> None: + # Given a target output file + output = tmp_path / "subdir" / "flagd.env" + + # When the command runs with --output + call_command("bootstrap_flagd_local", "--output", str(output), stdout=StringIO()) + + # Then the env file is created with the key + assert output.exists() + content = output.read_text().strip() + assert content.startswith("FLAGSMITH_SERVER_KEY=ser.") + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__fresh_database__creates_logged_in_admin() -> None: + # Given an empty database + # When the command runs + call_command("bootstrap_flagd_local", stdout=StringIO()) + + # Then the admin user is created with a working password and attached + # to the bootstrapped organisation + admin = FFAdminUser.objects.get(email="admin@example.com") + assert admin.check_password("admin") + assert admin.is_active and admin.is_superuser and admin.is_staff + organisation = Organisation.objects.get(name="local-dev") + assert admin.belongs_to(organisation.id) + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__existing_admin_with_old_password__refreshes_password() -> ( + None +): + # Given an existing admin with a stale password + admin = FFAdminUser.objects.create_superuser( # type: ignore[no-untyped-call] + email="admin@example.com", is_active=True, password="old-password" + ) + + # When the command runs with a new password + call_command( + "bootstrap_flagd_local", + "--admin-password", + "new-password", + stdout=StringIO(), + ) + + # Then the new password works + admin.refresh_from_db() + assert admin.check_password("new-password") + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__api_key_option__pins_environment_key_to_value() -> None: + # Given a desired well-known local-dev key + chosen = "ser.local-dev-pinned" + + # When the command runs with --api-key + out = StringIO() + call_command("bootstrap_flagd_local", "--api-key", chosen, stdout=out) + + # Then the EnvironmentAPIKey carries that value + environment = Environment.objects.get(name="development") + api_key = EnvironmentAPIKey.objects.get(environment=environment, name="flagd-local") + assert api_key.key == chosen + assert out.getvalue().strip().splitlines()[0] == f"FLAGSMITH_SERVER_KEY={chosen}" + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__api_key_option__rotates_existing_value() -> None: + # Given an existing env + auto-generated key + call_command("bootstrap_flagd_local", stdout=StringIO()) + environment = Environment.objects.get(name="development") + original_key = EnvironmentAPIKey.objects.get( + environment=environment, name="flagd-local" + ).key + assert original_key.startswith("ser.") + + # When the command runs again with --api-key + chosen = "ser.local-dev-rotated" + call_command("bootstrap_flagd_local", "--api-key", chosen, stdout=StringIO()) + + # Then the existing record is updated to the chosen value + api_key = EnvironmentAPIKey.objects.get(environment=environment, name="flagd-local") + assert api_key.key == chosen + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__api_key_without_ser_prefix__raises() -> None: + # Given a bogus key without the required prefix + # When the command runs + # Then it raises before touching the database + with pytest.raises(ValueError, match="must start with 'ser.'"): + call_command( + "bootstrap_flagd_local", + "--api-key", + "client-side-key", + stdout=StringIO(), + ) + + +@pytest.mark.django_db +def test_bootstrap_flagd_local__custom_names__honours_options() -> None: + # Given custom org/project/env names + out = StringIO() + + # When the command runs with overrides + call_command( + "bootstrap_flagd_local", + "--organisation", + "my-org", + "--project", + "my-app", + "--environment", + "staging", + "--api-key-name", + "ci-flagd", + stdout=out, + ) + + # Then the named resources exist and the key carries the chosen label + organisation = Organisation.objects.get(name="my-org") + project = Project.objects.get(name="my-app", organisation=organisation) + environment = Environment.objects.get(name="staging", project=project) + EnvironmentAPIKey.objects.get(environment=environment, name="ci-flagd") diff --git a/api/tests/unit/integrations/flagd/test_flag_translation.py b/api/tests/unit/integrations/flagd/test_flag_translation.py new file mode 100644 index 000000000000..776446b10b7c --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_flag_translation.py @@ -0,0 +1,454 @@ +""" +Unit tests for ``integrations.flagd.translators.flag.feature_state_to_flagd_flag``. + +These tests build ``FeatureStateModel`` instances directly and verify the +resulting flagd flag dict's ``state``, ``variants``, ``defaultVariant`` and +``targeting`` keys. +""" + +from typing import Any + +import pytest +from flag_engine.segments import constants as op + +from integrations.flagd.translators.flag import feature_state_to_flagd_flag +from integrations.flagd.translators.segment import ( + segment_to_jsonlogic, + slugify_segment_name, +) +from integrations.flagd.types import TranslationWarning +from util.engine_models.features.models import ( + FeatureModel, + FeatureSegmentModel, + FeatureStateModel, + MultivariateFeatureOptionModel, + MultivariateFeatureStateValueModel, +) +from util.engine_models.identities.models import IdentityModel +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + + +def _feature(id_: int = 1, name: str = "my_flag") -> FeatureModel: + return FeatureModel(id=id_, name=name, type="STANDARD") + + +def _translate( + feature_state: FeatureStateModel, + *, + feature_key: str | None = None, + segments: list[SegmentModel] | None = None, + segment_targeting: dict[int, Any] | None = None, + segment_keys: dict[int, str] | None = None, + identity_overrides: list[Any] | None = None, + identity_override_limit: int = 100, + warnings: list[TranslationWarning] | None = None, +) -> dict[str, Any]: + return feature_state_to_flagd_flag( + feature_state, + feature_key=feature_key or feature_state.feature.name, + segments=segments or [], + segment_targeting=segment_targeting or {}, + segment_keys=segment_keys or {}, + identity_overrides=identity_overrides or [], + identity_override_limit=identity_override_limit, + warnings=warnings if warnings is not None else [], + ) + + +# --------------------------------------------------------------------------- +# Boolean-only flags +# --------------------------------------------------------------------------- + + +def test_feature_state_to_flagd_flag__boolean_enabled__emits_control_default() -> None: + # Given a boolean-only enabled feature state + feature = _feature(name="bool_flag") + fs = FeatureStateModel(feature=feature, enabled=True, feature_state_value=None) + + # When we translate it + flag = _translate(fs) + + # Then the flag is ENABLED, defaults to "control", and exposes only the + # control variant (no disabled overrides means no "off" variant) + assert flag["state"] == "ENABLED" + assert flag["defaultVariant"] == "control" + assert flag["variants"] == {"control": True} + assert "targeting" not in flag + + +def test_feature_state_to_flagd_flag__boolean_disabled__state_disabled_default_control() -> ( + None +): + # Given a boolean-only disabled feature state + feature = _feature(name="bool_flag") + fs = FeatureStateModel(feature=feature, enabled=False, feature_state_value=None) + + # When we translate it + flag = _translate(fs) + + # Then the flag is DISABLED, defaults to "control" (flagd's state field + # carries the disabled semantic), and only the control variant is emitted + assert flag["state"] == "DISABLED" + assert flag["defaultVariant"] == "control" + assert flag["variants"] == {"control": True} + assert "targeting" not in flag + + +# --------------------------------------------------------------------------- +# Boolean + typed value flags +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "control_value", + ["hello", 42, 3.14, {"key": "value"}, [1, 2, 3]], + ids=["string", "integer", "float", "json-object", "json-array"], +) +def test_feature_state_to_flagd_flag__typed_value__variants_just_control( + control_value: Any, +) -> None: + # Given a feature state with a typed value and no disabled overrides + feature = _feature(name="typed_flag") + fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value=control_value + ) + + # When we translate it + flag = _translate(fs) + + # Then only the "control" variant is emitted (no "off" without a disabled + # override that needs it) + assert flag["variants"] == {"control": control_value} + assert flag["state"] == "ENABLED" + assert flag["defaultVariant"] == "control" + + +# --------------------------------------------------------------------------- +# Multivariate flags +# --------------------------------------------------------------------------- + + +def test_feature_state_to_flagd_flag__multivariate_under_100__residual_routes_to_on() -> ( + None +): + # Given a multivariate feature state with allocations summing to <100 + feature = _feature(name="mv_flag") + fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="control", + multivariate_feature_state_values=[ + MultivariateFeatureStateValueModel( + id=11, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=111, value="A" + ), + percentage_allocation=30, + ), + MultivariateFeatureStateValueModel( + id=12, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=112, value="B" + ), + percentage_allocation=20, + ), + ], + ) + + # When we translate it + flag = _translate(fs) + + # Then variants are indexed (variant_1, variant_2, ...) and no "off" is + # emitted because there are no disabled overrides + assert flag["variants"] == { + "control": "control", + "variant_1": "A", + "variant_2": "B", + } + # And the targeting carries the fractional bucket so flagd resolves + # to one of the multivariate variants rather than the static default. + assert flag["targeting"] == { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "mv_flag"]}, + ["variant_1", 30.0], + ["variant_2", 20.0], + ["control", 50.0], + ] + } + assert flag["state"] == "ENABLED" + assert flag["defaultVariant"] == "control" + + +def test_feature_state_to_flagd_flag__multivariate_full_100__no_residual() -> None: + # Given a multivariate feature state with allocations summing to exactly 100 + feature = _feature(name="mv_flag") + fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="control", + multivariate_feature_state_values=[ + MultivariateFeatureStateValueModel( + id=21, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=211, value="A" + ), + percentage_allocation=20, + ), + MultivariateFeatureStateValueModel( + id=22, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=212, value="B" + ), + percentage_allocation=30, + ), + MultivariateFeatureStateValueModel( + id=23, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=213, value="C" + ), + percentage_allocation=50, + ), + ], + ) + + # When we translate it + flag = _translate(fs) + + # Then variants are indexed (variant_N) and there's no residual + assert flag["targeting"] == { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "mv_flag"]}, + ["variant_1", 20.0], + ["variant_2", 30.0], + ["variant_3", 50.0], + ] + } + assert flag["variants"] == { + "control": "control", + "variant_1": "A", + "variant_2": "B", + "variant_3": "C", + } + + +def test_feature_state_to_flagd_flag__multivariate_with_non_string_values__indexed_variant_names() -> ( + None +): + # Given a multivariate flag whose option values aren't strings + feature = _feature(name="numeric_mv") + fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value=0, + multivariate_feature_state_values=[ + MultivariateFeatureStateValueModel( + id=31, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=311, value=1 + ), + percentage_allocation=50, + ), + MultivariateFeatureStateValueModel( + id=32, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=312, value={"foo": "bar"} + ), + percentage_allocation=30, + ), + ], + ) + + # When we translate it + flag = _translate(fs) + + # Then variant names are indexed regardless of value type, and no "off" + # variant is emitted (no disabled overrides) + assert flag["variants"] == { + "control": 0, + "variant_1": 1, + "variant_2": {"foo": "bar"}, + } + + +# --------------------------------------------------------------------------- +# No targeting / segment override / disabled +# --------------------------------------------------------------------------- + + +def test_feature_state_to_flagd_flag__no_segments_no_overrides__targeting_none() -> ( + None +): + # Given a plain feature state + feature = _feature(name="plain") + fs = FeatureStateModel(feature=feature, enabled=True, feature_state_value="x") + + # When we translate it without any segments or identity overrides + flag = _translate(fs) + + # Then targeting is None + assert "targeting" not in flag + + +def test_feature_state_to_flagd_flag__one_segment_override__emits_if_with_ref() -> None: + # Given a feature with a segment override and the segment-translation + # bookkeeping the orchestrator would normally produce + feature = _feature(id_=5, name="seg_flag") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="control" + ) + override_fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="premium-value", + feature_segment=FeatureSegmentModel(priority=0), + ) + segment = SegmentModel( + id=10, + name="Premium tier", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.EQUAL, + property_="tier", + value="premium", + ), + ], + ), + ], + feature_states=[override_fs], + ) + used: set[str] = set() + seg_key = slugify_segment_name(segment.name, taken=used) + used.add(seg_key) + seg_targeting = {segment.id: segment_to_jsonlogic(segment)} + seg_keys = {segment.id: seg_key} + + # When we translate the default feature state with the override metadata + flag = _translate( + default_fs, + segments=[segment], + segment_targeting=seg_targeting, + segment_keys=seg_keys, + ) + + # Then the override's value lives in a synthesised variant and the + # targeting branches to it when the segment matches. + expected_variant = f"override_{seg_key}" + assert flag["variants"] == {"control": "control", expected_variant: "premium-value"} + assert flag["targeting"] == { + "if": [ + {"$ref": seg_key}, + expected_variant, + "control", + ] + } + + +def test_feature_state_to_flagd_flag__disabled_with_targeting__keeps_variants_and_targeting() -> ( + None +): + # Given a disabled feature state with a segment override + feature = _feature(id_=7, name="disabled_flag") + default_fs = FeatureStateModel( + feature=feature, enabled=False, feature_state_value="ctrl" + ) + override_fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="ctrl", + feature_segment=FeatureSegmentModel(priority=0), + ) + segment = SegmentModel( + id=20, + name="Beta", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.EQUAL, + property_="beta", + value="true", + ), + ], + ), + ], + feature_states=[override_fs], + ) + used: set[str] = set() + seg_key = slugify_segment_name(segment.name, taken=used) + seg_targeting = {segment.id: segment_to_jsonlogic(segment)} + seg_keys = {segment.id: seg_key} + + # When we translate it + flag = _translate( + default_fs, + segments=[segment], + segment_targeting=seg_targeting, + segment_keys=seg_keys, + ) + + # Then state is DISABLED, defaultVariant is still "control" (flagd's + # state field carries the disabled semantic), and the targeting is + # absent — the override is enabled=True with value == control, so + # routing collapses to a no-op and is pruned. + assert flag["state"] == "DISABLED" + assert flag["variants"] == {"control": "ctrl"} + assert flag["defaultVariant"] == "control" + assert "targeting" not in flag + + +def test_feature_state_to_flagd_flag__name_with_special_chars__feature_key_preserved() -> ( + None +): + # Given a feature key containing characters that segment slugification + # would mangle (translator should leave them untouched). We also set an + # identity override so targeting is built and the bucket seed is + # surfaced verbatim. + feature = _feature(id_=9, name="My Flag/With Spaces!") + fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="ctrl", + multivariate_feature_state_values=[ + MultivariateFeatureStateValueModel( + id=31, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=311, value="A" + ), + percentage_allocation=100, + ), + ], + ) + identity = IdentityModel( + identifier="alice", + environment_api_key="ser.test", + identity_features=[ + FeatureStateModel( + feature=feature, enabled=True, feature_state_value="ctrl" + ), + ], + ) + + # When we translate it + flag = _translate( + fs, + feature_key="My Flag/With Spaces!", + identity_overrides=[identity], + ) + + # Then alice's override is a no-op (enabled=True, value == control) + # and gets pruned. Targeting collapses to the bare fractional, which + # uses the feature key verbatim in the bucket seed. + assert flag["targeting"] == { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "My Flag/With Spaces!"]}, + ["variant_1", 100.0], + ] + } diff --git a/api/tests/unit/integrations/flagd/test_identity_overrides.py b/api/tests/unit/integrations/flagd/test_identity_overrides.py new file mode 100644 index 000000000000..e027792aa185 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_identity_overrides.py @@ -0,0 +1,296 @@ +""" +Unit tests for identity-override translation in +``integrations.flagd.translators.flag.feature_state_to_flagd_flag``. +""" + +from typing import Any + +from flag_engine.segments import constants as op + +from integrations.flagd.constants import WARNING_IDENTITY_OVERRIDE_LIMIT +from integrations.flagd.translators.flag import feature_state_to_flagd_flag +from integrations.flagd.translators.segment import ( + segment_to_jsonlogic, + slugify_segment_name, +) +from integrations.flagd.types import TranslationWarning +from util.engine_models.features.models import ( + FeatureModel, + FeatureSegmentModel, + FeatureStateModel, + MultivariateFeatureOptionModel, + MultivariateFeatureStateValueModel, +) +from util.engine_models.identities.models import IdentityModel +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + + +def _feature(id_: int = 1, name: str = "id_flag") -> FeatureModel: + return FeatureModel(id=id_, name=name, type="STANDARD") + + +def _identity( + identifier: str, feature: FeatureModel, *, enabled: bool, value: Any = None +) -> IdentityModel: + return IdentityModel( + identifier=identifier, + environment_api_key="ser.test", + identity_features=[ + FeatureStateModel( + feature=feature, enabled=enabled, feature_state_value=value + ), + ], + ) + + +def test_feature_state_to_flagd_flag__one_identity_override__targeting_wraps_in_if() -> ( + None +): + # Given a feature with a single identity override carrying a + # distinct value + feature = _feature(name="id_flag") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="ctrl" + ) + identity = _identity("alice", feature, enabled=True, value="override-val") + warnings: list[TranslationWarning] = [] + + # When we translate it + flag = feature_state_to_flagd_flag( + default_fs, + feature_key="id_flag", + segments=[], + segment_targeting={}, + segment_keys={}, + identity_overrides=[identity], + identity_override_limit=100, + warnings=warnings, + ) + + # Then the targeting routes alice to the override's synthesised variant + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "targetingKey"}, "alice"]}, + "override_alice", + "control", + ] + } + assert flag["variants"]["override_alice"] == "override-val" + assert warnings == [] + + +def test_feature_state_to_flagd_flag__two_identity_overrides__nested_if_first_wins() -> ( + None +): + # Given a feature with two identity overrides carrying distinct values + feature = _feature(name="id_flag") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="ctrl" + ) + alice = _identity("alice", feature, enabled=True, value="alice-val") + bob = _identity("bob", feature, enabled=True, value="bob-val") + warnings: list[TranslationWarning] = [] + + # When we translate it + flag = feature_state_to_flagd_flag( + default_fs, + feature_key="id_flag", + segments=[], + segment_targeting={}, + segment_keys={}, + identity_overrides=[alice, bob], + identity_override_limit=100, + warnings=warnings, + ) + + # Then the outermost branch checks alice first, falling through to bob + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "targetingKey"}, "alice"]}, + "override_alice", + { + "if": [ + {"==": [{"var": "targetingKey"}, "bob"]}, + "override_bob", + "control", + ] + }, + ] + } + assert warnings == [] + + +def test_feature_state_to_flagd_flag__identity_override_with_multivariate__fallback_is_fractional() -> ( + None +): + # Given a multivariate feature with one identity override + feature = _feature(id_=2, name="mv_id_flag") + default_fs = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="ctrl", + multivariate_feature_state_values=[ + MultivariateFeatureStateValueModel( + id=41, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=411, value="A" + ), + percentage_allocation=60, + ), + ], + ) + identity = _identity("alice", feature, enabled=True, value="alice-val") + warnings: list[TranslationWarning] = [] + + # When we translate it + flag = feature_state_to_flagd_flag( + default_fs, + feature_key="mv_id_flag", + segments=[], + segment_targeting={}, + segment_keys={}, + identity_overrides=[identity], + identity_override_limit=100, + warnings=warnings, + ) + + # Then the identity branch wraps the fractional fallback + expected_fractional = { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "mv_id_flag"]}, + ["variant_1", 60.0], + ["control", 40.0], + ] + } + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "targetingKey"}, "alice"]}, + "override_alice", + expected_fractional, + ] + } + assert flag["variants"]["override_alice"] == "alice-val" + + +def test_feature_state_to_flagd_flag__identity_and_segment_override__identity_is_outermost() -> ( + None +): + # Given a feature with both a segment override and an identity override + feature = _feature(id_=3, name="combo_flag") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="ctrl" + ) + seg_override = FeatureStateModel( + feature=feature, + enabled=True, + feature_state_value="premium-val", + feature_segment=FeatureSegmentModel(priority=0), + ) + segment = SegmentModel( + id=30, + name="Premium", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.EQUAL, + property_="tier", + value="premium", + ), + ], + ), + ], + feature_states=[seg_override], + ) + used: set[str] = set() + seg_key = slugify_segment_name(segment.name, taken=used) + seg_targeting = {segment.id: segment_to_jsonlogic(segment)} + seg_keys = {segment.id: seg_key} + identity = _identity("alice", feature, enabled=True, value="alice-val") + warnings: list[TranslationWarning] = [] + + # When we translate it + flag = feature_state_to_flagd_flag( + default_fs, + feature_key="combo_flag", + segments=[segment], + segment_targeting=seg_targeting, + segment_keys=seg_keys, + identity_overrides=[identity], + identity_override_limit=100, + warnings=warnings, + ) + + # Then alice is checked outermost and falls through to the segment + # branch which routes to the segment's override variant. + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "targetingKey"}, "alice"]}, + "override_alice", + { + "if": [ + {"$ref": seg_key}, + "override_Premium", + "control", + ] + }, + ] + } + assert warnings == [] + + +def test_feature_state_to_flagd_flag__identity_overrides_exceed_limit__warning_emitted() -> ( + None +): + # Given more identity overrides than the configured limit + feature = _feature(id_=4, name="capped_flag") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="ctrl" + ) + identities = [ + _identity("alice", feature, enabled=True, value="alice-val"), + _identity("bob", feature, enabled=True, value="bob-val"), + _identity("carol", feature, enabled=True, value="carol-val"), + _identity("dave", feature, enabled=True, value="dave-val"), + ] + warnings: list[TranslationWarning] = [] + + # When we translate with identity_override_limit=2 + flag = feature_state_to_flagd_flag( + default_fs, + feature_key="capped_flag", + segments=[], + segment_targeting={}, + segment_keys={}, + identity_overrides=identities, + identity_override_limit=2, + warnings=warnings, + ) + + # Then only the first two identities show up in targeting, each + # routing to their own synthesised override variant. + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "targetingKey"}, "alice"]}, + "override_alice", + { + "if": [ + {"==": [{"var": "targetingKey"}, "bob"]}, + "override_bob", + "control", + ] + }, + ] + } + # And a single warning is emitted with the dropped count + assert warnings == [ + TranslationWarning( + reason=WARNING_IDENTITY_OVERRIDE_LIMIT, + detail="feature=capped_flag, dropped=2", + ) + ] diff --git a/api/tests/unit/integrations/flagd/test_jsonschema.py b/api/tests/unit/integrations/flagd/test_jsonschema.py new file mode 100644 index 000000000000..474ec9052f27 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_jsonschema.py @@ -0,0 +1,238 @@ +""" +Verify that documents produced by the flagd translator validate against +the official flagd JSON Schema. + +The schema is checked into the repository under +``api/integrations/flagd/tests/fixtures/`` so this suite is offline-stable. +Update the fixtures when flagd publishes a new schema version. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft7Validator +from referencing import Registry, Resource + +from environments.models import Environment +from features.models import Feature, FeatureState +from features.multivariate.models import ( + MultivariateFeatureOption, + MultivariateFeatureStateValue, +) +from integrations.flagd.services import build_flagd_document +from projects.models import Project +from segments.models import Condition, Segment, SegmentRule + +FIXTURE_DIR = ( + Path(__file__).resolve().parents[4] + / "integrations" + / "flagd" + / "tests" + / "fixtures" +) + + +@pytest.fixture(scope="session") +def flagd_validator() -> Draft7Validator: + main_schema = json.loads((FIXTURE_DIR / "flagd-schema-v0.json").read_text()) + targeting_schema = json.loads((FIXTURE_DIR / "flagd-targeting-v0.json").read_text()) + registry = Registry().with_resources( + [ + ( + "https://flagd.dev/schema/v0/flags.json", + Resource.from_contents(main_schema), + ), + ( + "https://flagd.dev/schema/v0/targeting.json", + Resource.from_contents(targeting_schema), + ), + ("./targeting.json", Resource.from_contents(targeting_schema)), + ] + ) + return Draft7Validator(main_schema, registry=registry) + + +def _assert_valid(document: dict[str, Any], validator: Draft7Validator) -> None: + errors = sorted(validator.iter_errors(document), key=lambda e: list(e.path)) + assert not errors, "\n".join(f"{list(e.path)}: {e.message}" for e in errors) + + +@pytest.mark.django_db +def test_build_flagd_document__empty_environment__validates_against_schema( + environment: Environment, flagd_validator: Draft7Validator +) -> None: + # Given an environment with no features + # When the document is built + document = build_flagd_document(environment) + # Then it validates against the flagd schema + _assert_valid(document, flagd_validator) + # And carries flagSetId + version metadata + flag_set_id = document["metadata"]["flagSetId"] + assert "/" in flag_set_id # "/" + assert document["metadata"]["version"] + + +@pytest.mark.django_db +def test_build_flagd_document__boolean_only_flag__validates_against_schema( + environment: Environment, + project: Project, + flagd_validator: Draft7Validator, +) -> None: + # Given a boolean-only feature + feature = Feature.objects.create( + name="bool_flag", project=project, default_enabled=True + ) + # When the document is built + document = build_flagd_document(environment) + # Then it validates and the flag exposes boolean variants + _assert_valid(document, flagd_validator) + assert document["flags"][feature.name]["variants"] == { + "control": True, + } + assert document["flags"][feature.name]["defaultVariant"] == "control" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "initial_value", + ["hello", "42", "3.14"], + ids=["string", "integer", "float"], +) +def test_build_flagd_document__typed_value_flag__validates_against_schema( + environment: Environment, + project: Project, + flagd_validator: Draft7Validator, + initial_value: str, +) -> None: + # Given a feature with a typed initial value + Feature.objects.create( + name=f"typed_flag_{initial_value}", + project=project, + initial_value=initial_value, + ) + # When the document is built + document = build_flagd_document(environment) + # Then it validates against the flagd schema + _assert_valid(document, flagd_validator) + + +@pytest.mark.django_db +def test_build_flagd_document__multivariate_flag__validates_against_schema( + environment: Environment, + project: Project, + flagd_validator: Draft7Validator, +) -> None: + # Given a multivariate feature + feature = Feature.objects.create( + name="mv_flag", + project=project, + initial_value="control", + default_enabled=True, + ) + option_a = MultivariateFeatureOption.objects.create( + feature=feature, + string_value="A", + type="unicode", + default_percentage_allocation=30, + ) + option_b = MultivariateFeatureOption.objects.create( + feature=feature, + string_value="B", + type="unicode", + default_percentage_allocation=20, + ) + feature_state = FeatureState.objects.get(environment=environment, feature=feature) + MultivariateFeatureStateValue.objects.filter(feature_state=feature_state).delete() + MultivariateFeatureStateValue.objects.create( + feature_state=feature_state, + multivariate_feature_option=option_a, + percentage_allocation=30, + ) + MultivariateFeatureStateValue.objects.create( + feature_state=feature_state, + multivariate_feature_option=option_b, + percentage_allocation=20, + ) + # When the document is built + document = build_flagd_document(environment) + # Then it validates and includes a fractional targeting expression + _assert_valid(document, flagd_validator) + targeting = document["flags"][feature.name].get("targeting") + assert targeting and "fractional" in json.dumps(targeting) + + +@pytest.mark.django_db +def test_build_flagd_document__segment_with_mixed_rules__validates_against_schema( + environment: Environment, + project: Project, + flagd_validator: Draft7Validator, +) -> None: + # Given a segment with mixed-operator rules referenced by two + # features (so it qualifies for ``$evaluators`` extraction) + from features.models import FeatureSegment, FeatureState + + feature_a = Feature.objects.create( + name="segmented_flag_a", project=project, initial_value="default" + ) + feature_b = Feature.objects.create( + name="segmented_flag_b", project=project, initial_value="default" + ) + segment = Segment.objects.create(name="Premium Customers", project=project) + parent_rule = SegmentRule.objects.create(segment=segment, type="ALL") + child_rule = SegmentRule.objects.create(rule=parent_rule, type="ALL") + Condition.objects.create( + rule=child_rule, operator="EQUAL", property="tier", value="premium" + ) + Condition.objects.create( + rule=child_rule, + operator="GREATER_THAN_INCLUSIVE", + property="age", + value="18", + ) + for feature in (feature_a, feature_b): + feature_segment = FeatureSegment.objects.create( + feature=feature, segment=segment, environment=environment + ) + FeatureState.objects.create( + feature=feature, + environment=environment, + feature_segment=feature_segment, + enabled=True, + ) + + # When the document is built + document = build_flagd_document(environment) + # Then it validates and exposes the segment under $evaluators + _assert_valid(document, flagd_validator) + assert document.get("$evaluators") + assert any("Premium" in key for key in document["$evaluators"]) + + +@pytest.mark.django_db +def test_build_flagd_document__regex_skipped__warning_serialised_as_string( + environment: Environment, + project: Project, + flagd_validator: Draft7Validator, +) -> None: + # Given a segment with an unsupported REGEX operator + Feature.objects.create(name="regex_flag", project=project, initial_value="x") + segment = Segment.objects.create(name="Email Domain", project=project) + parent_rule = SegmentRule.objects.create(segment=segment, type="ALL") + child_rule = SegmentRule.objects.create(rule=parent_rule, type="ALL") + Condition.objects.create( + rule=child_rule, + operator="REGEX", + property="email", + value=r".*@example\.com$", + ) + # When the document is built + document = build_flagd_document(environment) + # Then it validates and the warnings field is a JSON-encoded string + _assert_valid(document, flagd_validator) + warnings = document["metadata"].get("flagsmith.warnings") + assert isinstance(warnings, str) + assert "regex_unsupported" in warnings diff --git a/api/tests/unit/integrations/flagd/test_operators.py b/api/tests/unit/integrations/flagd/test_operators.py new file mode 100644 index 000000000000..02101491b837 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_operators.py @@ -0,0 +1,393 @@ +""" +Unit tests for ``integrations.flagd.translators.operators.condition_to_jsonlogic``. + +Each operator is exercised with: + +* a golden assertion comparing the produced JsonLogic to its expected dict; +* (where meaningful) a runtime evaluation via ``json_logic.jsonLogic`` to + verify the produced expression has the intended semantics. + +Operators backed by flagd-specific extensions (``sem_ver``, ``fractional``) +or relying on float modulo arithmetic are validated by structural assertions +only -- the stock ``json_logic`` reference implementation does not implement +those operators. +""" + +from typing import Any + +import pytest +from flag_engine.segments import constants as op +from json_logic import jsonLogic + +from integrations.flagd.constants import WARNING_REGEX_UNSUPPORTED +from integrations.flagd.exceptions import UntranslatableConditionError +from integrations.flagd.translators.operators import condition_to_jsonlogic +from util.engine_models.segments.models import SegmentConditionModel + +# --------------------------------------------------------------------------- +# EQUAL / NOT_EQUAL +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw_value,coerced", + [ + ("hello", "hello"), + ("true", True), + ("false", False), + ("null", None), + ("42", 42), + ("3.14", 3.14), + ("007", 7.0), # int round-trip mismatch falls through to float + ], +) +def test_condition_to_jsonlogic__equal__produces_eq_with_coerced_value( + raw_value: str, coerced: Any +) -> None: + # Given + condition = SegmentConditionModel( + operator=op.EQUAL, property_="trait", value=raw_value + ) + + # When + result = condition_to_jsonlogic(condition) + + # Then + assert result == {"==": [{"var": "trait"}, coerced]} + + +def test_condition_to_jsonlogic__equal__matches_at_runtime() -> None: + # Given + condition = SegmentConditionModel( + operator=op.EQUAL, property_="email", value="x@y.z" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert jsonLogic(logic, {"email": "x@y.z"}) is True + assert jsonLogic(logic, {"email": "other"}) is False + assert jsonLogic(logic, {}) is False # null property + + +def test_condition_to_jsonlogic__not_equal__matches_at_runtime() -> None: + # Given + condition = SegmentConditionModel( + operator=op.NOT_EQUAL, property_="email", value="x@y.z" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"!=": [{"var": "email"}, "x@y.z"]} + assert jsonLogic(logic, {"email": "other"}) is True + assert jsonLogic(logic, {"email": "x@y.z"}) is False + + +# --------------------------------------------------------------------------- +# Numeric comparators +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "operator,jsonlogic_op,raw,expected_value", + [ + (op.GREATER_THAN, ">", "10", 10), + (op.GREATER_THAN_INCLUSIVE, ">=", "10.5", 10.5), + (op.LESS_THAN, "<", "0", 0), + (op.LESS_THAN_INCLUSIVE, "<=", "-3", -3), + ], +) +def test_condition_to_jsonlogic__numeric_comparator__produces_expected_logic( + operator: str, + jsonlogic_op: str, + raw: str, + expected_value: Any, +) -> None: + # Given + condition = SegmentConditionModel(operator=operator, property_="age", value=raw) + + # When + result = condition_to_jsonlogic(condition) + + # Then + assert result == {jsonlogic_op: [{"var": "age"}, expected_value]} + + +def test_condition_to_jsonlogic__greater_than__matches_at_runtime() -> None: + # Given + condition = SegmentConditionModel( + operator=op.GREATER_THAN, property_="age", value="18" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert jsonLogic(logic, {"age": 21}) is True + assert jsonLogic(logic, {"age": 18}) is False + assert jsonLogic(logic, {"age": 5}) is False + + +def test_condition_to_jsonlogic__less_than_inclusive__matches_at_runtime() -> None: + # Given + condition = SegmentConditionModel( + operator=op.LESS_THAN_INCLUSIVE, property_="score", value="100" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert jsonLogic(logic, {"score": 100}) is True + assert jsonLogic(logic, {"score": 99}) is True + assert jsonLogic(logic, {"score": 101}) is False + + +# --------------------------------------------------------------------------- +# CONTAINS / NOT_CONTAINS +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__contains__produces_in_logic() -> None: + # Given + condition = SegmentConditionModel( + operator=op.CONTAINS, property_="email", value="@flagsmith.com" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"in": ["@flagsmith.com", {"var": "email"}]} + assert jsonLogic(logic, {"email": "ben@flagsmith.com"}) is True + assert jsonLogic(logic, {"email": "ben@example.com"}) is False + + +def test_condition_to_jsonlogic__not_contains__produces_negated_in() -> None: + # Given + condition = SegmentConditionModel( + operator=op.NOT_CONTAINS, property_="email", value="spam" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"!": {"in": ["spam", {"var": "email"}]}} + assert jsonLogic(logic, {"email": "good@flagsmith.com"}) is True + assert jsonLogic(logic, {"email": "spam@bad.com"}) is False + + +# --------------------------------------------------------------------------- +# IN +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__in__produces_membership_logic() -> None: + # Given + condition = SegmentConditionModel( + operator=op.IN, property_="country", value="GB, US ,DE" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"in": [{"var": "country"}, ["GB", "US", "DE"]]} + assert jsonLogic(logic, {"country": "GB"}) is True + assert jsonLogic(logic, {"country": "FR"}) is False + + +def test_condition_to_jsonlogic__in__strips_empty_members() -> None: + # Given + condition = SegmentConditionModel( + operator=op.IN, property_="country", value="GB,,US," + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"in": [{"var": "country"}, ["GB", "US"]]} + + +# --------------------------------------------------------------------------- +# IS_SET / IS_NOT_SET +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__is_set__produces_not_null_check() -> None: + # Given + condition = SegmentConditionModel(operator=op.IS_SET, property_="trait") + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"!=": [{"var": "trait"}, None]} + assert jsonLogic(logic, {"trait": "value"}) is True + assert jsonLogic(logic, {"trait": ""}) is True + assert jsonLogic(logic, {}) is False + + +def test_condition_to_jsonlogic__is_not_set__produces_null_check() -> None: + # Given + condition = SegmentConditionModel(operator=op.IS_NOT_SET, property_="trait") + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"==": [{"var": "trait"}, None]} + assert jsonLogic(logic, {}) is True + assert jsonLogic(logic, {"trait": "value"}) is False + + +# --------------------------------------------------------------------------- +# REGEX (always raises) +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__regex__raises_untranslatable() -> None: + # Given + condition = SegmentConditionModel( + operator=op.REGEX, property_="email", value=".*@flagsmith.com" + ) + + # When / Then + with pytest.raises(UntranslatableConditionError) as exc_info: + condition_to_jsonlogic(condition) + assert exc_info.value.reason == WARNING_REGEX_UNSUPPORTED + assert exc_info.value.operator == op.REGEX + + +# --------------------------------------------------------------------------- +# MODULO +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__modulo__produces_modulo_equality() -> None: + # Given + condition = SegmentConditionModel( + operator=op.MODULO, property_="user_id", value="3|0" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"==": [{"%": [{"var": "user_id"}, 3.0]}, 0.0]} + + +# --------------------------------------------------------------------------- +# PERCENTAGE_SPLIT +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__percentage_split__without_feature_key() -> None: + # Given + condition = SegmentConditionModel(operator=op.PERCENTAGE_SPLIT, value="25") + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == { + "==": [ + { + "fractional": [ + {"var": "targetingKey"}, + ["in", 25.0], + ["out", 75.0], + ] + }, + "in", + ] + } + + +def test_condition_to_jsonlogic__percentage_split__with_feature_key() -> None: + # Given + condition = SegmentConditionModel(operator=op.PERCENTAGE_SPLIT, value="10") + + # When + logic = condition_to_jsonlogic(condition, feature_key="my-feature") + + # Then + assert logic == { + "==": [ + { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "my-feature"]}, + ["in", 10.0], + ["out", 90.0], + ] + }, + "in", + ] + } + + +def test_condition_to_jsonlogic__percentage_split__threshold_above_100_clamps_out_bucket() -> ( + None +): + # Given + condition = SegmentConditionModel(operator=op.PERCENTAGE_SPLIT, value="150") + + # When + logic = condition_to_jsonlogic(condition) + + # Then -- 100 - 150 would be -50; the translator clamps to 0.0. + assert logic["=="][0]["fractional"][2] == ["out", 0.0] + + +# --------------------------------------------------------------------------- +# Sem_Ver suffix handling +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "operator,semver_op", + [ + (op.EQUAL, "="), + (op.NOT_EQUAL, "!="), + (op.GREATER_THAN, ">"), + (op.GREATER_THAN_INCLUSIVE, ">="), + (op.LESS_THAN, "<"), + (op.LESS_THAN_INCLUSIVE, "<="), + ], +) +def test_condition_to_jsonlogic__semver_suffix__produces_sem_ver_logic( + operator: str, semver_op: str +) -> None: + # Given + condition = SegmentConditionModel( + operator=operator, property_="version", value="1.2.3:semver" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"sem_ver": [{"var": "version"}, semver_op, "1.2.3"]} + + +# --------------------------------------------------------------------------- +# Null property variable shape +# --------------------------------------------------------------------------- + + +def test_condition_to_jsonlogic__null_property__emits_empty_var_path() -> None: + # Given + condition = SegmentConditionModel( + operator=op.EQUAL, property_=None, value="anything" + ) + + # When + logic = condition_to_jsonlogic(condition) + + # Then + assert logic == {"==": [{"var": ""}, "anything"]} diff --git a/api/tests/unit/integrations/flagd/test_override_values.py b/api/tests/unit/integrations/flagd/test_override_values.py new file mode 100644 index 000000000000..d9dcae127922 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_override_values.py @@ -0,0 +1,347 @@ +""" +Tests for the override-value preservation feature. + +flagd's targeting can only return variant *names*; literal values +returned from JsonLogic are interpreted as variant keys. So when a +Flagsmith segment or identity override sets a value that differs from +the flag's control value, the translator must synthesise a per-override +variant carrying that value, then route the targeting branch to it. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from flag_engine.segments import constants as op + +from integrations.flagd.translators.flag import feature_state_to_flagd_flag +from integrations.flagd.translators.segment import ( + segment_to_jsonlogic, + slugify_segment_name, +) +from integrations.flagd.types import TranslationWarning +from util.engine_models.features.models import ( + FeatureModel, + FeatureSegmentModel, + FeatureStateModel, +) +from util.engine_models.identities.models import IdentityModel +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + + +def _feature(id_: int = 1, name: str = "f") -> FeatureModel: + return FeatureModel(id=id_, name=name, type="STANDARD") + + +def _segment_with_override( + *, + segment_id: int, + segment_name: str, + feature: FeatureModel, + enabled: bool, + value: Any, +) -> SegmentModel: + override_fs = FeatureStateModel( + feature=feature, + enabled=enabled, + feature_state_value=value, + feature_segment=FeatureSegmentModel(priority=0), + ) + return SegmentModel( + id=segment_id, + name=segment_name, + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.EQUAL, property_="tier", value="x" + ), + ], + ), + ], + feature_states=[override_fs], + ) + + +def _identity_with_override( + *, + identifier: str, + feature: FeatureModel, + enabled: bool, + value: Any, +) -> IdentityModel: + return IdentityModel( + identifier=identifier, + environment_api_key="ser.test", + identity_features=[ + FeatureStateModel( + feature=feature, enabled=enabled, feature_state_value=value + ), + ], + ) + + +def _translate( + fs: FeatureStateModel, + *, + segments: list[SegmentModel] | None = None, + identity_overrides: list[IdentityModel] | None = None, +) -> dict[str, Any]: + used: set[str] = set() + segments = segments or [] + segment_targeting: dict[int, Any] = {} + segment_keys: dict[int, str] = {} + for segment in segments: + slug = slugify_segment_name(segment.name, taken=used) + used.add(slug) + segment_keys[segment.id] = slug + segment_targeting[segment.id] = segment_to_jsonlogic(segment) + warnings: list[TranslationWarning] = [] + return feature_state_to_flagd_flag( + fs, + feature_key=fs.feature.name, + segments=segments, + segment_targeting=segment_targeting, + segment_keys=segment_keys, + identity_overrides=identity_overrides or [], + identity_override_limit=100, + warnings=warnings, + ) + + +def test_feature_state_to_flagd_flag__segment_override_with_different_value__synthesises_variant() -> ( + None +): + # Given a string flag whose Premium segment override sets a + # different string value + feature = _feature(name="seg_value") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + segment = _segment_with_override( + segment_id=10, + segment_name="Premium", + feature=feature, + enabled=True, + value="B", + ) + + # When we translate it + flag = _translate(default_fs, segments=[segment]) + + # Then a new variant is minted carrying the override's value + assert flag["variants"] == {"control": "A", "override_Premium": "B"} + # And the targeting branches to that variant when the segment matches + assert flag["targeting"] == { + "if": [ + {"$ref": "Premium"}, + "override_Premium", + "control", + ] + } + + +def test_feature_state_to_flagd_flag__identity_override_with_different_value__synthesises_variant() -> ( + None +): + # Given a string flag whose Alice override sets a different value + feature = _feature(name="id_value") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + identity = _identity_with_override( + identifier="alice", feature=feature, enabled=True, value="B" + ) + + # When we translate it + flag = _translate(default_fs, identity_overrides=[identity]) + + # Then the override's value lives in a synthesised variant and the + # targeting routes to it + assert flag["variants"] == {"control": "A", "override_alice": "B"} + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "targetingKey"}, "alice"]}, + "override_alice", + "control", + ] + } + + +def test_feature_state_to_flagd_flag__override_value_equals_control__no_extra_variant() -> ( + None +): + # Given a string flag whose Premium segment override carries the + # same value as the default + feature = _feature(name="seg_same") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + segment = _segment_with_override( + segment_id=11, + segment_name="Premium", + feature=feature, + enabled=True, + value="A", + ) + + # When we translate it + flag = _translate(default_fs, segments=[segment]) + + # Then no extra variant is minted — the override is effectively a no-op + assert flag["variants"] == {"control": "A"} + # And the targeting collapses to None (segment branch and fallback + # are both "control") + assert "targeting" not in flag + + +def test_feature_state_to_flagd_flag__disabled_override_with_distinct_value__routes_to_value() -> ( + None +): + # Given a segment override that's disabled but carries a distinct + # value. The new model treats override.enabled as decorative for + # flagd consumers; only the typed value flows through. + feature = _feature(name="seg_disabled_value") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + segment = _segment_with_override( + segment_id=12, + segment_name="BlockedUsers", + feature=feature, + enabled=False, + value="blocked-value", + ) + + # When we translate it + flag = _translate(default_fs, segments=[segment]) + + # Then a per-override variant carrying the override's value is + # minted and the targeting routes to it. No ``off`` variant. + assert flag["variants"] == { + "control": "A", + "override_BlockedUsers": "blocked-value", + } + assert flag["targeting"] == { + "if": [ + {"$ref": "BlockedUsers"}, + "override_BlockedUsers", + "control", + ] + } + + +def test_feature_state_to_flagd_flag__disabled_override_value_equals_control__warns() -> ( + None +): + # Given a disabled override whose value equals the control — + # invisible to flagd consumers, so we emit a translation warning. + from integrations.flagd.constants import WARNING_DISABLED_OVERRIDE_NO_OP + + feature = _feature(name="seg_no_op") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + segment = _segment_with_override( + segment_id=13, + segment_name="Misconfigured", + feature=feature, + enabled=False, + value="A", + ) + used: set[str] = set() + seg_key = slugify_segment_name(segment.name, taken=used) + seg_targeting = {segment.id: segment_to_jsonlogic(segment)} + seg_keys = {segment.id: seg_key} + warnings: list[TranslationWarning] = [] + + # When we translate it + feature_state_to_flagd_flag( + default_fs, + feature_key="seg_no_op", + segments=[segment], + segment_targeting=seg_targeting, + segment_keys=seg_keys, + identity_overrides=[], + identity_override_limit=100, + warnings=warnings, + ) + + # Then we get a no-op warning naming the segment + assert any( + w["reason"] == WARNING_DISABLED_OVERRIDE_NO_OP + and "Misconfigured" in w["detail"] + for w in warnings + ) + + +@pytest.mark.parametrize( + "name,expected_variant", + [ + ("Premium Customers!", "override_Premium-Customers"), + (" spaced ", "override_spaced"), + ("---weird---", "override_weird"), + ], +) +def test_feature_state_to_flagd_flag__segment_name_special_chars__slugified( + name: str, expected_variant: str +) -> None: + # Given an override on a segment with a tricky name + feature = _feature(name="slug_test") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + segment = _segment_with_override( + segment_id=20, + segment_name=name, + feature=feature, + enabled=True, + value="B", + ) + + # When we translate it + flag = _translate(default_fs, segments=[segment]) + + # Then the variant name is slugified + assert expected_variant in flag["variants"] + assert flag["variants"][expected_variant] == "B" + + +def test_feature_state_to_flagd_flag__two_overrides_with_distinct_values__two_variants() -> ( + None +): + # Given a flag with two segment overrides setting different values + feature = _feature(id_=2, name="multi_override") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="default" + ) + seg_a = _segment_with_override( + segment_id=30, + segment_name="Premium", + feature=feature, + enabled=True, + value="premium-value", + ) + seg_b = _segment_with_override( + segment_id=31, + segment_name="Trial", + feature=feature, + enabled=True, + value="trial-value", + ) + + # When we translate it + flag = _translate(default_fs, segments=[seg_a, seg_b]) + + # Then both override values are preserved in their own variants + assert flag["variants"] == { + "control": "default", + "override_Premium": "premium-value", + "override_Trial": "trial-value", + } diff --git a/api/tests/unit/integrations/flagd/test_properties.py b/api/tests/unit/integrations/flagd/test_properties.py new file mode 100644 index 000000000000..23c6164dbdb2 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_properties.py @@ -0,0 +1,145 @@ +""" +Hypothesis-driven smoke tests that assert the translation pipeline +never crashes and produces output a JsonLogic evaluator can parse. + +These complement the targeted parametrised tests with broad coverage of +arbitrary segment trees and value shapes. +""" + +from __future__ import annotations + +import pytest +from flag_engine.segments import constants as op +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from integrations.flagd.translators.operators import condition_to_jsonlogic +from integrations.flagd.translators.segment import ( + rule_to_jsonlogic, + segment_to_jsonlogic, + slugify_segment_name, +) +from integrations.flagd.types import TranslationWarning +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + +# Operators safe to feed arbitrary string values to without raising. +_SAFE_STRING_OPERATORS = [ + op.EQUAL, + op.NOT_EQUAL, + op.CONTAINS, + op.NOT_CONTAINS, + op.IN, + op.IS_SET, + op.IS_NOT_SET, +] + + +@st.composite +def conditions(draw: st.DrawFn) -> SegmentConditionModel: + operator = draw(st.sampled_from(_SAFE_STRING_OPERATORS)) + property_name = draw( + st.text( + alphabet=st.characters(min_codepoint=33, max_codepoint=126), + min_size=1, + max_size=20, + ) + ) + if operator in (op.IS_SET, op.IS_NOT_SET): + value = None + else: + value = draw(st.text(min_size=0, max_size=30)) + return SegmentConditionModel( + operator=operator, property_=property_name, value=value + ) + + +@st.composite +def rules(draw: st.DrawFn, depth: int = 2) -> SegmentRuleModel: + rule_type = draw(st.sampled_from(["ALL", "ANY", "NONE"])) + cond_list = draw(st.lists(conditions(), min_size=0, max_size=3)) + sub_rules: list[SegmentRuleModel] = [] + if depth > 0: + sub_rules = draw(st.lists(rules(depth - 1), min_size=0, max_size=2)) + return SegmentRuleModel(type=rule_type, conditions=cond_list, rules=sub_rules) + + +@given(condition=conditions()) +@settings(suppress_health_check=[HealthCheck.too_slow], max_examples=50) +def test_condition_to_jsonlogic__arbitrary_supported_operator__never_crashes( + condition: SegmentConditionModel, +) -> None: + # Given any supported operator with a random value + # When the translator runs + result = condition_to_jsonlogic(condition) + # Then it returns a non-empty dict + assert isinstance(result, dict) + assert result + + +@given(rule=rules()) +@settings(suppress_health_check=[HealthCheck.too_slow], max_examples=50) +def test_rule_to_jsonlogic__arbitrary_tree__returns_dict_or_none( + rule: SegmentRuleModel, +) -> None: + # Given any rule tree of conditions + warnings: list[TranslationWarning] = [] + # When the rule is translated + result = rule_to_jsonlogic(rule, warnings=warnings) + # Then the result is None (when no clauses) or a dict + assert result is None or isinstance(result, dict) + + +@given( + name=st.text( + alphabet=st.characters(blacklist_categories=("Cs",)), + min_size=1, + max_size=20, + ), +) +@settings(max_examples=30) +def test_slugify_segment_name__arbitrary_input__produces_safe_key( + name: str, +) -> None: + # Given any user-supplied segment name + # When the slug is computed + slug = slugify_segment_name(name) + # Then it only contains characters flagd accepts in $evaluator keys + assert slug + assert all(ch.isalnum() or ch in "-_" for ch in slug) + + +@given(rules_=st.lists(rules(), min_size=0, max_size=3)) +@settings(suppress_health_check=[HealthCheck.too_slow], max_examples=30) +def test_segment_to_jsonlogic__arbitrary_segment__valid_or_none( + rules_: list[SegmentRuleModel], +) -> None: + # Given a randomly generated segment + segment = SegmentModel(id=1, name="random", rules=rules_) + # When translated + warnings: list[TranslationWarning] = [] + result = segment_to_jsonlogic(segment, warnings=warnings) + # Then the result is None or a dict + assert result is None or isinstance(result, dict) + + +@pytest.mark.parametrize( + "name,expected_prefix", + [ + ("My Segment!", "My-Segment"), + (" ", "segment"), + ("---", "segment"), + ("naïve customers ✨", "na-ve-customers"), + ], +) +def test_slugify_segment_name__various_inputs__match_expected_prefix( + name: str, expected_prefix: str +) -> None: + # Given a segment name + # When slugified + slug = slugify_segment_name(name) + # Then it starts with the expected normalised form + assert slug.startswith(expected_prefix) diff --git a/api/tests/unit/integrations/flagd/test_segment_extraction.py b/api/tests/unit/integrations/flagd/test_segment_extraction.py new file mode 100644 index 000000000000..62b81cf0c195 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_segment_extraction.py @@ -0,0 +1,294 @@ +""" +Tests for the inline-vs-extract decision on segments. + +Project-scoped segments referenced by multiple features go into the +top-level ``$evaluators`` block (so the definition is shared). Segments +referenced by exactly one feature are inlined into that feature's +``targeting`` so: + +- Single-use segments don't leak their name into a global keyspace. +- Two different feature-scoped segments that happen to share a display + name don't collide in ``$evaluators``. + +These tests exercise the orchestrator (``_build_from_engine``) directly +with synthesised engine models so no DB is needed. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from flag_engine.segments import constants as op +from jsonschema import Draft7Validator +from referencing import Registry, Resource + +from integrations.flagd.services import _build_from_engine +from tests.unit.integrations.flagd.test_jsonschema import FIXTURE_DIR +from util.engine_models.environments.models import EnvironmentModel +from util.engine_models.features.models import ( + FeatureModel, + FeatureSegmentModel, + FeatureStateModel, +) +from util.engine_models.organisations.models import OrganisationModel +from util.engine_models.projects.models import ProjectModel +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + + +def _feature(id_: int, name: str) -> FeatureModel: + return FeatureModel(id=id_, name=name, type="STANDARD") + + +def _segment( + *, + segment_id: int, + name: str, + property_: str, + value: str, + overrides: list[FeatureStateModel], +) -> SegmentModel: + return SegmentModel( + id=segment_id, + name=name, + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.EQUAL, property_=property_, value=value + ), + ], + ), + ], + feature_states=overrides, + ) + + +def _override( + *, + feature: FeatureModel, + enabled: bool, + value: Any, +) -> FeatureStateModel: + return FeatureStateModel( + feature=feature, + enabled=enabled, + feature_state_value=value, + feature_segment=FeatureSegmentModel(priority=0), + ) + + +def _environment( + *, + segments: list[SegmentModel], + feature_states: list[FeatureStateModel], +) -> EnvironmentModel: + return EnvironmentModel( + id=1, + api_key="ser.test", + project=ProjectModel( + id=1, + name="proj", + organisation=OrganisationModel( + id=1, name="org", feature_analytics=False, persist_trait_data=False + ), + segments=segments, + ), + feature_states=feature_states, + ) + + +def _build(env: EnvironmentModel) -> dict[str, Any]: + return _build_from_engine( + env, environment_id=env.id, flag_set_id="proj/env", version="v" + ) + + +def _validator() -> Draft7Validator: + main = json.loads((FIXTURE_DIR / "flagd-schema-v0.json").read_text()) + targeting = json.loads((FIXTURE_DIR / "flagd-targeting-v0.json").read_text()) + registry = Registry().with_resources( + [ + ( + "https://flagd.dev/schema/v0/flags.json", + Resource.from_contents(main), + ), + ( + "https://flagd.dev/schema/v0/targeting.json", + Resource.from_contents(targeting), + ), + ("./targeting.json", Resource.from_contents(targeting)), + ] + ) + return Draft7Validator(main, registry=registry) + + +def test_build_flagd_document__segment_used_by_one_feature__inlined() -> None: + # Given a single-use segment with one override + feature = _feature(1, "flag_a") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="default" + ) + segment = _segment( + segment_id=10, + name="single-use", + property_="email", + value="alice@acme.com", + overrides=[_override(feature=feature, enabled=True, value="overridden")], + ) + env = _environment(segments=[segment], feature_states=[default_fs]) + + # When the document is built + document = _build(env) + + # Then there is no $evaluators block — the segment is inlined. + assert "$evaluators" not in document + # And the flag's targeting carries the raw JsonLogic for the segment. + flag = document["flags"]["flag_a"] + assert flag["targeting"] == { + "if": [ + {"==": [{"var": "email"}, "alice@acme.com"]}, + "override_single-use", + "control", + ] + } + + +def test_build_flagd_document__segment_used_by_two_features__extracted_to_evaluators() -> ( + None +): + # Given a shared segment with overrides on two features + feature_a = _feature(1, "flag_a") + feature_b = _feature(2, "flag_b") + default_a = FeatureStateModel( + feature=feature_a, enabled=True, feature_state_value="a-default" + ) + default_b = FeatureStateModel( + feature=feature_b, enabled=True, feature_state_value="b-default" + ) + shared = _segment( + segment_id=20, + name="Premium", + property_="tier", + value="premium", + overrides=[ + _override(feature=feature_a, enabled=True, value="a-premium"), + _override(feature=feature_b, enabled=True, value="b-premium"), + ], + ) + env = _environment(segments=[shared], feature_states=[default_a, default_b]) + + # When the document is built + document = _build(env) + + # Then the segment is in $evaluators + assert document["$evaluators"] == {"Premium": {"==": [{"var": "tier"}, "premium"]}} + # And both flags reference it by $ref + for flag_key in ("flag_a", "flag_b"): + flag = document["flags"][flag_key] + assert flag["targeting"]["if"][0] == {"$ref": "Premium"} + + +def test_build_flagd_document__two_feature_scoped_segments_share_name__inlined_no_collision() -> ( + None +): + # Given two segments that happen to share a display name but live + # on different features (different IDs, different rules) — the + # exact scenario the inline strategy is designed to handle. + feature_a = _feature(1, "flag_a") + feature_b = _feature(2, "flag_b") + default_a = FeatureStateModel( + feature=feature_a, enabled=True, feature_state_value="a" + ) + default_b = FeatureStateModel( + feature=feature_b, enabled=True, feature_state_value="b" + ) + seg_a = _segment( + segment_id=30, + name="mail", + property_="email", + value="a.com", + overrides=[_override(feature=feature_a, enabled=True, value="a-special")], + ) + seg_b = _segment( + segment_id=31, + name="mail", + property_="email", + value="b.com", + overrides=[_override(feature=feature_b, enabled=True, value="b-special")], + ) + env = _environment(segments=[seg_a, seg_b], feature_states=[default_a, default_b]) + + # When the document is built + document = _build(env) + + # Then $evaluators is absent (both segments are single-use, inlined) + assert "$evaluators" not in document + # And each flag's targeting carries its own distinct rule even + # though the two segments share a display name. + flag_a_logic = document["flags"]["flag_a"]["targeting"]["if"][0] + flag_b_logic = document["flags"]["flag_b"]["targeting"]["if"][0] + assert flag_a_logic == {"==": [{"var": "email"}, "a.com"]} + assert flag_b_logic == {"==": [{"var": "email"}, "b.com"]} + + +@pytest.mark.parametrize( + "usage_count, expects_evaluators", + [(1, False), (2, True), (3, True)], + ids=["single-use", "double-use", "triple-use"], +) +def test_build_flagd_document__segment_usage_thresholds__extracts_at_count_two_plus( + usage_count: int, expects_evaluators: bool +) -> None: + # Given a segment referenced by N features + features = [_feature(i, f"flag_{i}") for i in range(1, usage_count + 1)] + defaults = [ + FeatureStateModel(feature=f, enabled=True, feature_state_value="d") + for f in features + ] + segment = _segment( + segment_id=40, + name="threshold", + property_="x", + value="y", + overrides=[ + _override(feature=f, enabled=True, value=f"override-{f.id}") + for f in features + ], + ) + env = _environment(segments=[segment], feature_states=defaults) + + # When the document is built + document = _build(env) + + # Then $evaluators only appears when usage >= 2 + assert ("$evaluators" in document) is expects_evaluators + + +def test_build_flagd_document__inline_segment_doc__still_passes_flagd_schema() -> None: + # Given an inline segment (single use) + feature = _feature(1, "flag_a") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="default" + ) + segment = _segment( + segment_id=50, + name="inline", + property_="email", + value="alice@acme.com", + overrides=[_override(feature=feature, enabled=True, value="overridden")], + ) + env = _environment(segments=[segment], feature_states=[default_fs]) + + # When the document is built + document = _build(env) + + # Then the document still validates against the flagd schema + errors = sorted(_validator().iter_errors(document), key=lambda e: list(e.path)) + assert not errors, "\n".join(f"{list(e.path)}: {e.message}" for e in errors) diff --git a/api/tests/unit/integrations/flagd/test_segments.py b/api/tests/unit/integrations/flagd/test_segments.py new file mode 100644 index 000000000000..11282464e52f --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_segments.py @@ -0,0 +1,358 @@ +""" +Unit tests for ``integrations.flagd.translators.segment``. + +These tests exercise rule-tree translation -- combination of ``ALL``/``ANY``/ +``NONE`` rules, deep nesting, mixed operators, empty rules, and the +``slugify_segment_name`` helper. +""" + +import pytest +from flag_engine.segments import constants as op + +from integrations.flagd.translators.segment import ( + rule_to_jsonlogic, + segment_to_jsonlogic, + slugify_segment_name, +) +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + +# --------------------------------------------------------------------------- +# Single-rule ALL / ANY / NONE +# --------------------------------------------------------------------------- + + +def _eq(prop: str, value: str) -> SegmentConditionModel: + return SegmentConditionModel(operator=op.EQUAL, property_=prop, value=value) + + +def test_segment_to_jsonlogic__single_all_rule__returns_combined_and() -> None: + # Given + segment = SegmentModel( + id=1, + name="all-segment", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[_eq("a", "1"), _eq("b", "2")], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic == { + "and": [ + {"==": [{"var": "a"}, 1]}, + {"==": [{"var": "b"}, 2]}, + ] + } + + +def test_segment_to_jsonlogic__single_any_rule__returns_or() -> None: + # Given + segment = SegmentModel( + id=2, + name="any-segment", + rules=[ + SegmentRuleModel( + type="ANY", + conditions=[_eq("a", "1"), _eq("b", "2")], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic == { + "or": [ + {"==": [{"var": "a"}, 1]}, + {"==": [{"var": "b"}, 2]}, + ] + } + + +def test_segment_to_jsonlogic__single_none_rule__returns_negated_or() -> None: + # Given + segment = SegmentModel( + id=3, + name="none-segment", + rules=[ + SegmentRuleModel( + type="NONE", + conditions=[_eq("a", "1"), _eq("b", "2")], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic == { + "!": { + "or": [ + {"==": [{"var": "a"}, 1]}, + {"==": [{"var": "b"}, 2]}, + ] + } + } + + +def test_segment_to_jsonlogic__single_condition__omits_redundant_combinator() -> None: + # Given + segment = SegmentModel( + id=4, + name="lone", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[_eq("a", "1")], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment) + + # Then -- a single condition does not need to be wrapped in `and`. + assert logic == {"==": [{"var": "a"}, 1]} + + +# --------------------------------------------------------------------------- +# Three-level nesting: NONE > ANY > ALL +# --------------------------------------------------------------------------- + + +def test_segment_to_jsonlogic__three_level_nested_rules__produces_deep_tree() -> None: + # Given + inner_all = SegmentRuleModel( + type="ALL", + conditions=[_eq("country", "GB"), _eq("plan", "premium")], + ) + middle_any = SegmentRuleModel( + type="ANY", + conditions=[_eq("beta", "true")], + rules=[inner_all], + ) + outer_none = SegmentRuleModel( + type="NONE", + rules=[middle_any], + ) + segment = SegmentModel(id=5, name="deep", rules=[outer_none]) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic == { + "!": { + "or": [ + {"==": [{"var": "beta"}, True]}, + { + "and": [ + {"==": [{"var": "country"}, "GB"]}, + {"==": [{"var": "plan"}, "premium"]}, + ] + }, + ] + } + } + + +# --------------------------------------------------------------------------- +# Mixed operators +# --------------------------------------------------------------------------- + + +def test_segment_to_jsonlogic__mixed_operators__translates_each() -> None: + # Given + segment = SegmentModel( + id=6, + name="mixed", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.GREATER_THAN, property_="age", value="18" + ), + SegmentConditionModel( + operator=op.CONTAINS, + property_="email", + value="@flagsmith.com", + ), + SegmentConditionModel( + operator=op.IN, + property_="country", + value="GB,US", + ), + ], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic == { + "and": [ + {">": [{"var": "age"}, 18]}, + {"in": ["@flagsmith.com", {"var": "email"}]}, + {"in": [{"var": "country"}, ["GB", "US"]]}, + ] + } + + +# --------------------------------------------------------------------------- +# Empty rule / segment +# --------------------------------------------------------------------------- + + +def test_segment_to_jsonlogic__no_rules__returns_none() -> None: + # Given + segment = SegmentModel(id=7, name="empty", rules=[]) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic is None + + +def test_rule_to_jsonlogic__rule_without_conditions_or_children__returns_none() -> None: + # Given + rule = SegmentRuleModel(type="ALL") + + # When + logic = rule_to_jsonlogic(rule) + + # Then + assert logic is None + + +def test_segment_to_jsonlogic__rule_with_only_empty_children__returns_none() -> None: + # Given + segment = SegmentModel( + id=8, + name="hollow", + rules=[ + SegmentRuleModel( + type="ANY", + rules=[SegmentRuleModel(type="ALL")], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment) + + # Then + assert logic is None + + +# --------------------------------------------------------------------------- +# PERCENTAGE_SPLIT inside a rule +# --------------------------------------------------------------------------- + + +def test_segment_to_jsonlogic__percentage_split_condition__translates_with_feature_key() -> ( + None +): + # Given + segment = SegmentModel( + id=9, + name="rollout", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel(operator=op.PERCENTAGE_SPLIT, value="42") + ], + ) + ], + ) + + # When + logic = segment_to_jsonlogic(segment, feature_key="dark-launch") + + # Then + assert logic == { + "==": [ + { + "fractional": [ + {"cat": [{"var": "targetingKey"}, "dark-launch"]}, + ["in", 42.0], + ["out", 58.0], + ] + }, + "in", + ] + } + + +# --------------------------------------------------------------------------- +# slugify_segment_name +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected", + [ + ("My Segment", "My-Segment"), + (" spaced out ", "spaced-out"), + ("safe-name_1", "safe-name_1"), + ("!!!", "segment"), + ("---weird---", "weird"), + ("emoji rocket", "emoji-rocket"), + ], +) +def test_slugify_segment_name__various_inputs__produces_safe_key( + name: str, expected: str +) -> None: + # Given / When + result = slugify_segment_name(name) + + # Then + assert result == expected + + +def test_slugify_segment_name__case_preserved__no_collision_with_lowercase() -> None: + # Given -- slugifier is case-preserving, so "My Segment" -> "My-Segment". + taken = {"my-segment"} + + # When + result = slugify_segment_name("My Segment", taken=taken) + + # Then + assert result == "My-Segment" + + +def test_slugify_segment_name__direct_collision__increments_counter() -> None: + # Given + taken = {"my-segment"} + + # When + result = slugify_segment_name("my-segment", taken=taken) + + # Then + assert result == "my-segment-2" + + +def test_slugify_segment_name__multiple_collisions__keeps_incrementing() -> None: + # Given + taken = {"my-segment", "my-segment-2", "my-segment-3"} + + # When + result = slugify_segment_name("my-segment", taken=taken) + + # Then + assert result == "my-segment-4" diff --git a/api/tests/unit/integrations/flagd/test_type_check.py b/api/tests/unit/integrations/flagd/test_type_check.py new file mode 100644 index 000000000000..45160023af50 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_type_check.py @@ -0,0 +1,202 @@ +""" +Tests for ``integrations.flagd.translators.type_check``, which spots +flags whose variants would land in different flagd typed-flag schemas. +""" + +from __future__ import annotations + +import pytest + +from integrations.flagd.translators.type_check import ( + WARNING_TYPE_MISMATCH, + detect_type_mismatch, +) +from util.engine_models.features.models import ( + FeatureModel, + FeatureStateModel, + MultivariateFeatureOptionModel, + MultivariateFeatureStateValueModel, +) +from util.engine_models.identities.models import IdentityModel +from util.engine_models.segments.models import SegmentModel + + +def _feature(name: str = "f") -> FeatureModel: + return FeatureModel(id=1, name=name, type="STANDARD") + + +def _mv(value, percentage: float, id_: int = 1) -> MultivariateFeatureStateValueModel: + return MultivariateFeatureStateValueModel( + id=id_, + multivariate_feature_option=MultivariateFeatureOptionModel( + id=id_ * 10, value=value + ), + percentage_allocation=percentage, + ) + + +def test_detect_type_mismatch__all_strings__no_warning() -> None: + # Given a flag whose control + MV options are all strings + fs = FeatureStateModel( + feature=_feature(), + enabled=True, + feature_state_value="hello", + multivariate_feature_state_values=[ + _mv("alpha", 50, id_=1), + _mv("beta", 30, id_=2), + ], + ) + + # When the type check runs + warnings = detect_type_mismatch(fs) + + # Then no warning is emitted + assert warnings == [] + + +@pytest.mark.parametrize( + "control,mv_value", + [ + ("hello", 42), + (1, "two"), + (True, "yes"), + ([1, 2], {"x": 1}), + ], + ids=["string+number", "number+string", "boolean+string", "array+object"], +) +def test_detect_type_mismatch__mixed_types__emits_warning(control, mv_value) -> None: + # Given a flag whose values land in different flagd type buckets + fs = FeatureStateModel( + feature=_feature("mixed"), + enabled=True, + feature_state_value=control, + multivariate_feature_state_values=[_mv(mv_value, 50)], + ) + + # When the type check runs + warnings = detect_type_mismatch(fs) + + # Then a single mismatch warning is emitted, listing the types involved + assert len(warnings) == 1 + assert warnings[0]["reason"] == WARNING_TYPE_MISMATCH + assert "feature=mixed" in warnings[0]["detail"] + assert "types=[" in warnings[0]["detail"] + + +def test_detect_type_mismatch__none_control_value__compatible_with_any_mv() -> None: + # Given a flag with no control value (boolean-only meaning) but a + # multivariate option carrying a string + fs = FeatureStateModel( + feature=_feature(), + enabled=True, + feature_state_value=None, + multivariate_feature_state_values=[_mv("variant", 50)], + ) + + # When the type check runs + warnings = detect_type_mismatch(fs) + + # Then no warning — null is compatible with anything + assert warnings == [] + + +def test_detect_type_mismatch__bool_and_number__counts_as_mismatch() -> None: + # Given Python treats `True == 1`, we still want booleans and numbers + # to be distinct flagd types (matching the JSON Schema split). + fs = FeatureStateModel( + feature=_feature(), + enabled=True, + feature_state_value=True, + multivariate_feature_state_values=[_mv(1, 50)], + ) + + # When the type check runs + warnings = detect_type_mismatch(fs) + + # Then we emit a warning — boolean and number are different flagd types + assert len(warnings) == 1 + assert warnings[0]["reason"] == WARNING_TYPE_MISMATCH + + +def test_detect_type_mismatch__single_variant__no_warning() -> None: + # Given a flag with just a control value, no MV options + fs = FeatureStateModel( + feature=_feature(), + enabled=True, + feature_state_value=42, + ) + + # When the type check runs + warnings = detect_type_mismatch(fs) + + # Then nothing to warn about + assert warnings == [] + + +def test_detect_type_mismatch__segment_override_with_different_type__emits_warning() -> ( + None +): + # Given a string flag whose segment override sets a number value + feature = _feature("seg_mix") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="default" + ) + segment_override_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value=42 + ) + segment = SegmentModel(id=10, name="Premium", feature_states=[segment_override_fs]) + + # When the type check runs + warnings = detect_type_mismatch(default_fs, segments=[segment]) + + # Then a mismatch warning is emitted naming both types + assert len(warnings) == 1 + assert warnings[0]["reason"] == WARNING_TYPE_MISMATCH + assert "number" in warnings[0]["detail"] + assert "string" in warnings[0]["detail"] + + +def test_detect_type_mismatch__identity_override_with_different_type__emits_warning() -> ( + None +): + # Given a string flag whose identity override sets a boolean value + feature = _feature("id_mix") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="default" + ) + identity = IdentityModel( + identifier="alice", + environment_api_key="ser.test", + identity_features=[ + FeatureStateModel(feature=feature, enabled=True, feature_state_value=True), + ], + ) + + # When the type check runs + warnings = detect_type_mismatch(default_fs, identity_overrides=[identity]) + + # Then a mismatch warning is emitted + assert len(warnings) == 1 + assert warnings[0]["reason"] == WARNING_TYPE_MISMATCH + assert "boolean" in warnings[0]["detail"] + assert "string" in warnings[0]["detail"] + + +def test_detect_type_mismatch__override_value_matches_control_type__no_warning() -> ( + None +): + # Given a string flag whose segment override sets a different string + feature = _feature("seg_match") + default_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="A" + ) + segment_override_fs = FeatureStateModel( + feature=feature, enabled=True, feature_state_value="B" + ) + segment = SegmentModel(id=11, name="Premium", feature_states=[segment_override_fs]) + + # When the type check runs + warnings = detect_type_mismatch(default_fs, segments=[segment]) + + # Then no warning — both values are strings, just different ones + assert warnings == [] diff --git a/api/tests/unit/integrations/flagd/test_warnings.py b/api/tests/unit/integrations/flagd/test_warnings.py new file mode 100644 index 000000000000..4ec9e2bb05a3 --- /dev/null +++ b/api/tests/unit/integrations/flagd/test_warnings.py @@ -0,0 +1,200 @@ +""" +Unit tests for warning emission during segment translation. + +The segment translator catches ``UntranslatableConditionError`` and records +a ``TranslationWarning`` so callers can surface skipped conditions in the +sync response. +""" + +import pytest +from flag_engine.segments import constants as op + +from integrations.flagd.constants import ( + WARNING_MALFORMED_VALUE, + WARNING_REGEX_UNSUPPORTED, + WARNING_UNKNOWN_OPERATOR, +) +from integrations.flagd.translators.segment import segment_to_jsonlogic +from integrations.flagd.types import TranslationWarning +from util.engine_models.segments.models import ( + SegmentConditionModel, + SegmentModel, + SegmentRuleModel, +) + + +def _wrap(condition: SegmentConditionModel) -> SegmentModel: + return SegmentModel( + id=1, + name="test-segment", + rules=[SegmentRuleModel(type="ALL", conditions=[condition])], + ) + + +def test_segment_to_jsonlogic__regex_condition__emits_regex_warning() -> None: + # Given + condition = SegmentConditionModel( + operator=op.REGEX, property_="email", value=".*@flagsmith.com" + ) + segment = _wrap(condition) + warnings: list[TranslationWarning] = [] + + # When + logic = segment_to_jsonlogic(segment, warnings=warnings) + + # Then + assert logic is None # the only condition was skipped + assert warnings == [ + TranslationWarning( + reason=WARNING_REGEX_UNSUPPORTED, + detail=f"operator={op.REGEX}, property=email", + ) + ] + + +def test_segment_to_jsonlogic__unknown_operator__emits_unknown_operator_warning() -> ( + None +): + # Given -- bypass pydantic validation by constructing then mutating, since + # ConditionOperator is a constrained enum-like type. + condition = SegmentConditionModel(operator=op.EQUAL, property_="trait", value="x") + object.__setattr__(condition, "operator", "TOTALLY_UNKNOWN") + segment = _wrap(condition) + warnings: list[TranslationWarning] = [] + + # When + logic = segment_to_jsonlogic(segment, warnings=warnings) + + # Then + assert logic is None + assert warnings == [ + TranslationWarning( + reason=WARNING_UNKNOWN_OPERATOR, + detail="operator=TOTALLY_UNKNOWN, property=trait", + ) + ] + + +def test_segment_to_jsonlogic__malformed_modulo_value__emits_malformed_warning() -> ( + None +): + # Given + condition = SegmentConditionModel( + operator=op.MODULO, property_="user_id", value="abc" + ) + segment = _wrap(condition) + warnings: list[TranslationWarning] = [] + + # When + logic = segment_to_jsonlogic(segment, warnings=warnings) + + # Then + assert logic is None + assert warnings == [ + TranslationWarning( + reason=WARNING_MALFORMED_VALUE, + detail=f"operator={op.MODULO}, property=user_id", + ) + ] + + +def test_segment_to_jsonlogic__semver_suffix_only__emits_malformed_warning() -> None: + # Given -- value is just the suffix, no version part. + condition = SegmentConditionModel( + operator=op.EQUAL, property_="version", value=":semver" + ) + segment = _wrap(condition) + warnings: list[TranslationWarning] = [] + + # When + logic = segment_to_jsonlogic(segment, warnings=warnings) + + # Then + assert logic is None + assert warnings == [ + TranslationWarning( + reason=WARNING_MALFORMED_VALUE, + detail=f"operator={op.EQUAL}, property=version", + ) + ] + + +def test_segment_to_jsonlogic__warning_alongside_translatable_condition__keeps_translatable() -> ( + None +): + # Given -- one regex (skipped) plus one valid equality (kept). + segment = SegmentModel( + id=2, + name="mixed", + rules=[ + SegmentRuleModel( + type="ALL", + conditions=[ + SegmentConditionModel( + operator=op.REGEX, + property_="email", + value=".*", + ), + SegmentConditionModel( + operator=op.EQUAL, + property_="plan", + value="premium", + ), + ], + ) + ], + ) + warnings: list[TranslationWarning] = [] + + # When + logic = segment_to_jsonlogic(segment, warnings=warnings) + + # Then + assert logic == {"==": [{"var": "plan"}, "premium"]} + assert len(warnings) == 1 + assert warnings[0]["reason"] == WARNING_REGEX_UNSUPPORTED + + +def test_segment_to_jsonlogic__no_warnings_list__silently_skips_untranslatable() -> ( + None +): + # Given -- caller passes None for warnings, indicating they don't care. + condition = SegmentConditionModel(operator=op.REGEX, property_="email", value=".*") + segment = _wrap(condition) + + # When + logic = segment_to_jsonlogic(segment) # warnings defaults to None + + # Then -- must not raise; condition silently dropped. + assert logic is None + + +@pytest.mark.parametrize( + "raw_value,operator,reason", + [ + ("not-a-number", op.GREATER_THAN, WARNING_MALFORMED_VALUE), + ("not-a-number", op.LESS_THAN_INCLUSIVE, WARNING_MALFORMED_VALUE), + ("oops", op.PERCENTAGE_SPLIT, WARNING_MALFORMED_VALUE), + ], +) +def test_segment_to_jsonlogic__malformed_numeric_value__emits_malformed_warning( + raw_value: str, operator: str, reason: str +) -> None: + # Given + condition = SegmentConditionModel( + operator=operator, property_="trait", value=raw_value + ) + segment = _wrap(condition) + warnings: list[TranslationWarning] = [] + + # When + logic = segment_to_jsonlogic(segment, warnings=warnings) + + # Then + assert logic is None + assert warnings == [ + TranslationWarning( + reason=reason, + detail=f"operator={operator}, property=trait", + ) + ] diff --git a/api/uv.lock b/api/uv.lock index b1af3ddd0ece..72f190c63fba 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -97,6 +97,7 @@ overrides = [ { name = "httplib2", specifier = "==0.22.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "hubspot-api-client", specifier = "==12.0.0" }, + { name = "hypothesis", specifier = "==6.152.4" }, { name = "identify", specifier = "==2.6.3" }, { name = "importlib-metadata", specifier = "==8.7.1" }, { name = "inflect", specifier = "==5.6.2" }, @@ -111,6 +112,7 @@ overrides = [ { name = "jedi", specifier = "==0.19.2" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "jmespath", specifier = "==1.0.1" }, + { name = "json-logic-qubit", specifier = "==0.9.1" }, { name = "jsonpath-rfc9535", specifier = "==0.2.0" }, { name = "jsonschema", specifier = "==4.25.1" }, { name = "jsonschema-specifications", specifier = "==2025.9.1" }, @@ -221,6 +223,7 @@ overrides = [ { name = "slack-sdk", specifier = "==3.9.1" }, { name = "social-auth-app-django", specifier = "==5.6.0" }, { name = "social-auth-core", specifier = "==4.4.2" }, + { name = "sortedcontainers", specifier = "==2.4.0" }, { name = "sqlparse", specifier = "==0.5.4" }, { name = "sseclient-py", specifier = "==1.8.0" }, { name = "stack-data", specifier = "==0.6.3" }, @@ -1474,7 +1477,9 @@ dev = [ { name = "djangorestframework-stubs" }, { name = "email-validator" }, { name = "flagsmith-common", extra = ["test-tools"] }, + { name = "hypothesis" }, { name = "ipython" }, + { name = "json-logic-qubit" }, { name = "moto" }, { name = "mypy" }, { name = "mypy-boto3-dynamodb" }, @@ -1566,8 +1571,10 @@ requires-dist = [ { name = "google-re2", specifier = ">=1.0,<2.0.0" }, { name = "gunicorn", specifier = ">=23.0.0,<23.1.0" }, { name = "hubspot-api-client", specifier = ">=12.0.0,<13.0.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.152.4,<7.0.0" }, { name = "influxdb-client", specifier = ">=1.50.0,<1.51.0" }, { name = "ipython", marker = "extra == 'dev'", specifier = ">=9.10.0,<10.0.0" }, + { name = "json-logic-qubit", marker = "extra == 'dev'", specifier = ">=0.9.1,<0.10.0" }, { name = "moto", marker = "extra == 'dev'", specifier = ">=4.1.3,<4.2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15.0,<2.0.0" }, { name = "mypy-boto3-dynamodb", marker = "extra == 'dev'", specifier = ">=1.33.0,<2.0.0" }, @@ -1935,6 +1942,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/bf/b417bcf30d6fc4f473f3c1ab13cbffc3a809caa464d5aa2ae43de96416d9/hubspot_api_client-12.0.0-py3-none-any.whl", hash = "sha256:5426627ff808fdf259d5b5e4791667a323a2a82d36a834e7e43ec8e8f021aa08", size = 4295367, upload-time = "2025-05-07T12:56:12.203Z" }, ] +[[package]] +name = "hypothesis" +version = "6.152.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/c7/3147bd903d6b18324a016d43a259cf5b4bb4545e1ead6773dc8a0374e70a/hypothesis-6.152.4.tar.gz", hash = "sha256:31c8f9ce619716f543e2710b489b1633c833586641d9e6c94cee03f109a5afc4", size = 466444, upload-time = "2026-04-27T20:18:37.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" }, +] + [[package]] name = "identify" version = "2.6.3" @@ -2114,6 +2133,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, ] +[[package]] +name = "json-logic-qubit" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/62/2b023e1dcc6917d40a9f5687916d1c647e26af06c579f200bd0f3b91481a/json_logic_qubit-0.9.1-py2.py3-none-any.whl", hash = "sha256:d024ff0c77659eb97ddf742e0bd1b7caacd44bc53fdfc8f3977f4062a3c3b056", size = 13196, upload-time = "2018-08-15T14:41:05.867Z" }, +] + [[package]] name = "jsonpath-rfc9535" version = "0.2.0" @@ -3823,6 +3853,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/3b/8c775c585411690fb8898fa5ca11794c91281b3db49f8ead7f09ffdd739f/social_auth_core-4.4.2-py3-none-any.whl", hash = "sha256:ea7a19c46b791b767e95f467881b53c5fd0d1efb40048d9ed3dbc46daa05c954", size = 349097, upload-time = "2023-04-22T05:49:09.414Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlparse" version = "0.5.4" diff --git a/docker-compose.flagd-dev.yml b/docker-compose.flagd-dev.yml new file mode 100644 index 000000000000..622a3a692eed --- /dev/null +++ b/docker-compose.flagd-dev.yml @@ -0,0 +1,77 @@ +# Local-dev stack: Flagsmith UI + flagd runtime + Postgres for persistence. +# +# Build the Flagsmith image once with: +# docker build -t flagsmith-flagd-dev:local --target oss-unified . +# Then bring everything up: +# docker compose -f docker-compose.flagd-dev.yml up +# +# The local-dev server-side key is intentionally hard-coded; override it +# via `FLAGSMITH_SERVER_KEY=ser.your-key docker compose up`. The +# bootstrap step pins the Flagsmith EnvironmentAPIKey to whatever value +# you provide, so flagd and Flagsmith stay in sync without any runtime +# file ferrying. +# +# See docs/docs/integrating-with-flagsmith/flagd-local-dev.md for details. + +x-flagsmith-env: &flagsmith-env + DATABASE_URL: postgresql://postgres:password@postgres:5432/flagsmith + USE_POSTGRES_FOR_ANALYTICS: "true" + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_SECRET_KEY: dev-only-secret-not-for-prod + ENVIRONMENT: local + PREVENT_SIGNUP: "true" + TASK_RUN_METHOD: SYNCHRONOUSLY + +volumes: + flagsmith-pgdata: + +services: + postgres: + image: postgres:15.5-alpine + environment: + POSTGRES_PASSWORD: password + POSTGRES_DB: flagsmith + volumes: + - flagsmith-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -d flagsmith -U postgres"] + interval: 2s + retries: 20 + + flagsmith: + image: flagsmith-flagd-dev:local + ports: + - "8000:8000" + environment: + <<: *flagsmith-env + depends_on: + postgres: + condition: service_healthy + # Healthcheck is baked into the image; don't override it. + + # Idempotent: creates a default org/project/env on first run, reuses + # them thereafter. Pins the EnvironmentAPIKey to FLAGSMITH_SERVER_KEY + # so flagd can consume the same value via compose interpolation. + flagsmith-bootstrap: + image: flagsmith-flagd-dev:local + depends_on: + flagsmith: + condition: service_healthy + environment: + <<: *flagsmith-env + entrypoint: ["python", "manage.py"] + command: + - bootstrap_flagd_local + - --api-key=${FLAGSMITH_SERVER_KEY:-ser.local-dev-flagd-sync-not-secret} + + flagd: + image: ghcr.io/open-feature/flagd:v0.13.2 + depends_on: + flagsmith-bootstrap: + condition: service_completed_successfully + ports: + - "8013:8013" # gRPC + - "8016:8016" # OFREP + command: + - start + - --sources=[{"uri":"http://flagsmith:8000/api/v1/flagd/flags.json","provider":"http","authHeader":"Bearer ${FLAGSMITH_SERVER_KEY:-ser.local-dev-flagd-sync-not-secret}","interval":5}] diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index c0e1d4d35f5a..58b2ab7800e5 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -108,6 +108,24 @@ Logged at `warning` from: Attributes: - `path` +### `flagd_sync.document.served` + +Logged at `info` from: + - `api/integrations/flagd/views.py:72` + +Attributes: + - `environment.id` + - `warnings.count` + +### `flagd_sync.translation.warnings` + +Logged at `info` from: + - `api/integrations/flagd/services.py:132` + +Attributes: + - `environment.id` + - `warnings.count` + ### `gitlab.api_call.failed` Logged at `error` from: diff --git a/docs/docs/deployment-self-hosting/observability/_metrics-catalogue.md b/docs/docs/deployment-self-hosting/observability/_metrics-catalogue.md index b931a958595e..f66ba8c4dfd7 100644 --- a/docs/docs/deployment-self-hosting/observability/_metrics-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_metrics-catalogue.md @@ -37,6 +37,40 @@ Results of cache retrieval for environment document. `result` label is either `h Labels: - `result` +### `flagsmith_flagd_document_build_seconds` + +Histogram. + +Wall-clock time spent translating an environment to a flagd document. + +Labels: + +### `flagsmith_flagd_document_size_bytes` + +Histogram. + +Size in bytes of the flagd document returned by the sync endpoint. + +Labels: + +### `flagsmith_flagd_sync_requests` + +Counter. + +Number of flagd HTTP sync requests served by the Flagsmith API. + +Labels: + - `status` + +### `flagsmith_flagd_translation_warnings` + +Counter. + +Translation warnings emitted while building flagd documents. + +Labels: + - `reason` + ### `flagsmith_http_server_request_duration_seconds` Histogram. diff --git a/docs/docs/integrating-with-flagsmith/flagd-local-dev.md b/docs/docs/integrating-with-flagsmith/flagd-local-dev.md new file mode 100644 index 000000000000..d7386d845ec6 --- /dev/null +++ b/docs/docs/integrating-with-flagsmith/flagd-local-dev.md @@ -0,0 +1,250 @@ +--- +description: Run Flagsmith + flagd together locally for OpenFeature development +sidebar_label: flagd Local Development +sidebar_position: 56 +--- + +# Local Development with Flagsmith and flagd + +This guide spins up a self-contained stack for OpenFeature work: Flagsmith as the management UI, flagd as the runtime, and your service evaluating flags through an OpenFeature provider — all on your laptop, with flag state persisted in a Postgres volume so you keep your work between restarts. + +The whole thing is `docker compose up` and done — no UI clicks to mint keys, no env vars to fill in. A small bootstrap container provisions a default `local-dev` organisation / project / environment on first boot, writes the resulting server-side key to a shared volume, and flagd reads it from there. You don't think about org/project/env locally; you just author flags in the UI and they show up in flagd within seconds. + +``` +┌─────────────────┐ HTTP poll ┌──────────────────┐ OpenFeature +│ Flagsmith API │ ◄────────────── │ flagd │ ◄──────────────┐ +│ + UI :8000 │ │ :8013 (gRPC) │ │ +│ ───────────── │ │ :8016 (OFREP) │ ┌─────────┴─────────┐ +│ Postgres vol. │ └──────────────────┘ │ your service │ +└─────────────────┘ └───────────────────┘ +``` + +The walkthrough below assumes Docker, Docker Compose, and `curl`. About 5 minutes to first flag. + +## 1. The compose file + +A ready-to-use compose file lives at the repo root as `docker-compose.flagd-dev.yml`. Its shape: + +```yaml +x-flagsmith-env: &flagsmith-env + DATABASE_URL: postgresql://postgres:password@postgres:5432/flagsmith + USE_POSTGRES_FOR_ANALYTICS: "true" + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_SECRET_KEY: dev-only-secret-not-for-prod + ENVIRONMENT: local + PREVENT_SIGNUP: "true" + TASK_RUN_METHOD: SYNCHRONOUSLY + +volumes: + flagsmith-pgdata: + flagd-config: + +services: + postgres: + image: postgres:15.5-alpine + environment: + POSTGRES_PASSWORD: password + POSTGRES_DB: flagsmith + volumes: + - flagsmith-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -d flagsmith -U postgres"] + interval: 2s + retries: 20 + + flagsmith: + image: flagsmith-flagd-dev:local # built locally — see "First boot" + ports: ["8000:8000"] + environment: + <<: *flagsmith-env + depends_on: + postgres: + condition: service_healthy + # Healthcheck is baked into the image; don't override it. + + # One-shot init: ensures a default org/project/env exists and writes + # the resulting server-side key to a file flagd can source. + # Idempotent: pins the EnvironmentAPIKey to FLAGSMITH_SERVER_KEY so + # flagd can use the same value via compose interpolation. + flagsmith-bootstrap: + image: flagsmith-flagd-dev:local + depends_on: + flagsmith: + condition: service_healthy + environment: + <<: *flagsmith-env + entrypoint: ["python", "manage.py"] + command: + - bootstrap_flagd_local + - --api-key=${FLAGSMITH_SERVER_KEY:-ser.local-dev-flagd-sync-not-secret} + + flagd: + image: ghcr.io/open-feature/flagd:v0.13.2 + depends_on: + flagsmith-bootstrap: + condition: service_completed_successfully + ports: + - "8013:8013" # gRPC + - "8016:8016" # OFREP + command: + - start + - --sources=[{"uri":"http://flagsmith:8000/api/v1/flagd/flags.json","provider":"http","authHeader":"Bearer ${FLAGSMITH_SERVER_KEY:-ser.local-dev-flagd-sync-not-secret}","interval":5}] +``` + +## First boot — build the Flagsmith image + +Until this PR lands in a published image, build it once from your branch: + +```bash +docker build -t flagsmith-flagd-dev:local --target oss-unified . +``` + +The `oss-unified` target bundles the API + frontend into a single image. Builds take ~5 minutes the first time and are cached after that. + +Key things to note about the compose file: + +- **Postgres-backed**: Flagsmith requires Postgres (some migrations use `NOW()`). The `flagsmith-pgdata` volume keeps your flags around across `docker compose down && up`. +- **Static local-dev key**: `FLAGSMITH_SERVER_KEY` defaults to `ser.local-dev-flagd-sync-not-secret` via compose interpolation. The bootstrap container *pins* the Flagsmith `EnvironmentAPIKey` to that value, and flagd uses the same one in the `authHeader` field of its `--sources` JSON. To use a different key (e.g. on shared machines), set the env var: `FLAGSMITH_SERVER_KEY=ser.your-key docker compose -f docker-compose.flagd-dev.yml up`. +- **`flagsmith-bootstrap` init container**: runs the `bootstrap_flagd_local` Django management command. Idempotent — first run creates `local-dev` / `local-dev` / `development`, an `EnvironmentAPIKey` with the chosen key, **enables the flagd integration on the project** (required since flagd endpoints are opt-in per project), and creates / refreshes an admin user. Subsequent runs reconcile each piece. +- **flagd uses the upstream image**: no launcher / shell wrapping needed because the key is known at compose-parse time. +- **Short poll interval (5 s)**: nice for development; set to 30–60 s in real deployments. + +## 2. Bring it all up + +```bash +docker compose -f docker-compose.flagd-dev.yml up -d +``` + +That's it. Within a few seconds: + +1. Flagsmith starts (creates the Postgres DB on first boot). +2. `flagsmith-bootstrap` ensures a default org/project/env and emits the server key to `/shared/flagd.env`. +3. flagd reads the key, starts polling Flagsmith. + +Confirm flagd picked up the document: + +```bash +docker compose -f docker-compose.flagd-dev.yml logs flagd | grep -i sync +``` + +You should see flagd reporting it loaded a flag set. + +## 3. Log into the Flagsmith UI + +The bootstrap container creates a local admin alongside the org/project/env, with the credentials it prints to stdout (default: `admin@example.com` / `admin`). Log in at with those, or override via `--admin-email` / `--admin-password` on the bootstrap command if you want different ones. + +You'll land in the `local-dev` organisation, with the `local-dev` project and `development` environment already there — start authoring flags. + +## 4. Create a flag + +Back in the Flagsmith UI, in your `Development` environment: + +- Click **Create Feature**. +- Name it `welcome_banner`, set the value to `true`, leave it enabled. +- Save. + +Within 5 seconds flagd will reload the document. Verify via the OFREP endpoint: + +```bash +curl -s -X POST http://localhost:8016/ofrep/v1/evaluate/flags/welcome_banner \ + -H 'Content-Type: application/json' \ + -d '{"context": {"targetingKey": "user-123"}}' | jq +``` + +```json +{ + "key": "welcome_banner", + "reason": "STATIC", + "value": true, + "variant": "on" +} +``` + +## 5. Evaluate from your service + +Pick the OpenFeature flagd provider for your stack. Examples assume the compose stack above (gRPC on `localhost:8013`). + +### Python + +```python +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider + +api.set_provider(FlagdProvider(host="localhost", port=8013)) +client = api.get_client() + +ctx = {"targetingKey": "user-123", "tier": "premium"} +print(client.get_boolean_value("welcome_banner", default_value=False, evaluation_context=ctx)) +``` + +### Node / TypeScript + +```ts +import { OpenFeature } from '@openfeature/server-sdk'; +import { FlagdProvider } from '@openfeature/flagd-provider'; + +await OpenFeature.setProviderAndWait(new FlagdProvider({ host: 'localhost', port: 8013 })); +const client = OpenFeature.getClient(); + +const enabled = await client.getBooleanValue('welcome_banner', false, { + targetingKey: 'user-123', +}); +console.log(enabled); +``` + +### Go + +```go +import ( + "github.com/open-feature/go-sdk/openfeature" + flagd "github.com/open-feature/go-sdk-contrib/providers/flagd/pkg" +) + +openfeature.SetProvider(flagd.NewProvider(flagd.WithHost("localhost"), flagd.WithPort(8013))) +client := openfeature.NewClient("local-dev") + +enabled, _ := client.BooleanValue(ctx, "welcome_banner", false, + openfeature.NewEvaluationContext("user-123", nil)) +``` + +## 6. Wiring your service into the compose stack + +To run your service alongside Flagsmith and flagd, add another service entry: + +```yaml + my-service: + build: . + depends_on: + - flagd + environment: + FLAGD_HOST: flagd + FLAGD_PORT: "8013" +``` + +Inside the compose network, flagd is reachable as `flagd:8013` (gRPC) or `flagd:8016` (OFREP). + +## What does the env key actually scope? + +The server-side key (`ser.…`) you mint above pins the request to **one specific Environment** in Flagsmith. From there, the project and organisation are implied through foreign keys: + +- The lookup is `Environment.get_from_cache(api_key)`; the env carries `project` (which carries `organisation`). +- `organisation.stop_serving_flags` will reject the request if the org is paused. +- The `ser.` prefix is what allows the request through `EnvironmentKeyAuthentication(required_key_prefix="ser.")` — client-side keys (no prefix) are rejected. + +So one env key = one environment = one flagd flag-set. To target multiple Flagsmith environments from a single flagd, run multiple flagd sync sources — one per env key. + +## Resetting your local stack + +```bash +docker compose -f docker-compose.flagd-dev.yml down # keep data +docker compose -f docker-compose.flagd-dev.yml down -v # wipe Postgres volume +``` + +The named `flagsmith-pgdata` volume is the only persistent state. Delete it if you want a fresh org/project tree. + +## Troubleshooting + +- **flagd logs `unauthorized` / `403`** — the `FLAGSMITH_SERVER_KEY` must start with `ser.`. The bootstrap container pins the Flagsmith `EnvironmentAPIKey` to whatever value you provide, so override + restart should be all you need: `FLAGSMITH_SERVER_KEY=ser.your-key docker compose -f docker-compose.flagd-dev.yml up`. +- **flagd logs `404`** — the URL is wrong; the compose example uses `flagsmith:8000` as the in-network hostname. +- **Flag changes don't show up** — check `pollInterval`; the example uses 5 s. Also confirm the response: `curl -s -H "X-Environment-Key: ser.…" http://localhost:8000/api/v1/flagd/flags.json | jq '.metadata'` should show a fresh `version` after each change. +- **`metadata.flagsmith.warnings` present** — the environment has rules that don't translate cleanly (typically REGEX). Consult the [compatibility matrix](./flagd-sync.md#compatibility-matrix). diff --git a/docs/docs/integrating-with-flagsmith/flagd-sync.md b/docs/docs/integrating-with-flagsmith/flagd-sync.md new file mode 100644 index 000000000000..d1be325b031f --- /dev/null +++ b/docs/docs/integrating-with-flagsmith/flagd-sync.md @@ -0,0 +1,336 @@ +--- +description: Use Flagsmith as a flagd HTTP sync source +sidebar_label: flagd Sync Source +sidebar_position: 55 +--- + +# flagd Sync Source + +**Flagsmith is the management UI for your flagd deployment.** Authors create flags, segments, and identity overrides in Flagsmith; Flagsmith emits a [flagd](https://flagd.dev)-compatible flag-definition document on an HTTP sync endpoint; flagd polls it, loads the document, and evaluates flags in-process via OpenFeature. + +The Flagsmith SDK is **not** part of this topology. In this mode the Flagsmith UI is a pure authoring surface — your services never call Flagsmith directly. They evaluate via flagd, which is the runtime source of truth. + +This is the right setup when: + +- You already run flagd in your platform and want a UI / audit log / segmentation tooling on top. +- You want OpenFeature-native evaluation across many services without operating a per-language SDK. + +## Architecture + +``` +┌──────────┐ HTTP poll ┌───────────────────┐ +│ flagd │ ───────────> │ Flagsmith API │ +│ (runtime)│ │ /api/v1/flagd/ │ +└────┬─────┘ │ flags.json │ + │ └───────────────────┘ + │ OpenFeature provider (gRPC / OFREP / in-process) + ▼ +┌──────────────┐ +│ your service │ +└──────────────┘ +``` + +## Quick start + +### 1. Enable the integration for the project + +The flagd endpoints are **opt-in per Flagsmith project**. Until enabled, the sync and diagnostics URLs return `404`. Toggle it on with a single PATCH from a project admin: + +```bash +curl -X PATCH https://api.flagsmith.com/api/v1/projects//integrations/flagd// \ + -H 'Authorization: Token ' \ + -H 'Content-Type: application/json' \ + -d '{"enabled": true}' +``` + +A GET on `…/integrations/flagd/` lists the current configuration (creating a disabled default row on first access if needed). + +### 2. Mint a server-side environment key + +The endpoint requires a server-side key (prefix `ser.`). In Flagsmith, go to **Environment Settings → SDK Keys** and create one. + +### 3. Point flagd at the endpoint + +The endpoint URL is: + +``` +GET /api/v1/flagd/flags.json +``` + +The endpoint accepts the key either as an `X-Environment-Key` header (for `curl` / direct HTTP clients) or as a bearer token in the `Authorization` header (for flagd, which exposes only `Authorization` via its `authHeader` config field). + +Configure flagd to poll over HTTP using its `--sources` JSON: + +```bash +flagd start \ + --sources='[{ + "uri": "https://api.flagsmith.com/api/v1/flagd/flags.json", + "provider": "http", + "authHeader": "Bearer ser.your-server-key", + "interval": 30 + }]' +``` + +### 4. Evaluate flags via OpenFeature + +flagd exposes evaluation through gRPC, OFREP, and in-process providers. Pick the OpenFeature [flagd provider](https://flagd.dev/reference/providers/) for your language: + +#### Go + +```go +import ( + "github.com/open-feature/go-sdk/openfeature" + flagd "github.com/open-feature/go-sdk-contrib/providers/flagd/pkg" +) + +openfeature.SetProvider(flagd.NewProvider()) +client := openfeature.NewClient("my-app") + +enabled, _ := client.BooleanValue(ctx, "my_flag", false, openfeature.NewEvaluationContext("user-123", map[string]interface{}{ + "tier": "premium", +})) +``` + +#### Python + +```python +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider + +api.set_provider(FlagdProvider()) # defaults to localhost:8013 +client = api.get_client() + +ctx = {"targetingKey": "user-123", "tier": "premium"} +enabled = client.get_boolean_value("my_flag", False, ctx) +``` + +#### Node / TypeScript + +```ts +import { OpenFeature } from '@openfeature/server-sdk'; +import { FlagdProvider } from '@openfeature/flagd-provider'; + +await OpenFeature.setProviderAndWait(new FlagdProvider()); +const client = OpenFeature.getClient(); + +const enabled = await client.getBooleanValue('my_flag', false, { + targetingKey: 'user-123', + tier: 'premium', +}); +``` + +The `targetingKey` is what flagd hashes for `fractional` (multivariate) evaluation, so use a stable per-user identifier. + +## Deployment recipes + +### docker-compose + +```yaml +services: + flagd: + image: ghcr.io/open-feature/flagd:v0.13.2 + ports: + - "8013:8013" # gRPC + - "8016:8016" # OFREP + command: + - start + - --sources=[{"uri":"https://api.flagsmith.com/api/v1/flagd/flags.json","provider":"http","authHeader":"Bearer ${FLAGSMITH_SERVER_KEY}","interval":30}] +``` + +### Kubernetes (flagd Helm chart values) + +```yaml +flagd: + sources: + - uri: https://api.flagsmith.com/api/v1/flagd/flags.json + provider: http + interval: 30 + authHeader: "Bearer ser.your-server-key" +``` + +For self-hosted Flagsmith deployments, replace the host with your own. + +### Polling interval + +30–60 seconds is a sensible default. The endpoint sets `Last-Modified` and a strong `ETag`, so unchanged environments return `304 Not Modified` with an empty body — short intervals are cheap. + +## Document anatomy + +A successful response body looks like: + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "my_flag": { + "state": "ENABLED", + "variants": { "control": true }, + "defaultVariant": "control" + }, + "experiment": { + "state": "ENABLED", + "variants": { + "control": "default", + "variant_1": "treatment_a", + "variant_2": "treatment_b" + }, + "defaultVariant": "control", + "targeting": { + "fractional": [ + { "cat": [{ "var": "targetingKey" }, "experiment"] }, + ["variant_1", 30], + ["variant_2", 30], + ["control", 40] + ] + } + }, + "premium_feature": { + "state": "ENABLED", + "variants": { + "control": "free-tier", + "override_Premium-Customers": "premium-tier" + }, + "defaultVariant": "control", + "targeting": { + "if": [ + { "$ref": "Premium-Customers" }, + "override_Premium-Customers", + "control" + ] + } + } + }, + "$evaluators": { + "Premium-Customers": { "==": [{ "var": "tier" }, "premium"] } + }, + "metadata": { + "flagSetId": "my-project/production", + "version": "2026-05-08T10:42:11+00:00", + "flagsmith.environmentId": 42, + "flagsmith.translatorVersion": "v1" + } +} +``` + +### Variant naming + +- **`control`** — the flag's typed value, served when no targeting branch matches. Always present; always the `defaultVariant`. +- **`variant_1`, `variant_2`, …** — multivariate options in declaration order. +- **`override_`** — synthesised when a segment or identity override carries a value distinct from `control`. The override's typed value lives in the variant; targeting routes to it. (flagd targeting can only return a variant *key*, never a literal value — so override values must be expressed as variants.) + +There is no `off` variant. flagd's `state: DISABLED` carries the disabled signal; what a consumer receives in that case is determined by `defaultVariant` (always `"control"`) and the consumer's caller-supplied default. Override `enabled` flags are decorative for flagd consumers — operators encode "off for this segment" by setting the override's value explicitly (e.g. `false` for boolean flags). + +### Segment placement + +Segments referenced by **two or more** features are extracted to the top-level `$evaluators` block and referenced via `$ref` from each flag. Segments referenced by **exactly one** feature are inlined directly into that flag's `targeting`. This keeps `$evaluators` for genuinely shared definitions and avoids name collisions between feature-scoped segments that happen to share a display name. + +### Metadata fields + +- **`flagSetId`** — `"/"`. Stable across renames within Flagsmith only if you don't rename; consumer-side caches keyed on this should expect changes. +- **`version`** — ISO timestamp of the environment's last update; useful for detecting fresh documents in dashboards. +- **`flagsmith.environmentId`** — numeric Flagsmith environment id (helpful for support tickets). +- **`flagsmith.translatorVersion`** — bumped when this translator's output format changes; treat new versions as cache-invalidating. +- **`flagsmith.warnings`** — JSON-encoded list of translation warnings; only present when an environment contains content that couldn't be fully translated. Parse it once at startup to surface gaps. + +## Compatibility matrix + +| Flagsmith concept | flagd translation | Notes | +|----------------------------------------|-----------------------------------------------------------|----------------------------------------| +| Boolean flag (`enabled`) | `state: ENABLED`/`DISABLED` | Always emitted. | +| Typed value (`value`) | `control` variant | `defaultVariant` is always `"control"`.| +| Multivariate options | `fractional` over generated `variant_N` variants | Residual % maps to `"control"`. | +| Segment override with distinct value | `override_` variant carrying the override's value | Routed via inline JsonLogic or `$ref`. | +| Project segment used by ≥2 features | `$evaluators` entry, slugified key, `$ref` from each flag | | +| Segment used by 1 feature | Inlined JsonLogic in that flag's `targeting` | No `$evaluators` entry; no name leakage.| +| Segment rule type ALL | JsonLogic `and` | | +| Segment rule type ANY | JsonLogic `or` | | +| Segment rule type NONE | JsonLogic `!` over `or` | | +| `EQUAL`, `NOT_EQUAL` | `==`, `!=` | With best-effort native typing. | +| `GREATER_THAN`(`_INCLUSIVE`), `LESS_*` | `>`, `>=`, `<`, `<=` | Numeric coercion. | +| `CONTAINS`, `NOT_CONTAINS` | `in` (substring), wrapped in `!` for negation | | +| `IN` | JsonLogic `in` against a list | Values split on `,`. | +| `MODULO` | `{"==": [{"%": [var, D]}, R]}` | Value format `"D|R"`. | +| `IS_SET`, `IS_NOT_SET` | Null comparison | | +| SemVer comparisons | `sem_ver` flagd custom op | Recognised by `:semver` value suffix. | +| `PERCENTAGE_SPLIT` in segment rules | `fractional` two-bucket trick | flagd owns bucketing. | +| Identity overrides | `targetingKey` equality, chained | Capped at 100 per flag (configurable). | +| **`REGEX` operator** | **Skipped** — not supported by flagd | Warning surfaced in `metadata`. | + +## Unsupported features + +- **REGEX segment operator** — skipped; flagd has no equivalent custom op. +- **Change-request previews** — only the active environment state is exposed. +- **Identities created on the flagd side** — Flagsmith never sees them; create identities in Flagsmith if you need persistent overrides. + +## Endpoint reference + +`GET /api/v1/flagd/flags.json` + +| Header | Direction | Notes | +|----------------------|-----------|----------------------------------------------------------------------------------------| +| `X-Environment-Key` | request | Server-side key (prefix `ser.`). Required *unless* `Authorization` is provided. | +| `Authorization` | request | Alternative to `X-Environment-Key`. Accepts a bare token or `Bearer ` form; this is what flagd's HTTP sync `authHeader` field sets. | +| `If-Modified-Since` | request | Optional. Returns `304` when the environment hasn't changed. | +| `If-None-Match` | request | Optional. Same effect; matched against the response `ETag`. | +| `Last-Modified` | response | Max of `environment.updated_at` and the most recent live `FeatureState.live_from` / `EnvironmentFeatureVersion.live_from`, so scheduled-change activations invalidate flagd's conditional cache. | +| `ETag` | response | Strong tag covering content + translator version + the same `Last-Modified` signal. | + +Status codes: + +- `200` — body is the flagd document. +- `304` — short-circuit; body empty. +- `401` / `403` — missing or non-`ser.` key. +- `404` — the flagd integration isn't enabled for the project owning this environment. + +For self-hosted setups behind a proxy, ensure both `Last-Modified` and `ETag` headers are forwarded so flagd can short-circuit polls efficiently. + +## Diagnostics endpoint + +`GET /api/v1/flagd/diagnostics.json` — same authentication as the sync endpoint, same per-project gate. Returns a structured report of translation warnings instead of a flagd document: + +```json +{ + "flagSetId": "my-project/production", + "translatorVersion": "v1", + "environmentWarnings": [], + "features": [ + { + "name": "my_flag", + "warnings": [ + { + "reason": "regex_unsupported", + "detail": "operator=REGEX, property=email" + }, + { + "reason": "type_mismatch", + "detail": "feature=my_flag, types=[number, string]" + } + ] + } + ], + "summary": { "featuresWithWarnings": 1, "totalWarnings": 2 } +} +``` + +Curl this to audit an environment before consumers depend on it. Useful in CI: fail the pipeline when `summary.totalWarnings > 0` on an environment promoted to production. + +### Warning reasons + +| Reason | What it means | +|-----------------------------------|-------------------------------------------------------------------------------| +| `regex_unsupported` | A segment condition uses `REGEX`; flagd has no equivalent. Condition skipped. | +| `unknown_operator` | Internal — flagd translator hit an operator it doesn't know. | +| `malformed_value` | Value for an operator couldn't be parsed (e.g. `MODULO` value not `D|R`). | +| `identity_override_limit_exceeded`| More identity overrides on a flag than the per-flag cap; extras dropped. | +| `disabled_override_no_op` | An override is `enabled=False` with value matching control — invisible to flagd. Set the override value explicitly to make it visible. | +| `type_mismatch` | A flag's control / multivariate / override values land in different flagd typed-flag schemas. Will fail schema validation. | + +## Admin REST endpoint + +`/api/v1/projects//integrations/flagd/` — uses Flagsmith's standard project-admin permissions, follows the same convention as every other integration toggle (Datadog, Grafana, etc.). + +| Method | Effect | +|--------|---------------------------------------------------------------| +| `GET` | List the project's flagd configuration (one row per project). | +| `POST` | Create the configuration row (body: `{"enabled": true}`). | +| `PATCH`| Flip `enabled` on the existing row. | diff --git a/sdk/openapi.yaml b/sdk/openapi.yaml index 2422d7dc3133..a54f4d079158 100644 --- a/sdk/openapi.yaml +++ b/sdk/openapi.yaml @@ -29,6 +29,19 @@ paths: - Environment API Key: [] tags: - sdk + /api/v1/flagd/flags.json: + get: + operationId: sdk_v1_flagd_sync + description: |- + HTTP sync endpoint consumed by flagd: returns the current + Flagsmith environment as a flagd flag-definition document. + responses: + '200': + description: No response body + security: + - Environment API Key: [] + tags: + - sdk /api/v1/flags/: get: operationId: sdk_v1_flags