Skip to content

Latest commit

Β 

History

History
409 lines (315 loc) Β· 16.6 KB

ARCHITECTURE.md

File metadata and controls

409 lines (315 loc) Β· 16.6 KB

Pest Framework Architecture

This document details how Pest Framework works internally.

Application Creation Flow

The entry point for any Pest application is the Pest.create(...) method in factory/__init__.py:

class Pest:
    @classmethod
    def create(
        cls,
        root_module: type,
        *,
        logging: Union[LoggingOptions, None] = None,
        middleware: MiddlewareDef = [],
        prefix: str = '',
        cors: Union[CorsOptions, None] = None,
        **fastapi_params: Unpack[FastAPIParams],
    ) -> PestApplication:
        pre_app.setup(logging=logging)
        app = make_app(fastapi_params, root_module, prefix=prefix, middleware=middleware)
        return post_app.setup(app, cors=cors)

This method orchestrates the entire application setup process. It first configures logging, then creates the application through make_app(...), and finally applies post-creation configurations like CORS settings.

The actual PestApplication (FastAPI subclass) creation happens in app_creator.py:

def make_app(
  fastapi_params: dict, root_module: type, middleware: list = [], prefix: str = ''
) -> PestApplication:
    # 1. Setup the module tree starting from root
    module_tree = setup_module(root_module)
    
    # 2. Create FastAPI application with the module tree
    app = PestApplication(module=module_tree, middleware=middleware, **fastapi_params)
    
    # 3. Get all routers from the module tree and register them
    for router in module_tree.routers:
        app.include_router(router, prefix=prefix)

    return app

This function builds the entire application structure. It first creates the module hierarchy through setup_module(), which recursively processes all modules and their dependencies. Then it instantiates the PestApplication with the module tree and registers all routers found in the modules.

Decorator System & Metadata

Pest uses decorators to configure classes and methods.

There are three main decorators:

These decorators provide two main functionalities:

  • Store metadata for later use (metadata is stored in an internal __pest__ attribute in the decorated class or function/method)
  • Optionally, they can make the decorated class extend from a base class (subclasses of PestPrimitive)
    • @module makes the class extend Module
    • @controller makes the class extend Controller
META_KEY = '__pest__'  # Stored in meta.py

@module(controllers=[TodoController])
class TodoModule: pass
# Internally adds: TodoModule.__pest__ = {'controllers': [TodoController], ...}
# and makes TodoModule extend Module(PestPrimitive)

@controller('/todos')
class TodoController: pass 
# Internally adds: TodoController.__pest__ = {'prefix': '/todos', ...}
# and makes TodoController extend Controller(PestPrimitive)

@get('/items')
def get_items(): pass
# Internally adds: get_items.__pest__ = {'methods': ['GET'], 'path': '/items', ...}

Primitives

At the core of Pest are its three fundamental primitives: PestApplication, Module and Controller.

By applying the decorators @module and @controller, we make sure all modules and controllers inherit from these primitives.

PestApplication primitive

The first primitive is the PestApplication class. This class is a subclass of FastAPI's FastAPI class and is responsible for managing the entire application.

The main difference between PestApplication and FastAPI are:

The di_scope_middleware is responsible for creating a new DI scope for each request and injecting it into the FastAPI request state. This is what allows the injection of request-scoped dependencies into controllers, services and other middlewares.

A DI scope is, basically, a DI container that is created for each request and destroyed at the end of it. This ensures that certain dependencies can have a request-level scope. That is, if a dependency is resolved during the lifecycle of a request, that same instance of the dependency will be reused in all places where it is injected in that request.

Module primitive

The module primitive provides all modules with:

  • __setup_module__ method to initialize the module, all its controllers, and child modules. This method is called first for the root module and then recursively for all child, grandchild, etc. modules to initilaize the entire module tree.
  • Self-contained DI container to manage dependencies and methods to register and resolve providers from its own DI container and from the providers exported by their child modules.

Note: The modules are not responsible for creating PestRouters. This is done by the controllers (see below).

Controller primitive

The controller primitive provides all controller classes with a classmethod __setup_controller_class__ to setup the controller and its handlers.

The __setup_controller_class__ method is called by its parent module during the module setup. It will provide the controllers with their own PestRouter with their respective routes.

It's important to note that the PestRouters are instantiated at a class level, meaning that they are shared across all instances of the controller class. We will cover this in more detail in the "Request Flow and DI" section.

During the controller setup, the controller class will process all its methods to find the ones decorated with HTTP method decorators (e.g., @get, @post, etc.). These methods are then converted to FastAPI APIRoutes and added to the controller's PestRouter.

The converting process is done by the setup_handler() function, which is called for each handler in the controller.

Again, we will cover this in more detail in the "Request Flow and DI" section.

Module Tree Creation

When the application is created, it will recieve a root Module subclass. This class is used as the entry point to build the entire module tree. This is the equivalent of an AppModule in NestJS/Angular.

Modules are set up recursively through setup_module(). Here's the detailed flow:

sequenceDiagram
    participant User
    participant Pest
    participant Module
    participant Controller
    participant Handler

    User->>Pest: create(RootModule)
    Pest->>Module: setup_module(RootModule)
    activate Module
    
    Module->>Module: Create DI container
    Module->>Module: Process metadata
    
    loop For each controller
        Module->>Controller: setup_controller()
        activate Controller
        Controller->>Controller: Create router
        
        loop For each method
            Controller->>Handler: setup_handler()
            Handler-->>Controller: Return route
        end
        
        Controller-->>Module: Return configured controller
        deactivate Controller
    end
    
    loop For each child module
        Module->>Module: setup_module(child)
    end
    
    Module-->>Pest: Return module tree
    deactivate Module
    
    Pest->>Pest: Create PestApplication
    Pest-->>User: Return application
Loading

The process works as follows:

  1. Module Creation:

    • A module is instantiated when setup_module() is called
    • Its metadata is processed to extract controllers, imports, and providers
    • A DI container is created to manage dependencies
    • Child modules are recursively setup
    • Controllers are setup and registered in the module's DI container
  2. Controller Setup:

    • Controllers are processed during module setup
    • Each controller creates its own PestRouter at a class level
    • Handler methods are discovered through metadata
    • Routes are created from handler metadata and added to the controller's router
  3. Handler Processing:

    • Handler methods are decorated with HTTP method decorators (@get, @post, etc)
    • During controller setup, these handlers are converted to FastAPI APIRoutes
    • Dependencies are extracted from handler signatures
    • Routes are added to the controller's router

Runtime

The handling of routes and requests in pest involves several key components working together:

  1. Route Integration When a Pest application is created through Pest.create(), the make_app function handles integrating all controller routes into the final PestApplication application:
module_tree = setup_module(root_module)
app = PestApplication(module=module_tree, middleware=middleware, **fastapi_params)

for router in routers:
    app.include_router(router, prefix=prefix)
  1. Handler Transformation Handler methods from controllers are transformed into FastAPI APIRoutes through the following process:
  • The controller's __setup_controller_class__ method processes each handler method
  • Each handler's metadata (from @get, @post, etc. decorators) is used to create route configurations
  • The controller itself becomes a dependency of its handler methods, ensuring proper injection:
@controller('/todo')
class TodoController:
    todos: TodoService  # Injected automatically
    
    @get('/')
    def get_all_todos(self) -> List[ReadTodoModel]:
        return self.todos.get_all()

The PestFastAPIInjector plays a crucial role here by:

  • Managing the dependency injection chain for each request
  • Ensuring controllers are instantiated with their dependencies resolved
  • Making controllers available to their handler methods as dependencies (injecting an instance of the controller as the self parameter)

This last point is crucial for Pest's architecture, as it allows the class-based system to work.

All this is done by the setup_handler function:

def setup_handler(cls, handler) -> APIRoute:
    ...
    handler_fn, handler_meta = handler
    _patch_handler_fn(cls, handler_fn) # <--- This is where the magic happens
    route = APIRoute(...)
    return route

The _patch_handler_fn function is responsible for:

  • Replaces the first parameter of the handler method with a FastAPI's Depends(PestFastAPIInjector(token=controller_cls, controller=controller_cls)). This ensures that FastAPI will inject a controller instance into the handler self parameter when a request is received. A new instance of the controller is created for each request. If the controller itself has dependencies, they are resolved and injected as well by the PestFastAPIInjector.
  • Replaces all arguments that are dependencies of the handler method; that is, those arguments marked with = inject(something) (or annotated with inject) in the method signature:
    • If the dependency is a function, we simple replace it with Depends(the_function).
    • If the dependency is a class, we replace it with Depends(PestFastAPIInjector(controller=ctrl, token=dependency_token)). I this case, the PestFastAPIInjector. By using the controller parameter, PestFastAPIInjector will be able to get the parent module of the controller and resolve the dependency from there.

In summary, all dependencies (including the controller itself) are resolved and injected into the handler method by FastAPI's dependency injection system using Depends() on a PestFastAPIInjector instance. Then the PestFastAPIInjector will resolve the dependencies using the module's internal DI container.

Request Flow and DI

When a request hits a pest application, here's what happens:

Request Reception

di_scope_mw = [
    Middleware(
        PestBaseHTTPMiddleware,
        dispatch=di_scope_middleware,
        parent_module=root_module(self),
    )
]

The di_scope_middleware creates a new DI scope for each request and injects it into the FastAPI request state. This is what allows the injection of pest request-scoped dependencies into controllers and services.

Dependency Resolution

Since all handlers were patched during setup, they are now ready to be called by FastAPI. When a handler is called, FastAPI will call PestFastAPIInjector to resolve the handler's dependencies that were markes to be injected by pest.

The PestFastAPIInjector then resolves the dependencies of the route handler by accessing the parent module of the controller and resolving the dependencies from its DI container.

Controller Integration

A key aspect is how controllers work with their handlers:

  • The controller class becomes a dependency of its handler methods
  • When a handler is called, its controller is instantiated and injected into the handler as self. By resolving the controller, it also resolves all dependencies of the controller itself.
  • The handler then has access to its fully-initialized controller instance

Summary

sequenceDiagram
    participant Client
    participant PestApplication
    participant DIMiddleware
    participant PestFastAPIInjector
    participant DI Container
    participant Controller
    participant Handler
   
    Client->>PestApplication: HTTP Request to /todo/1
    activate PestApplication
      PestApplication->>PestApplication: Match and route request
      
      PestApplication->>DIMiddleware: Handle middleware stack

      activate DIMiddleware
        DIMiddleware->>DIMiddleware: Create new request scope
        DIMiddleware->>PestApplication: Attach scope to request state
      deactivate DIMiddleware
      
      PestApplication->>PestFastAPIInjector: Resolve handler dependencies
      activate PestFastAPIInjector
        
        PestFastAPIInjector->>DI Container: Get controller instance
        activate DI Container
          DI Container->>Controller: Create new instance
          activate Controller
          DI Container-->>Controller: Inject controller dependencies
          deactivate Controller
          DI Container-->>PestFastAPIInjector: Return initialized controller
        deactivate DI Container

        PestFastAPIInjector-->>PestApplication: Return initialized controller

        loop For each handler dependency      
          PestFastAPIInjector->>DI Container: Resolve handler dependency
          
          activate DI Container
            DI Container-->>PestFastAPIInjector: Return resolved dependency
          deactivate DI Container
        end
        
        PestFastAPIInjector-->>PestApplication: Return resolved dependencies
      deactivate PestFastAPIInjector
      
      PestApplication->>Handler: execute handler injecting resolved dependencies
      activate Handler
        Handler<<-->>Controller: maybe use controller dependencies
        Handler-->>PestApplication: Return handler result
      deactivate Handler
      
      PestApplication-->>PestApplication: Format response
      PestApplication-->>Client: Send HTTP response
    deactivate PestApplication
Loading