Creating an API client in Python - Part 1: The Basics

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)

from requests import Session, codes as http_status

class ApiClient(Object):
	""" A new object that we can use to call our external API """
    
    def __init__(self, base_url: str, *args, **kwargs):
    	""" Initializes the base object with certain required values """
        
        self.session = Session()
        self.base_url = base_url
              
        # Set the content type to application/json
        self.session.headers.update({"Content-Type":"application/json"})
The basic building block for our API client/module

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

def basic_auth(self, username: str, password: str) -> None:
    """
    If the API calls for using a username and password to obtain
    an API key, use this function.  If not call api_key() directly.
    """
    
    # Create the username/password payload to be POSTed to the API
    payload = {
        "username": username,
        "password": password
    }
    
    endpoint = "authenticate"
    endpoint_url = f"{self.base_url}/{endpoint}"
    
    # Call the authenticate endpoint
    response = self.session.post(endpoint_url, data=json.dumps(payload))
    
    # If authentication successful, capture the returned API key
    # and update the client session with the key
    if response.status_code == 200:
        data = response.json()
        api_key = data['api_key']
    
    	# Set the api_key in the session
    	self.api_key(api_key)


def api_key(self, api_key: str) -> None:
    """
    Set the sessions authorization header with the bearer token
    """
    
    self.session.headers.update({"Authorization":f"Bearer {api_key}"})
Functions used to authenticate our client to the target API

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.