Components
Components are a cornerstone of Flama's design, enabling robust dependency injection throughout your application. They promote clean, maintainable, and testable code by decoupling parts of your application and managing how objects (dependencies) are created and provided where they are needed.
What are components?
In Flama, a Component is an object responsible for providing an instance of another object (a dependency) when it's requested. Think of them as specialised factories or providers within the dependency injection system.
Why are they important?
- Decoupling: Components help decouple the logic of how an object is created from where it is used. Your route handlers or other services don't need to know the instantiation details of their dependencies; they simply declare what they need.
- Modularity: They encourage breaking down complex functionalities into smaller, manageable, and often reusable units.
- Testability: Dependencies provided by components can be easily replaced with mock objects or alternative implementations during testing, isolating the unit under test.
- Centralised Dependency Management: Components provide a clear and centralised way to manage the lifecycle and configuration of services or objects used across your application.
The main virtue Components bring is the automation of dependency resolution. Flama's injector can automatically identify and provide the necessary dependencies to your functions (like route handlers) based on type hints, significantly reducing boilerplate code and making your application easier to reason about.
Building a component
How to
Creating a custom component in Flama involves two main steps:
-
Inherit from flama.Component: Your custom component class must inherit from the base flama.Component class, which is found in flama.injection.components.
-
Implement the resolve method: This is the core method of any component. It's responsible for creating, configuring, and returning the instance of the dependency that the component manages.
- The resolve() method's return type hint is crucial for the default mechanism Flama uses to match components to requested dependencies. Flama's dependency injector primarily uses this return type hint (via the default can_handle_parameter() method of the Component class) to identify which component can satisfy a particular dependency request.
- The resolve() method can also have its own parameters. These parameters can, in turn, be resolved by Flama's injector from various sources:
- Request data: Path parameters, query parameters, request headers, cookies, or the request body (if it's a Pydantic model, for instance).
- Other Components: If a parameter of a resolve() method is type-hinted with a type that another registered component can provide, Flama will recursively resolve it.
- Predefined values or services: Such as the Request object itself, WebSocket, etc.
- Default values specified in the resolve() method's signature.
Advanced: Customising parameter handling
While relying on the resolve() method's return type hint is the most common way for a component to declare what it provides, Flama offers a more advanced mechanism through the can_handle_parameter() method. If you override can_handle_parameter() in your component, you can implement custom logic to determine if your component can satisfy a given parameter, potentially without relying on the resolve() method's return type hint.
For example, consider a generic WorkerComponent that is initialised with a specific worker instance. You might want this component to handle any parameter whose type annotation matches the type of the worker it holds.
import inspectfrom flama import Component
class AbstractWorker: app = None def __init__(self, name): self.name = name
class WorkerComponent(Component): def __init__(self, worker: AbstractWorker): self.worker = worker
def can_handle_parameter(self, parameter: inspect.Parameter) -> bool: # Custom logic: checks if the parameter's type annotation # is exactly the class of the worker instance this component holds. return parameter.annotation is self.worker.__class__
def resolve(self, scope: "flama.types.Scope"): # Note: No return type hint here # self.worker.app = scope["root_app"] # Example of using scope return self.worker
In this scenario, WorkerComponent.can_handle_parameter() directly compares the parameter's annotation with self.worker.__class__. If it returns True, Flama will use this component to provide the worker instance by calling its resolve() method. This approach allows for more dynamic or instance-based component resolution. However, for most common use cases, relying on the return type hint of the resolve() method is simpler and clearer.
Here's a basic structure for a typical component relying on the return type hint of resolve():
from flama import Component # For <FlamaName /> version < 0.10, it's from flama.componentsfrom typing import Any
class MyService: def __init__(self, config_value: str): self.config_value = config_value
def do_work(self): return f"Service working with {self.config_value}"
class MyServiceComponent(Component): # The resolve method's parameters (e.g., 'some_config') # will also be injected by <FlamaName /> if possible from request data or other components. def resolve(self, some_config: str = "default_config") -> MyService: # This is where you create and return the instance # of the object this component provides. return MyService(config_value=some_config)
In this example, MyServiceComponent provides instances of MyService. Its resolve() method takes a some_config parameter.
Utilising components
Once components are defined, you need to make them available to your Flama application and then declare dependencies where needed.
Registering Components
Components are registered with a Flama application instance, typically during its initialisation, by passing a list of component instances to the components parameter.
from flama import Flama
# Assuming MyServiceComponent is defined as aboveapp = Flama(components=[MyServiceComponent()])
The Flama application initialises an Injector (from flama.injection) with these components.
Injecting Dependencies
After a component is registered, Flama can automatically inject the objects it provides into functions that declare them as dependencies using type hints. This is most commonly seen in route handlers.
@app.get("/my-service-info/")async def get_service_info(service_instance: MyService): # 'service_instance' will be an instance of MyService, # resolved and provided by MyServiceComponent. # The 'some_config' parameter of MyServiceComponent.resolve # would be injected from a request query parameter named 'some_config' # if provided in the URL, e.g., /my-service-info/?some_config=custom_value return {"info": service_instance.do_work(), "config_used_for_service": service_instance.config_value}
When a request to /my-service-info/
is made, Flama's injector inspects the get_service_info handler:
- It sees the service_instance: MyService parameter.
- It looks for a registered component whose resolve() method has a return type hint of MyService (or whose can_handle_parameter() returns true for it). It finds MyServiceComponent.
- It then calls MyServiceComponent.resolve().
- The resolve(self, some_config: str = "default_config") method of MyServiceComponent needs some_config. If the request URL was
/my-service-info/?some_config=custom
, then "custom" would be passed to some_config. Otherwise, the default "default_config" is used.
- The resolve(self, some_config: str = "default_config") method of MyServiceComponent needs some_config. If the request URL was
- The returned MyService instance is passed as service_instance to the route handler.
This mechanism allows for powerful and flexible dependency management, including nested dependencies where one component's resolve() method depends on an object provided by another component.
Example
Let's illustrate this with a self-contained example that demonstrates nested dependencies resolved from query parameters. We'll define Address and Person objects, with corresponding components to provide them. We will also add a direct route for Address.
import flamafrom flama import Component, Flama
# Step 1: Define your data structures (or services)class Address: def __init__(self, street: str, city: str, zip_code: str): self.street = street self.city = city self.zip_code = zip_code
def to_dict(self): return {"street": self.street, "city": self.city, "zip_code": self.zip_code}
class Person: def __init__(self, name: str, age: int, address: Address): self.name = name self.age = age self.address = address # Person depends on Address
def to_dict(self): return {"name": self.name, "age": self.age, "address": self.address.to_dict()}
# Step 2: Define Components for your data structures/servicesclass AddressComponent(Component): # Flama will try to inject 'street', 'city', and 'zip_code' # for this resolve method, typically from request query parameters. def resolve(self, street: str, city: str, zip_code: str) -> Address: return Address(street=street, city=city, zip_code=zip_code)
class PersonComponent(Component): # Flama will inject 'name', 'age' from query parameters. # For 'address: Address', it will look for a component that returns Address. # It will find AddressComponent and use it. def resolve(self, name: str, age: int, address: Address) -> Person: return Person(name=name, age=age, address=address)
# Step 3: Create a Flama application and register your componentsapp = Flama( openapi={ "info": { "title": "Hello-🔥", "version": "1.0", "description": "My first API", }, }, components=[ PersonComponent(), AddressComponent() ])
# Step 4: Define routes that use injected dependencies@app.get("/person-info/")def get_person_details(person_instance: Person): # 'person_instance' is automatically created and injected by Flama # using PersonComponent, which in turn uses AddressComponent. return {"data": person_instance.to_dict()}
@app.get("/address-info/")def get_address_details(address_instance: Address): # 'address_instance' is automatically created and injected by Flama # using AddressComponent. return {"data": address_instance.to_dict()}
# Step 5: Run the application (optional, for direct execution)if __name__ == "__main__": flama.run(flama_app=app, server_host="0.0.0.0", server_port=8080)