Skip to content

Commit a013d7b

Browse files
emyllermatthewelwellflagsmith-engineering[bot]
authored
feat(__future__): Experimental new flag endpoint (#8102)
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com> Co-authored-by: flagsmith-engineering[bot] <flagsmith-engineering[bot]@users.noreply.github.com>
1 parent 14e860c commit a013d7b

24 files changed

Lines changed: 3610 additions & 300 deletions

File tree

api/api/urls/future.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
Experimental API endpoints intended to become canonical.
3+
4+
These endpoints are subject to change and should not be considered stable.
5+
Use at your own risk - breaking changes may occur without prior notice.
6+
"""
7+
8+
from django.urls import path
9+
10+
from features.future.views import FlagAPIView
11+
12+
app_name = "future"
13+
14+
urlpatterns = [
15+
path(
16+
"environments/<str:environment_key>/features/<int:feature_id>/",
17+
FlagAPIView.as_view(),
18+
name="flag",
19+
),
20+
]

api/app/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
r"^api/experiments/",
3838
include("api.urls.experiments", namespace="api-experiments"),
3939
),
40+
re_path(
41+
r"^api/__future__/", include("api.urls.future", namespace="api-future")
42+
),
4043
re_path(r"^admin/", admin.site.urls),
4144
re_path(
4245
r"^sales-dashboard/",

api/experimentation/services.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@
7070
compare_to_control,
7171
srm_p_value,
7272
)
73+
from features.feature_states.models import API_VALUE_TYPES
7374
from features.models import FeatureState
74-
from features.value_types import BOOLEAN, INTEGER, STRING
75+
from features.value_types import BOOLEAN, STRING
7576
from features.versioning.dataclasses import FlagChangeSet, MultivariateValueChangeSet
7677
from features.versioning.versioning_service import (
7778
get_environment_flags_list,
@@ -84,12 +85,6 @@
8485
# TODO: Delete alias as per https://github.com/Flagsmith/flagsmith/issues/7818
8586
from segments.types import SegmentRule as SegmentRuleType
8687

87-
_ROLLOUT_VALUE_TYPE: dict[str, "FeatureValueType"] = {
88-
INTEGER: "integer",
89-
STRING: "string",
90-
BOOLEAN: "boolean",
91-
}
92-
9388
if typing.TYPE_CHECKING:
9489
from collections.abc import Sequence
9590
from datetime import datetime
@@ -858,7 +853,7 @@ def _serialize_feature_state_value(
858853
return "", "string"
859854
return (
860855
str(value.value).lower() if value.type == BOOLEAN else str(value.value),
861-
_ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"),
856+
API_VALUE_TYPES.get(value.type or STRING, "string"),
862857
)
863858

864859

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class FeatureValueError(ValueError):
2+
"""Raised when a feature value is invalid."""
3+
4+
pass

api/features/feature_states/models.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.conf import settings
55
from django.db import models
66

7+
from features.feature_states.exceptions import FeatureValueError
78
from features.value_types import (
89
BOOLEAN,
910
FEATURE_STATE_VALUE_TYPES,
@@ -14,6 +15,12 @@
1415
# TODO: use Pydantic TypeAdapter to map serializer data to DTOs
1516
FeatureValueType = Literal["string", "integer", "boolean"]
1617

18+
API_VALUE_TYPES: dict[str, FeatureValueType] = {
19+
INTEGER: "integer",
20+
STRING: "string",
21+
BOOLEAN: "boolean",
22+
}
23+
1724

1825
class AbstractBaseFeatureValueModel(models.Model):
1926
class Meta:
@@ -54,19 +61,19 @@ def set_value(self, value: str, type_: FeatureValueType) -> None:
5461
try:
5562
typed_value = int(value)
5663
except ValueError:
57-
raise ValueError(f"'{value}' is not a valid integer")
64+
raise FeatureValueError(f"'{value}' is not a valid integer")
5865
field = "integer_value"
5966
type_const = INTEGER
6067
case "boolean":
6168
if value.lower() not in ("true", "false"):
62-
raise ValueError(
69+
raise FeatureValueError(
6370
f"'{value}' is not a valid boolean (use 'true' or 'false')"
6471
)
6572
typed_value = value.lower() == "true"
6673
field = "boolean_value"
6774
type_const = BOOLEAN
6875
case _:
69-
raise ValueError(
76+
raise FeatureValueError(
7077
f"'{type_}' is not a valid type (use 'string', 'integer', or 'boolean')"
7178
)
7279

api/features/future/__init__.py

Whitespace-only changes.

api/features/future/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""https://docs.flagsmith.com/managing-flags/updating-flags"""
2+
3+
from rest_framework import status
4+
from rest_framework.exceptions import APIException
5+
6+
7+
class ChangeRequestsEnabledError(APIException):
8+
"""Raised where a flag can only be changed by going through a change request."""
9+
10+
status_code = status.HTTP_409_CONFLICT
11+
default_code = "change_requests_enabled"
12+
default_detail = (
13+
"Cannot update flags in an environment with change requests enabled."
14+
)
15+
16+
def __init__(self) -> None:
17+
# DRF's default exception handler renders `detail` alone.
18+
super().__init__({"detail": self.default_detail, "code": self.default_code})
19+
20+
21+
class DuplicatePriorityError(APIException):
22+
"""Raised where a flag's segment overrides would end up sharing a priority."""
23+
24+
status_code = status.HTTP_400_BAD_REQUEST
25+
default_detail = "Segment overrides must not share a priority."

api/features/future/mappers.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""https://docs.flagsmith.com/managing-flags/updating-flags"""
2+
3+
from features.feature_states.models import API_VALUE_TYPES
4+
from features.future.types import (
5+
EnvironmentDefaultResponse,
6+
FlagValue,
7+
SegmentOverrideResponse,
8+
SegmentReference,
9+
Variant,
10+
)
11+
from features.models import FeatureState, FeatureStateValue
12+
13+
14+
def map_flag_value(feature_state_value: FeatureStateValue) -> FlagValue | None:
15+
"""Render a stored value as a typed value object, always with a string value."""
16+
value = feature_state_value.value
17+
if value is None:
18+
return None
19+
return FlagValue(
20+
type=API_VALUE_TYPES.get(feature_state_value.type, "string"), # type: ignore[arg-type]
21+
value=("true" if value else "false") if isinstance(value, bool) else str(value),
22+
)
23+
24+
25+
def map_variants(feature_state: FeatureState) -> list[Variant]:
26+
"""List a feature state's variant weights, ordered by variant."""
27+
return [
28+
Variant(
29+
id=multivariate_value.multivariate_feature_option_id,
30+
weight=multivariate_value.percentage_allocation,
31+
)
32+
for multivariate_value in sorted(
33+
feature_state.multivariate_feature_state_values.all(),
34+
key=lambda multivariate_value: (
35+
multivariate_value.multivariate_feature_option_id
36+
),
37+
)
38+
]
39+
40+
41+
def map_environment_default(feature_state: FeatureState) -> EnvironmentDefaultResponse:
42+
"""Render a feature state as the flag's default for its environment."""
43+
return EnvironmentDefaultResponse(
44+
enabled=feature_state.enabled,
45+
value=map_flag_value(feature_state.feature_state_value),
46+
variants=map_variants(feature_state),
47+
)
48+
49+
50+
def map_segment_override(
51+
feature_state: FeatureState,
52+
) -> SegmentOverrideResponse | None:
53+
"""Render a feature state as one of the flag's segment overrides."""
54+
feature_segment = feature_state.feature_segment
55+
if feature_segment is None:
56+
return None
57+
return SegmentOverrideResponse(
58+
segment=SegmentReference(id=feature_segment.segment_id),
59+
priority=feature_segment.priority,
60+
enabled=feature_state.enabled,
61+
value=map_flag_value(feature_state.feature_state_value),
62+
variants=map_variants(feature_state),
63+
)

api/features/future/permissions.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""https://docs.flagsmith.com/managing-flags/updating-flags"""
2+
3+
from collections.abc import Mapping
4+
5+
from common.environments.permissions import (
6+
MANAGE_SEGMENT_OVERRIDES,
7+
UPDATE_FEATURE_STATE,
8+
VIEW_ENVIRONMENT,
9+
)
10+
from rest_framework.exceptions import NotFound, PermissionDenied
11+
12+
from environments.models import Environment
13+
from users.abc import UserABC
14+
15+
PROPERTY_PERMISSIONS = {
16+
"environment_default": UPDATE_FEATURE_STATE,
17+
"segment_overrides": MANAGE_SEGMENT_OVERRIDES,
18+
}
19+
20+
READ_PERMISSIONS = [VIEW_ENVIRONMENT, *PROPERTY_PERMISSIONS.values()]
21+
22+
23+
def check_read_permissions(user: UserABC, environment: Environment) -> None:
24+
"""Authorise a caller to read a flag.
25+
26+
A caller who may neither view nor write the environment is not told it exists.
27+
"""
28+
if not any(
29+
user.has_environment_permission(permission, environment)
30+
for permission in READ_PERMISSIONS
31+
):
32+
raise NotFound()
33+
34+
35+
def check_update_permissions(
36+
user: UserABC, environment: Environment, properties: Mapping[str, object]
37+
) -> None:
38+
"""Authorise a caller to write the flag properties they sent.
39+
40+
A caller who may write no property at all is not told the environment exists.
41+
"""
42+
denied = {
43+
property_name
44+
for property_name, permission in PROPERTY_PERMISSIONS.items()
45+
if not user.has_environment_permission(permission, environment)
46+
}
47+
if len(denied) == len(PROPERTY_PERMISSIONS):
48+
raise NotFound()
49+
if any(property_name in properties for property_name in denied):
50+
raise PermissionDenied()

api/features/future/serializers.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""https://docs.flagsmith.com/managing-flags/updating-flags"""
2+
3+
from typing import get_args
4+
5+
from rest_framework import serializers
6+
7+
from features.feature_states.exceptions import FeatureValueError
8+
from features.feature_states.models import FeatureValueType
9+
from features.feature_types import MULTIVARIATE
10+
from features.future.types import (
11+
EnvironmentDefaultRequest,
12+
FlagValue,
13+
SegmentOverrideRequest,
14+
UpdateFlagRequest,
15+
Variant,
16+
)
17+
from features.models import Feature, FeatureStateValue
18+
from segments.models import Segment
19+
20+
21+
class FlagValueSerializer(serializers.Serializer[FlagValue]):
22+
"""A flag value, typed by the caller so it survives the round trip as a string."""
23+
24+
type = serializers.ChoiceField(choices=get_args(FeatureValueType))
25+
value = serializers.CharField(allow_blank=True)
26+
27+
28+
class VariantSerializer(serializers.Serializer[Variant]):
29+
"""The share of an environment or segment a multivariate variant is served to."""
30+
31+
id = serializers.IntegerField()
32+
weight = serializers.FloatField(min_value=0, max_value=100)
33+
34+
35+
class SegmentReferenceSerializer(serializers.Serializer[Segment]):
36+
"""A segment of the feature's project, referenced by an override."""
37+
38+
id = serializers.IntegerField()
39+
40+
def validate_id(self, id: int) -> int:
41+
feature: Feature = self.context["feature"]
42+
if not Segment.live_objects.filter(
43+
id=id, project_id=feature.project_id
44+
).exists():
45+
raise serializers.ValidationError("Segment not found.")
46+
return id
47+
48+
49+
class FlagStateSerializer(serializers.Serializer[dict[str, object]]):
50+
"""What a flag serves somewhere in an environment."""
51+
52+
enabled = serializers.BooleanField(required=False)
53+
value = FlagValueSerializer(required=False)
54+
variants = VariantSerializer(many=True, required=False)
55+
56+
def validate_value(self, value: FlagValue) -> FlagValue:
57+
try:
58+
FeatureStateValue().set_value(value["value"], value["type"])
59+
except FeatureValueError as error:
60+
raise serializers.ValidationError(str(error)) from error
61+
return value
62+
63+
def validate_variants(self, variants: list[Variant]) -> list[Variant]:
64+
feature: Feature = self.context["feature"]
65+
if feature.type != MULTIVARIATE:
66+
raise serializers.ValidationError("Feature is not multivariate.")
67+
known_ids = set(feature.multivariate_options.values_list("id", flat=True))
68+
given_ids = {variant["id"] for variant in variants}
69+
if given_ids - known_ids:
70+
raise serializers.ValidationError("Variant not found.")
71+
if known_ids - given_ids:
72+
raise serializers.ValidationError("Must include all feature's variants.")
73+
if sum(variant["weight"] for variant in variants) > 100:
74+
raise serializers.ValidationError("Total weight must not exceed 100.")
75+
return variants
76+
77+
78+
class EnvironmentDefaultSerializer(FlagStateSerializer):
79+
"""What the flag serves to everyone the segment overrides do not match."""
80+
81+
def validate(self, attrs: EnvironmentDefaultRequest) -> EnvironmentDefaultRequest:
82+
feature: Feature = self.context["feature"]
83+
if (
84+
self.context["replace"]
85+
and feature.type == MULTIVARIATE
86+
and "variants" not in attrs
87+
):
88+
raise serializers.ValidationError(
89+
{"variants": ["Must include all feature's variants."]}
90+
)
91+
return attrs
92+
93+
94+
class SegmentOverrideSerializer(FlagStateSerializer):
95+
"""What the flag serves to the identities a segment matches."""
96+
97+
segment = SegmentReferenceSerializer()
98+
priority = serializers.IntegerField(min_value=0, required=False)
99+
100+
101+
class UpdateFlagSerializer(serializers.Serializer[UpdateFlagRequest]):
102+
"""The parts of a flag a caller wants to write in one request."""
103+
104+
environment_default = EnvironmentDefaultSerializer(required=False)
105+
segment_overrides = SegmentOverrideSerializer(many=True, required=False)
106+
107+
def validate_segment_overrides(
108+
self, segment_overrides: list[SegmentOverrideRequest]
109+
) -> list[SegmentOverrideRequest]:
110+
seen: set[int] = set()
111+
for override in segment_overrides:
112+
segment_id = override["segment"]["id"]
113+
if segment_id in seen:
114+
raise serializers.ValidationError(f"Duplicate segment: {segment_id}.")
115+
seen.add(segment_id)
116+
return segment_overrides

0 commit comments

Comments
 (0)