From af7b4a81da7532ecfcb57c5dc6354a005e15faf2 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 2 Feb 2025 13:07:24 -0800 Subject: [PATCH] Added initial switcher remote API integration (auth) --- .snyk | 7 ++++ README.md | 21 +++++----- requirements.txt | 1 + switcher_client/client.py | 11 ++++- switcher_client/errors/__init__.py | 4 ++ switcher_client/lib/__init__.py | 5 +++ switcher_client/lib/remote.py | 29 +++++++++++++ switcher_client/lib/remote_auth.py | 30 ++++++++++++++ switcher_client/switcher.py | 36 +++++++++++++++++ tests/requirements.txt | 3 +- tests/test_client_context.py | 6 +-- tests/test_switcher_remote.py | 65 ++++++++++++++++++++++++++++++ 12 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 .snyk create mode 100644 switcher_client/errors/__init__.py create mode 100644 switcher_client/lib/__init__.py create mode 100644 switcher_client/lib/remote.py create mode 100644 switcher_client/lib/remote_auth.py create mode 100644 switcher_client/switcher.py create mode 100644 tests/test_switcher_remote.py diff --git a/.snyk b/.snyk new file mode 100644 index 0000000..de93a60 --- /dev/null +++ b/.snyk @@ -0,0 +1,7 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.25.0 +ignore: {} +patch: {} +exclude: + global: + - tests/** diff --git a/README.md b/README.md index 1f6315b..59df495 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Python SDK for Switcher API [![Master CI](https://github.com/switcherapi/switcher-client-py/actions/workflows/master.yml/badge.svg)](https://github.com/switcherapi/switcher-client-py/actions/workflows/master.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=switcherapi_switcher-client-py&metric=alert_status)](https://sonarcloud.io/dashboard?id=switcherapi_switcher-client-py) +[![Known Vulnerabilities](https://snyk.io/test/github/switcherapi/switcher-client-py/badge.svg)](https://snyk.io/test/github/switcherapi/switcher-client-py) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Slack: Switcher-HQ](https://img.shields.io/badge/slack-@switcher/hq-blue.svg?logo=slack)](https://switcher-hq.slack.com/) @@ -97,14 +98,14 @@ There are a few different ways to call the API using the JavaScript module. Here are some examples: 1. **No parameters** -Invoking the API can be done by instantiating the switcher and calling *isItOn* passing its key as a parameter. +Invoking the API can be done by instantiating the switcher and calling *is_on* passing its key as a parameter. ```python switcher = Client.get_switcher() -switcher.check('FEATURE01') +switcher.is_on('FEATURE01') # or -result, reason, metadata = switcher.detail().check('FEATURE01') +result, reason, metadata = switcher.detail().is_on('FEATURE01') ``` 2. **Strategy validation - preparing input** @@ -112,14 +113,14 @@ Loading information into the switcher can be made by using *prepare*, in case yo ```python switcher.check_value('USER_1').prepare('FEATURE01') -switcher.check() +switcher.is_on() ``` 3. **Strategy validation - all-in-one execution** All-in-one method is fast and include everything you need to execute a complex call to the API. ```python -result, reason, metadata = switcher.detail().check_value('User 1').check_network('192.168.0.1').check('FEATURE01') +result, reason, metadata = switcher.detail().check_value('User 1').check_network('192.168.0.1').is_on('FEATURE01') ``` 4. **Throttle** @@ -127,7 +128,7 @@ Throttling is useful when placing Feature Flags at critical code blocks require API calls will happen asynchronously and the result returned is based on the last API response. ```python -switcher.throttle(1000).check('FEATURE01') +switcher.throttle(1000).is_on('FEATURE01') ``` In order to capture issues that may occur during the process, it is possible to log the error by subscribing to the error events. @@ -141,7 +142,7 @@ Forcing Switchers to resolve remotely can help you define exclusive features tha This feature is ideal if you want to run the SDK in local mode but still want to resolve a specific switcher remotely. ```python -switcher.remote().check('FEATURE01') +switcher.remote().is_on('FEATURE01') ``` ## Built-in mock feature @@ -149,13 +150,13 @@ You can also bypass your switcher configuration by invoking 'Client.assume'. Thi ```python Client.assume('FEATURE01').true() -switcher.check('FEATURE01') # True +switcher.is_on('FEATURE01') # True Client.forget('FEATURE01') -switcher.check('FEATURE01') # Now, it's going to return the result retrieved from the API or the Snaopshot file +switcher.is_on('FEATURE01') # Now, it's going to return the result retrieved from the API or the Snaopshot file Client.assume('FEATURE01').false().with_metadata({ 'message': 'Feature is disabled' }) # Include metadata to emulate Relay response -response = switcher.detail().check('FEATURE01') # False +response = switcher.detail().is_on('FEATURE01') # False print(response.metadata['message']) # Feature is disabled ``` diff --git a/requirements.txt b/requirements.txt index e69de29..ef487e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +requests==2.32.3 \ No newline at end of file diff --git a/switcher_client/client.py b/switcher_client/client.py index 0ae759f..6ee7bb6 100644 --- a/switcher_client/client.py +++ b/switcher_client/client.py @@ -1,7 +1,9 @@ from typing import Optional +from switcher_client.lib.remote_auth import RemoteAuth from switcher_client.context import Context, ContextOptions from switcher_client.context import DEFAULT_ENVIRONMENT +from switcher_client.switcher import Switcher class Client: context: Optional[Context] = None @@ -26,6 +28,7 @@ def build_context( """ Client.context = Context(domain, url, api_key, component, environment, options) + RemoteAuth.init(Client.context) @staticmethod def clear_context(): @@ -34,4 +37,10 @@ def clear_context(): @staticmethod def verify_context(): if not Client.context: - raise ValueError('Context is not set') \ No newline at end of file + raise ValueError('Context is not set') + + @staticmethod + def get_switcher(key: str = None) -> Switcher: + """ Get a switcher by key """ + Client.verify_context() + return Switcher(Client.context, key) \ No newline at end of file diff --git a/switcher_client/errors/__init__.py b/switcher_client/errors/__init__.py new file mode 100644 index 0000000..1d950a0 --- /dev/null +++ b/switcher_client/errors/__init__.py @@ -0,0 +1,4 @@ +class RemoteAuthError(Exception): + def __init__(self, message): + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/switcher_client/lib/__init__.py b/switcher_client/lib/__init__.py new file mode 100644 index 0000000..0051b89 --- /dev/null +++ b/switcher_client/lib/__init__.py @@ -0,0 +1,5 @@ +from .remote_auth import RemoteAuth + +__all__ = [ + 'RemoteAuth' +] \ No newline at end of file diff --git a/switcher_client/lib/remote.py b/switcher_client/lib/remote.py new file mode 100644 index 0000000..d8f904e --- /dev/null +++ b/switcher_client/lib/remote.py @@ -0,0 +1,29 @@ +import requests + +from switcher_client.errors import RemoteAuthError +from switcher_client.context import Context + +class Remote: + + @staticmethod + def auth(context: Context): + url = context.url + '/criteria/auth' + + response = Remote.do_post(url, { + 'domain': context.domain, + 'component': context.component, + 'environment': context.environment, + }, { + 'switcher-api-key': context.api_key, + 'Content-Type': 'application/json', + }) + + if response.status_code == 200: + return response.json()['token'], response.json()['exp'] + + raise RemoteAuthError('Invalid API key') + + @staticmethod + def do_post(url, data, headers) -> requests.Response: + """ Perform a POST request """ + return requests.post(url, json=data, headers=headers) \ No newline at end of file diff --git a/switcher_client/lib/remote_auth.py b/switcher_client/lib/remote_auth.py new file mode 100644 index 0000000..895f718 --- /dev/null +++ b/switcher_client/lib/remote_auth.py @@ -0,0 +1,30 @@ +from typing import Optional + +from switcher_client.errors import RemoteAuthError +from switcher_client.lib.remote import Remote +from switcher_client.context import Context + +class RemoteAuth: + __context: Optional[Context] = None + __token = None + __exp = None + + @staticmethod + def init(context: Context): + RemoteAuth.__context = context + RemoteAuth.__token = None + RemoteAuth.__exp = None + + @staticmethod + def auth(): + token, exp = Remote.auth(RemoteAuth.__context) + RemoteAuth.__token = token + RemoteAuth.__exp = exp + + @staticmethod + def get_token(): + return RemoteAuth.__token + + @staticmethod + def get_exp(): + return RemoteAuth.__exp \ No newline at end of file diff --git a/switcher_client/switcher.py b/switcher_client/switcher.py new file mode 100644 index 0000000..37b2d01 --- /dev/null +++ b/switcher_client/switcher.py @@ -0,0 +1,36 @@ +from typing import Optional + +from switcher_client.context import Context +from switcher_client.lib.remote_auth import RemoteAuth + +class Switcher: + def __init__(self, context: Context, key: str = None): + self.context = context + self.key = key + + def is_on(self, key: str = None) -> bool: + """ Execute criteria """ + result = False + + self.__validate_args(key) + self.__execute_api_checks() + result = self.__execute_remote_criteria() + + return result + + def __validate_args(self, key: str = None): + if self.key is None: + self.key = key + + if self.key is None: + raise ValueError('Key is required') + + def __execute_api_checks(self): + """ Assure API is available and token is valid """ + RemoteAuth.auth() + + def __execute_remote_criteria(self): + """ Execute remote criteria """ + RemoteAuth.get_token() + RemoteAuth.get_exp() + return True \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt index 549199a..31e6500 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,3 @@ pytest==8.3.3 -pytest-cov==5.0.0 \ No newline at end of file +pytest-cov==5.0.0 +responses==0.25.6 \ No newline at end of file diff --git a/tests/test_client_context.py b/tests/test_client_context.py index 662c5f9..ff386f2 100644 --- a/tests/test_client_context.py +++ b/tests/test_client_context.py @@ -3,7 +3,7 @@ from switcher_client import Client, ContextOptions def test_context(): - """ Test building and verifying context """ + """ Should build and verify context """ Client.build_context( domain='My Domain', @@ -23,7 +23,7 @@ def test_context(): pytest.fail(f'Context verification failed: {e}') def test_clear_context(): - """ Test clearing context """ + """ Should clear context """ Client.build_context( domain='My Domain', @@ -38,7 +38,7 @@ def test_clear_context(): Client.verify_context() def test_context_with_optionals(): - """ Test building context with optional parameters - local and snapshot_location """ + """ Should build context with optional parameters - local and snapshot_location """ Client.build_context( domain='My Domain', diff --git a/tests/test_switcher_remote.py b/tests/test_switcher_remote.py new file mode 100644 index 0000000..8406faa --- /dev/null +++ b/tests/test_switcher_remote.py @@ -0,0 +1,65 @@ +import pytest +import responses +import time + +from switcher_client.errors import RemoteAuthError +from switcher_client import Client, ContextOptions + +@responses.activate +def test_remote(): + """ Should call the remote API with success """ + + # given + given_auth() + given_context() + + switcher = Client.get_switcher() + + # test + assert switcher.is_on('MY_SWITCHER') + +def test_remote_err_no_key(): + """ Should raise an exception when no key is provided """ + + # given + given_context() + + switcher = Client.get_switcher() + + # test + with pytest.raises(ValueError): + switcher.is_on() + +@responses.activate +def test_remote_err_invalid_api_key(): + """ Should raise an exception when the API key is invalid """ + + # given + given_auth(status=401) + given_context() + + switcher = Client.get_switcher() + + # test + with pytest.raises(RemoteAuthError) as excinfo: + switcher.is_on('MY_SWITCHER') + + assert 'Invalid API key' in str(excinfo.value) + +# Helpers + +def given_context(url='https://api.switcherapi.com', api_key='[API_KEY]'): + Client.build_context( + url=url, + api_key=api_key, + domain='Playground', + component='switcher-playground' + ) + +def given_auth(status=200, token='[token]', exp=int(round(time.time() * 1000))): + responses.add( + responses.POST, + 'https://api.switcherapi.com/criteria/auth', + json={'token': token, 'exp': exp}, + status=status + ) \ No newline at end of file