Creating an API client in Python - Part 1: The Basics
I like to automate...a lot. Along the way it occurred to me that I tend to create many different tools that interact with many of the same target systems in similar ways, and I tend to recode those interactions each time. As a result I constantly had to update many of my tools when an API I was consuming changed, which is time consuming and defeats the purpose of automation; to give me back my time.
The solution to all this is modularizing your interactions with external APIs into a nice package that you can re-use in all your projects. This series tries to explain how I approach that problem; it's not perfect but I hope it helps.
NOTE: This is going to be super high level and won't include things like asynchronous API calls, pagination, filter parameters, etc.
The problem
As a Security Engineer, we have lot's of tools that we need to interact with all the time, they need to feed each other, we need to repeat complex process and it needs to be timely; especially during an incident.
Over time we end up with our own tool sets that make API calls and we tend to reuse the same code over and over. These tools become hard to maintain as target systems change. The following used to be an okay example of how to interact with an API
import requests
def get_computers_from_api():
computers = []
headers = {
"Authorization":"myapikey",
"Content-Type":"application/json"
}
response = requests.get('https://mytenant.someawesometool.com/web/api/v2.0/computers', headers=headers)
if response.status_code == 200:
computers = response.json()['data']
else:
return computers
print(get_computers_from_api())
But what happens if we need to use that code over and over again. What happens if the API changes on the backend. Wouldn't it be easier to do something like this
from someawesometool import api_client
client = api_client(api_key="myapikey")
computers = client.get_computers()
print(computers)
The Solution
Modularize your code. Full stop.
Create your project structure
I like to lay out my projects in the following structure. We are really only focusing on the myapiclient
folder but having the whole picture will help. I won't cover Pipfile
and Pipfile.lock
those are for package management purposes you can read about them here https://pipenv.pypa.io/en/latest/
mytool/
├─ myapiclient/
│ ├─ base.py
│ ├─ __init__.py
├─ util/
│ ├─ base.py
│ ├─ __init__.py
├─ mytool.py
├─ Pipfile
├─ Pipfile.lock
├─ README.md
- The
mytool
directory is the base folder where all our main code lives - The
myapiclient
is where our external interactions with third party APIs will live - The
util
folder is where generic functions that get used in all my scripts live, things like checking if a folder/file exist, normalizing data, regular expression patterns, etc.
Create your client
It's time to create your client, I will try and break down what the code does in each section as best I can using comments in the code.
We start by defining a Class for our API. The bare minimum we need is the __init__
function which tells Python how we want to initialize our object. The base_url
parameter is the URL to our API (excluding specific API endpoints) (e.g. https://mytenant.someawesometool.com
)
Next we need to add some authentication to our client. Some APIs use username/password authentication to get an API key, others just make you use an API key so we will solve for both by creating two new functions: basic_auth
and api_key
. Add the following code to your class
Now that we have authenticated, we need to make it easy to call the API in a reliable, repeatable fashion so lets create a call_api
function that handles all that HTTP method switching and parameter checking.
def call_api(self, method: str, endpoint: str, data: dict = None, filters: dict = None) -> dict:
endpoint_url = f"{self.base_url}/{endpoint}"
response = None
if method == 'GET':
response = self.session.get(endpoint_url)
if method == 'POST':
if data == None:
raise ValueError("Must supply JSON data to send")
else:
response = self.session.post(endpoint_url, data=json.dumps(data))
# If the call was successful return the data
if response.status_code == http_status.ok:
return response.json()
# If not authorized, return no data
elif response.status_code == http_status.unauthorized:
raise ValueError("Not authorized.")
# Catch all other conditions here and return nothing
else:
return {}
Finally lets make a call to a very specific endpoint and format the returned data
def get_computers(self) -> list:
"""
Calls the /web/api/v2.0/computers endpoint and
returns a list of computers
"""
endpoint = "web/api/v2.0/computers"
data = self.call_api(endpoint, method="GET")
# If the API returns data and the collection
# of computers is not empty return it
# else return an empty array
if data and len(data['computers']) > 0:
return data['computers']
else:
return []
We now have established the bare minimum we need to use this API client in our code!
Create your tool
Heading back to the root of your project, in this instance mytool.py
we can start to use this API client we just created. It's as easy as the following:
from myapiclient.base import ApiClient
client = ApiClient("https://mytenant.someawesometool.com")
client.basic_auth("n3tsurge","hunter2")
for computer in client.get_computers():
print(computer)
Any time you want to use this code going forward all you would need to include is the myapiclient
folder in your next project. Obviously you would want to centralize that code in a repository and package it on pypi
. If you want to read up on how to do that you can go here https://packaging.python.org/tutorials/packaging-projects/
Next time I will cover adding POST
requests to our API client.