feat(decide): added decision API
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FlagsApiConfig(AppConfig):
|
||||||
|
name = "api.v1.decision"
|
||||||
|
label = "api_v1_decision"
|
||||||
@@ -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)
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user