Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,25 @@ def get_segments_from_cache(self) -> typing.List[Segment]:
def get_environment_document(
cls,
api_key: str,
*,
include_scheduled: bool = False,
) -> dict[str, typing.Any]:
"""
:param include_scheduled: opt-in. When True, feature states in the
returned document carry an additional `scheduled_change` field
describing their next not-yet-live version, if any. Defaults to
False, in which case the response shape is unchanged.
"""
# No cache entry is ever refreshed by a schedule maturing (its
# `live_from` passing) — that's a pure time-based transition with no
# DB write to hang a cache-invalidation hook off. A cached document
# could therefore describe a `scheduled_change` as still upcoming
# long after it has actually gone live. Opted-in requests bypass
# caching entirely so they're always resolved fresh from the DB.
if include_scheduled:
return cls._get_environment_document_from_db(
api_key, include_scheduled=True
)
if (
settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0
or settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
Expand Down Expand Up @@ -575,6 +593,8 @@ def _get_environment_document_from_cache(
def _get_environment_document_from_db(
cls,
api_key: str,
*,
include_scheduled: bool = False,
) -> dict[str, typing.Any]:
manager = using_database_replica(cls.objects)
environment = manager.filter_for_document_builder(
Expand Down Expand Up @@ -611,7 +631,9 @@ def _get_environment_document_from_db(
),
],
).get()
return map_environment_to_sdk_document(environment)
return map_environment_to_sdk_document(
environment, include_scheduled=include_scheduled
)

def _get_environment(self): # type: ignore[no-untyped-def]
return self
Expand Down
39 changes: 38 additions & 1 deletion api/environments/sdk/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from django.utils.decorators import method_decorator
from django.views.decorators.http import condition
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from flagsmith_schemas.api import V1EnvironmentDocumentResponse
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -17,7 +17,26 @@
from environments.permissions.permissions import EnvironmentKeyPermissions


def _is_include_scheduled_requested(request: Request) -> bool:
# Opt-in query param for `scheduled_change` data.
return request.GET.get("include_scheduled", "").lower() in ("1", "true", "yes")


def get_last_modified(request: Request) -> datetime | None:
# A schedule maturing (`live_from` passing) doesn't touch
# `environment.updated_at` — nothing is saved when time simply
# elapses. Requests that opt into `scheduled_change` data therefore can't
# rely on this field to decide freshness, and worse, a client that has a
# cached body from *before* it started opting in could otherwise get a
# 304 short-circuit here and keep reusing that stale, scheduled-change-free
# body forever. Returning None tells Django's `condition()` there is no
# known last-modified value, so it always falls through to `get()` instead.
# Trade-off: this disables the 304 short-circuit for every
# `include_scheduled=True` request (each one always executes `get()` in
# full), in exchange for guaranteed freshness rather than a cheap-but-stale
# response.
if _is_include_scheduled_requested(request):
return None
updated_at: Optional[datetime] = request.environment.updated_at
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return updated_at

Expand All @@ -31,6 +50,23 @@ def get_authenticators(self): # type: ignore[no-untyped-def]
return [EnvironmentKeyAuthentication(required_key_prefix="ser.")]

@extend_schema(
parameters=[
OpenApiParameter(
name="include_scheduled",
type=bool,
required=False,
description=(
"Opt-in. When true, each feature state in "
"the response carries an additional `scheduled_change` "
"field describing its next not-yet-live version, if any. "
"Defaults to false, in which case the response shape is "
"unchanged. Note: not every scheduled-change shape is "
"currently surfaced (e.g. v2 segment overrides via the "
"versioning flow, brand-new segment overrides, scheduled "
"deletions, and multivariate weight changes)."
),
),
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
responses={200: V1EnvironmentDocumentResponse},
operation_id="sdk_v1_environment_document",
)
Expand All @@ -42,6 +78,7 @@ def get(self, request: Request) -> Response:
"""
environment_document = Environment.get_environment_document(
request.environment.api_key,
include_scheduled=_is_include_scheduled_requested(request),
)
updated_at = self.request.environment.updated_at
return Response(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import time
from typing import TYPE_CHECKING
from unittest.mock import ANY
Expand Down Expand Up @@ -317,3 +318,86 @@ def test_get_environment_document__if_modified_since_header__returns_304_or_200(
# Then - actual environment is returned with a 200
assert response4.status_code == status.HTTP_200_OK
assert len(response4.content) > 0


def test_get_environment_document__include_scheduled_query_param__feature_state_carries_scheduled_change(
organisation_one: "Organisation",
organisation_one_project_one: "Project",
) -> None:
# Given
project = organisation_one_project_one
environment = Environment.objects.create(name="Test Environment", project=project)
api_key = EnvironmentAPIKey.objects.create(environment=environment).key
feature = Feature.objects.create(name="test_feature", project=project)

live_from = timezone.now() + datetime.timedelta(hours=1)
FeatureState.objects.create(
environment=environment,
feature=feature,
version=2,
live_from=live_from,
enabled=True,
)

client = APIClient()
client.credentials(HTTP_X_ENVIRONMENT_KEY=api_key)
url = reverse("api-v1:environment-document")

# When - opted in via the query param
response = client.get(url, {"include_scheduled": "true"})

# Then - the feature state carries the scheduled change
assert response.status_code == status.HTTP_200_OK
[feature_state] = [
fs
for fs in response.data["feature_states"]
if fs["feature"]["id"] == feature.id
]
assert feature_state["scheduled_change"] == {
"live_from": live_from,
"enabled": True,
"feature_state_value": None,
}

# When - not opted in
default_response = client.get(url)

# Then - the field is omitted entirely, not present-and-null
[default_feature_state] = [
fs
for fs in default_response.data["feature_states"]
if fs["feature"]["id"] == feature.id
]
assert "scheduled_change" not in default_feature_state


def test_get_environment_document__include_scheduled_query_param__bypasses_conditional_get(
organisation_one: "Organisation",
organisation_one_project_one: "Project",
) -> None:
# Given
project = organisation_one_project_one
environment = Environment.objects.create(name="Test Environment", project=project)
api_key = EnvironmentAPIKey.objects.create(environment=environment).key

client = APIClient()
client.credentials(HTTP_X_ENVIRONMENT_KEY=api_key)
url = reverse("api-v1:environment-document")

# a cached If-Modified-Since value that matches the environment's
# current state, so a plain request would 304
if_modified_since = http_date(environment.updated_at.timestamp())
baseline_response = client.get(url, HTTP_IF_MODIFIED_SINCE=if_modified_since)
assert baseline_response.status_code == status.HTTP_304_NOT_MODIFIED

# When - an opted-in request with the same If-Modified-Since header
scheduled_response = client.get(
url,
{"include_scheduled": "true"},
HTTP_IF_MODIFIED_SINCE=if_modified_since,
)

# Then - it's never short-circuited to 304, and carries no Last-Modified
# header for a client to cache against
assert scheduled_response.status_code == status.HTTP_200_OK
assert "Last-Modified" not in scheduled_response.headers
Loading
Loading