connexion is a great tool for building APIs in Python. It allows you to make the openAPI specification define input validation that is automatically enforced, maps API endpoints to a named function and handles JSON serialising and de-serialising for you.
One of the things to keep in mind when using connexion is that, if you have defined query parameters, only the query parameters that you have define can every get passed to the functions handling the endpoint. For example, consider the following API specification for retrieving employees from a database:
specification.yml
# specification.yml
openapi: "3.0.0"
info:
title: Sample API
description: API demonstrating how to check for optional parameters.
version: "0.1"
paths:
/employee:
get:
summary: Gets Employees that match query parameters.
operationId: library.employee.search
parameters:
- in: query
name: join_date
schema:
type: string
format: date
required: false
description: Filters employees for a particular date that they joined the company.
- in: query
name: business_unit
schema:
type: string
required: false
description: Filters employees for a particular business unit.
- in: query
name: city
schema:
type: string
required: false
description: Filters employees for which city they are working in.
responses:
200:
description: Returns all the Employees in the database that match the query parameters.
content:
application/json:
schema:
type: object
properties:
first_name:
description: The first name of the Employee.
type: string
last_name:
description: The last name of the Employee.
type: string
If you call /employee with some_parameter, you don’t receive an error because connexion will not call your function withsome_parameter as it is not defined as a query parameter in the specification and hence gets ignored. To help prove that, consider the following project setup.
Project Setup
The above specification.yml is included in the root directory. The library folder is also in the root directory. Inside the library folder is the following __init__.py file:
library/__init__.py
# library.__init__.py
from . import employee
The library folder contains the employee folder which has the following __init__.py and controller.py files:
library/employee/__init__.py
library/employee/controller.py
# library.employee.__init__.py
from .controller import search
# library.data.employee.controller.py
def search(join_date = None, business_unit = None, city = None):
return [{'first_name': 'David', 'last_name': 'Andersson'}]
Test Setup
pytest is used to test the API. To help with the API tests, pytest-flask is used. To show that calling the /employee endpoint with parameters that aren’t in the specification does not result in an error, the following test configuration is placed in the root directory:
conftest.py
# conftest.py
from unittest import mock
import pytest
import connexion
import library
@pytest.fixture(scope='session')
def setup_mocked_search():
"""Sets up spy fr search function."""
with mock.patch.object(library.employee, 'search', wraps=library.employee.search) as mock_search:
yield mock_search
@pytest.fixture(scope='function')
def mocked_search(setup_mocked_search):
"""Resets search spy."""
setup_mocked_search.reset_mock()
return setup_mocked_search
@pytest.fixture(scope='session')
def app(setup_mocked_search):
"""Flask app for testing."""
# Adding swagger file
test_app = connexion.FlaskApp(__name__, specification_dir='.')
test_app.add_api('specification.yml')
return test_app.app
For now, focus on the app fixture which uses the standard connexion API setup and then yields the flask application. Through the magic of purest-flask, we can then define the following test to demonstrate that the API can be called with parameters not named in the openAPI specification:
def test_some_parameter(client):
"""
GIVEN a value for some parameter
WHEN GET /employee is called with the some_parameter set to the some parameter value
THEN no exception is raised.
"""
client.get('/employee?some_parameter=value 1')
Verifying Endpoint Parameter Names
This means that making a mistake with the query parameter names in the openAPI specification and the arguments for the function that handles the endpoint can go undetected. To check this is not the case, you could come up with a number of tests that only pass if the query parameter was correctly passed to the search function. This can be tedious without mocking the search function as you would have to create tests with just the right setup so that the effect of a particular query parameter is noticed.
There is an easier way with mocks. Unfortunately, it is not as easy as monkey patching the search function in the body of a test function. The problem is that, after the app fixture has been called, the reference to the search function has been hard coded into the flask application and will not get affected by mocking the search function. It is possible to retrieve the reference to the search function in the flask application instance, but you will have to dig fairly deep and may have to use some private member variables, which is not advisable.
Instead, let’s take another look at the contest.py file above. The trick is to apply a spy to the search function before the app fixture is invoked. The reason a spy rather than a mock is used is because the spy will be in p0lace for all tests and we may want to write a test where the real search function is called! With a spy the underlying function still gets called, which it does not if a mock is used instead of the search function. The fixture on lines 8-12 adds a spy to the search function. To ensure that it gets called before the app fixture, the app fixture takes it as an argument, although it doesn’t make use of the fixture. Because the app fixture is a session level fixture, we also want the fixture that sets up the spy to be a session level fixture, otherwise all the tests will be slowed down. However, this means that the spy state will leak into other tests. This can be solved with a fixture similar to the fixture on lines 15-19 which resets the spy state before each test.
Now we can use the test client to define tests that check that each of the query parameters is passed to the search function. The tests that perform these checks may look like the following:
test.py
# test.py
"""Endpoint tests for /employee"""
def test_some_parameter(client, mocked_search):
"""
GIVEN library.data.employee.search has a spy and a value for some parameter
WHEN GET /employee is called with the some_parameter set to the some parameter value
THEN library.data.employee.search is called with no parameters.
"""
client.get('/employee?some_parameter=value 1')
# Checking search call
mocked_search.assert_called_once_with()
def test_join_date(client, mocked_search):
"""
GIVEN library.data.employee.search has a spy and a date
WHEN GET /employee is called with the join_date set to the date
THEN library.data.employee.search is called with the join date.
"""
client.get('/employee?join_date=2000-01-01')
# Checking search call
mocked_search.assert_called_once_with(join_date='2000-01-01')
def test_business_unit(client, mocked_search):
"""
GIVEN library.data.employee.search has a spy and a business unit
WHEN GET /employee is called with the business_unit set to the business unit
THEN library.data.employee.search is called with the business unit.
"""
client.get('/employee?business_unit=business unit 1')
# Checking search call
mocked_search.assert_called_once_with(business_unit='business unit 1')
def test_city(client, mocked_search):
"""
GIVEN library.data.employee.search has a spy and a city
WHEN GET /employee is called with the city set to the city
THEN library.data.employee.search is called with the city.
"""
client.get('/employee?city=city 1')
# Checking search call
mocked_search.assert_called_once_with(city='city 1')
You now have a starting point for how to test that the function arguments and query parameter names in the specification match. You may not want to create these tests for each query parameter for every endpoint, especially if a query parameter is required, as an exception will be raised because the underlying function will be called with a query parameter which is not listed in the function signature.