-
Notifications
You must be signed in to change notification settings - Fork 465
feat(api): add experimental feature flag update endpoints #6305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+2,173
−31
Merged
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
4ea79aa
Initial PoC for new endpoint
matthewelwell 891598e
feat(api): add experimental feature flag update endpoints
gagantrivedi 0bc2de2
Merge branch 'main' into feat/experimental-flag-update-endpoints
gagantrivedi d128862
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 4e6cec8
Typing fixes
matthewelwell b0c0a6a
Update SegmentSerializer name
matthewelwell 2c03d5e
test: add comprehensive test coverage for experimental flag endpoints
gagantrivedi fc21bc0
refactor: remove SDK integration patterns from unit tests
gagantrivedi bf7bd7b
misc
gagantrivedi db2477e
return 403 if change request is enabled
gagantrivedi 03d0aa6
cleanup
gagantrivedi cf91675
fixup! cleanup
gagantrivedi 6ecba23
feat: add UPDATE_FEATURE_STATE permission check to experimental endpo…
gagantrivedi 8a34a26
refactor: use environment_key instead of environment_id in experiment…
gagantrivedi 5627624
fix: use explicit isnull filter for environment defaults in V1 versio…
gagantrivedi 6a3fa62
refactor: add set_value method to clear stale fields
gagantrivedi 39c4b44
fix: add identity filter to V1 versioning environment default queries
gagantrivedi 8d43e11
fix: use .to() for priority reordering when creating new segment over…
gagantrivedi ce92682
cleanup: refactor versioning service to remove early returns and use …
gagantrivedi b8eeb6d
fix: validate segment belongs to project before creating segment over…
gagantrivedi 2b78c5e
chore: review fixes
gagantrivedi 3704c9c
chore: remove outdated TODO comment
gagantrivedi c979e28
chore: address PR review feedback from khvn26
gagantrivedi 0d3bfbc
fix: set environment_feature_version on FeatureSegment for V2 versioning
gagantrivedi d7e2434
fix: improve validation and test accuracy
gagantrivedi 20e515b
fix: use explicit None checks for segment_id instead of truthiness
gagantrivedi 6af33d2
chore: add TODO for Pydantic TypeAdapter suggestion
gagantrivedi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| """ | ||
| Experimental API endpoints. | ||
|
|
||
| These endpoints are subject to change and should not be considered stable. | ||
| Use at your own risk - breaking changes may occur without prior notice. | ||
| """ | ||
|
|
||
| from django.urls import path | ||
|
|
||
| from features.feature_states.views import update_flag_v1, update_flag_v2 | ||
|
|
||
| app_name = "experiments" | ||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "environments/<str:environment_key>/update-flag-v1/", | ||
| update_flag_v1, | ||
| name="update-flag-v1", | ||
| ), | ||
| path( | ||
| "environments/<str:environment_key>/update-flag-v2/", | ||
| update_flag_v2, | ||
| name="update-flag-v2", | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| from common.environments.permissions import UPDATE_FEATURE_STATE | ||
| from rest_framework.permissions import BasePermission | ||
| from rest_framework.request import Request | ||
| from rest_framework.views import APIView | ||
|
|
||
| from environments.models import Environment | ||
|
|
||
|
|
||
| class EnvironmentUpdateFeatureStatePermission(BasePermission): | ||
| def has_permission(self, request: Request, view: APIView) -> bool: | ||
| environment_key = view.kwargs.get("environment_key") | ||
| try: | ||
| environment = Environment.objects.get(api_key=environment_key) | ||
| except Environment.DoesNotExist: | ||
| return False | ||
|
|
||
| return request.user.has_environment_permission( # type: ignore[union-attr] | ||
| UPDATE_FEATURE_STATE, environment | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| from rest_framework import serializers | ||
|
|
||
| from environments.models import Environment | ||
| from features.models import Feature, FeatureState | ||
| from features.versioning.dataclasses import ( | ||
| FlagChangeSet, | ||
| FlagChangeSetV2, | ||
| SegmentOverrideChangeSet, | ||
| ) | ||
| from features.versioning.versioning_service import update_flag, update_flag_v2 | ||
| from segments.models import Segment | ||
|
|
||
|
|
||
| class BaseFeatureUpdateSerializer(serializers.Serializer): # type: ignore[type-arg] | ||
| @property | ||
| def environment(self) -> Environment: | ||
| environment: Environment | None = self.context.get("environment") | ||
| if not environment: | ||
| raise serializers.ValidationError("Environment context is required") | ||
| return environment | ||
gagantrivedi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def get_feature(self) -> Feature: | ||
| feature_data = self.validated_data["feature"] | ||
| try: | ||
| feature: Feature = Feature.objects.get( | ||
| project_id=self.environment.project_id, **feature_data | ||
| ) | ||
| return feature | ||
| except Feature.DoesNotExist: | ||
| raise serializers.ValidationError( | ||
| f"Feature '{feature_data}' not found in project" | ||
| ) | ||
|
|
||
| def validate_segment_id(self, segment_id: int) -> None: | ||
| if not Segment.objects.filter( | ||
| id=segment_id, project_id=self.environment.project_id | ||
| ).exists(): | ||
| raise serializers.ValidationError( | ||
| f"Segment with id {segment_id} not found in project" | ||
| ) | ||
|
|
||
|
|
||
| class FeatureIdentifierSerializer(serializers.Serializer): # type: ignore[type-arg] | ||
| name = serializers.CharField(required=False, allow_blank=False) | ||
| id = serializers.IntegerField(required=False) | ||
|
|
||
| def validate(self, data: dict) -> dict: # type: ignore[type-arg] | ||
| if not data.get("name") and not data.get("id"): | ||
| raise serializers.ValidationError( | ||
| "Either 'name' or 'id' is required for feature identification" | ||
| ) | ||
| if data.get("name") and data.get("id"): | ||
| raise serializers.ValidationError("Provide either 'name' or 'id', not both") | ||
| return data | ||
gagantrivedi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| class FeatureUpdateSegmentDataSerializer(serializers.Serializer): # type: ignore[type-arg] | ||
| id = serializers.IntegerField(required=True) | ||
| priority = serializers.IntegerField(required=False, allow_null=True) | ||
gagantrivedi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| class FeatureValueSerializer(serializers.Serializer): # type: ignore[type-arg] | ||
| type = serializers.ChoiceField( | ||
| choices=["integer", "string", "boolean"], required=True | ||
| ) | ||
| string_value = serializers.CharField(required=True, allow_blank=True) | ||
khvn26 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| def validate(self, data: dict) -> dict: # type: ignore[type-arg] | ||
| value_type = data["type"] | ||
| string_val = data["string_value"] | ||
|
|
||
| if value_type == "integer": | ||
| try: | ||
| int(string_val) | ||
| except ValueError: | ||
| raise serializers.ValidationError( | ||
| f"'{string_val}' is not a valid integer" | ||
| ) | ||
| elif value_type == "boolean": | ||
| if string_val.lower() not in ("true", "false"): | ||
| raise serializers.ValidationError( | ||
| f"'{string_val}' is not a valid boolean (use 'true' or 'false')" | ||
| ) | ||
gagantrivedi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return data | ||
|
|
||
|
|
||
| class UpdateFlagSerializer(BaseFeatureUpdateSerializer): | ||
| feature = FeatureIdentifierSerializer(required=True) | ||
| segment = FeatureUpdateSegmentDataSerializer(required=False) | ||
| enabled = serializers.BooleanField(required=True) | ||
| value = FeatureValueSerializer(required=True) | ||
|
|
||
| def validate_segment(self, value: dict) -> dict: # type: ignore[type-arg] | ||
| if value and value.get("id"): | ||
gagantrivedi marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self.validate_segment_id(value["id"]) | ||
| return value | ||
|
|
||
| @property | ||
| def flag_change_set(self) -> FlagChangeSet: | ||
| validated_data = self.validated_data | ||
| value_data = validated_data["value"] | ||
| segment_data = validated_data.get("segment") | ||
|
|
||
| change_set = FlagChangeSet( | ||
| enabled=validated_data["enabled"], | ||
| feature_state_value=value_data["string_value"], | ||
| type_=value_data["type"], | ||
| segment_id=segment_data.get("id") if segment_data else None, | ||
| segment_priority=segment_data.get("priority") if segment_data else None, | ||
| ) | ||
|
|
||
| change_set.set_audit_fields_from_request(self.context["request"]) | ||
| return change_set | ||
|
|
||
| def save(self, **kwargs: object) -> FeatureState: | ||
| feature = self.get_feature() | ||
| return update_flag(self.environment, feature, self.flag_change_set) | ||
|
|
||
|
|
||
| class EnvironmentDefaultSerializer(serializers.Serializer): # type: ignore[type-arg] | ||
| enabled = serializers.BooleanField(required=True) | ||
| value = FeatureValueSerializer(required=True) | ||
|
|
||
|
|
||
| class SegmentOverrideSerializer(serializers.Serializer): # type: ignore[type-arg] | ||
| segment_id = serializers.IntegerField(required=True) | ||
| priority = serializers.IntegerField(required=False, allow_null=True) | ||
| enabled = serializers.BooleanField(required=True) | ||
| value = FeatureValueSerializer(required=True) | ||
|
|
||
|
|
||
| class UpdateFlagV2Serializer(BaseFeatureUpdateSerializer): | ||
| feature = FeatureIdentifierSerializer(required=True) | ||
| environment_default = EnvironmentDefaultSerializer(required=True) | ||
| segment_overrides = SegmentOverrideSerializer(many=True, required=False) | ||
|
|
||
| def validate_segment_overrides( | ||
| self, | ||
| value: list[dict], # type: ignore[type-arg] | ||
| ) -> list[dict]: # type: ignore[type-arg] | ||
| if not value: | ||
| return value | ||
|
|
||
| segment_ids = [override["segment_id"] for override in value] | ||
| if len(segment_ids) != len(set(segment_ids)): | ||
| raise serializers.ValidationError( | ||
| "Duplicate segment_id values are not allowed" | ||
| ) | ||
|
|
||
| for segment_id in segment_ids: | ||
| self.validate_segment_id(segment_id) | ||
khvn26 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return value | ||
|
|
||
| @property | ||
khvn26 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| def change_set_v2(self) -> FlagChangeSetV2: | ||
| validated_data = self.validated_data | ||
|
|
||
| env_default = validated_data["environment_default"] | ||
| env_value_data = env_default["value"] | ||
|
|
||
| segment_overrides_data = validated_data.get("segment_overrides", []) | ||
| segment_overrides = [] | ||
|
|
||
| for override_data in segment_overrides_data: | ||
| value_data = override_data["value"] | ||
|
|
||
| segment_override = SegmentOverrideChangeSet( | ||
| segment_id=override_data["segment_id"], | ||
| enabled=override_data["enabled"], | ||
| feature_state_value=value_data["string_value"], | ||
| type_=value_data["type"], | ||
| priority=override_data.get("priority"), | ||
| ) | ||
| segment_overrides.append(segment_override) | ||
|
|
||
| change_set = FlagChangeSetV2( | ||
| environment_default_enabled=env_default["enabled"], | ||
| environment_default_value=env_value_data["string_value"], | ||
| environment_default_type=env_value_data["type"], | ||
| segment_overrides=segment_overrides, | ||
| ) | ||
|
|
||
| change_set.set_audit_fields_from_request(self.context["request"]) | ||
| return change_set | ||
|
|
||
| def save(self, **kwargs: object) -> dict: # type: ignore[type-arg] | ||
| feature = self.get_feature() | ||
| return update_flag_v2(self.environment, feature, self.change_set_v2) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.