From 66bff9e5d4b9e87d571bf04b19003ec53f55a444 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 18 Feb 2026 15:00:02 +0300 Subject: [PATCH] feat(decide): added decision API --- src/backend/api/v1/decision/__init__.py | 0 src/backend/api/v1/decision/apps.py | 6 ++ src/backend/api/v1/decision/endpoints.py | 34 +++++++ src/backend/api/v1/decision/schemas.py | 25 ++++++ src/backend/api/v1/decision/tests/__init__.py | 0 .../v1/decision/tests/test_decision_api.py | 88 +++++++++++++++++++ 6 files changed, 153 insertions(+) create mode 100644 src/backend/api/v1/decision/__init__.py create mode 100644 src/backend/api/v1/decision/apps.py create mode 100644 src/backend/api/v1/decision/endpoints.py create mode 100644 src/backend/api/v1/decision/schemas.py create mode 100644 src/backend/api/v1/decision/tests/__init__.py create mode 100644 src/backend/api/v1/decision/tests/test_decision_api.py diff --git a/src/backend/api/v1/decision/__init__.py b/src/backend/api/v1/decision/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/decision/apps.py b/src/backend/api/v1/decision/apps.py new file mode 100644 index 0000000..231bf2e --- /dev/null +++ b/src/backend/api/v1/decision/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FlagsApiConfig(AppConfig): + name = "api.v1.decision" + label = "api_v1_decision" diff --git a/src/backend/api/v1/decision/endpoints.py b/src/backend/api/v1/decision/endpoints.py new file mode 100644 index 0000000..100d610 --- /dev/null +++ b/src/backend/api/v1/decision/endpoints.py @@ -0,0 +1,34 @@ +from http import HTTPStatus + +from django.http import HttpRequest +from ninja import Router + +from api.v1.decision.schemas import DecisionIn, DecisionOut, SingleDecisionOut +from apps.decision.services import decide_for_flag + +router = Router(tags=["decision"]) + + +@router.post( + "", + response={HTTPStatus.OK: DecisionOut}, + summary="Get decisions for feature flags", + description=( + "Resolve feature flag values for a given subject. " + "Returns default values when no active experiment applies, " + "or experiment variant values when the subject is assigned." + ), +) +def decide( + request: HttpRequest, + payload: DecisionIn, +) -> tuple[HTTPStatus, DecisionOut]: + decisions = [] + for flag_key in payload.flags: + result = decide_for_flag( + flag_key=flag_key, + subject_id=payload.subject_id, + subject_attributes=payload.subject_attributes, + ) + decisions.append(SingleDecisionOut(**result)) + return HTTPStatus.OK, DecisionOut(decisions=decisions) diff --git a/src/backend/api/v1/decision/schemas.py b/src/backend/api/v1/decision/schemas.py new file mode 100644 index 0000000..dc77422 --- /dev/null +++ b/src/backend/api/v1/decision/schemas.py @@ -0,0 +1,25 @@ +from ninja import Field, Schema + + +class DecisionIn(Schema): + subject_id: str + subject_attributes: dict[str, str | int | float | bool] = Field( + default_factory=dict, + ) + flags: list[str] = Field( + ..., + min_length=1, + ) + + +class SingleDecisionOut(Schema): + flag: str + value: str | int | float | bool | None + decision_id: str + experiment_id: str | None = None + variant_id: str | None = None + reason: str + + +class DecisionOut(Schema): + decisions: list[SingleDecisionOut] diff --git a/src/backend/api/v1/decision/tests/__init__.py b/src/backend/api/v1/decision/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/decision/tests/test_decision_api.py b/src/backend/api/v1/decision/tests/test_decision_api.py new file mode 100644 index 0000000..b14656c --- /dev/null +++ b/src/backend/api/v1/decision/tests/test_decision_api.py @@ -0,0 +1,88 @@ +import json +from decimal import Decimal +from typing import override + +from django.core.cache import cache +from django.test import Client, TestCase +from django.urls import reverse + +from apps.experiments.models import Experiment, ExperimentStatus +from apps.experiments.services import variant_create +from apps.experiments.tests.helpers import make_experiment, make_flag +from apps.users.tests.helpers import make_user + + +class DecisionAPITest(TestCase): + @override + def setUp(self) -> None: + cache.clear() + self.client = Client() + self.flag = make_flag(suffix="_dapi", default="default_val") + self.owner = make_user( + username="dapi_owner", email="dapi_owner@lotty.local" + ) + + def _decide(self, data): + return self.client.post( + reverse("api-1:decide"), + data=json.dumps(data), + content_type="application/json", + ) + + def test_unknown_flag(self) -> None: + resp = self._decide({"subject_id": "u1", "flags": ["nonexistent"]}) + self.assertEqual(resp.status_code, 200) + decisions = resp.json()["decisions"] + self.assertEqual(len(decisions), 1) + self.assertEqual(decisions[0]["reason"], "flag_not_found") + + def test_no_active_experiment(self) -> None: + resp = self._decide({"subject_id": "u1", "flags": [self.flag.key]}) + self.assertEqual(resp.status_code, 200) + d = resp.json()["decisions"][0] + self.assertEqual(d["reason"], "no_active_experiment") + self.assertEqual(d["value"], "default_val") + + def test_running_experiment(self) -> None: + exp = make_experiment( + flag=self.flag, + owner=self.owner, + suffix="_drun", + traffic_allocation=Decimal("100.00"), + ) + variant_create( + experiment=exp, + name="control", + value="ctrl", + weight=Decimal("50.00"), + is_control=True, + ) + variant_create( + experiment=exp, + name="treatment", + value="treat", + weight=Decimal("50.00"), + ) + Experiment.objects.filter(pk=exp.pk).update( + status=ExperimentStatus.RUNNING + ) + resp = self._decide({"subject_id": "user42", "flags": [self.flag.key]}) + self.assertEqual(resp.status_code, 200) + d = resp.json()["decisions"][0] + self.assertEqual(d["reason"], "experiment_assigned") + self.assertIn(d["value"], ("ctrl", "treat")) + + def test_multiple_flags(self) -> None: + flag2 = make_flag(suffix="_dapi2", default="other") + resp = self._decide( + { + "subject_id": "u1", + "flags": [self.flag.key, flag2.key], + } + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()["decisions"]), 2) + + def test_empty_flags_rejected(self) -> None: + resp = self._decide({"subject_id": "u1", "flags": []}) + self.assertEqual(resp.status_code, 422)