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