Skip to content

Commit

Permalink
PRMDR-674: AppConfig Feature Flags lambda (#299)
Browse files Browse the repository at this point in the history
Feature flags added as a python service and api gateway endpoint. 
Can handle singular or full list of flags.

---------

Co-authored-by: Scott Alexander <scott.alexander@madetech.com>
  • Loading branch information
abbas-khan10 and Scott Alexander authored Feb 20, 2024
1 parent ccf6a5d commit dfbce6a
Show file tree
Hide file tree
Showing 11 changed files with 506 additions and 1 deletion.
13 changes: 13 additions & 0 deletions .github/workflows/base-lambdas-reusable-deploy-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,16 @@ jobs:
lambda_aws_name: NemsMessageLambda
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_feature_flags_lambda:
name: Deploy feature flags lambda
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
with:
environment: ${{ inputs.environment}}
python_version: ${{ inputs.python_version }}
build_branch: ${{ inputs.build_branch}}
sandbox: ${{ inputs.sandbox }}
lambda_handler_name: feature_flags_handler
lambda_aws_name: FeatureFlagsLambda
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
18 changes: 18 additions & 0 deletions lambdas/enums/lambda_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,24 @@ def to_str(self) -> str:
"message": "Failed to fetch parameters for sending email from SSM param store",
}

"""
Errors for Feature Flags lambda
"""
FeatureFlagNotFound = {
"err_code": "FFL_4001",
"message": "Feature flag/s may not exist in AppConfig profile",
}

FeatureFlagParseError = {
"err_code": "FFL_5001",
"message": "Failed to parse feature flag/s from AppConfig response",
}

FeatureFlagFailure = {
"err_code": "FFL_5002",
"message": "Failed to retrieve feature flag/s from AppConfig profile",
}

"""
Errors with no exception
"""
Expand Down
1 change: 1 addition & 0 deletions lambdas/enums/logging_app_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ class LoggingAppInteraction(Enum):
UPLOAD_RECORD = "Upload a record"
LOGOUT = "Logout"
SEND_FEEDBACK = "Send feedback"
FEATURE_FLAGS = "Feature flags"
37 changes: 37 additions & 0 deletions lambdas/handlers/feature_flags_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import json

from enums.logging_app_interaction import LoggingAppInteraction
from services.feature_flags_service import FeatureFlagService
from utils.audit_logging_setup import LoggingService
from utils.decorators.ensure_env_var import ensure_environment_variables
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions
from utils.decorators.override_error_check import override_error_check
from utils.decorators.set_audit_arg import set_request_context_for_logging
from utils.lambda_response import ApiGatewayResponse
from utils.request_context import request_context

logger = LoggingService(__name__)


@set_request_context_for_logging
@ensure_environment_variables(
names=["APPCONFIG_APPLICATION", "APPCONFIG_CONFIGURATION", "APPCONFIG_ENVIRONMENT"]
)
@handle_lambda_exceptions
@override_error_check
def lambda_handler(event, context):
request_context.app_interaction = LoggingAppInteraction.FEATURE_FLAGS.value
logger.info("Starting feature flag retrieval process")

query_string_params = event.get("queryStringParameters")
feature_flag_service = FeatureFlagService()

if query_string_params and "flagName" in query_string_params:
flag_name = query_string_params["flagName"]
response = feature_flag_service.get_feature_flags_by_flag(flag_name)
else:
response = feature_flag_service.get_feature_flags()

return ApiGatewayResponse(
200, json.dumps(response), "GET"
).create_api_gateway_response()
14 changes: 14 additions & 0 deletions lambdas/models/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Dict, Optional

from pydantic import BaseModel


class FlagStatus(BaseModel):
enabled: bool


class FeatureFlag(BaseModel):
feature_flags: Optional[Dict[str, FlagStatus]] = {}

def format_flags(self):
return {key: value.enabled for key, value in self.feature_flags.items()}
1 change: 1 addition & 0 deletions lambdas/requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pip-audit==2.6.1
pytest-cov==4.1.0
pytest-mock==3.11.1
pytest==7.4.3
requests_mock==1.11.0
ruff==0.0.284
98 changes: 98 additions & 0 deletions lambdas/services/feature_flags_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import os

import requests
from enums.lambda_error import LambdaError
from models.feature_flags import FeatureFlag
from pydantic import ValidationError
from requests.exceptions import JSONDecodeError
from utils.audit_logging_setup import LoggingService
from utils.lambda_exceptions import FeatureFlagsException

logger = LoggingService(__name__)


class FeatureFlagService:
def __init__(self):
app_config_port = 2772
self.app_config_url = (
f"http://localhost:{app_config_port}"
+ f'/applications/{os.environ["APPCONFIG_APPLICATION"]}'
+ f'/environments/{os.environ["APPCONFIG_ENVIRONMENT"]}'
+ f'/configurations/{os.environ["APPCONFIG_CONFIGURATION"]}'
)

@staticmethod
def request_app_config_data(url: str):
config_data = requests.get(url)
try:
data = config_data.json()
except JSONDecodeError as e:
logger.error(
str(e),
{"Result": "Error when retrieving feature flag from AppConfig profile"},
)
raise FeatureFlagsException(
error=LambdaError.FeatureFlagParseError,
status_code=config_data.status_code,
)

if config_data.status_code == 200:
return data
if config_data.status_code == 400:
logger.error(
str(data),
{"Result": "Error when retrieving feature flag from AppConfig profile"},
)
raise FeatureFlagsException(
error=LambdaError.FeatureFlagNotFound,
status_code=404,
)
else:
logger.error(
str(data),
{"Result": "Error when retrieving feature flag from AppConfig profile"},
)
raise FeatureFlagsException(
error=LambdaError.FeatureFlagFailure,
status_code=config_data.status_code,
)

def get_feature_flags(self) -> dict:
logger.info("Retrieving all feature flags")

url = self.app_config_url
response = self.request_app_config_data(url)

try:
feature_flags = FeatureFlag(feature_flags=response)
return feature_flags.format_flags()
except ValidationError as e:
logger.error(
str(e),
{"Result": "Error when retrieving feature flag from AppConfig profile"},
)
raise FeatureFlagsException(
error=LambdaError.FeatureFlagParseError,
status_code=500,
)

def get_feature_flags_by_flag(self, flag: str):
logger.info(f"Retrieving feature flag: {flag}")

config_url = self.app_config_url
url = config_url + f"?flag={flag}"

response = self.request_app_config_data(url)

try:
feature_flag = FeatureFlag(feature_flags={flag: response})
return feature_flag.format_flags()
except ValidationError as e:
logger.error(
str(e),
{"Result": "Error when retrieving feature flag from AppConfig profile"},
)
raise FeatureFlagsException(
error=LambdaError.FeatureFlagParseError,
status_code=500,
)
26 changes: 25 additions & 1 deletion lambdas/tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from dataclasses import dataclass
from enum import Enum
from unittest import mock

import pytest
Expand Down Expand Up @@ -40,6 +41,9 @@
MOCK_FEEDBACK_SENDER_EMAIL_ENV_NAME = "FROM_EMAIL_ADDRESS"
MOCK_FEEDBACK_EMAIL_SUBJECT_ENV_NAME = "EMAIL_SUBJECT"
MOCK_EMAIL_RECIPIENT_SSM_PARAM_KEY_ENV_NAME = "EMAIL_RECIPIENT_SSM_PARAM_KEY"
MOCK_APPCONFIG_APPLICATION_ENV_NAME = "APPCONFIG_APPLICATION"
MOCK_APPCONFIG_ENVIRONMENT_ENV_NAME = "APPCONFIG_ENVIRONMENT"
MOCK_APPCONFIG_CONFIGURATION_ENV_NAME = "APPCONFIG_CONFIGURATION"

MOCK_ARF_TABLE_NAME = "test_arf_dynamoDB_table"
MOCK_LG_TABLE_NAME = "test_lg_dynamoDB_table"
Expand Down Expand Up @@ -75,14 +79,17 @@
SSM_PARAM_JWT_TOKEN_PUBLIC_KEY_ENV_NAME = "SSM_PARAM_JWT_TOKEN_PUBLIC_KEY"
SSM_PARAM_JWT_TOKEN_PUBLIC_KEY = "test_jwt_token_public_key"


MOCK_FEEDBACK_SENDER_EMAIL = "feedback@localhost"
MOCK_FEEDBACK_RECIPIENT_EMAIL_LIST = ["gp2gp@localhost", "test_email@localhost"]
MOCK_FEEDBACK_EMAIL_SUBJECT = "Digitised Lloyd George feedback"
MOCK_EMAIL_RECIPIENT_SSM_PARAM_KEY = "/prs/dev/user-input/feedback-recipient-email-list"

MOCK_INTERACTION_ID = "88888888-4444-4444-4444-121212121212"

MOCK_APPCONFIG_APPLICATION_ID = "A1234"
MOCK_APPCONFIG_ENVIRONMENT_ID = "B1234"
MOCK_APPCONFIG_CONFIGURATION_ID = "C1234"


@pytest.fixture
def set_env(monkeypatch):
Expand Down Expand Up @@ -124,6 +131,15 @@ def set_env(monkeypatch):
monkeypatch.setenv(
MOCK_EMAIL_RECIPIENT_SSM_PARAM_KEY_ENV_NAME, MOCK_EMAIL_RECIPIENT_SSM_PARAM_KEY
)
monkeypatch.setenv(
MOCK_APPCONFIG_APPLICATION_ENV_NAME, MOCK_APPCONFIG_APPLICATION_ID
),
monkeypatch.setenv(
MOCK_APPCONFIG_ENVIRONMENT_ENV_NAME, MOCK_APPCONFIG_ENVIRONMENT_ID
),
monkeypatch.setenv(
MOCK_APPCONFIG_CONFIGURATION_ENV_NAME, MOCK_APPCONFIG_CONFIGURATION_ID
)


@pytest.fixture(scope="session", autouse=True)
Expand Down Expand Up @@ -204,3 +220,11 @@ def validation_error() -> ValidationError:
DocumentReference.model_validate(data)
except ValidationError as e:
return e


class MockError(Enum):
Error = {
"message": "Client error",
"err_code": "AB_XXXX",
"interaction_id": "88888888-4444-4444-4444-121212121212",
}
100 changes: 100 additions & 0 deletions lambdas/tests/unit/handlers/test_feature_flags_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import json

import pytest
from handlers.feature_flags_handler import lambda_handler
from tests.unit.conftest import MockError
from utils.lambda_exceptions import FeatureFlagsException
from utils.lambda_response import ApiGatewayResponse

test_all_feature_flags = {
"testFeature1": True,
"testFeature2": True,
"testFeature3": False,
}


@pytest.fixture
def all_feature_flags_event():
api_gateway_proxy_event = {
"httpMethod": "GET",
"queryStringParameters": {},
}
return api_gateway_proxy_event


@pytest.fixture
def filter_feature_flags_event():
api_gateway_proxy_event = {
"httpMethod": "GET",
"queryStringParameters": {"flagName": "testFeature1"},
}
return api_gateway_proxy_event


@pytest.fixture
def mock_feature_flag_service(set_env, mocker):
mocked_class = mocker.patch("handlers.feature_flags_handler.FeatureFlagService")
mocked_instance = mocked_class.return_value
yield mocked_instance


@pytest.fixture
def mock_service_all_feature_flags(mock_feature_flag_service):
service = mock_feature_flag_service
service.get_feature_flags.return_value = test_all_feature_flags
yield service


def test_lambda_handler_all_flags_returns_200(
all_feature_flags_event, context, mock_service_all_feature_flags
):
expected = ApiGatewayResponse(
200, json.dumps(test_all_feature_flags), "GET"
).create_api_gateway_response()

actual = lambda_handler(all_feature_flags_event, context)

assert expected == actual


def test_lambda_handler_all_flags_without_query_string_params_returns_200(
event, context, mock_service_all_feature_flags
):
expected = ApiGatewayResponse(
200, json.dumps(test_all_feature_flags), "GET"
).create_api_gateway_response()

actual = lambda_handler(event, context)

assert expected == actual


def test_lambda_handler_with_filter_flags_returns_200(
filter_feature_flags_event, context, mock_feature_flag_service
):
feature_flags = {"testFeature1": True}
mock_feature_flag_service.get_feature_flags_by_flag.return_value = feature_flags

expected = ApiGatewayResponse(
200, json.dumps(feature_flags), "GET"
).create_api_gateway_response()

actual = lambda_handler(filter_feature_flags_event, context)

assert expected == actual


def test_lambda_handler_handles_service_raises_exception(
event, context, mock_feature_flag_service
):
mock_feature_flag_service.get_feature_flags.side_effect = FeatureFlagsException(
500, MockError.Error
)

expected = ApiGatewayResponse(
500, json.dumps(MockError.Error.value), "GET"
).create_api_gateway_response()

actual = lambda_handler(event, context)

assert expected == actual
Loading

0 comments on commit dfbce6a

Please sign in to comment.