Run methods dynamically by name

In this project I want to verify the availability of the APIs that we use to ingest data into our data platform. In the example I will use Jira, Workable and HiBob since they offer clean APIs without too much configuration. First I will create a test suite to verify the availability and once this works move it to a Lambda function that could be scheduled with CloudWatch on a fixed schedule.

Prerequisites

Make sure the following environment variables are set. Change it to the correct profile and region for the AWS account you want to run the tests for. The profile should be available in ~/.aws/credentials.

  • AWS_PROFILE=prod
  • AWS_DEFAULT_REGION=eu-west-1
  • ENV=prod

Testing

One of the main reasons I switched from unittest to pytest is the ease of use of fixtures. You can define the functions or variables that you will need throughout the whole test set.

Fixture class

In this case I define a FixtureClass that contains a function to retrieve the parameters from AWS SSM, a key-value store where we store our API keys and other secrets. Note that in the following function I only retrieve the parameters for the base path /. The class has a Boto3 session and ssm_client.

class FixtureClass:
    """ Defines the FixtureClass """

    def get_ssm_parameters(self):
        """ Returns the SSM parameters """
        paginator = self.ssm_client.get_paginator("get_parameters_by_path")
        iterator = paginator.paginate(Path="/", WithDecryption=True)
        params = {}
        for page in iterator:
            for param in page.get("Parameters", []):
                params[param.get("Name")] = param.get("Value")
        return params

    @property
    def session(self):
        return boto3.session.Session()

    @property
    def ssm_client(self):
        return self.session.client("ssm")

Set the fixture

Now I can create an instance of the FixtureClass and add the ssm_parameters as fixture for my tests. Define them either on conftest.py next to your test files, or add them on top of the file where you will write the tests.

fc = FixtureClass()

@pytest.fixture(scope="session")
def ssm_parameters():
    return fc.get_ssm_parameters()

Add fixture to test class

In order to make the fixture available for all the tests I add the fixture as attribute to the test class. By using autouse=True the fixtures become available in the setup_class method. This method is only called once per execution of the tests.

""" testsourceavailability.py """
class TestSourceAvailability:
    """ Defines the tests to verify the source availability """

    @pytest.fixture(autouse=True)
    def setup_class(self, ssm_parameters):
        """ Setup the test class """
        self.ssm_parameters = ssm_parameters
        self.session = requests.Session()

    def _get_param(self, key):
        return self.ssm_parameters.get(key)

Create the tests

Below I have defined five different tests to validate the availability of three different sources. Three of the tests are to verify the availability (good weather) and two of them ensure that the API does not work with the wrong parameters (bad weather).

  • HiBob (tool used by HR)
  • Jira (tool used by Tech)
  • Workable (tool used by Recruiting)

Every tests consists of the following steps:

  1. Retrieve the parameters from SSM
  2. Set the arguments for the API call
  3. Assert the status code of the API call
# Append to previous TestSourceAvailability file.
        def test_hibob_is_available(self):
        """ Test that the HiBob API is available """
        api_key = self._get_param("HIBOB_API_KEY")
        api_url = self._get_param("HIBOB_API_URL")

        kwargs = {
            'method': 'get',
            'url': api_url,
            'headers': {
                "Authorization": api_key
            }
        }
        assert self.session.request(**kwargs).status_code == 200

    def test_jira_is_available(self):
        """ Test that the Jira API is available """
        api_key = self._get_param("JIRA_API_KEY")
        api_url = self._get_param("JIRA_URL")
        jira_user = self._get_param("JIRA_USER")

        kwargs = {
            'method': 'get',
            'url': f"{api_url}/rest/api/2/project",
            'auth': (jira_user, api_key)
        }
        assert self.session.request(**kwargs).status_code == 200

    def test_jira_is_unavailable_without_api_key(self):
        """ Test that the Jira API is unavailable without API key """
        api_key = None
        api_url = self._get_param("JIRA_URL")
        jira_user = self._get_param("JIRA_USER")

        kwargs = {
            'method': 'get',
            'url': f"{api_url}/rest/api/2/project",
            'auth': (jira_user, "")
        }
        assert self.session.request(**kwargs).status_code == 401

    def test_workable_api_is_available(self):
        """ Test that the Workable API is available """
        api_key = self._get_param("WORKABLE_API_KEY")
        api_url = self._get_param("WORKABLE_API_URL")

        kwargs = {
            'method': 'get',
            'url': f"{api_url}jobs",
            'headers': {"Authorization": "Bearer {}".format(api_key)},
            'params': {"limit": 1, "include_fields": "description"}
        }
        assert self.session.request(**kwargs).status_code == 200

    def test_workable_api_is_not_available_for_wrong_credentials(self):
        """ Test that the Workable API is not available for wrong credentials """
        api_key = "fake_key"
        api_url = self._get_param("WORKABLE_API_URL")

        kwargs = {
            'method': 'get',
            'url': f"{api_url}jobs",
            'headers': {"Authorization": "Bearer {}".format(api_key)},
            'params': {"limit": 1, "include_fields": "description"}
        }
        assert self.session.request(**kwargs).status_code == 401

Execution of the tests

Make surepytest is installed on your machine. Run the file we've created before and add verbosity if you wish. Note that I use Python 3.7 and pytest 5.3.1. In my case all tests are green, so we can continue!

$ pytest testsourceavailability.py -v
============================= test session starts ==============================
platform darwin -- Python 3.7.4, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /Library/Frameworks/Python.framework/Versions/3.7/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/jitsejan/Documents
collected 5 items

testsourceavailability.py::TestSourceAvailability::test_hibob_is_available PASSED [ 20%]
testsourceavailability.py::TestSourceAvailability::test_jira_is_available PASSED [ 40%]
testsourceavailability.py::TestSourceAvailability::test_jira_is_unavailable_without_api_key PASSED [ 60%]
testsourceavailability.py::TestSourceAvailability::test_workable_api_is_available PASSED [ 80%]
testsourceavailability.py::TestSourceAvailability::test_workable_api_is_not_available_for_wrong_credentials PASSED [100%]

Lambda function

Because I do not only want to verify availability at the test stage, I will create a Lambda function that I can schedule to periodically check that the APIs are still available.

AvailabilityChecker class

The class is initialized with the ssm_client to access the parameters stored in SSM and again the requests.Session that is used to call the API. The function to get the parameter by key has the same name, but the underlying logic is of course different compared to the one I used in the tests before with a fixture. By keeping the function name the same it is slightly easier to copy the tests to this Lambda function.

""" availabilitychecker.py """
class AvailabilityChecker:

    def __init__(self):
        self.ssm_client = boto3.client("ssm")
        self.session = requests.Session()

    def _get_param(self, key):
        """ Return the SSM parameter """
        return self.ssm_client.get_parameter(Name=key, WithDecryption=True)["Parameter"]["Value"]

Verify functions

I will only add the good weather tests from the previous test set, hence I will verify HiBob, Jira and Workable, but I don't check for the negative cases.

# continue availabilitychecker.py
    def verify_hibob_is_available(self):
        """ Verify that the HiBob API is available """
        api_key = self._get_param("HIBOB_API_KEY")
        api_url = self._get_param("HIBOB_API_URL")

        arguments = {
            'method': 'get',
            'url': api_url,
            'headers': {
                "Authorization": api_key
            }
        }
        return self.session.request(**arguments).status_code == 200

    def verify_jira_is_available(self):
        """ Verify that the Jira API is available """
        api_key = self._get_param("JIRA_API_KEY")
        api_url = self._get_param("JIRA_URL")
        jira_user = self._get_param("JIRA_USER")

        arguments = {
            'method': 'get',
            'url': f"{api_url}/rest/api/2/project",
            'auth': (jira_user, api_key)
        }
        return self.session.request(**arguments).status_code == 200

    def verify_workable_api_is_available(self):
        """ Verify that the Workable API is available """
        api_key = self._get_param("WORKABLE_API_KEY")
        api_url = self._get_param("WORKABLE_API_URL")

        arguments = {
            'method': 'get',
            'url': f"{api_url}jobs",
            'headers': {
                "Authorization": "Bearer {}".format(api_key)
            },
            'params': {
                "limit": 1,
                "include_fields": "description"
            }
        }
        return self.session.request(**arguments).status_code == 200

Retrieve verify methods automatically

Because in reality this file is way larger since I need to test way more sources, I do not want to write out all the verify functions explicitly in my Lambda function like below.

def lambda_handler(event, context):
    avc = AvailabilityChecker()
    avc.verify_hibob_is_available()
    avc.verify_jira_is_available()
    avc.verify_workable_is_available()

Instead, I want to dynamically retrieve these functions by iterating through the methods of the class.

def _get_verify_functions(self):
        """ Return verify functions inside this class """
    return [func for func in dir(self) if callable(getattr(self, func)) and func.startswith("verify")]

This method will loop through the callable functions, check if it starts with verify and return a list of functions.

Final Lambda

I have updated the lambda_handler to retrieve the functions, iterate through them and execute the method to validate for the different sources if the API is available. Of course this code is a bit longer, but when I add more verify functions to the class, I do not have to change any other code!

def lambda_handler(event, context):
    avc = AvailabilityChecker()
    for method in avc._get_verify_functions():
        is_available = getattr(avc, method)()
        source = ' '.join(method.split('_')[1:-2]).title()
        if not is_available:
            print(f"[NOK] Please check availability for `{source}`.")            
        else:
            print(f"[OK] `{source}`")

Running it will give the results for the three sources.

$ python availabilitychecker.py
[OK] `Hibob`
[OK] `Jira`
[OK] `Workable Api`

Check the Gist for the final code. Hope it helps :)