DEV Community

Cover image for The Proper Way to Write API Clients
Tribulnation Labs
Tribulnation Labs

Posted on • Originally published at tribulnation.com

The Proper Way to Write API Clients

Introduction

Details matter. Developer experience matters. Every unnecessary import, every unnecessary check on the docs because the type hints are lacking, every hard-to-understand feature, every hard-coded quirk the user can’t turn off—these matter.

So, some guidelines are obvious:

  1. Inputs shouldn’t require custom imports
  2. Annotate types precisely
  3. Avoid unnecessary complication
  4. Provide extra behavior optionally

Let’s unpack them and give examples one by one.

1. Inputs shouldn’t require custom imports

So simple, yet so commonly ignored. How often do you see this?

from sdk.deeply.nested.path.to.the.method.module import Parameters

await client.get_user(Parameters(email='...'))
Enter fullscreen mode Exit fullscreen mode

I don’t want to walk your stupid repo at every god-damn call! No, give me a TypedDict instead, for pity’s sake. This way I still get the type safety, and no import required:

# SDK code
class Parameters(TypedDict):
    email: str

async def get_user(self, parameters: Parameters):
    ...

# user code
await client.get_user({ 'email': '...' })
Enter fullscreen mode Exit fullscreen mode

Isn’t that nicer?


2. Annotate types precisely

Nothing frustrates me more than an API “SDK” that looks like this:

async def get_users(**kwargs) -> list:
    return await request('GET', '/data/users', params=kwargs)
Enter fullscreen mode Exit fullscreen mode

Thanks for your help! I was worrying integrating with the API would be a mess, but these precise annotations make it much easier!

LOL

But that’s not quite it. Correct type annotations may not tell the full picture either. Consider this:

class User(TypedDict):
    name: str
    ...
    full_name: NotRequired[str]
    """Only if return_type='full'"""

async def get_users(return_type: Literal['simple', 'full']) -> list[User]:
    ...
Enter fullscreen mode Exit fullscreen mode

Almost there, but not quite… We can narrow it further:

class SimpleUser(TypedDict):
    name: str
    ...

class FullUser(SimpleUser):
    full_name: str

@overload
async def get_users(return_type: Literal['simple']) -> list[SimpleUser]:
    ...
@overload
async def get_users(return_type: Literal['full']) -> list[FullUser]:
    ...
Enter fullscreen mode Exit fullscreen mode

Now we’re talking! Chef kiss.


3. Avoid unnecessary complication

Keep your implementation details to yourself, thank you. Nobody cares about them. Yet how often is one force-fed with this kind of thing?

from sdk.client import Client
from sdk.gateway import ApiGateway
from sdk.transport import HttpClient

client = Client(
    api_gateway=ApiGateway('api3.company.com'),
    transport_client=HttpClient(use_httpx=True),
)
Enter fullscreen mode Exit fullscreen mode

Why?! Why do I have to go through convulsions just to instantiate your damn client? Can’t you pick an HTTP library for me? Can’t you pick an API endpoint? I don’t want to choose, nor care.

No, give me some solid defaults. I should only go through convulsions if I want something really custom:

from sdk import Client

client = Client.new()
Enter fullscreen mode Exit fullscreen mode

And that’s it! If you want, you can optionally give me more options:

# SDK code
class Client:
    @classmethod
    def new(
        cls, *, api_gateway: str = DEFAULT_GATEWAY,
        transport: Literal['httpx', 'requests', 'websockets']
    ):
        return cls(...)
Enter fullscreen mode Exit fullscreen mode

4. Provide extra behavior optionally

You’re writing an API client. Don’t get fancy. I don’t need retry logic, I don’t need an exaggeratedly complicated exception hierarchy. I don’t need the SDK to make me a fucking coffee! Your mission is to make integration easy and type-checkable.

Some examples:

  1. Retries could be great! But make them opt-in:

    client = Client.new(retries={
        'max': 5,
        'delay': 2
    })
    
  2. Paging is nice. But not by default:

    users = await client.get_users(page=2) # normal, as exposed by the API
    
    async for page in client.get_users_paged(): # okay, as a separate thing
        for user in page:
            ...
    

Closing

And that’s it. It’s not complicated, just some solid basics. The principles are clear: simplicity, precision and ease of use.

Top comments (0)