Skip to content

Commit 2d31fb4

Browse files
Zaimwa9emyllerpre-commit-ci[bot]
authored
fix(LaunchDarkly importer): Update API version to 20240415 (#6603)
Co-authored-by: Evandro Myller <22429+emyller@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6fe6265 commit 2d31fb4

6 files changed

Lines changed: 216 additions & 26 deletions

File tree

api/integrations/launch_darkly/client.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import timedelta
2-
from typing import Any, Callable, Generator, Iterator, Optional, TypeVar
2+
from typing import Any, Callable, Generator, Iterable, Iterator, Optional, TypeVar
33

44
import backoff
55
from backoff.types import Details
@@ -12,7 +12,9 @@
1212
BACKOFF_DEFAULT_RETRY_AFTER_SECONDS,
1313
BACKOFF_MAX_RETRIES,
1414
LAUNCH_DARKLY_API_BASE_URL,
15+
LAUNCH_DARKLY_API_FLAGS_LIMIT_PER_PAGE,
1516
LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE,
17+
LAUNCH_DARKLY_API_MAX_ENVIRONMENTS_PER_REQUEST,
1618
LAUNCH_DARKLY_API_VERSION,
1719
)
1820
from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError
@@ -172,17 +174,46 @@ def get_environments(self, project_key: str) -> list[ld_types.Environment]:
172174
)
173175
)
174176

175-
def get_flags(self, project_key: str) -> list[ld_types.FeatureFlag]:
176-
"""operationId: getFeatureFlags"""
177+
def get_flags_by_envs(
178+
self,
179+
project_key: str,
180+
environment_keys: list[str],
181+
) -> Iterable[ld_types.FeatureFlag]:
182+
"""
183+
Get flags by environment keys.
184+
185+
:param project_key: Project key to get flags for.
186+
:param environment_keys: List of environment keys to include configs for.
187+
In API v20240415, the environments field is only returned when filtering.
188+
Number of environment keys in one single request is limited to 3.
189+
"""
177190
endpoint = f"/api/v2/flags/{project_key}"
178-
return list(
179-
self._iter_paginated_items(
191+
base_params: dict[str, Any] = {
192+
"summary": "0",
193+
"limit": LAUNCH_DARKLY_API_FLAGS_LIMIT_PER_PAGE,
194+
}
195+
196+
flags_by_key: dict[str, ld_types.FeatureFlag] = {}
197+
for i in range(
198+
0, len(environment_keys), LAUNCH_DARKLY_API_MAX_ENVIRONMENTS_PER_REQUEST
199+
):
200+
batch = environment_keys[
201+
i : i + LAUNCH_DARKLY_API_MAX_ENVIRONMENTS_PER_REQUEST
202+
]
203+
params = {**base_params, "env": batch}
204+
205+
flags_iter: Iterator[ld_types.FeatureFlag] = self._iter_paginated_items(
180206
collection_endpoint=endpoint,
181-
# Summary should be set to 0 in order to get the full flag data including rules.
182-
# https://apidocs.launchdarkly.com/tag/Feature-flags#operation/getFeatureFlags!in=query&path=summary&t=request
183-
additional_params={"summary": "0"},
207+
additional_params=params,
184208
)
185-
)
209+
for flag in flags_iter:
210+
key = flag["key"]
211+
if key in flags_by_key:
212+
flags_by_key[key]["environments"].update(flag["environments"])
213+
else:
214+
flags_by_key[key] = flag
215+
216+
return flags_by_key.values()
186217

187218
def get_flag_count(self, project_key: str) -> int:
188219
"""operationId: getFeatureFlags

api/integrations/launch_darkly/constants.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
LAUNCH_DARKLY_API_BASE_URL = "https://app.launchdarkly.com"
2-
LAUNCH_DARKLY_API_VERSION = "20220603"
3-
# Maximum limit for /api/v2/projects/
4-
# /api/v2/flags/ seemingly not limited, but let's not get too greedy
2+
LAUNCH_DARKLY_API_VERSION = "20240415"
3+
# Maximum limit for /api/v2/projects/ and other endpoints
54
LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE = 1000
5+
# Maximum limit for /api/v2/flags/ is now 100 by launchdarkly API design
6+
LAUNCH_DARKLY_API_FLAGS_LIMIT_PER_PAGE = 100
7+
# Maximum limit for env query parameter values in /api/v2/flags/
8+
LAUNCH_DARKLY_API_MAX_ENVIRONMENTS_PER_REQUEST = 3
69

710
LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6"
811
LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported"

api/integrations/launch_darkly/services.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import json
12
import logging
23
import re
34
from contextlib import contextmanager
4-
from typing import Callable, Generator, Optional, Tuple
5+
from typing import Any, Callable, Generator, Iterable, Optional, Tuple
56

67
from django.conf import settings
78
from django.core import signing
@@ -56,6 +57,12 @@ def _unsign_ld_value(value: str, user_id: int) -> str:
5657
)
5758

5859

60+
def _serialize_variation_value(value: Any) -> str:
61+
if isinstance(value, (dict, list)):
62+
return json.dumps(value)
63+
return str(value)
64+
65+
5966
def _log_error(
6067
import_request: LaunchDarklyImportRequest,
6168
error_message: str,
@@ -710,7 +717,9 @@ def _create_string_feature_states_with_segments_identities(
710717
enabled_variations = ld_flag_config_summary.get("variations") or {}
711718
for idx, variation_config in enabled_variations.items():
712719
if variation_config.get(variation_config_key):
713-
string_value = variations_by_idx[idx]["value"]
720+
string_value = _serialize_variation_value(
721+
variations_by_idx[idx]["value"]
722+
)
714723
break
715724

716725
feature_state, _ = FeatureState.objects.update_or_create(
@@ -758,7 +767,7 @@ def _create_mv_feature_states_with_segments_identities(
758767

759768
for idx, variation in enumerate(variations):
760769
variation_idx = str(idx)
761-
variation_value = variation["value"]
770+
variation_value = _serialize_variation_value(variation["value"])
762771
variation_values_by_idx[variation_idx] = variation_value
763772
(
764773
mv_feature_options_by_variation[str(idx)],
@@ -920,7 +929,7 @@ def _create_feature_from_ld(
920929

921930
def _create_features_from_ld(
922931
import_request: LaunchDarklyImportRequest,
923-
ld_flags: list[ld_types.FeatureFlag],
932+
ld_flags: Iterable[ld_types.FeatureFlag],
924933
environments_by_ld_environment_key: dict[str, Environment],
925934
tags_by_ld_tag: dict[str, Tag],
926935
segments_by_ld_key: dict[str, Segment],
@@ -1103,7 +1112,10 @@ def process_import_request(
11031112

11041113
try:
11051114
ld_environments = ld_client.get_environments(project_key=ld_project_key)
1106-
ld_flags = ld_client.get_flags(project_key=ld_project_key)
1115+
ld_flags = ld_client.get_flags_by_envs(
1116+
project_key=ld_project_key,
1117+
environment_keys=[env["key"] for env in ld_environments],
1118+
)
11071119
ld_flag_tags = ld_client.get_flag_tags()
11081120
# ld_segment_tags = ld_client.get_segment_tags()
11091121
# Keyed by (segment, environment)

api/tests/unit/integrations/launch_darkly/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def ld_client_mock(
3131
for method_name, response_data_path in {
3232
"get_project": "client_responses/get_project.json",
3333
"get_environments": "client_responses/get_environments.json",
34-
"get_flags": "client_responses/get_flags.json",
34+
"get_flags_by_envs": "client_responses/get_flags.json",
3535
"get_segments": "client_responses/get_segments.json",
3636
}.items():
3737
getattr(ld_client_mock, method_name).return_value = json.loads(

api/tests/unit/integrations/launch_darkly/test_client.py

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from typing import Any
23

34
import pytest
45
from pytest_mock import MockerFixture
@@ -17,7 +18,7 @@ def test_launch_darkly_client__get_project__return_expected(
1718
# Given
1819
token = "test-token"
1920
project_key = "test-project-key"
20-
api_version = "20230101"
21+
api_version = "20240415"
2122

2223
mocker.patch(
2324
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION",
@@ -53,7 +54,7 @@ def test_launch_darkly_client__get_environments__return_expected(
5354
# Given
5455
token = "test-token"
5556
project_key = "test-project-key"
56-
api_version = "20230101"
57+
api_version = "20240415"
5758

5859
mocker.patch(
5960
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION",
@@ -104,14 +105,14 @@ def test_launch_darkly_client__get_flags__return_expected(
104105
# Given
105106
token = "test-token"
106107
project_key = "test-project-key"
107-
api_version = "20230101"
108+
api_version = "20240415"
108109

109110
mocker.patch(
110111
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION",
111112
new=api_version,
112113
)
113114
mocker.patch(
114-
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE",
115+
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_FLAGS_LIMIT_PER_PAGE",
115116
new=3,
116117
)
117118

@@ -141,7 +142,12 @@ def test_launch_darkly_client__get_flags__return_expected(
141142
client = LaunchDarklyClient(token=token)
142143

143144
# When
144-
result = client.get_flags(project_key=project_key)
145+
result = list(
146+
client.get_flags_by_envs(
147+
project_key=project_key,
148+
environment_keys=["env1", "env2", "env3"],
149+
)
150+
)
145151

146152
# Then
147153
assert result == expected_result
@@ -155,7 +161,7 @@ def test_launch_darkly_client__get_flag_count__return_expected(
155161
# Given
156162
token = "test-token"
157163
project_key = "test-project-key"
158-
api_version = "20230101"
164+
api_version = "20240415"
159165

160166
mocker.patch(
161167
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION",
@@ -188,7 +194,7 @@ def test_launch_darkly_client__get_flag_tags__return_expected(
188194
) -> None:
189195
# Given
190196
token = "test-token"
191-
api_version = "20230101"
197+
api_version = "20240415"
192198

193199
mocker.patch(
194200
"integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION",
@@ -361,3 +367,115 @@ def test_launch_darkly_client__rate_limit_no_headers__waits_expected(
361367

362368
# Then
363369
assert result == expected_result
370+
371+
372+
def test_launch_darkly_client__get_flags_with_env_keys__return_expected(
373+
requests_mock: RequestsMockerFixture,
374+
) -> None:
375+
# Given
376+
token = "test-token"
377+
project_key = "test-project-env-keys"
378+
environment_keys = ["production", "test"]
379+
380+
response_data = {
381+
"items": [
382+
{
383+
"key": "test-flag",
384+
"name": "Test Flag",
385+
"kind": "boolean",
386+
"environments": {
387+
"production": {"on": True},
388+
"test": {"on": False},
389+
},
390+
}
391+
],
392+
"totalCount": 1,
393+
}
394+
395+
requests_mock.get(
396+
f"https://app.launchdarkly.com/api/v2/flags/{project_key}",
397+
json=response_data,
398+
request_headers={"Authorization": token},
399+
)
400+
401+
client = LaunchDarklyClient(token=token)
402+
403+
# When
404+
result = list(
405+
client.get_flags_by_envs(
406+
project_key=project_key,
407+
environment_keys=environment_keys,
408+
)
409+
)
410+
411+
# Then
412+
assert result == response_data["items"]
413+
assert "env=production" in requests_mock.request_history[0].url
414+
assert "env=test" in requests_mock.request_history[0].url
415+
416+
417+
def test_launch_darkly_client__get_flags_with_env_keys_batching__merges_environments(
418+
requests_mock: RequestsMockerFixture,
419+
) -> None:
420+
# Given
421+
token = "test-token"
422+
project_key = "test-project-batching"
423+
environment_keys = ["env1", "env2", "env3", "env4"]
424+
425+
batch1_response = {
426+
"items": [
427+
{
428+
"key": "flag1",
429+
"environments": {
430+
"env1": {"on": True},
431+
"env2": {"on": False},
432+
"env3": {"on": True},
433+
},
434+
}
435+
],
436+
"totalCount": 1,
437+
}
438+
439+
batch2_response = {
440+
"items": [
441+
{
442+
"key": "flag1",
443+
"environments": {
444+
"env4": {"on": False},
445+
},
446+
}
447+
],
448+
"totalCount": 1,
449+
}
450+
451+
requests_mock.get(
452+
f"https://app.launchdarkly.com/api/v2/flags/{project_key}",
453+
[
454+
{"json": batch1_response},
455+
{"json": batch2_response},
456+
],
457+
request_headers={"Authorization": token},
458+
)
459+
460+
client = LaunchDarklyClient(token=token)
461+
462+
# When
463+
result = list(
464+
client.get_flags_by_envs(
465+
project_key=project_key,
466+
environment_keys=environment_keys,
467+
)
468+
)
469+
470+
# Then
471+
assert len(result) == 1
472+
assert result[0]["key"] == "flag1"
473+
environments: Any = result[0]["environments"]
474+
assert environments == {
475+
"env1": {"on": True},
476+
"env2": {"on": False},
477+
"env3": {"on": True},
478+
"env4": {"on": False},
479+
}
480+
481+
assert len(requests_mock.request_history) == 2

api/tests/unit/integrations/launch_darkly/test_services.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from features.models import Feature, FeatureState
1818
from integrations.launch_darkly.models import LaunchDarklyImportRequest
1919
from integrations.launch_darkly.services import (
20+
_serialize_variation_value,
2021
create_import_request,
2122
process_import_request,
2223
)
@@ -63,7 +64,8 @@ def test_create_import_request__return_expected(
6364

6465

6566
@pytest.mark.parametrize(
66-
"failing_ld_client_method_name", ["get_environments", "get_flags", "get_flag_tags"]
67+
"failing_ld_client_method_name",
68+
["get_environments", "get_flags_by_envs", "get_flag_tags"],
6769
)
6870
@pytest.mark.parametrize(
6971
"exception, expected_error_message",
@@ -611,3 +613,27 @@ def test_process_import_request__large_segments__correctly_imported(
611613
]
612614
)
613615
assert buf.getvalue() == expected_condition_data_snapshot
616+
617+
618+
@pytest.mark.parametrize(
619+
"value, expected",
620+
[
621+
(
622+
{"enabled": True, "description": "test"},
623+
'{"enabled": true, "description": "test"}',
624+
),
625+
([1, 2, 3], "[1, 2, 3]"),
626+
("string_value", "string_value"),
627+
(123, "123"),
628+
(True, "True"),
629+
],
630+
)
631+
def test_serialize_variation_value__return_expected(
632+
value: object,
633+
expected: str,
634+
) -> None:
635+
# When
636+
result = _serialize_variation_value(value)
637+
638+
# Then
639+
assert result == expected

0 commit comments

Comments
 (0)