Clientele

repository·main·Indexed 43 days ago

https://github.com/phalt/clientele

A Python framework for building robust API integrations with Pydantic-based validation, async support, and automated client scaffolding from OpenAPI and GraphQL schemas. Version 2.3.0 provides tools like APIClient for REST and GraphQLClient for GraphQL, featuring automatic response hydration and compatibility with FastAPI, Django REST Framework, and Django Ninja.

Tokens
33.4K
Snippets
119
Records
138
Agent score
52%

What's inside Clientele

  1. What is Clientele?

    main
    Clientele is a framework designed for building and maintaining API integrations. It allows developers to focus on API functionality and data structures while the framework handles the underlying HTTP complexities, such as networking, retries, and caching. It is designed to feel similar to popular Python API server frameworks and integrates deeply with Pydantic for data validation.
  2. How Clientele API requests work

    main

    Clientele uses decorators to define API clients. When you call a decorated function, Clientele handles the HTTP request and injects the response into your function.

    Parameter Injection Rules

    • result parameter (Mandatory): This parameter is injected by Clientele after the HTTP response arrives. Its type annotation (e.g., User) determines how the response is parsed and validated. Callers never supply this argument.
    • response parameter (Optional): Injects the httpx.Response object, useful for debugging or accessing headers.
    • Ordering Requirement: Both result and response must be declared first in your function signature, before any caller-supplied parameters (path parameters, query parameters, or data).

    Request Parameters

    • Path Parameters: Variables inside {} in the URL (e.g., {user_id}) are automatically filled from function arguments matching those names.
    • Query Parameters: Any remaining keyword arguments that are not path parameters or data are treated as query parameters.
    • Request Body (data): For body-based methods (POST, PUT, PATCH, DELETE), the request body must be provided via the data keyword argument. data can be a Pydantic model or a TypedDict.
    @client.get("/users/{user_id}")
    def get_user(result: User, user_id: int, include_details: bool = True) -> User:
        return result
    
    # user_id is a path param, include_details is a query param
    user = get_user(42, include_details=True)
  3. How injected parameters work in Clientele

    main

    When using decorators like @client.get(...), Clientele needs to provide the parsed HTTP response to your function. To do this without forcing the caller to provide the response manually, Clientele uses injected parameters.

    These parameters (result and response) are included in your function definition so you can use them inside the function body, but Clientele strips them from the public signature. This means callers and IDEs only see the parameters they are supposed to provide (like path or query parameters), while the injected parameters remain invisible to the caller.

    # What you write:
    def get_user(result: User, user_id: int) -> User
    
    # What your IDE and type checker see after decoration:
    def get_user(user_id: int) -> User
  4. Use Pydantic schemas for request and response data

    main

    Django Ninja Schema objects are automatically converted into Pydantic BaseModel classes within the generated client's schemas module. This provides full type safety for both request payloads and response bodies.

    • Requests: Pass a schema instance to the data argument of a client method.
    • Responses: The returned object is typed as the corresponding schema (e.g., schemas.UserOut).
    # In my_client/schemas.py
    import pydantic
    
    class UserIn(pydantic.BaseModel):
        username: str
        email: str
        age: int
    
    class UserOut(pydantic.BaseModel):
        id: int
        username: str
        email: str
        age: int
        created_at: datetime
    
    # Usage
    user_data = schemas.UserIn(username="alice", email="alice@example.com", age=30)
    response = client.create_user(data=user_data)
    
    # response is typed as schemas.UserOut
    print(response.id)
  5. How type-based hydration works in streams

    main

    Clientele uses the type hint inside the AsyncIterator[T] (or Iterator[T]) to determine how to parse and hydrate each line of the stream:

    • str: No parsing is performed; each yielded item is a raw string.
    • dict: Each line is parsed as JSON into a dictionary.
    • Pydantic Model: Each line is parsed as JSON and validated against the provided Pydantic model.

    This hydration happens automatically based on the type signature of the result parameter in your decorated function.

    from typing import AsyncIterator
    from pydantic import BaseModel
    
    class Token(BaseModel):
        text: str
        id: int
    
    # Using Pydantic for automatic JSON parsing and validation
    @client.get("/stream", streaming_response=True)
    async def stream_tokens(*, result: AsyncIterator[Token]) -> AsyncIterator[Token]:
        return result
  6. Customize client method names using operation_id

    main

    By default, Clientele generates client method names by combining the operation_id from the OpenAPI schema with the HTTP method (e.g., list_users_api_users_get).

    To get cleaner, more readable method names in your Python client, define a custom operation_id in your Django Ninja decorators. Clientele will use this exact string as the method name.

    Example Transformation:

    • Default: @api.get("/users") $\rightarrow$ client.list_users_api_users_get()
    • Custom: @api.get("/users", operation_id="list_users") $\rightarrow$ client.list_users()
    from ninja import NinjaAPI
    
    api = NinjaAPI()
    
    # This results in client.list_users()
    @api.get("/users", operation_id="list_users")
    def get_users(request):
        return []
    
    # This results in client.create_user()
    @api.post("/users", operation_id="create_user")
    def create_user(request, user: UserIn):
        return user
  7. Compare direct requests vs. decorated functions

    main

    Clientele offers two ways to interact with APIs. Choosing between them depends on your use case.

    Direct Requests (APIClient.request)

    Best for: Small, ad-hoc requests.

    What you get:

    • Strongly typed responses
    • Automatic data validation on responses
    • Response hydration into Pydantic models

    What you lose:

    • Strongly typed inputs for parameters (inputs are passed as kwargs)
    • An abstract functional interface to the API
    • Support for streaming responses

    Decorated Functions

    Best for: Primary API integration and structured client code.

    What you get:

    • Strongly typed inputs
    • Abstract functional interface
    • Streaming response support
  8. Mix sync and async functions in a Clientele API client

    main

    The Clientele API supports both synchronous (def) and asynchronous (async def) function definitions within the same APIClient instance. You can define standard synchronous endpoints alongside asynchronous ones to suit your specific logic requirements or integration needs.

    from clientele import api as clientele_api
    from .my_config import Config
    from .my_models import BookResponse, CreateBookResponse, CreateBookRequest
    
    client = clientele_api.APIClient(config=Config())
    
    # A synchronous endpoint
    @client.post("/books")
    def create_user(
        result: CreateBookResponse,
        data: CreateBookResponse,
    ) -> CreateBookResponse:
        return result
    
    # An asynchronous endpoint in the same client
    @client.get("/book/{book_id}")
    async def get_book(result: BookResponse, book_id: int) -> BookResponse:
        return result
  9. Customize client function names using operationId

    main

    By default, Clientele generates function names using the pattern {function_name}_{path}_{method} (e.g., get_users_users_get).

    To get cleaner, more readable function names in your client, define a custom operation_id in your FastAPI route decorators. Clientele will use this exact string as the method name.

    from fastapi import FastAPI
    
    app = FastAPI()
    
    # This will generate client.list_users()
    @app.get("/users", operation_id="list_users")
    def get_users():
        return []
    
    # This will generate client.create_user()
    @app.post("/users", operation_id="create_user")
    def create_user(user: UserCreate):
        return user
  10. Handle optional GraphQL variables

    main

    To handle optional (nullable) GraphQL variables, use Python's | None type annotation for the function parameters.

    When a parameter is passed as None, GraphQLClient will omit the variable from the request payload entirely rather than sending a null value. This follows GraphQL best practices for optional variables.

    from pydantic import BaseModel
    from clientele.graphql import GraphQLClient
    
    client = GraphQLClient(base_url="https://api.github.com/graphql")
    
    class Repository(BaseModel):
        name: str
        language: str | None = None
    
    class RepositoriesQueryData(BaseModel):
        repositories: list[Repository]
    
    class RepositoriesQueryResponse(BaseModel):
        data: RepositoriesQueryData
    
    @client.query("""
        query($owner: String!, $language: String) {
            repositories(owner: $owner, language: $language) {
                name
                language
            }
        }
    """)
    def search_repos(result: RepositoriesQueryResponse, owner: str, language: str | None) -> list[Repository]:
        return result.data.repositories
    
    # language is omitted from variables
    repos = search_repos(owner="phalt")
    
    # language is included
    python_repos = search_repos(owner="phalt", language="Python")