Advanced TopicsFlama Client
Advanced Topics~ 5 min read

Flama Client

In the lifecycle of API development, two activities consume the majority of a developer's time: consuming the API to verify it works, and writing automated tests to ensure it keeps working. Flama provides a unified tool to handle both scenarios with elegance and precision: the Flama Client.

What is it about?

The Client is a powerful asynchronous HTTP client built upon the industry-standard httpx library. However, it is much more than just a request sender, it is an Application Orchestrator. It understands the internal state of your Flama application, manages its lifecycle events, and allows you to write robust tests that are decoupled from your specific URL structure.

The Client serves two primary purposes in your ecosystem:

  • First, it acts as a Request Interface. It provides all the standard HTTP methods you expect (get, post, put, delete, patch) with a clean, Pythonic API. It handles JSON serialisation, headers, and parameter encoding automatically.
  • Second, and perhaps more importantly, it acts as a Lifecycle Manager. When testing an application, you often need to set up complex resources before a request can be handled—connecting to a database, loading a machine learning model into memory, or establishing a cache connection. The Client handles this orchestration for you seamlessly.

Application orchestration

One of the most common pitfalls in testing ASGI applications is forgetting to trigger the startup and shutdown events. This leads to confusing errors where database connections are missing or models are not loaded.

The Flama Client solves this by implementing the asynchronous Context Manager protocol. When you use the async with syntax, the client automatically triggers your application's startup events before the first line of your block runs, and ensures shutdown events are triggered when the block exits.

# The 'async with' block ensures your app is fully readyasync with Client(app=app) as client:    # At this point, DBs are connected and models are loaded    response = await client.get("/")
# At this point, connections are closed and resources are released

Intelligent URL Resolution

Hardcoding URL strings in your tests is a fragile practice. Consider a test like this:

# Bad Practiceawait client.get("/user/123/")

If you decide to refactor your API tomorrow and change the route to /users/123/ or /api/v1/user/123/, every single test containing that string will fail. You will spend hours fixing typos in test files instead of improving your code.

Flama solves this via Intelligent URL Resolution. Every route in Flama, whether manually defined or automatically generated by a Resource, has a unique name. The Client allows you to resolve these names into URLs dynamically.

# Best Practice# Resolve the URL object and convert it to a string or access .pathurl = app.resolve_url("user:retrieve", user_id=123)await client.get(str(url))

By using resolve_url, your tests become resilient. You can change the underlying path structure of your API completely, and as long as the route names remain consistent, your tests will pass without modification.

Testing

The Flama Client is designed to be the backbone of your test suite. The most effective way to use it is by defining a pytest fixture. This allows you to inject a fully configured, lifecycle-managed client into any test function.

Here is the standard recipe for integrating the Client with pytest:

import pytestfrom flama.client import Clientfrom my_project.main import app
@pytest.fixture(scope="function")async def client(): """ Creates a Flama Client for the application. The 'async with' block ensures startup/shutdown events run for every test. """ async with Client(app=app) as client: yield client

Example

Let's look at a complete example that demonstrates the full power of the Client. We will build a simple application with CRUD endpoints and use the Client to interact with them.

Notice how we never hardcode a URL string. We rely entirely on resolve_url to discover the paths for creating, listing, and retrieving data. For CRUD resources, Flama automatically names routes using the pattern resource_name:action (e.g., animal:create, animal:retrieve).

import asyncioimport typing as t
import pydanticimport sqlalchemyfrom flama import Flamafrom flama.client import Clientfrom flama.resources.crud import CRUDResourcefrom flama.sqlalchemy import SQLAlchemyModule
metadata = sqlalchemy.MetaData()table = sqlalchemy.Table( "animal", metadata, sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True, autoincrement=True), sqlalchemy.Column("name", sqlalchemy.String, nullable=False),)
class Animal(pydantic.BaseModel): id: t.Optional[int] = None name: str
# This automatically generates routes named 'animal:create', 'animal:retrieve', etc.class AnimalResource(CRUDResource): name = "animal" model = table schema = Animal
app = Flama( modules=[SQLAlchemyModule("sqlite+aiosqlite:///")],)app.resources.add_resource("/animal/", AnimalResource)
@app.on_event("startup")async def startup(): async with app.sqlalchemy.engine.begin() as conn: await conn.run_sync(metadata.create_all)

async def main(): # Initialise the Client # The 'async with' block ensures 'startup' runs (creating the DB table) async with Client(app=app) as client: # We resolve the URL for the 'create' action. # Naming convention is '{resource_name}:create' url_create = app.resolve_url("animal:create") print(f"\n[POST] Resolving 'animal:create' -> {url_create}")
response = await client.post(str(url_create), json={"name": "Canna"}) print(f" Response: {response.json()}")
# We resolve the URL for the 'list' action. # Naming convention is '{resource_name}:list' url_list = app.resolve_url("animal:list") print(f"\n[GET] Resolving 'animal:list' -> {url_list}")
response = await client.get(str(url_list)) print(f" Response: {response.json()}")
# We resolve the URL for the 'retrieve' action. # Naming convention is '{resource_name}:retrieve' # Note: CRUDResource uses 'resource_id' as the path parameter by default. url_retrieve = app.resolve_url("animal:retrieve", resource_id=1) print(f"\n[GET] Resolving 'animal:retrieve' (id=1) -> {url_retrieve}")
response = await client.get(str(url_retrieve)) print(f" Response: {response.json()}")
if __name__ == "__main__": asyncio.run(main())

When you run this script, you will see how the URLs are dynamically resolved. If you were to change the resource prefix from /animal/ to /creatures/, the code would continue to work perfectly because the route names (animal:create, animal:retrieve) remain constant.

[POST] Resolving 'animal:create' -> /animal/       Response: {'id': 1, 'name': 'Canna'}
[GET] Resolving 'animal:list' -> /animal/ Response: {'data': [{'id': 1, 'name': 'Canna'}], 'meta': {'limit': 10, 'offset': 0, 'count': 1}}
[GET] Resolving 'animal:retrieve' (id=1) -> /animal/1/ Response: {'id': 1, 'name': 'Canna'}

Conclusion

The Flama Client is the bridge between your development environment and a production-ready application. By leveraging its lifecycle management capabilities, you ensure your tests run against a fully initialised application state, mirroring real-world conditions. Furthermore, adopting intelligent URL resolution transforms your test suite from a brittle collection of hardcoded strings into a robust verification layer that evolves seamlessly alongside your API architecture.