diff --git a/api/Makefile b/api/Makefile index 0067adfb41c6..fcb6880758f2 100644 --- a/api/Makefile +++ b/api/Makefile @@ -84,3 +84,17 @@ django-collect-static: .PHONY: serve serve: poetry run gunicorn --bind 0.0.0.0:8000 app.wsgi --reload + +.PHONY: generate-ld-client-types +generate-ld-client-types: + curl -sSL https://app.launchdarkly.com/api/v2/openapi.json | \ + npx openapi-format /dev/fd/0 \ + --filterFile ld-openapi-filter.yaml | \ + datamodel-codegen \ + --output integrations/launch_darkly/types.py \ + --output-model-type typing.TypedDict \ + --target-python-version 3.10 \ + --use-double-quotes \ + --use-standard-collections \ + --wrap-string-literal \ + --special-field-name-prefix= diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 262afab83701..63b9247ab372 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -148,6 +148,7 @@ "integrations.webhook", "integrations.dynatrace", "integrations.flagsmith", + "integrations.launch_darkly", # Rate limiting admin endpoints "axes", "telemetry", diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index e0a24d69c6a3..3c15d5858901 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -8,3 +8,4 @@ class RelatedObjectType(enum.Enum): ENVIRONMENT = "Environment" CHANGE_REQUEST = "Change request" EDGE_IDENTITY = "Edge Identity" + IMPORT_REQUEST = "Import request" diff --git a/api/integrations/launch_darkly/__init__.py b/api/integrations/launch_darkly/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/launch_darkly/apps.py b/api/integrations/launch_darkly/apps.py new file mode 100644 index 000000000000..46224efd72ad --- /dev/null +++ b/api/integrations/launch_darkly/apps.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig + + +class LaunchDarklyConfigurationConfig(AppConfig): + name = "integrations.launch_darkly" diff --git a/api/integrations/launch_darkly/client.py b/api/integrations/launch_darkly/client.py new file mode 100644 index 000000000000..4164efc21f43 --- /dev/null +++ b/api/integrations/launch_darkly/client.py @@ -0,0 +1,108 @@ +from typing import Any, Iterator, Optional + +from requests import Session + +from integrations.launch_darkly import types as ld_types +from integrations.launch_darkly.constants import ( + LAUNCH_DARKLY_API_BASE_URL, + LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE, + LAUNCH_DARKLY_API_VERSION, +) + + +class LaunchDarklyClient: + def __init__(self, token: str) -> None: + client_session = Session() + client_session.headers.update( + { + "Authorization": token, + "LD-API-Version": LAUNCH_DARKLY_API_VERSION, + } + ) + self.client_session = client_session + + def _get_json_response( + self, + endpoint: str, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + full_url = f"{LAUNCH_DARKLY_API_BASE_URL}{endpoint}" + response = self.client_session.get(full_url, params=params) + response.raise_for_status() + return response.json() + + def _iter_paginated_items( + self, + collection_endpoint: str, + additional_params: Optional[dict[str, str]] = None, + ) -> Iterator[dict[str, Any]]: + params = {"limit": LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE} + if additional_params: + params.update(additional_params) + + response_json = self._get_json_response( + endpoint=collection_endpoint, + params=params, + ) + while True: + yield from response_json.get("items") or [] + links: Optional[dict[str, ld_types.Link]] = response_json.get("_links") + if ( + links + and (next_link := links.get("next")) + and (next_endpoint := next_link.get("href")) + ): + # Don't specify params here because links.next.href includes the + # original limit and calculates offsets accordingly. + response_json = self._get_json_response( + endpoint=next_endpoint, + ) + else: + return + + def get_project(self, project_key: str) -> ld_types.Project: + """operationId: getProject""" + endpoint = f"/api/v2/projects/{project_key}" + return self._get_json_response( + endpoint=endpoint, params={"expand": "environments"} + ) + + def get_environments(self, project_key: str) -> list[ld_types.Environment]: + """operationId: getEnvironmentsByProject""" + endpoint = f"/api/v2/projects/{project_key}/environments" + return list( + self._iter_paginated_items( + collection_endpoint=endpoint, + ) + ) + + def get_flags(self, project_key: str) -> list[ld_types.FeatureFlag]: + """operationId: getFeatureFlags""" + endpoint = f"/api/v2/flags/{project_key}" + return list( + self._iter_paginated_items( + collection_endpoint=endpoint, + ) + ) + + def get_flag_count(self, project_key: str) -> int: + """operationId: getFeatureFlags + + Request minimal info and return the total flag count. + """ + endpoint = f"/api/v2/flags/{project_key}" + flags: ld_types.FeatureFlags = self._get_json_response( + endpoint=endpoint, + params={"limit": 1}, + ) + return flags["totalCount"] + + def get_flag_tags(self) -> list[str]: + """operationId: getTags""" + endpoint = "/api/v2/tags" + return list( + self._iter_paginated_items( + collection_endpoint=endpoint, + additional_params={"kind": "flag"}, + ) + ) diff --git a/api/integrations/launch_darkly/constants.py b/api/integrations/launch_darkly/constants.py new file mode 100644 index 000000000000..c1aeca17337a --- /dev/null +++ b/api/integrations/launch_darkly/constants.py @@ -0,0 +1,8 @@ +LAUNCH_DARKLY_API_BASE_URL = "https://app.launchdarkly.com" +LAUNCH_DARKLY_API_VERSION = "20220603" +# Maximum limit for /api/v2/projects/ +# /api/v2/flags/ seemingly not limited, but let's not get too greedy +LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE = 1000 + +LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6" +LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported" diff --git a/api/integrations/launch_darkly/migrations/0001_initial.py b/api/integrations/launch_darkly/migrations/0001_initial.py new file mode 100644 index 000000000000..23ecd00f5038 --- /dev/null +++ b/api/integrations/launch_darkly/migrations/0001_initial.py @@ -0,0 +1,129 @@ +# Generated by Django 3.2.20 on 2023-09-17 14:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("api_keys", "0003_masterapikey_is_admin"), + ("projects", "0019_add_limits"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="LaunchDarklyImportRequest", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("ld_project_key", models.CharField(max_length=2000)), + ("ld_token", models.CharField(max_length=2000)), + ("status", models.JSONField()), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="projects.project", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="HistoricalLaunchDarklyImportRequest", + fields=[ + ( + "id", + models.IntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("ld_project_key", models.CharField(max_length=2000)), + ("ld_token", models.CharField(max_length=2000)), + ("status", models.JSONField()), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "master_api_key", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="api_keys.masterapikey", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "historical launch darkly import request", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/api/integrations/launch_darkly/migrations/0002_importrequest_unique_project_ld_project_key_status_result_null.py b/api/integrations/launch_darkly/migrations/0002_importrequest_unique_project_ld_project_key_status_result_null.py new file mode 100644 index 000000000000..066d8b4e6aa9 --- /dev/null +++ b/api/integrations/launch_darkly/migrations/0002_importrequest_unique_project_ld_project_key_status_result_null.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.20 on 2023-10-03 17:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("launch_darkly", "0001_initial"), + ] + + operations = [ + migrations.AddConstraint( + model_name="launchdarklyimportrequest", + constraint=models.UniqueConstraint( + condition=models.Q(("status__result__isnull", True)), + fields=("project", "ld_project_key"), + name="unique_project_ld_project_key_status_result_null", + ), + ), + ] diff --git a/api/integrations/launch_darkly/migrations/__init__.py b/api/integrations/launch_darkly/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/launch_darkly/models.py b/api/integrations/launch_darkly/models.py new file mode 100644 index 000000000000..2431c702a229 --- /dev/null +++ b/api/integrations/launch_darkly/models.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING, Literal, Optional, TypedDict + +from core.models import abstract_base_auditable_model_factory +from django.db import models +from typing_extensions import NotRequired + +from audit.related_object_type import RelatedObjectType +from projects.models import Project + +if TYPE_CHECKING: # pragma: no cover + from users.models import FFAdminUser + + +class LaunchDarklyImportStatus(TypedDict): + requested_environment_count: int + requested_flag_count: int + result: NotRequired[Literal["success", "failure"]] + error_message: NotRequired[str] + + +class LaunchDarklyImportRequest( + abstract_base_auditable_model_factory(), +): + history_record_class_path = "features.models.HistoricalLaunchDarklyImportRequest" + related_object_type = RelatedObjectType.IMPORT_REQUEST + + created_by = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE) + project = models.ForeignKey(Project, on_delete=models.CASCADE) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) + + ld_project_key = models.CharField(max_length=2000) + ld_token = models.CharField(max_length=2000) + + status: LaunchDarklyImportStatus = models.JSONField() + + def get_create_log_message(self, _) -> str: + return "New LaunchDarkly import requested" + + def get_update_log_message(self, _) -> Optional[str]: + if not self.completed_at: + return None + if self.status.get("result") == "success": + return "LaunchDarkly import completed successfully" + if error_message := self.status.get("error_message"): + return f"LaunchDarkly import failed with error: {error_message}" + return "LaunchDarkly import failed" + + def get_audit_log_author(self) -> "FFAdminUser": + return self.created_by + + def _get_project(self) -> Project: + return self.project + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_project_ld_project_key_status_result_null", + fields=["project", "ld_project_key"], + condition=models.Q(status__result__isnull=True), + ) + ] diff --git a/api/integrations/launch_darkly/serializers.py b/api/integrations/launch_darkly/serializers.py new file mode 100644 index 000000000000..4dc4c7af0222 --- /dev/null +++ b/api/integrations/launch_darkly/serializers.py @@ -0,0 +1,45 @@ +from rest_framework import serializers + +from integrations.launch_darkly.models import LaunchDarklyImportRequest + + +class LaunchDarklyImportRequestStatusSerializer(serializers.Serializer): + requested_environment_count = serializers.IntegerField(read_only=True) + requested_flag_count = serializers.IntegerField(read_only=True) + result = serializers.ChoiceField( + ["success", "failure"], + read_only=True, + allow_null=True, + ) + error_message = serializers.CharField(read_only=True, allow_null=True) + + +class CreateLaunchDarklyImportRequestSerializer(serializers.Serializer): + token = serializers.CharField() + project_key = serializers.CharField() + + +class LaunchDarklyImportRequestSerializer(serializers.ModelSerializer): + created_by = serializers.SlugRelatedField(slug_field="email", read_only=True) + status = LaunchDarklyImportRequestStatusSerializer() + + class Meta: + model = LaunchDarklyImportRequest + fields = ( + "id", + "created_by", + "created_at", + "updated_at", + "completed_at", + "status", + "project", + ) + read_only_fields = ( + "id", + "created_by", + "created_at", + "updated_at", + "completed_at", + "status", + "project", + ) diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py new file mode 100644 index 000000000000..7f655a006104 --- /dev/null +++ b/api/integrations/launch_darkly/services.py @@ -0,0 +1,310 @@ +from contextlib import contextmanager +from typing import TYPE_CHECKING, Optional + +from django.core import signing +from django.utils import timezone +from requests.exceptions import HTTPError, RequestException + +from environments.models import Environment +from features.feature_types import MULTIVARIATE, STANDARD +from features.models import Feature, FeatureState, FeatureStateValue +from features.multivariate.models import ( + MultivariateFeatureOption, + MultivariateFeatureStateValue, +) +from features.value_types import STRING +from integrations.launch_darkly import types as ld_types +from integrations.launch_darkly.client import LaunchDarklyClient +from integrations.launch_darkly.constants import ( + LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL, + LAUNCH_DARKLY_IMPORTED_TAG_COLOR, +) +from integrations.launch_darkly.models import ( + LaunchDarklyImportRequest, + LaunchDarklyImportStatus, +) +from projects.tags.models import Tag + +if TYPE_CHECKING: # pragma: no cover + from projects.models import Project + from users.models import FFAdminUser + + +def _sign_ld_value(value: str, user_id: int) -> str: + return signing.dumps(value, salt=f"ld_import_{user_id}") + + +def _unsign_ld_value(value: str, user_id: int) -> str: + return signing.loads( + value, + salt=f"ld_import_{user_id}", + ) + + +@contextmanager +def _complete_import_request( + import_request: LaunchDarklyImportRequest, +) -> None: + """ + Wrap code so the import request always ends up completed. + + If no exception raised, assume successful import. + + In case wrapped code needs to expose an error to the user, it should populate + `import_request.status["error_message"]` before raising an exception. + """ + try: + yield + except Exception as exc: + import_request.status["result"] = "failure" + raise exc + else: + import_request.status["result"] = "success" + finally: + import_request.ld_token = "" + import_request.completed_at = timezone.now() + import_request.save() + + +def _create_environments_from_ld( + ld_environments: list[ld_types.Environment], + project_id: int, +) -> dict[str, Environment]: + environments_by_ld_environment_key: dict[str, Environment] = {} + + for ld_environment in ld_environments: + ( + environments_by_ld_environment_key[ld_environment["key"]], + _, + ) = Environment.objects.get_or_create( + name=ld_environment["name"], + project_id=project_id, + ) + + return environments_by_ld_environment_key + + +def _create_tags_from_ld( + ld_tags: list[str], + project_id: int, +) -> dict[str, Tag]: + tags_by_ld_tag = {} + + for ld_tag in (*ld_tags, LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL): + tags_by_ld_tag[ld_tag], _ = Tag.objects.update_or_create( + label=ld_tag, + project_id=project_id, + defaults={ + "color": LAUNCH_DARKLY_IMPORTED_TAG_COLOR, + }, + ) + + return tags_by_ld_tag + + +def _create_standard_feature_states( + ld_flag: ld_types.FeatureFlag, + feature: Feature, + environments_by_ld_environment_key: dict[str, Environment], +) -> None: + for ld_environment_key, environment in environments_by_ld_environment_key.items(): + ld_flag_config = ld_flag["environments"][ld_environment_key] + feature_state, _ = FeatureState.objects.update_or_create( + feature=feature, + environment=environment, + defaults={"enabled": ld_flag_config["on"]}, + ) + FeatureStateValue.objects.update_or_create( + feature_state=feature_state, + defaults={"feature_state": feature_state}, + ) + + +def _create_mv_feature_states( + ld_flag: ld_types.FeatureFlag, + feature: Feature, + environments_by_ld_environment_key: dict[str, Environment], +) -> None: + variations = ld_flag["variations"] + mv_feature_options_by_variation: dict[str, MultivariateFeatureOption] = {} + + for idx, variation in enumerate(variations): + mv_feature_options_by_variation[str(idx)] = MultivariateFeatureOption( + feature=feature, + type=STRING, + string_value=variation["value"], + ) + + MultivariateFeatureOption.objects.bulk_create( + mv_feature_options_by_variation.values(), + ) + + for ld_environment_key, environment in environments_by_ld_environment_key.items(): + ld_flag_config = ld_flag["environments"][ld_environment_key] + feature_state, _ = FeatureState.objects.update_or_create( + feature=feature, + environment=environment, + defaults={"enabled": ld_flag_config["on"]}, + ) + + cumulative_rollout = rollout_baseline = 0 + + if ld_flag_config_summary := ld_flag_config.get("_summary"): + enabled_variations = ld_flag_config_summary.get("variations") or {} + for variation_idx, variation_config in enabled_variations.items(): + mv_feature_option = mv_feature_options_by_variation[variation_idx] + percentage_allocation = 0 + + if variation_config.get("isFallthrough"): + # We expect only one fallthrough variation. + percentage_allocation = 100 + + elif rollout := variation_config.get("rollout"): + # 50% allocation is recorded as 50000 in LD. + # It's possible to allocate e.g. 50.999, resulting + # in rollout == 50999. + # Round the values nicely by keeping the `cumulative_rollout` tally. + cumulative_rollout += rollout / 1000 + cumulative_rollout_rounded = round(cumulative_rollout) + percentage_allocation = ( + cumulative_rollout_rounded - rollout_baseline + ) + rollout_baseline = cumulative_rollout_rounded + + MultivariateFeatureStateValue.objects.update_or_create( + feature_state=feature_state, + multivariate_feature_option=mv_feature_option, + defaults={"percentage_allocation": percentage_allocation}, + ) + + +def _create_feature_from_ld( + ld_flag: ld_types.FeatureFlag, + environments_by_ld_environment_key: dict[str, Environment], + tags_by_ld_tag: dict[str, Tag], + project_id: int, +) -> Feature: + feature_type, feature_state_factory = { + "boolean": (STANDARD, _create_standard_feature_states), + "multivariate": (MULTIVARIATE, _create_mv_feature_states), + }[ld_flag["kind"]] + + tags = [ + tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL], + *(tags_by_ld_tag[ld_tag] for ld_tag in ld_flag["tags"]), + ] + + feature, _ = Feature.objects.update_or_create( + project_id=project_id, + name=ld_flag["key"], + defaults={ + "description": ld_flag.get("description"), + "default_enabled": False, + "type": feature_type, + "is_archived": ld_flag["archived"], + }, + ) + feature.tags.set(tags) + + feature_state_factory( + ld_flag=ld_flag, + feature=feature, + environments_by_ld_environment_key=environments_by_ld_environment_key, + ) + + return feature + + +def _create_features_from_ld( + ld_flags: list[ld_types.FeatureFlag], + environments_by_ld_environment_key: dict[str, Environment], + tags_by_ld_tag: dict[str, Tag], + project_id: int, +) -> list[Feature]: + return [ + _create_feature_from_ld( + ld_flag=ld_flag, + environments_by_ld_environment_key=environments_by_ld_environment_key, + tags_by_ld_tag=tags_by_ld_tag, + project_id=project_id, + ) + for ld_flag in ld_flags + ] + + +def get_import_request( + project: "Project", ld_project_key: str +) -> Optional[LaunchDarklyImportRequest]: + return LaunchDarklyImportRequest.objects.get( + project=project, + ld_project_key=ld_project_key, + ) + + +def create_import_request( + project: "Project", + user: "FFAdminUser", + ld_project_key: str, + ld_token: str, +) -> LaunchDarklyImportRequest: + ld_client = LaunchDarklyClient(ld_token) + ld_project = ld_client.get_project(project_key=ld_project_key) + + requested_environment_count = ld_project["environments"]["totalCount"] + requested_flag_count = ld_client.get_flag_count(project_key=ld_project_key) + + status: LaunchDarklyImportStatus = { + "requested_environment_count": requested_environment_count, + "requested_flag_count": requested_flag_count, + } + + return LaunchDarklyImportRequest.objects.create( + project=project, + created_by=user, + ld_project_key=ld_project_key, + ld_token=_sign_ld_value(ld_token, user.id), + status=status, + ) + + +def process_import_request( + import_request: LaunchDarklyImportRequest, +) -> None: + with _complete_import_request(import_request): + ld_token = _unsign_ld_value( + import_request.ld_token, + import_request.created_by.id, + ) + ld_project_key = import_request.ld_project_key + + ld_client = LaunchDarklyClient(ld_token) + + try: + ld_environments = ld_client.get_environments(project_key=ld_project_key) + ld_flags = ld_client.get_flags(project_key=ld_project_key) + ld_tags = ld_client.get_flag_tags() + except HTTPError as exc: + import_request.status[ + "error_message" + ] = f"HTTP {exc.response.status_code} when requesting LaunchDarkly" + raise + except RequestException as exc: + import_request.status[ + "error_message" + ] = f"{exc.__class__.__name__} when requesting LaunchDarkly" + raise + + environments_by_ld_environment_key = _create_environments_from_ld( + ld_environments=ld_environments, + project_id=import_request.project_id, + ) + tags_by_ld_tag = _create_tags_from_ld( + ld_tags=ld_tags, + project_id=import_request.project_id, + ) + _create_features_from_ld( + ld_flags=ld_flags, + environments_by_ld_environment_key=environments_by_ld_environment_key, + tags_by_ld_tag=tags_by_ld_tag, + project_id=import_request.project_id, + ) diff --git a/api/integrations/launch_darkly/tasks.py b/api/integrations/launch_darkly/tasks.py new file mode 100644 index 000000000000..e4c63f7f154d --- /dev/null +++ b/api/integrations/launch_darkly/tasks.py @@ -0,0 +1,12 @@ +from django.db import transaction + +from integrations.launch_darkly.models import LaunchDarklyImportRequest +from integrations.launch_darkly.services import process_import_request +from task_processor.decorators import register_task_handler + + +@register_task_handler() +def process_launch_darkly_import_request(import_request_id: int) -> None: + with transaction.atomic(): + import_request = LaunchDarklyImportRequest.objects.get(id=import_request_id) + process_import_request(import_request) diff --git a/api/integrations/launch_darkly/types.py b/api/integrations/launch_darkly/types.py new file mode 100644 index 000000000000..572d93ae7ec9 --- /dev/null +++ b/api/integrations/launch_darkly/types.py @@ -0,0 +1,356 @@ +# generated by datamodel-codegen: +# filename: +# timestamp: 2023-09-28T03:54:47+00:00 + +from __future__ import annotations + +from typing import Any, Literal, Optional, TypedDict + +from typing_extensions import NotRequired + +ActionIdentifier = str + + +ActionSpecifier = str + + +class ApprovalSettings(TypedDict): + required: bool + bypassApprovalsForPendingChanges: bool + minNumApprovals: int + canReviewOwnRequest: bool + canApplyDeclinedChanges: bool + serviceKind: str + serviceConfig: dict[str, Any] + requiredApprovalTags: list[str] + + +class ClientSideAvailability(TypedDict): + usingMobileKey: NotRequired[bool] + usingEnvironmentId: NotRequired[bool] + + +class Defaults(TypedDict): + onVariation: int + offVariation: int + + +class ExperimentAllocationRep(TypedDict): + defaultVariation: int + canReshuffle: bool + + +class FlagConfigEvaluation(TypedDict): + contextKinds: NotRequired[list[str]] + + +class ForbiddenErrorRep(TypedDict): + code: str + message: str + + +class InvalidRequestErrorRep(TypedDict): + code: str + message: str + + +class Link(TypedDict): + href: NotRequired[str] + type: NotRequired[str] + + +class MaintainerTeam(TypedDict): + key: NotRequired[str] + name: NotRequired[str] + _links: NotRequired[dict[str, Link]] + + +class MemberSummary(TypedDict): + _links: dict[str, Link] + _id: str + firstName: NotRequired[str] + lastName: NotRequired[str] + role: str + email: str + + +class MethodNotAllowedErrorRep(TypedDict): + code: str + message: str + + +class Modification(TypedDict): + date: NotRequired[str] + + +class NotFoundErrorRep(TypedDict): + code: str + message: str + + +Operator = str + + +class Prerequisite(TypedDict): + key: str + variation: int + + +class RateLimitedErrorRep(TypedDict): + code: str + message: str + + +class Target(TypedDict): + values: list[str] + variation: int + contextKind: NotRequired[str] + + +class UnauthorizedErrorRep(TypedDict): + code: str + message: str + + +UnixMillis = int + + +class Variation(TypedDict): + _id: NotRequired[str] + value: Any + description: NotRequired[str] + name: NotRequired[str] + + +class VariationSummary(TypedDict): + rules: int + nullRules: int + targets: int + contextTargets: int + isFallthrough: NotRequired[bool] + isOff: NotRequired[bool] + rollout: NotRequired[int] + bucketBy: NotRequired[str] + + +class WeightedVariation(TypedDict): + variation: int + weight: int + _untracked: NotRequired[bool] + + +class CustomProperty(TypedDict): + name: str + value: list[str] + + +class AccessAllowedReason(TypedDict): + resources: NotRequired[list[str]] + notResources: NotRequired[list[str]] + actions: NotRequired[list[ActionSpecifier]] + notActions: NotRequired[list[ActionSpecifier]] + effect: Literal["allow", "deny"] + role_name: NotRequired[str] + + +class AccessAllowedRep(TypedDict): + action: ActionIdentifier + reason: AccessAllowedReason + + +class AccessDeniedReason(TypedDict): + resources: NotRequired[list[str]] + notResources: NotRequired[list[str]] + actions: NotRequired[list[ActionSpecifier]] + notActions: NotRequired[list[ActionSpecifier]] + effect: Literal["allow", "deny"] + role_name: NotRequired[str] + + +AllVariationsSummary = Optional[dict[str, VariationSummary]] + + +class Clause(TypedDict): + _id: NotRequired[str] + attribute: str + op: Operator + values: list + contextKind: NotRequired[str] + negate: bool + + +CustomProperties = Optional[dict[str, CustomProperty]] + + +class Environment(TypedDict): + _links: dict[str, Link] + _id: str + key: str + name: str + apiKey: str + mobileKey: str + color: str + defaultTtl: int + secureMode: bool + defaultTrackEvents: bool + requireComments: bool + confirmChanges: bool + tags: list[str] + approvalSettings: NotRequired[ApprovalSettings] + + +class Environments(TypedDict): + _links: NotRequired[dict[str, Link]] + totalCount: NotRequired[int] + items: NotRequired[list[Environment]] + + +class ExperimentEnabledPeriodRep(TypedDict): + startDate: NotRequired[UnixMillis] + stopDate: NotRequired[UnixMillis] + + +class ExperimentEnvironmentSettingRep(TypedDict): + startDate: NotRequired[UnixMillis] + stopDate: NotRequired[UnixMillis] + enabledPeriods: NotRequired[list[ExperimentEnabledPeriodRep]] + + +class FlagSummary(TypedDict): + variations: AllVariationsSummary + prerequisites: int + + +class Project(TypedDict): + _links: dict[str, Link] + _id: str + key: str + includeInSnippetByDefault: bool + defaultClientSideAvailability: NotRequired[ClientSideAvailability] + name: str + tags: list[str] + environments: NotRequired[Environments] + + +class Rollout(TypedDict): + variations: list[WeightedVariation] + experimentAllocation: NotRequired[ExperimentAllocationRep] + seed: NotRequired[int] + bucketBy: NotRequired[str] + contextKind: NotRequired[str] + + +class Rule(TypedDict): + _id: NotRequired[str] + variation: NotRequired[int] + rollout: NotRequired[Rollout] + clauses: list[Clause] + trackEvents: bool + description: NotRequired[str] + ref: NotRequired[str] + + +class VariationOrRolloutRep(TypedDict): + variation: NotRequired[int] + rollout: NotRequired[Rollout] + + +class AccessDenied(TypedDict): + action: ActionIdentifier + reason: AccessDeniedReason + + +class Access(TypedDict): + denied: list[AccessDenied] + allowed: list[AccessAllowedRep] + + +class FeatureFlagConfig(TypedDict): + on: bool + archived: bool + salt: str + sel: str + lastModified: UnixMillis + version: int + targets: NotRequired[list[Target]] + contextTargets: NotRequired[list[Target]] + rules: NotRequired[list[Rule]] + fallthrough: NotRequired[VariationOrRolloutRep] + offVariation: NotRequired[int] + prerequisites: NotRequired[list[Prerequisite]] + _site: Link + _access: NotRequired[Access] + _environmentName: str + trackEvents: bool + trackEventsFallthrough: bool + _debugEventsUntilDate: NotRequired[UnixMillis] + _summary: NotRequired[FlagSummary] + evaluation: NotRequired[FlagConfigEvaluation] + + +class MetricListingRep(TypedDict): + experimentCount: NotRequired[int] + _id: str + key: str + name: str + kind: Literal["pageview", "click", "custom"] + _attachedFlagCount: NotRequired[int] + _links: dict[str, Link] + _site: NotRequired[Link] + _access: NotRequired[Access] + tags: list[str] + _creationDate: UnixMillis + lastModified: NotRequired[Modification] + maintainerId: NotRequired[str] + _maintainer: NotRequired[MemberSummary] + description: NotRequired[str] + isNumeric: NotRequired[bool] + successCriteria: NotRequired[Literal["HigherThanBaseline", "LowerThanBaseline"]] + unit: NotRequired[str] + eventKey: NotRequired[str] + randomizationUnits: NotRequired[list[str]] + + +class LegacyExperimentRep(TypedDict): + metricKey: NotRequired[str] + _metric: NotRequired[MetricListingRep] + environments: NotRequired[list[str]] + _environmentSettings: NotRequired[dict[str, ExperimentEnvironmentSettingRep]] + + +class ExperimentInfoRep(TypedDict): + baselineIdx: int + items: list[LegacyExperimentRep] + + +class FeatureFlag(TypedDict): + name: str + kind: Literal["boolean", "multivariate"] + description: NotRequired[str] + key: str + _version: int + creationDate: UnixMillis + includeInSnippet: NotRequired[bool] + clientSideAvailability: NotRequired[ClientSideAvailability] + variations: list[Variation] + temporary: bool + tags: list[str] + _links: dict[str, Link] + maintainerId: NotRequired[str] + _maintainer: NotRequired[MemberSummary] + maintainerTeamKey: NotRequired[str] + _maintainerTeam: NotRequired[MaintainerTeam] + goalIds: NotRequired[list[str]] + experiments: ExperimentInfoRep + customProperties: CustomProperties + archived: bool + archivedDate: NotRequired[UnixMillis] + defaults: NotRequired[Defaults] + environments: dict[str, FeatureFlagConfig] + + +class FeatureFlags(TypedDict): + items: list[FeatureFlag] + _links: dict[str, Link] + totalCount: NotRequired[int] + totalCountWithDifferences: NotRequired[int] diff --git a/api/integrations/launch_darkly/views.py b/api/integrations/launch_darkly/views.py new file mode 100644 index 000000000000..b6dc98af71c6 --- /dev/null +++ b/api/integrations/launch_darkly/views.py @@ -0,0 +1,79 @@ +from django.db.models import QuerySet +from django.db.utils import IntegrityError +from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response + +from integrations.launch_darkly.models import LaunchDarklyImportRequest +from integrations.launch_darkly.serializers import ( + CreateLaunchDarklyImportRequestSerializer, + LaunchDarklyImportRequestSerializer, +) +from integrations.launch_darkly.services import create_import_request +from integrations.launch_darkly.tasks import ( + process_launch_darkly_import_request, +) +from projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT + + +class LaunchDarklyImportRequestViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + serializer_class = LaunchDarklyImportRequestSerializer + pagination_class = None # set here to ensure documentation is correct + model_class = LaunchDarklyImportRequest + + def get_queryset(self) -> QuerySet[LaunchDarklyImportRequest]: + if getattr(self, "swagger_fake_view", False): + return self.model_class.objects.none() + + project = get_object_or_404( + self.request.user.get_permitted_projects(VIEW_PROJECT), + pk=self.kwargs["project_pk"], + ) + return self.model_class.objects.filter(project=project) + + @swagger_auto_schema( + request_body=CreateLaunchDarklyImportRequestSerializer, + responses={status.HTTP_201_CREATED: LaunchDarklyImportRequestSerializer()}, + ) + def create(self, request: Request, *args, **kwargs) -> Response: + request_serializer = CreateLaunchDarklyImportRequestSerializer( + data=request.data + ) + request_serializer.is_valid(raise_exception=True) + + project = get_object_or_404( + self.request.user.get_permitted_projects(CREATE_ENVIRONMENT), + pk=self.kwargs["project_pk"], + ) + + try: + instance = create_import_request( + project=project, + user=self.request.user, + ld_token=request_serializer.validated_data["token"], + ld_project_key=request_serializer.validated_data["project_key"], + ) + except IntegrityError: + raise ValidationError( + "Existing import already in progress for this project" + ) + + process_launch_darkly_import_request.delay( + kwargs={"import_request_id": instance.id} + ) + + serializer = LaunchDarklyImportRequestSerializer(instance) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) diff --git a/api/ld-openapi-filter.yaml b/api/ld-openapi-filter.yaml new file mode 100644 index 000000000000..13520e0e0b21 --- /dev/null +++ b/api/ld-openapi-filter.yaml @@ -0,0 +1,12 @@ +inverseOperationIds: + # List operations used by Flagsmith's LaucnhDarkly importer here. + - getProject + - getEnvironmentsByProject + - getFeatureFlags +unusedComponents: + - schemas + - parameters + - examples + - headers + - requestBodies + - responses diff --git a/api/poetry.lock b/api/poetry.lock index c3ede66e7771..3f84c2a333c8 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -168,6 +168,20 @@ files = [ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +[[package]] +name = "argcomplete" +version = "3.1.2" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.6" +files = [ + {file = "argcomplete-3.1.2-py3-none-any.whl", hash = "sha256:d97c036d12a752d1079f190bc1521c545b941fda89ad85d15afa909b4d1b9a99"}, + {file = "argcomplete-3.1.2.tar.gz", hash = "sha256:d5d1e5efd41435260b8f85673b74ea2e883affcbec9f4230c582689e8e78251b"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + [[package]] name = "asgiref" version = "3.5.2" @@ -197,8 +211,8 @@ files = [ lazy-object-proxy = ">=1.4.0" typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} wrapt = [ - {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}, + {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, ] [[package]] @@ -508,6 +522,17 @@ files = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + [[package]] name = "chargebee" version = "2.7.9" @@ -778,6 +803,37 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "datamodel-code-generator" +version = "0.22.0" +description = "Datamodel Code Generator" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "datamodel_code_generator-0.22.0-py3-none-any.whl", hash = "sha256:5cf8fc4fb6fe7aa750595a558cd4fcd43e36e862f40b0fa4cc123b4548b16a1e"}, + {file = "datamodel_code_generator-0.22.0.tar.gz", hash = "sha256:73ebcefa498e39d0f210923856cb4a498bacc3b7bdea140cca7324e25f5c581b"}, +] + +[package.dependencies] +argcomplete = ">=1.10,<4.0" +black = ">=19.10b0" +genson = ">=1.2.1,<2.0" +inflect = ">=4.1.0,<6.0" +isort = ">=4.3.21,<6.0" +jinja2 = ">=2.10.1,<4.0" +openapi-spec-validator = ">=0.2.8,<=0.5.7" +packaging = "*" +prance = ">=0.18.2" +pydantic = [ + {version = ">=1.10.0,<3.0", extras = ["email"], markers = "python_version >= \"3.11\" and python_version < \"4.0\""}, + {version = ">=1.9.0,<3.0", extras = ["email"], markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, +] +PySnooper = ">=0.4.1,<2.0.0" +toml = ">=0.10.0,<1.0.0" + +[package.extras] +http = ["httpx"] + [[package]] name = "defusedxml" version = "0.7.1" @@ -1193,6 +1249,25 @@ social-auth-app-django = ">=5.0.0,<6.0.0" djet = ["djet (>=0.3.0,<0.4.0)"] webauthn = ["webauthn (<1.0)"] +[[package]] +name = "dnspython" +version = "2.4.2" +description = "DNS toolkit" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "dnspython-2.4.2-py3-none-any.whl", hash = "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8"}, + {file = "dnspython-2.4.2.tar.gz", hash = "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984"}, +] + +[package.extras] +dnssec = ["cryptography (>=2.6,<42.0)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=0.17.3)", "httpx (>=0.24.1)"] +doq = ["aioquic (>=0.9.20)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.23)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + [[package]] name = "drf-nested-routers" version = "0.92.5" @@ -1256,6 +1331,21 @@ files = [ [package.extras] dev = ["Sphinx", "coverage", "flake8", "lxml", "lxml-stubs", "memory-profiler", "memray", "mypy", "tox", "xmlschema (>=2.0.0)"] +[[package]] +name = "email-validator" +version = "2.0.0.post2" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.7" +files = [ + {file = "email_validator-2.0.0.post2-py3-none-any.whl", hash = "sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c"}, + {file = "email_validator-2.0.0.post2.tar.gz", hash = "sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "environs" version = "9.2.0" @@ -1450,6 +1540,16 @@ files = [ {file = "frozenlist-1.4.0.tar.gz", hash = "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251"}, ] +[[package]] +name = "genson" +version = "1.2.2" +description = "GenSON is a powerful, user-friendly JSON Schema generator." +optional = false +python-versions = "*" +files = [ + {file = "genson-1.2.2.tar.gz", hash = "sha256:8caf69aa10af7aee0e1a1351d1d06801f4696e005f06cedef438635384346a16"}, +] + [[package]] name = "google-api-core" version = "2.11.1" @@ -1687,6 +1787,21 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "inflect" +version = "5.6.2" +description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles; convert numbers to words" +optional = false +python-versions = ">=3.7" +files = [ + {file = "inflect-5.6.2-py3-none-any.whl", hash = "sha256:b45d91a4a28a4e617ff1821117439b06eaa86e2a4573154af0149e9be6687238"}, + {file = "inflect-5.6.2.tar.gz", hash = "sha256:aadc7ed73928f5e014129794bbac03058cca35d0a973a5fc4eb45c7fa26005f9"}, +] + +[package.extras] +docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] +testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + [[package]] name = "inflection" version = "0.5.1" @@ -1791,6 +1906,42 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "jsonschema" +version = "4.17.3" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonschema-4.17.3-py3-none-any.whl", hash = "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6"}, + {file = "jsonschema-4.17.3.tar.gz", hash = "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d"}, +] + +[package.dependencies] +attrs = ">=17.4.0" +pyrsistent = ">=0.14.0,<0.17.0 || >0.17.0,<0.17.1 || >0.17.1,<0.17.2 || >0.17.2" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] + +[[package]] +name = "jsonschema-spec" +version = "0.1.6" +description = "JSONSchema Spec with object-oriented paths" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "jsonschema_spec-0.1.6-py3-none-any.whl", hash = "sha256:f2206d18c89d1824c1f775ba14ed039743b41a9167bd2c5bdb774b66b3ca0bbf"}, + {file = "jsonschema_spec-0.1.6.tar.gz", hash = "sha256:90215863b56e212086641956b20127ccbf6d8a3a38343dad01d6a74d19482f76"}, +] + +[package.dependencies] +jsonschema = ">=4.0.0,<4.18.0" +pathable = ">=0.4.1,<0.5.0" +PyYAML = ">=5.1" +requests = ">=2.31.0,<3.0.0" + [[package]] name = "lazy-object-proxy" version = "1.9.0" @@ -2173,6 +2324,41 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "openapi-schema-validator" +version = "0.4.4" +description = "OpenAPI schema validation for Python" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "openapi_schema_validator-0.4.4-py3-none-any.whl", hash = "sha256:79f37f38ef9fd5206b924ed7a6f382cea7b649b3b56383c47f1906082b7b9015"}, + {file = "openapi_schema_validator-0.4.4.tar.gz", hash = "sha256:c573e2be2c783abae56c5a1486ab716ca96e09d1c3eab56020d1dc680aa57bf8"}, +] + +[package.dependencies] +jsonschema = ">=4.0.0,<4.18.0" +rfc3339-validator = "*" + +[package.extras] +docs = ["sphinx (>=5.3.0,<6.0.0)", "sphinx-immaterial (>=0.11.0,<0.12.0)"] + +[[package]] +name = "openapi-spec-validator" +version = "0.5.7" +description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "openapi_spec_validator-0.5.7-py3-none-any.whl", hash = "sha256:8712d2879db7692974ef89c47a3ebfc79436442921ec3a826ac0ce80cde8c549"}, + {file = "openapi_spec_validator-0.5.7.tar.gz", hash = "sha256:6c2d42180045a80fd6314de848b94310bdb0fa4949f4b099578b69f79d9fa5ac"}, +] + +[package.dependencies] +jsonschema = ">=4.0.0,<4.18.0" +jsonschema-spec = ">=0.1.1,<0.2.0" +lazy-object-proxy = ">=1.7.1,<2.0.0" +openapi-schema-validator = ">=0.4.2,<0.5.0" + [[package]] name = "opencensus" version = "0.11.2" @@ -2243,6 +2429,17 @@ files = [ {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, ] +[[package]] +name = "pathable" +version = "0.4.3" +description = "Object-oriented paths" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "pathable-0.4.3-py3-none-any.whl", hash = "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14"}, + {file = "pathable-0.4.3.tar.gz", hash = "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab"}, +] + [[package]] name = "pathspec" version = "0.11.2" @@ -2347,6 +2544,32 @@ docs = ["sphinx (>=1.7.1)"] redis = ["redis"] tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)"] +[[package]] +name = "prance" +version = "23.6.21.0" +description = "Resolving Swagger/OpenAPI 2.0 and 3.0.0 Parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "prance-23.6.21.0-py3-none-any.whl", hash = "sha256:6a4276fa07ed9f22feda4331097d7503c4adc3097e46ffae97425f2c1026bd9f"}, + {file = "prance-23.6.21.0.tar.gz", hash = "sha256:d8c15f8ac34019751cc4945f866d8d964d7888016d10de3592e339567177cabe"}, +] + +[package.dependencies] +chardet = ">=3.0" +packaging = ">=21.3" +requests = ">=2.25" +"ruamel.yaml" = ">=0.17.10" +six = ">=1.15,<2.0" + +[package.extras] +cli = ["click (>=7.0)"] +dev = ["bumpversion (>=0.6)", "pytest (>=6.1)", "pytest-cov (>=2.11)", "sphinx (>=3.4)", "towncrier (>=19.2)", "tox (>=3.4)"] +flex = ["flex (>=6.13,<7.0)"] +icu = ["PyICU (>=2.4,<3.0)"] +osv = ["openapi-spec-validator (>=0.5.1,<0.6.0)"] +ssv = ["swagger-spec-validator (>=2.4,<3.0)"] + [[package]] name = "pre-commit" version = "3.0.4" @@ -2575,6 +2798,7 @@ files = [ ] [package.dependencies] +email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""} typing-extensions = ">=4.2.0" [package.extras] @@ -2641,8 +2865,8 @@ files = [ astroid = ">=2.14.2,<=2.16.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, + {version = ">=0.2", markers = "python_version < \"3.11\""}, ] isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.8" @@ -2742,6 +2966,42 @@ files = [ [package.dependencies] tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +[[package]] +name = "pyrsistent" +version = "0.19.3" +description = "Persistent/Functional/Immutable data structures" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyrsistent-0.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a"}, + {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64"}, + {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf"}, + {file = "pyrsistent-0.19.3-cp310-cp310-win32.whl", hash = "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a"}, + {file = "pyrsistent-0.19.3-cp310-cp310-win_amd64.whl", hash = "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da"}, + {file = "pyrsistent-0.19.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9"}, + {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393"}, + {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19"}, + {file = "pyrsistent-0.19.3-cp311-cp311-win32.whl", hash = "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3"}, + {file = "pyrsistent-0.19.3-cp311-cp311-win_amd64.whl", hash = "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-win32.whl", hash = "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b"}, + {file = "pyrsistent-0.19.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8"}, + {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a"}, + {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c"}, + {file = "pyrsistent-0.19.3-cp38-cp38-win32.whl", hash = "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c"}, + {file = "pyrsistent-0.19.3-cp38-cp38-win_amd64.whl", hash = "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7"}, + {file = "pyrsistent-0.19.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc"}, + {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2"}, + {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3"}, + {file = "pyrsistent-0.19.3-cp39-cp39-win32.whl", hash = "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2"}, + {file = "pyrsistent-0.19.3-cp39-cp39-win_amd64.whl", hash = "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98"}, + {file = "pyrsistent-0.19.3-py3-none-any.whl", hash = "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64"}, + {file = "pyrsistent-0.19.3.tar.gz", hash = "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440"}, +] + [[package]] name = "pysaml2" version = "7.4.2" @@ -2765,6 +3025,20 @@ xmlschema = ">=1.2.1" [package.extras] s2repoze = ["paste", "repoze.who", "zope.interface"] +[[package]] +name = "pysnooper" +version = "1.2.0" +description = "A poor man's debugger for Python." +optional = false +python-versions = "*" +files = [ + {file = "PySnooper-1.2.0-py2.py3-none-any.whl", hash = "sha256:aa859aa9a746cffc1f35e4ee469d49c3cc5185b5fc0c571feb3af3c94d2eb625"}, + {file = "PySnooper-1.2.0.tar.gz", hash = "sha256:810669e162a250a066d8662e573adbc5af770e937c5b5578f28bb7355d1c859b"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pytest" version = "7.2.2" @@ -3078,6 +3352,25 @@ requests = ">=1.2.0" [package.extras] dev = ["black (>=22.3.0)", "build (>=0.7.0)", "isort (>=5.11.4)", "pyflakes (>=2.2.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "pytest-network (>=0.0.1)", "readme-renderer[rst] (>=26.0)", "twine (>=3.4.2)"] +[[package]] +name = "requests-mock" +version = "1.11.0" +description = "Mock out responses from the requests package" +optional = false +python-versions = "*" +files = [ + {file = "requests-mock-1.11.0.tar.gz", hash = "sha256:ef10b572b489a5f28e09b708697208c4a3b2b89ef80a9f01584340ea357ec3c4"}, + {file = "requests_mock-1.11.0-py2.py3-none-any.whl", hash = "sha256:f7fae383f228633f6bececebdab236c478ace2284d6292c6e7e2867b9ab74d15"}, +] + +[package.dependencies] +requests = ">=2.3,<3" +six = "*" + +[package.extras] +fixture = ["fixtures"] +test = ["fixtures", "mock", "purl", "pytest", "requests-futures", "sphinx", "testtools"] + [[package]] name = "requests-oauthlib" version = "1.3.1" @@ -3116,6 +3409,20 @@ urllib3 = ">=1.25.10" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "types-requests"] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "rsa" version = "4.9" @@ -3130,6 +3437,70 @@ files = [ [package.dependencies] pyasn1 = ">=0.1.3" +[[package]] +name = "ruamel-yaml" +version = "0.17.32" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3" +files = [ + {file = "ruamel.yaml-0.17.32-py3-none-any.whl", hash = "sha256:23cd2ed620231677564646b0c6a89d138b6822a0d78656df7abda5879ec4f447"}, + {file = "ruamel.yaml-0.17.32.tar.gz", hash = "sha256:ec939063761914e14542972a5cba6d33c23b0859ab6342f61cf070cfc600efc2"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.12\""} + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.7" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.5" +files = [ + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win32.whl", hash = "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231"}, + {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:1a6391a7cabb7641c32517539ca42cf84b87b667bad38b78d4d42dd23e957c81"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:9c7617df90c1365638916b98cdd9be833d31d337dbcd722485597b43c4a215bf"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win32.whl", hash = "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38"}, + {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_12_0_arm64.whl", hash = "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win32.whl", hash = "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e"}, + {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win_amd64.whl", hash = "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win32.whl", hash = "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93"}, + {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win32.whl", hash = "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac"}, + {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win32.whl", hash = "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8"}, + {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5"}, + {file = "ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497"}, +] + [[package]] name = "rudder-sdk-python" version = "1.0.5" @@ -3870,4 +4241,4 @@ requests = ">=2.7,<3.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "8343a6027a66981622db4dd1b3189c8cf6c638abc7cea6d8b439cb5ecc1d1393" +content-hash = "47b38ca471d9b731546899037f7084d4582517730abe3e0a0fc5243f4da0f49b" diff --git a/api/projects/urls.py b/api/projects/urls.py index e2de7399cf27..ca0f7c3a2844 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -6,6 +6,7 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet +from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet from projects.tags.views import TagViewSet from segments.views import SegmentViewSet @@ -44,6 +45,11 @@ NewRelicConfigurationViewSet, basename="integrations-new-relic", ) +projects_router.register( + r"imports/launch-darkly", + LaunchDarklyImportRequestViewSet, + basename="imports-launch-darkly", +) projects_router.register( "audit", ProjectAuditLogViewSet, diff --git a/api/pyproject.toml b/api/pyproject.toml index 4d21755e84e4..41f52ed603a9 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -127,6 +127,8 @@ pytest-django = "~4.5.2" black = "~23.7.0" pip-tools = "~6.13.0" pytest-cov = "~4.1.0" +datamodel-code-generator = "~0.22" +requests-mock = "^1.11.0" [build-system] requires = ["poetry-core>=1.5.0"] diff --git a/api/tests/unit/integrations/launch_darkly/__init__.py b/api/tests/unit/integrations/launch_darkly/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json new file mode 100644 index 000000000000..84e12c46be45 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_environments.json @@ -0,0 +1,152 @@ +[ + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64c102aeca98f813c9eb1b65.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/another-project/environments/test/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/another-project/environments/test/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/another-project/environments/test", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64c102aeca98f813c9eb1b65.js", + "type": "text/html" + } + }, + "_id": "64c102aeca98f813c9eb1b65", + "_pubnub": { + "channel": "bce8df91f7bc13f3a0e623fb18523d82f94a93e64ad768b48a19d0cdc1c9acee", + "cipherKey": "83aa965574be6d7d55854d89efa499d815981b650a08578b61c8cbaeca626ce1" + }, + "key": "test", + "name": "Test", + "apiKey": "sdk-eec99e0d-300a-4b72-9a2e-1f2a325f39d9", + "mobileKey": "mob-9e1b7c64-da86-417a-8272-e42ad0b44500", + "color": "EBFF38", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": false, + "confirmChanges": false, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + }, + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64c102aeca98f813c9eb1b66.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/another-project/environments/production/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/another-project/environments/production/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/another-project/environments/production", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64c102aeca98f813c9eb1b66.js", + "type": "text/html" + } + }, + "_id": "64c102aeca98f813c9eb1b66", + "_pubnub": { + "channel": "11facfd22d3ef9cd3206b46732b99e048f303455d4ca17930dba41efcb6d9c53", + "cipherKey": "55d970a9033916f1bc9bea1daf40f08ae526351c526719042c4460045a530cfa" + }, + "key": "production", + "name": "Production", + "apiKey": "sdk-691532b0-3700-41a0-9a15-9b83514d45ee", + "mobileKey": "mob-060f691c-8d59-4b14-ac57-94623b5e492c", + "color": "00DA7B", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": true, + "confirmChanges": true, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + }, + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142c.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/test-project-key/environments/production/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/test-project-key/environments/production/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/test-project-key/environments/production", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142c.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142c", + "_pubnub": { + "channel": "1dbc7bd143e2711f6bdb9a242a4f99d34e26e2d7767e2254ca64a6293336af25", + "cipherKey": "9c2ab94ab61d603539829701814dfeeed9cf37f05ec14012e61da008fa924bf9" + }, + "key": "production", + "name": "Production", + "apiKey": "sdk-d222b487-4486-4c11-95b7-d3e8fadf8ea9", + "mobileKey": "mob-63341f83-9943-4fe6-9ca5-26ee4bc4e4a9", + "color": "417505", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": true, + "confirmChanges": true, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + } +] \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json new file mode 100644 index 000000000000..d7111c2cd754 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json @@ -0,0 +1,655 @@ +[ + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag1", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905528781, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag1", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905528795, + "on": false, + "salt": "ac3a69d89de94c109ed9538893418a96", + "sel": "7eaf26e6d74244229ad8b010216824bc", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag1", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905528795, + "on": true, + "salt": "14ebfdad4b3c4d499ed6bcf7b426065b", + "sel": "20a254c86f1f4d358871566a2bfb3efe", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag1", + "kind": "boolean", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag1", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "c85b3e69-7c56-4b82-90d2-0b8440199236", + "value": true + }, + { + "_id": "1f09e182-2b54-43e1-9e7a-ee024ea01b0d", + "value": false + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag2_value", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905545779, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag2_value", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905545793, + "on": false, + "salt": "639f7591720c4c31a61289009baca9c4", + "sel": "b33c28a01d624e6b8b0d2e74fecb40b4", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag2_value", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905545793, + "on": true, + "salt": "86273b7da4a0430580c21a1ab2943477", + "sel": "91a48251e10c43ba91282443ae938c8d", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag2_value", + "kind": "multivariate", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag2_value", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "9a411e4d-a7c8-4469-bce3-8e1a41cadc45", + "value": "123123" + }, + { + "_id": "b2874b64-767d-4a73-9914-4588063e73b3", + "value": "" + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag3_multivalue", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905575815, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag3_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693940309041, + "on": true, + "salt": "c9e240cf83a149d8a69d6d413504d7de", + "sel": "faa802c193ef43c9a6b328880a55f4a6", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 2 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag3_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905575829, + "on": false, + "salt": "b0bdf137f1be476d8d6fa0c4145fb168", + "sel": "6edd1d7d37804d2eb236a3a7857080ec", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag3_multivalue", + "kind": "multivariate", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag3_multivalue", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "f3b2bb69-9ddc-4ce1-a10b-40bb8bbfb6e4", + "value": "variation1" + }, + { + "_id": "473a40f1-8ded-44d4-bffc-8da4817af471", + "value": "variation2" + }, + { + "_id": "b55d9b88-5bce-4677-860b-0967592bec66", + "value": "variation3" + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/default", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/default/flag4_multivalue", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905575623, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag4_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "nullRules": 0, + "rollout": 23995, + "rules": 0, + "targets": 0 + }, + "1": { + "nullRules": 0, + "rollout": 25000, + "rules": 0, + "targets": 0 + }, + "2": { + "isOff": true, + "nullRules": 0, + "rollout": 51005, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905575623, + "on": true, + "salt": "c9e240cf83a149d8a69d6d413504d7de", + "sel": "faa802c193ef43c9a6b328880a55f4a6", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 9 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag4_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905575623, + "on": false, + "salt": "d6501721ba17406d823511401ae1beb8", + "sel": "75963315efd34bfdb8499ec25deeabe6", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag4_multivalue", + "kind": "multivariate", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag4_multivalue", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "ce3f951b-971c-465f-8252-6b15d5c65414", + "value": "variation1" + }, + { + "_id": "748ee024-6eb7-4d45-8d53-0c5e7caa70f9", + "value": "variation2" + }, + { + "_id": "bd94ec24-d43d-43d2-919c-6fb78da6eed1", + "value": "variation3" + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag5", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1695690165108, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag5", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1695690165274, + "on": false, + "salt": "ff624f87209e452b9cdd39340624e80b", + "sel": "0bb098caac304ec092618f4b9b5a9255", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag5", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1695690165274, + "on": false, + "salt": "3f4e3b81b4984723b18ff5c633322eb7", + "sel": "bebca34e88d64257b050ac7745121467", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag5", + "kind": "boolean", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag5", + "tags": [ + "testtag", + "testtag2" + ], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "410d5e64-d96a-4aa6-ba3a-043be5ba5da2", + "value": true + }, + { + "_id": "54e274c8-47c8-44e1-b962-9de78e745019", + "value": false + } + ] + } +] \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_project.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_project.json new file mode 100644 index 000000000000..0f2c060bcfdb --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_project.json @@ -0,0 +1,140 @@ +{ + "_links": { + "environments": { + "href": "/api/v2/projects/default/environments", + "type": "application/json" + }, + "flagDefaults": { + "href": "/api/v2/projects/default/flag-defaults", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default", + "type": "application/json" + } + }, + "_id": "64f6f21653c76f12626d1429", + "key": "default", + "includeInSnippetByDefault": false, + "defaultClientSideAvailability": { + "usingMobileKey": false, + "usingEnvironmentId": false + }, + "name": "TestProject", + "tags": [], + "environments": { + "_links": { + "parent": { + "href": "/api/v2/projects/default", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default/environments?limit=20", + "type": "application/json" + } + }, + "totalCount": 2, + "items": [ + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142b.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/default/environments/test/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/default/environments/test/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default/environments/test", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142b.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142b", + "_pubnub": { + "channel": "998de857419c22b4088e392f78e55ca0b37786e562c6b4d43d6d9efce50617c6", + "cipherKey": "ab186f79fe320f43a8381b868a88dda5b227c92afa2160eb52c48a09a491221d" + }, + "key": "test", + "name": "Test", + "apiKey": "sdk-3d2a7b99-65bc-468d-8177-b60d3b2fba9d", + "mobileKey": "mob-1ccfdaf7-170a-40ae-bbf3-2a3a7f043693", + "color": "F5A623", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": false, + "confirmChanges": false, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + }, + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142c.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/default/environments/production/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/default/environments/production/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default/environments/production", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142c.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142c", + "_pubnub": { + "channel": "1dbc7bd143e2711f6bdb9a242a4f99d34e26e2d7767e2254ca64a6293336af25", + "cipherKey": "9c2ab94ab61d603539829701814dfeeed9cf37f05ec14012e61da008fa924bf9" + }, + "key": "production", + "name": "Production", + "apiKey": "sdk-d222b487-4486-4c11-95b7-d3e8fadf8ea9", + "mobileKey": "mob-63341f83-9943-4fe6-9ca5-26ee4bc4e4a9", + "color": "417505", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": true, + "confirmChanges": true, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + } + ] + } +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/conftest.py b/api/tests/unit/integrations/launch_darkly/conftest.py new file mode 100644 index 000000000000..15b77ad2c93f --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/conftest.py @@ -0,0 +1,68 @@ +import json +from os.path import abspath, dirname, join +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from integrations.launch_darkly.client import LaunchDarklyClient +from integrations.launch_darkly.models import LaunchDarklyImportRequest +from integrations.launch_darkly.services import create_import_request +from projects.models import Project +from users.models import FFAdminUser + + +@pytest.fixture +def ld_project_key() -> str: + return "test-project-key" + + +@pytest.fixture +def ld_token() -> str: + return "test-token" + + +@pytest.fixture +def ld_client_mock(mocker: MockerFixture) -> MagicMock: + ld_client_mock = mocker.MagicMock(spec=LaunchDarklyClient) + + for method_name, response_data_path in { + "get_project": "client_responses/get_project.json", + "get_environments": "client_responses/get_environments.json", + "get_flags": "client_responses/get_flags.json", + }.items(): + getattr(ld_client_mock, method_name).return_value = json.load( + open(join(dirname(abspath(__file__)), response_data_path)) + ) + + ld_client_mock.get_flag_count.return_value = 5 + ld_client_mock.get_flag_tags.return_value = ["testtag", "testtag2"] + + return ld_client_mock + + +@pytest.fixture +def ld_client_class_mock( + mocker: MockerFixture, + ld_client_mock: MagicMock, +) -> MagicMock: + return mocker.patch( + "integrations.launch_darkly.services.LaunchDarklyClient", + return_value=ld_client_mock, + ) + + +@pytest.fixture +def import_request( + ld_client_class_mock: MagicMock, + project: Project, + test_user: FFAdminUser, + ld_project_key: str, + ld_token: str, +) -> LaunchDarklyImportRequest: + return create_import_request( + project=project, + user=test_user, + ld_project_key=ld_project_key, + ld_token=ld_token, + ) diff --git a/api/tests/unit/integrations/launch_darkly/example_api_responses/getEnvironmentsByProject_1.json b/api/tests/unit/integrations/launch_darkly/example_api_responses/getEnvironmentsByProject_1.json new file mode 100644 index 000000000000..86351aea01d3 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/example_api_responses/getEnvironmentsByProject_1.json @@ -0,0 +1,73 @@ +{ + "_links": { + "last": { + "href": "/api/v2/projects/test-project-key/environments?limit=1&offset=1", + "type": "application/json" + }, + "next": { + "href": "/api/v2/projects/test-project-key/environments?limit=1&offset=1", + "type": "application/json" + }, + "parent": { + "href": "/api/v2/projects/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/test-project-key/environments?limit=1", + "type": "application/json" + } + }, + "totalCount": 2, + "items": [ + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142b.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/test-project-key/environments/test/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/test-project-key/environments/test/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/test-project-key/environments/test", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142b.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142b", + "_pubnub": { + "channel": "998de857419c22b4088e392f78e55ca0b37786e562c6b4d43d6d9efce50617c6", + "cipherKey": "ab186f79fe320f43a8381b868a88dda5b227c92afa2160eb52c48a09a491221d" + }, + "key": "test", + "name": "Test", + "apiKey": "sdk-3d2a7b99-65bc-468d-8177-b60d3b2fba9d", + "mobileKey": "mob-1ccfdaf7-170a-40ae-bbf3-2a3a7f043693", + "color": "F5A623", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": false, + "confirmChanges": false, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + } + ] +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/example_api_responses/getEnvironmentsByProject_2.json b/api/tests/unit/integrations/launch_darkly/example_api_responses/getEnvironmentsByProject_2.json new file mode 100644 index 000000000000..3eba5b4845b9 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/example_api_responses/getEnvironmentsByProject_2.json @@ -0,0 +1,73 @@ +{ + "_links": { + "first": { + "href": "/api/v2/projects/test-project-key/environments?limit=1", + "type": "application/json" + }, + "parent": { + "href": "/api/v2/projects/test-project-key", + "type": "application/json" + }, + "prev": { + "href": "/api/v2/projects/test-project-key/environments?limit=1", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/test-project-key/environments?limit=1&offset=1", + "type": "application/json" + } + }, + "totalCount": 2, + "items": [ + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142c.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/test-project-key/environments/production/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/test-project-key/environments/production/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/test-project-key/environments/production", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142c.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142c", + "_pubnub": { + "channel": "1dbc7bd143e2711f6bdb9a242a4f99d34e26e2d7767e2254ca64a6293336af25", + "cipherKey": "9c2ab94ab61d603539829701814dfeeed9cf37f05ec14012e61da008fa924bf9" + }, + "key": "production", + "name": "Production", + "apiKey": "sdk-d222b487-4486-4c11-95b7-d3e8fadf8ea9", + "mobileKey": "mob-63341f83-9943-4fe6-9ca5-26ee4bc4e4a9", + "color": "417505", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": true, + "confirmChanges": true, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + } + ] +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/example_api_responses/getFeatureFlags_1.json b/api/tests/unit/integrations/launch_darkly/example_api_responses/getFeatureFlags_1.json new file mode 100644 index 000000000000..3aae5041fd90 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/example_api_responses/getFeatureFlags_1.json @@ -0,0 +1,542 @@ +{ + "_links": { + "last": { + "href": "/api/v2/flags/test-project-key?limit=3\u0026offset=3\u0026summary=true", + "type": "application/json" + }, + "next": { + "href": "/api/v2/flags/test-project-key?limit=3\u0026offset=3\u0026summary=true", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key?limit=3\u0026summary=true", + "type": "application/json" + } + }, + "items": [ + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag1", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905528781, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag1", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905528795, + "on": false, + "salt": "ac3a69d89de94c109ed9538893418a96", + "sel": "7eaf26e6d74244229ad8b010216824bc", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag1", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905528795, + "on": true, + "salt": "14ebfdad4b3c4d499ed6bcf7b426065b", + "sel": "20a254c86f1f4d358871566a2bfb3efe", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag1", + "kind": "boolean", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag1", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "c85b3e69-7c56-4b82-90d2-0b8440199236", + "value": true + }, + { + "_id": "1f09e182-2b54-43e1-9e7a-ee024ea01b0d", + "value": false + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag2_value", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905545779, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag2_value", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905545793, + "on": false, + "salt": "639f7591720c4c31a61289009baca9c4", + "sel": "b33c28a01d624e6b8b0d2e74fecb40b4", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag2_value", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905545793, + "on": false, + "salt": "86273b7da4a0430580c21a1ab2943477", + "sel": "91a48251e10c43ba91282443ae938c8d", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag2_value", + "kind": "multivariate", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag2_value", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "9a411e4d-a7c8-4469-bce3-8e1a41cadc45", + "value": "123123" + }, + { + "_id": "b2874b64-767d-4a73-9914-4588063e73b3", + "value": "" + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag3_multivalue", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905575815, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag3_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693940309041, + "on": true, + "salt": "c9e240cf83a149d8a69d6d413504d7de", + "sel": "faa802c193ef43c9a6b328880a55f4a6", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 2 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag3_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905575829, + "on": false, + "salt": "b0bdf137f1be476d8d6fa0c4145fb168", + "sel": "6edd1d7d37804d2eb236a3a7857080ec", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag3_multivalue", + "kind": "multivariate", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag3_multivalue", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "f3b2bb69-9ddc-4ce1-a10b-40bb8bbfb6e4", + "value": "variation1" + }, + { + "_id": "473a40f1-8ded-44d4-bffc-8da4817af471", + "value": "variation2" + }, + { + "_id": "b55d9b88-5bce-4677-860b-0967592bec66", + "value": "variation3" + } + ] + }, + { + "_links": { + "parent": { + "href": "/api/v2/flags/default", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/default/flag4_multivalue", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1693905575623, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag4_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "nullRules": 0, + "rollout": 23995, + "rules": 0, + "targets": 0 + }, + "1": { + "nullRules": 0, + "rollout": 25000, + "rules": 0, + "targets": 0 + }, + "2": { + "isOff": true, + "nullRules": 0, + "rollout": 51005, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905575623, + "on": true, + "salt": "c9e240cf83a149d8a69d6d413504d7de", + "sel": "faa802c193ef43c9a6b328880a55f4a6", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 9 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag4_multivalue", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1693905575623, + "on": false, + "salt": "d6501721ba17406d823511401ae1beb8", + "sel": "75963315efd34bfdb8499ec25deeabe6", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag4_multivalue", + "kind": "multivariate", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag4_multivalue", + "tags": [], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "ce3f951b-971c-465f-8252-6b15d5c65414", + "value": "variation1" + }, + { + "_id": "748ee024-6eb7-4d45-8d53-0c5e7caa70f9", + "value": "variation2" + }, + { + "_id": "bd94ec24-d43d-43d2-919c-6fb78da6eed1", + "value": "variation3" + } + ] + } + ], + "totalCount": 5 +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/example_api_responses/getFeatureFlags_2.json b/api/tests/unit/integrations/launch_darkly/example_api_responses/getFeatureFlags_2.json new file mode 100644 index 000000000000..e9c6a21a9bd2 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/example_api_responses/getFeatureFlags_2.json @@ -0,0 +1,149 @@ +{ + "_links": { + "first": { + "href": "/api/v2/flags/test-project-key?limit=3\u0026summary=true", + "type": "application/json" + }, + "prev": { + "href": "/api/v2/flags/test-project-key?limit=3\u0026summary=true", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key?limit=3\u0026offset=3\u0026summary=true", + "type": "application/json" + } + }, + "items": [ + { + "_links": { + "parent": { + "href": "/api/v2/flags/test-project-key", + "type": "application/json" + }, + "self": { + "href": "/api/v2/flags/test-project-key/flag5", + "type": "application/json" + } + }, + "_maintainer": { + "_id": "64f6f21653c76f12626d142a", + "_links": { + "self": { + "href": "/api/v2/members/64f6f21653c76f12626d142a", + "type": "application/json" + } + }, + "email": "test@test.com", + "firstName": "John", + "lastName": "Doe", + "role": "owner" + }, + "_version": 1, + "archived": false, + "clientSideAvailability": { + "usingEnvironmentId": false, + "usingMobileKey": false + }, + "creationDate": 1695690165108, + "customProperties": {}, + "defaults": { + "offVariation": 1, + "onVariation": 0 + }, + "description": "", + "environments": { + "production": { + "_environmentName": "Production", + "_site": { + "href": "/default/production/features/flag5", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1695690165274, + "on": false, + "salt": "ff624f87209e452b9cdd39340624e80b", + "sel": "0bb098caac304ec092618f4b9b5a9255", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + }, + "test": { + "_environmentName": "Test", + "_site": { + "href": "/default/test/features/flag5", + "type": "text/html" + }, + "_summary": { + "prerequisites": 0, + "variations": { + "0": { + "isFallthrough": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + }, + "1": { + "isOff": true, + "nullRules": 0, + "rules": 0, + "targets": 0 + } + } + }, + "archived": false, + "lastModified": 1695690165274, + "on": false, + "salt": "3f4e3b81b4984723b18ff5c633322eb7", + "sel": "bebca34e88d64257b050ac7745121467", + "trackEvents": false, + "trackEventsFallthrough": false, + "version": 1 + } + }, + "experiments": { + "baselineIdx": 0, + "items": [] + }, + "goalIds": [], + "includeInSnippet": false, + "key": "flag5", + "kind": "boolean", + "maintainerId": "64f6f21653c76f12626d142a", + "name": "flag5", + "tags": [ + "testtag", + "testtag2" + ], + "temporary": true, + "variationJsonSchema": null, + "variations": [ + { + "_id": "410d5e64-d96a-4aa6-ba3a-043be5ba5da2", + "value": true + }, + { + "_id": "54e274c8-47c8-44e1-b962-9de78e745019", + "value": false + } + ] + } + ], + "totalCount": 5 +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/example_api_responses/getProject.json b/api/tests/unit/integrations/launch_darkly/example_api_responses/getProject.json new file mode 100644 index 000000000000..0f2c060bcfdb --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/example_api_responses/getProject.json @@ -0,0 +1,140 @@ +{ + "_links": { + "environments": { + "href": "/api/v2/projects/default/environments", + "type": "application/json" + }, + "flagDefaults": { + "href": "/api/v2/projects/default/flag-defaults", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default", + "type": "application/json" + } + }, + "_id": "64f6f21653c76f12626d1429", + "key": "default", + "includeInSnippetByDefault": false, + "defaultClientSideAvailability": { + "usingMobileKey": false, + "usingEnvironmentId": false + }, + "name": "TestProject", + "tags": [], + "environments": { + "_links": { + "parent": { + "href": "/api/v2/projects/default", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default/environments?limit=20", + "type": "application/json" + } + }, + "totalCount": 2, + "items": [ + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142b.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/default/environments/test/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/default/environments/test/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default/environments/test", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142b.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142b", + "_pubnub": { + "channel": "998de857419c22b4088e392f78e55ca0b37786e562c6b4d43d6d9efce50617c6", + "cipherKey": "ab186f79fe320f43a8381b868a88dda5b227c92afa2160eb52c48a09a491221d" + }, + "key": "test", + "name": "Test", + "apiKey": "sdk-3d2a7b99-65bc-468d-8177-b60d3b2fba9d", + "mobileKey": "mob-1ccfdaf7-170a-40ae-bbf3-2a3a7f043693", + "color": "F5A623", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": false, + "confirmChanges": false, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + }, + { + "_links": { + "analytics": { + "href": "https://app.launchdarkly.com/snippet/events/v1/64f6f21653c76f12626d142c.js", + "type": "text/html" + }, + "apiKey": { + "href": "/api/v2/projects/default/environments/production/apiKey", + "type": "application/json" + }, + "mobileKey": { + "href": "/api/v2/projects/default/environments/production/mobileKey", + "type": "application/json" + }, + "self": { + "href": "/api/v2/projects/default/environments/production", + "type": "application/json" + }, + "snippet": { + "href": "https://app.launchdarkly.com/snippet/features/64f6f21653c76f12626d142c.js", + "type": "text/html" + } + }, + "_id": "64f6f21653c76f12626d142c", + "_pubnub": { + "channel": "1dbc7bd143e2711f6bdb9a242a4f99d34e26e2d7767e2254ca64a6293336af25", + "cipherKey": "9c2ab94ab61d603539829701814dfeeed9cf37f05ec14012e61da008fa924bf9" + }, + "key": "production", + "name": "Production", + "apiKey": "sdk-d222b487-4486-4c11-95b7-d3e8fadf8ea9", + "mobileKey": "mob-63341f83-9943-4fe6-9ca5-26ee4bc4e4a9", + "color": "417505", + "defaultTtl": 0, + "secureMode": false, + "defaultTrackEvents": false, + "requireComments": true, + "confirmChanges": true, + "tags": [], + "approvalSettings": { + "required": false, + "bypassApprovalsForPendingChanges": false, + "minNumApprovals": 1, + "canReviewOwnRequest": false, + "canApplyDeclinedChanges": true, + "serviceKind": "launchdarkly", + "serviceConfig": {}, + "requiredApprovalTags": [] + } + } + ] + } +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/example_api_responses/getTags.json b/api/tests/unit/integrations/launch_darkly/example_api_responses/getTags.json new file mode 100644 index 000000000000..f22d6d79bb67 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/example_api_responses/getTags.json @@ -0,0 +1,13 @@ +{ + "items": [ + "testtag", + "testtag2" + ], + "_links": { + "self": { + "href": "/api/v2/tags", + "type": "application/json" + } + }, + "totalCount": 2 +} \ No newline at end of file diff --git a/api/tests/unit/integrations/launch_darkly/test_client.py b/api/tests/unit/integrations/launch_darkly/test_client.py new file mode 100644 index 000000000000..12e9b06aa99b --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/test_client.py @@ -0,0 +1,225 @@ +import json +from os.path import abspath, dirname, join + +from pytest_mock import MockerFixture +from requests_mock import Mocker as RequestsMockerFixture + +from integrations.launch_darkly.client import LaunchDarklyClient + + +def test_launch_darkly_client__get_project__return_expected( + mocker: MockerFixture, + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + api_version = "20230101" + + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION", + new=api_version, + ) + + example_response_file_path = join( + dirname(abspath(__file__)), "example_api_responses/getProject.json" + ) + with open(example_response_file_path) as example_response_fp: + example_response_content = example_response_fp.read() + + expected_result = json.loads(example_response_content) + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key", + text=example_response_content, + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + + client = LaunchDarklyClient(token=token) + + # When + result = client.get_project(project_key=project_key) + + # Then + assert result == expected_result + + +def test_launch_darkly_client__get_environments__return_expected( + mocker: MockerFixture, + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + api_version = "20230101" + + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION", + new=api_version, + ) + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE", + new=1, + ) + + example_response_1_file_path = join( + dirname(abspath(__file__)), + "example_api_responses/getEnvironmentsByProject_1.json", + ) + example_response_2_file_path = join( + dirname(abspath(__file__)), + "example_api_responses/getEnvironmentsByProject_2.json", + ) + with open(example_response_1_file_path) as example_response_1_fp: + example_response_1_content = example_response_1_fp.read() + with open(example_response_2_file_path) as example_response_2_fp: + example_response_2_content = example_response_2_fp.read() + + expected_result = [ + *json.loads(example_response_1_content)["items"], + *json.loads(example_response_2_content)["items"], + ] + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key/environments?limit=1", + text=example_response_1_content, + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + requests_mock.get( + "https://app.launchdarkly.com/api/v2/projects/test-project-key/environments?limit=1&offset=1", + text=example_response_2_content, + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + + client = LaunchDarklyClient(token=token) + + # When + result = client.get_environments(project_key=project_key) + + # Then + assert result == expected_result + + +def test_launch_darkly_client__get_flags__return_expected( + mocker: MockerFixture, + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + api_version = "20230101" + + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION", + new=api_version, + ) + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE", + new=3, + ) + + example_response_1_file_path = join( + dirname(abspath(__file__)), + "example_api_responses/getFeatureFlags_1.json", + ) + example_response_2_file_path = join( + dirname(abspath(__file__)), + "example_api_responses/getFeatureFlags_2.json", + ) + with open(example_response_1_file_path) as example_response_1_fp: + example_response_1_content = example_response_1_fp.read() + with open(example_response_2_file_path) as example_response_2_fp: + example_response_2_content = example_response_2_fp.read() + + expected_result = [ + *json.loads(example_response_1_content)["items"], + *json.loads(example_response_2_content)["items"], + ] + + requests_mock.get( + "https://app.launchdarkly.com/api/v2/flags/test-project-key?limit=3", + text=example_response_1_content, + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + requests_mock.get( + "https://app.launchdarkly.com/api/v2/flags/test-project-key?limit=3&offset=3&summary=true", + text=example_response_2_content, + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + + client = LaunchDarklyClient(token=token) + + # When + result = client.get_flags(project_key=project_key) + + # Then + assert result == expected_result + + +def test_launch_darkly_client__get_flag_count__return_expected( + mocker: MockerFixture, + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + api_version = "20230101" + + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION", + new=api_version, + ) + + example_response_file_path = join( + dirname(abspath(__file__)), + "example_api_responses/getFeatureFlags_1.json", + ) + requests_mock.get( + "https://app.launchdarkly.com/api/v2/flags/test-project-key?limit=1", + text=open(example_response_file_path).read(), + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + + expected_result = 5 + + client = LaunchDarklyClient(token=token) + + # When + result = client.get_flag_count(project_key=project_key) + + # Then + assert result == expected_result + + +def test_launch_darkly_client__get_flag_tags__return_expected( + mocker: MockerFixture, + requests_mock: RequestsMockerFixture, +) -> None: + # Given + token = "test-token" + api_version = "20230101" + + mocker.patch( + "integrations.launch_darkly.client.LAUNCH_DARKLY_API_VERSION", + new=api_version, + ) + + example_response_file_path = join( + dirname(abspath(__file__)), + "example_api_responses/getTags.json", + ) + requests_mock.get( + "https://app.launchdarkly.com/api/v2/tags?kind=flag", + text=open(example_response_file_path).read(), + request_headers={"Authorization": token, "LD-API-Version": api_version}, + ) + + expected_result = ["testtag", "testtag2"] + + client = LaunchDarklyClient(token=token) + + # When + result = client.get_flag_tags() + + # Then + assert result == expected_result diff --git a/api/tests/unit/integrations/launch_darkly/test_services.py b/api/tests/unit/integrations/launch_darkly/test_services.py new file mode 100644 index 000000000000..96173622e998 --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/test_services.py @@ -0,0 +1,185 @@ +from typing import Type +from unittest.mock import MagicMock + +import pytest +from django.core import signing +from requests.exceptions import HTTPError, Timeout + +from environments.models import Environment +from features.models import Feature, FeatureState +from integrations.launch_darkly.models import LaunchDarklyImportRequest +from integrations.launch_darkly.services import ( + create_import_request, + process_import_request, +) +from projects.models import Project +from projects.tags.models import Tag +from users.models import FFAdminUser + + +def test_create_import_request__return_expected( + ld_client_mock: MagicMock, + ld_client_class_mock: MagicMock, + project: Project, + test_user: FFAdminUser, +) -> None: + # Given + ld_project_key = "test-project-key" + ld_token = "test-token" + + expected_salt = f"ld_import_{test_user.id}" + + # When + result = create_import_request( + project=project, + user=test_user, + ld_project_key=ld_project_key, + ld_token=ld_token, + ) + + # Then + ld_client_class_mock.assert_called_once_with(ld_token) + ld_client_mock.get_project.assert_called_once_with(project_key=ld_project_key) + ld_client_mock.get_flag_count.assert_called_once_with(project_key=ld_project_key) + + assert result.status == { + "requested_environment_count": 2, + "requested_flag_count": 5, + } + assert signing.loads(result.ld_token, salt=expected_salt) == ld_token + assert result.ld_project_key == ld_project_key + assert result.created_by == test_user + assert result.project == project + + +@pytest.mark.parametrize( + "failing_ld_client_method_name", ["get_environments", "get_flags", "get_flag_tags"] +) +@pytest.mark.parametrize( + "exception, expected_error_message", + [ + ( + HTTPError(response=MagicMock(status_code=503)), + "HTTP 503 when requesting LaunchDarkly", + ), + (Timeout(), "Timeout when requesting LaunchDarkly"), + ], +) +def test_process_import_request__api_error__expected_status( + ld_client_mock: MagicMock, + ld_client_class_mock: MagicMock, + failing_ld_client_method_name: str, + exception: Type[Exception], + expected_error_message: str, + import_request: LaunchDarklyImportRequest, +) -> None: + # Given + getattr(ld_client_mock, failing_ld_client_method_name).side_effect = exception + + # When + with pytest.raises(type(exception)): + process_import_request(import_request) + + # Then + assert import_request.completed_at + assert import_request.ld_token == "" + assert import_request.status["result"] == "failure" + assert import_request.status["error_message"] == expected_error_message + + +def test_process_import_request__success__expected_status( + project: Project, + import_request: LaunchDarklyImportRequest, +): + # When + process_import_request(import_request) + + # Then + # Import request is marked as completed successfully. + assert import_request.completed_at + assert import_request.ld_token == "" + assert import_request.status["result"] == "success" + + # Environment names are correct. + assert list( + Environment.objects.filter(project=project).values_list("name", flat=True) + ) == ["Test", "Production"] + + # Feature names are correct. + assert list( + Feature.objects.filter(project=project).values_list("name", flat=True) + ) == ["flag1", "flag2_value", "flag3_multivalue", "flag4_multivalue", "flag5"] + + # Tags are created and set as expected. + assert list(Tag.objects.filter(project=project).values_list("label", "color")) == [ + ("testtag", "#3d4db6"), + ("testtag2", "#3d4db6"), + ("Imported", "#3d4db6"), + ] + assert set( + Feature.objects.filter(project=project).values_list("name", "tags__label") + ) == { + ("flag1", "Imported"), + ("flag2_value", "Imported"), + ("flag3_multivalue", "Imported"), + ("flag4_multivalue", "Imported"), + ("flag5", "testtag"), + ("flag5", "Imported"), + ("flag5", "testtag2"), + } + + # Standard feature states have expected values. + boolean_standard_feature = Feature.objects.get(project=project, name="flag1") + boolean_standard_feature_states_by_env_name = { + fs.environment.name: fs + for fs in FeatureState.objects.filter(feature=boolean_standard_feature) + } + boolean_standard_feature_states_by_env_name["Test"].enabled is True + boolean_standard_feature_states_by_env_name["Production"].enabled is False + + value_standard_feature = Feature.objects.get(project=project, name="flag2_value") + value_standard_feature_states_by_env_name = { + fs.environment.name: fs + for fs in FeatureState.objects.filter(feature=value_standard_feature) + } + value_standard_feature_states_by_env_name["Test"].enabled is True + value_standard_feature_states_by_env_name[ + "Test" + ].get_feature_state_value() == "123123" + value_standard_feature_states_by_env_name["Production"].enabled is False + value_standard_feature_states_by_env_name[ + "Production" + ].get_feature_state_value() == "" + + # Multivariate feature states with percentage rollout have expected values. + percentage_mv_feature = Feature.objects.get( + project=project, name="flag4_multivalue" + ) + percentage_mv_feature_states_by_env_name = { + fs.environment.name: fs + for fs in FeatureState.objects.filter(feature=percentage_mv_feature) + } + + assert percentage_mv_feature_states_by_env_name["Test"].enabled is False + assert list( + percentage_mv_feature_states_by_env_name[ + "Test" + ].multivariate_feature_state_values.values_list( + "multivariate_feature_option__string_value", + "percentage_allocation", + ) + ) == [("variation1", 100), ("variation2", 0)] + + assert percentage_mv_feature_states_by_env_name["Production"].enabled is True + assert list( + percentage_mv_feature_states_by_env_name[ + "Production" + ].multivariate_feature_state_values.values_list( + "multivariate_feature_option__string_value", + "percentage_allocation", + ) + ) == [("variation1", 24), ("variation2", 25), ("variation3", 51)] + + # Tags are imported correctly. + tagged_feature = Feature.objects.get(project=project, name="flag5") + [tag.label for tag in tagged_feature.tags.all()] == ["testtag", "testtag2"] diff --git a/api/tests/unit/integrations/launch_darkly/test_views.py b/api/tests/unit/integrations/launch_darkly/test_views.py new file mode 100644 index 000000000000..4b62f532163d --- /dev/null +++ b/api/tests/unit/integrations/launch_darkly/test_views.py @@ -0,0 +1,129 @@ +from unittest.mock import MagicMock + +from django.urls import reverse +from pytest_mock import MockerFixture +from rest_framework import status +from rest_framework.test import APIClient + +from integrations.launch_darkly.models import LaunchDarklyImportRequest +from projects.models import Project +from users.models import FFAdminUser + + +def test_launch_darkly_import_request_view__list__wrong_project__return_expected( + import_request: LaunchDarklyImportRequest, + project: Project, + api_client: APIClient, +) -> None: + # Given + url = reverse("api-v1:projects:imports-launch-darkly-list", args=[project.id]) + user = FFAdminUser.objects.create(email="clueless@example.com") + api_client.force_authenticate(user) + + # When + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_launch_darkly_import_request_view__create__return_expected( + ld_client_class_mock: MagicMock, + project: Project, + admin_user: FFAdminUser, + admin_client: APIClient, + mocker: MockerFixture, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + process_launch_darkly_import_request_mock = mocker.patch( + "integrations.launch_darkly.views.process_launch_darkly_import_request" + ) + + url = reverse("api-v1:projects:imports-launch-darkly-list", args=[project.id]) + + # When + response = admin_client.post(url, data={"token": token, "project_key": project_key}) + + # Then + assert response.status_code == status.HTTP_201_CREATED + created_import_request = LaunchDarklyImportRequest.objects.get(project=project) + process_launch_darkly_import_request_mock.delay.assert_called_once_with( + kwargs={"import_request_id": created_import_request.id}, + ) + assert response.json() == { + "completed_at": None, + "created_at": mocker.ANY, + "created_by": admin_user.email, + "id": created_import_request.id, + "project": project.id, + "status": { + "error_message": None, + "requested_environment_count": 2, + "requested_flag_count": 5, + "result": None, + }, + "updated_at": mocker.ANY, + } + + +def test_launch_darkly_import_request_view__create__existing_unfinished__return_expected( + ld_client_class_mock: MagicMock, + project: Project, + admin_client: APIClient, + mocker: MockerFixture, + import_request: LaunchDarklyImportRequest, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + process_launch_darkly_import_request_mock = mocker.patch( + "integrations.launch_darkly.views.process_launch_darkly_import_request" + ) + + url = reverse("api-v1:projects:imports-launch-darkly-list", args=[project.id]) + + # When + response = admin_client.post(url, data={"token": token, "project_key": project_key}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == ["Existing import already in progress for this project"] + process_launch_darkly_import_request_mock.assert_not_called() + + +def test_launch_darkly_import_request_view__create__existing_finished__return_expected( + ld_client_class_mock: MagicMock, + project: Project, + admin_client: APIClient, + mocker: MockerFixture, + import_request: LaunchDarklyImportRequest, +) -> None: + # Given + token = "test-token" + project_key = "test-project-key" + + import_request.status["result"] = "success" + import_request.save() + + process_launch_darkly_import_request_mock = mocker.patch( + "integrations.launch_darkly.views.process_launch_darkly_import_request" + ) + + url = reverse("api-v1:projects:imports-launch-darkly-list", args=[project.id]) + + # When + response = admin_client.post(url, data={"token": token, "project_key": project_key}) + + # Then + assert response.status_code == status.HTTP_201_CREATED + created_import_request = LaunchDarklyImportRequest.objects.get( + project=project, + status__result__isnull=True, + ) + process_launch_darkly_import_request_mock.delay.assert_called_once_with( + kwargs={"import_request_id": created_import_request.id}, + ) diff --git a/docs/docs/integrations/importers/_category_.json b/docs/docs/integrations/importers/_category_.json new file mode 100644 index 000000000000..32e02144d4c1 --- /dev/null +++ b/docs/docs/integrations/importers/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Importers", + "collapsed": false, + "position": 50 +} diff --git a/docs/docs/integrations/importers/launchdarkly.md b/docs/docs/integrations/importers/launchdarkly.md new file mode 100644 index 000000000000..979f01f71de8 --- /dev/null +++ b/docs/docs/integrations/importers/launchdarkly.md @@ -0,0 +1,54 @@ +--- +title: LaunchDarkly Importer +description: Import your LaunchDarkly data into Flagsmith +sidebar_label: LaunchDarkly +sidebar_position: 10 +--- + +You can import your Flags and Segments from LaunchDarkly into Flagsmith. + +## Integration Setup + +1. Create a LaunchDarkly Access Token. In LaunchDarkly: Account settings > Authorization > Access tokens. +2. Using your Access Token and a LaunchDarkly project key (usually `"default"`) , create an Import Request for a + Flagsmith project of your choice: + +```bash +curl -X 'POST' \ + 'https://api.flagsmith.com/api/v1/projects//imports/launch-darkly/' \ + -H 'content-type: application/json' \ + -H 'authorization: Token ' \ + -d '{"token":"","project_key":"default"}' +``` + +4. The import will begin immediately. Check the import request status: + +```bash +curl https://api.flagsmith.com/api/v1/projects//imports/launch-darkly// \ + -H 'authorization: Token ' +``` + +## What we will import + +For each Project imported the importer will create a new Project in Flagsmith and copy across the following Entities. + +### Environments + +All of the LaunchDarkly `Environments` within the Project will be copied into Flagsmith as `Environments` + +### Flags + +LaunchDarkly `Flags` will be copied into Flagsmith. + +#### Boolean Flags + +Boolean LaunchDarkly flags are imported into Flagsmith with the appropriate boolean state, with no flag value set on the +Flagsmith side. + +Boolean values will be taken from the `_summary -> on` field of within LaunchDarkly. + +#### Multivariate Flags + +Multivariate LaunchDarkly flags will be imported into Flagsmith as MultiVariate Flagsmith flag values. + +Multivariate values will be taken from the `variations` field of within LaunchDarkly.