Skip to content

🪄 Client example

Let's build an API Client using clientele.

Our GitHub has a bunch of schemas that are proven to work with clientele, so let's use one of those!

Generate the client

In your project's root directory:

clientele generate -u https://raw.githubusercontent.com/phalt/clientele/main/example_openapi_specs/best.json -o my_client/

Note

The example above uses one of our test schemas, and will work if you copy/paste it!

The -u parameter expects a URL, you can provide a path to a file with -f instead if you download the file.

The -o parameter is the output directory of the generated client.

Run it now and you will see this output:

my_client/
    __init__.py
    client.py
    config.py
    http.py
    MANIFEST
    schemas.py

Let's go over each file and talk about what it does.

Client

GET functions

The client.py file provides all the API functions from the OpenAPI schema. Functions are a combination of the path and the HTTP method for those paths. So, a path with two HTTP methods will be turned into two python functions.

my_client/client.py
from my_client import http, schemas


def simple_request_simple_request_get() -> schemas.SimpleResponse:
    """Simple Request"""

    response = http.get(url="/simple-request")
    return http.handle_response(simple_request_simple_request_get, response)

...

We can see one of the functions here, simple_request_simple_request_get, is for a straight-forward HTTP GET request without any input arguments, and it returns a schema object.

Here is how you might use it:

from my_client import client

client.simple_request_simple_request_get()
>>> SimpleResponse(name='Hello, clientele')

POST and PUT functions

A more complex example is shown just below.

This is for an HTTP POST method, and it requires an input property called data that is an instance of a schema, and returns one of many potential responses. If the endpoint has url parameters or query parameters, they will appear as input arguments to the function alongside the data argument.

def request_data_request_data_post(
    data: schemas.RequestDataRequest
) -> schemas.RequestDataResponse | schemas.HTTPValidationError:
    """Request Data"""

    response = http.post(url="/request-data", data=data.model_dump())
    return http.handle_response(request_data_request_data_post, response)

Here is how you might use it:

from my_client import client, schemas

data = schemas.RequestDataRequest(my_input="Hello, world")
response = client.request_data_request_data_post(data=data)
>>> RequestDataResponse(your_input='Hello, world')

Clientele also supports the major HTTP methods PUT and DELETE in the same way.

URL and Query parameters

If your endpoint takes path parameters (aka URL parameters) then clientele will turn them into parameters in the function:

from my_client import client

client.parameter_request_simple_request(your_input="gibberish")
>>> ParameterResponse(your_input='gibberish')

Query parameters will also be generated the same way. See this example for a function that takes a required query parameter.

Note that, optional parameters that are not passed will be omitted when the URL is generated by Clientele.

Handling responses

Because we're using Pydantic to manage the input data, we get a strongly-typed response object. This works beautifully with the new structural pattern matching feature in Python 3.10 and up:

response = client.request_data_request_data_post(data=data)

# Handle responses elegantly
match response:
    case schemas.RequestDataResponse():
        # Handle valid response
        ...
    case schemas.ValidationError():
        # Handle validation error
        ...

API Exceptions

Clientele keeps a mapping of the paths and their potential response codes. When it gets a response code that fits into the map, it generates the pydantic object associated to it.

If the HTTP response code is an unintended one, it will not match a return type. In this case, the function will raise an http.APIException.

from my_client import client, http
try:
    good_response = my_client.get_my_thing()
except http.APIException as e:
    # The API got a response code we didn't expect
    print(e.response.status_code)

The response object will be attached to this exception class for your own debugging.

Schemas

The schemas.py file has all the possible schemas, request and response, and even Enums, for the API. These are taken from OpenAPI's schemas objects and turned into Python classes. They are all subclassed from pydantic's BaseModel.

Here are a few examples:

my_client/schemas.py
import pydantic
from enum import Enum


class ParameterResponse(pydantic.BaseModel):
    your_input: str

class RequestDataRequest(pydantic.BaseModel):
    my_input: str

class RequestDataResponse(pydantic.BaseModel):
    my_input: str

# Enums subclass str so they serialize to JSON nicely
class ExampleEnum(str, Enum):
    ONE = "ONE"
    TWO = "TWO"

Configuration

One of the problems with auto-generated clients is that you often need to configure them, and if you try and regenerate the client at some point in the future then your configuration gets wiped clean and you have to do it all over again.

Clientele solves this problem by providing an entry point for configuration that will never be overwritten - config.py.

When you first generate the project, you will see a file called config.py and it will offer configuration functions a bit like this:

"""
This file will never be updated on subsequent clientele runs.
Use it as a space to store configuration and constants.

DO NOT CHANGE THE FUNCTION NAMES
"""


def api_base_url() -> str:
    """
    Modify this function to provide the current api_base_url.
    """
    return "http://localhost"

Subsequent runs of the generate command with --regen t will not change this file if it exists, so you are free to modify the defaults to suit your needs.

For example, if you need to source the base url of your API for different configurations, you can modify the api_base_url function like this:

from my_project import my_config

def api_base_url() -> str:
    """
    Modify this function to provide the current api_base_url.
    """
    if my_config.debug:
        return "http://localhost:8000"
    elif my_config.production:
        return "http://my-production-url.com"

Just keep the function names the same and you're good to go.

Authentication

If your OpenAPI spec provides security information for the following authentication methods:

  • HTTP Bearer
  • HTTP Basic

Then clientele will provide you information on the environment variables you need to set to make this work during the generation. For example:

Please see my_client/config.py to set authentication variables

The config.py file will have entry points for you to configure, for example, HTTP Bearer authentication will need the get_bearer_token function to be updated, something like this:

def get_bearer_token() -> str:
    """
    HTTP Bearer authentication.
    Used by many authentication methods - token, jwt, etc.
    Does not require the "Bearer" content, just the key as a string.
    """
    from os import environ
    return environ.get("MY_AUTHENTICATION_TOKEN")

Additional headers

If you want to pass specific headers with all requests made by the client, you can configure the additional_headers function in config.py to do this.

def additional_headers() -> dict:
    """
    Modify this function ot provide additional headers to all
    HTTP requests made by this client.
    """
    return {}

Please note that if you are using this with authentication headers, then authentication headers will overwrite these defaults if they keys match.