Documentation
Learn
Controller

The Controller Layer

This section explores the creation of the HauntedHousesController class, a pivotal component in any pest API. The controller is responsible for handling incoming HTTP requests and generating appropriate responses. It defines the routes and actions necessary for managing haunted houses.

HauntedHousesController class

When we first introduced the Hounted House API, we mentioned that our API would respond to HTTP requests for managing haunted houses CRUD operations (create, read, update, delete).

The HauntedHousesController class is where we define these routes and actions.

A Controller is a class decorated with the @controller decorator.

The decorator takes at least one argument: the route prefix. This prefix will be prepended to all the routes defined in the controller.

Each route is defined by a method, also known as a request handler, decorated with one of the HTTP method decorators: @get, @post, @put, @delete provided by pest.

~/haunted-houses
 touch haunted_houses/houses/haunted_houses_controller.py

Now, let's create the HauntedHousesController class in the haunted_houses_controller.py file and implement our first request handler: a get_houses method to handle GET /haunted-houses requests.

haunted_houses/houses/haunted_houses_controller.py
from pest import controller, get, post, put, delete
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHouse
 
@controller("/haunted-houses")
class HauntedHousesController:
    """Haunted-houses routes"""
 
    service: HauntedHousesService  # 💉 injected
    
    @get("/")
    def get_houses(self) -> list[HauntedHouse]:
        """Get all haunted houses"""
        return self.service.get_houses()

A lot is going on in this small code snippet, so let's break it down.

haunted_houses/houses/haunted_houses_controller.py
@controller("/haunted-houses")
class HauntedHousesController:

By decorating the HauntedHousesController class with the @controller decorator, we are telling pest that this class serves as a controller, and all the routes defined within it will carry the prefix /haunted-houses.

haunted_houses/houses/haunted_houses_controller.py
    service: HauntedHousesService  # 💉 injected

Within the HauntedHousesController class, there's a service service attribute of type HauntedHousesService. It's important to note that we're not creating an instance of the service class here; instead, we're declaring it as a class attribute. When pest initializes the controller, it will automatically instantiate the required service and inject it into the controller.

haunted_houses/houses/haunted_houses_controller.py
    @get("/")
    def get_houses(self) -> list[HauntedHouse]:
        """Get all haunted houses"""
        return self.service.get_houses()
  • The get_houses method is decorated with the @get("/") decorator, indicating pest that this method will handle GET requests to the root /haunted-houses route.

  • The docstring of the method will be used as the description of the route in the OpenAPI schema.

  • The method returns a list of HauntedHouse objects; but since HauntedHouse is a pydantic model, and pest is built on top of FastAPI, the response is automatically serialized to JSON.

  • Typically, we keep controller methods simple, focusing on transport-layer logic like parsing the request and validating it. Business logic is often delegated to a service class. In this case, we call the get_houses method of the HauntedHousesService class and return the result. While not mandatory, it's a good practice to maintain simplicity in controller methods.

    Controllers ideally handle transport-layer-related tasks such as request parsing, validation, and data transformation. pest and FastAPI streamline many of these processes before reaching our handler and after the handler returns.

Let's test it!

Before proceeding with the implementation of the rest of the controller, it's time to test what we have so far.

In order to do so, we need to revisit the HauntedHousesModule class again, and include the controller class in the controllers list. This step ensures that the controller is registered within the module, allowing pest to discover it.

haunted_houses/houses/haunted_houses_module.py
from pest import module
from .haunted_houses_controller import HauntedHousesController
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHousesRepository
 
@module(
    controllers=[HauntedHousesController],
    providers=[HauntedHousesService, HauntedHousesRepository],
)
class HauntedHousesModule:
    pass

With the controller now registered, it's time to test our route. Start the server and make a request to the /haunted-houses route.

~/haunted-houses
 uvicorn haunted_houses.main:app --reload

Open your browser or use a tool like curl, postman, etc. to send a GET request:

GET
Response200 OK
[
  {
    "id": 1,
    "name": "Spooky Manor",
    "ghost_count": 5
  },
  {
    "id": 2,
    "name": "Eerie Estate",
    "ghost_count": 3
  }
]

If everything went well, you should see the list of our haunted houses in the response as a JSON array. Hurrah! We have our first route up and running!

Define the rest of the request handlers (or routes)

Now that we know how to define a route, let's define the rest of the routes for managing all the CRUD operations for haunted houses.

Get a haunted house by id

haunted_houses/houses/haunted_houses_controller.py
from pest import controller, get, post, put, delete
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHouse
 
@controller("/haunted-houses")
class HauntedHousesController:
    """Haunted-houses routes"""
 
    service: HauntedHousesService  # 💉 injected
    
    ...
 
    @get("/{house_id}")
    def get_house_by_id(self, house_id: int) -> HauntedHouse:
        """Get a haunted house by ID"""
        return self.service.get_house_by_id(house_id)

Similar to the previous route, the only difference this time is the addition of a path parameter: /{house_id}. This parameter will be passed as an argument to the get_house_by_id method.

For instance, when the route /haunted-houses/1 is requested, the get_house_by_id method will receive a house_id argument with the value 1. Using our service instance, we retrieve the haunted house by its ID and return it.

Let's test it!

GET
Response200 OK
{
  "id": 1,
  "name": "Spooky Manor",
  "ghost_count": 5
}

Create a haunted house

When it comes to creating entities, the POST HTTP method is commonly used. Let's create a route and a request handler for creating haunted houses.

haunted_houses/houses/haunted_houses_controller.py
from pest import controller, get, post, put, delete
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHouse
 
@controller("/haunted-houses")
class HauntedHousesController:
    """Haunted-houses routes"""
 
    service: HauntedHousesService  # 💉 injected
    
    ...
 
    @post("/")
    def create_house(self, house: HauntedHouse) -> HauntedHouse:
        """Create a new haunted house"""
        return self.service.create_house(house)

In order to handle POST requests, we use the @post decorator. The create_house method takes a house argument of type HauntedHouse and returns a HauntedHouse object.

The HauntedHouse object is automatically deserialized from the request body, validated, and converted to a Python object, thanks to the power of Pydantic and FastAPI. We then delegate the actual creation of the haunted house to the previously defined service and return the result.

Time to give it a try!

POST
Body
{
    "id": 3,
    "name": "Creepy Cottage",
    "ghost_count": 8
}
Response200 OK
{
    "id": 3,
    "name": "Creepy Cottage",
    "ghost_count": 8
}

Nice! Apparently, our haunted house was successfully created. Let's check if it's in the list of haunted houses by making a GET request to the /haunted-houses route.

GET
Response200 OK
[
  {
    "id": 1,
    "name": "Spooky Manor",
    "ghost_count": 5
  },
  {
    "id": 2,
    "name": "Eerie Estate",
    "ghost_count": 3
  }
]

Oh no! It looks like our new haunted house was not saved 😢

What's going on here?

By default, pest registers all providers with a transient scope. This means that a new instance of the provider is created every time it's injected into a controller or another provider.

That's the case of our HauntedHousesRepository class. Every time the HauntedHousesService class is instantiated, a new instance of the HauntedHousesRepository class is created and injected into the service. This behavior might be desirable in most cases, but not in this one because we want to keep the list of haunted houses in memory.

Let's change the scope of the HauntedHousesRepository class to a singleton. This way, only one instance of the repository will be created and shared across the application.

haunted_houses/houses/haunted_houses_module.py
from pest import module
from pest.metadata.types.module_meta import ValueProvider
 
from .haunted_houses_controller import HauntedHousesController
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHousesRepository
 
 
@module(
    controllers=[HauntedHousesController],
    providers=[
        HauntedHousesService,
        ValueProvider(
            provide=HauntedHousesRepository,
            use_value=HauntedHousesRepository(),
        ),
    ],
)
class HauntedHousesModule:
    pass

Now, instead of registering the HauntedHousesRepository class, we register a ValueProvider with a specific instance of the repository. This way, the same instance of the repository will be shared across the application.

Now if we create our haunted house again and then try to retrieve the list of haunted houses, we should see our new haunted house in the list:

GET
Response200 OK
[
  {
    "id": 1,
    "name": "Spooky Manor",
    "ghost_count": 5
  },
  {
    "id": 2,
    "name": "Eerie Estate",
    "ghost_count": 3
  },
  {
    "id": 3,
    "name": "Creepy Cottage",
    "ghost_count": 8
  }
]

Beautiful! 🍾

Update a haunted house

When it comes to updating entities, we utilize the PUT HTTP method. Let's create a route for updating haunted houses.

haunted_houses/houses/haunted_houses_controller.py
from pest import controller, get, post, put, delete
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHouse
 
@controller("/haunted-houses")
class HauntedHousesController:
    """Haunted-houses routes"""
 
    service: HauntedHousesService  # 💉 injected
    
    ...
 
    @put("/{house_id}")
    def update_house(self, house_id: int, updated_house: HauntedHouse) -> HauntedHouse:
        """Update an existing haunted house"""
        return self.service.update_house(house_id, updated_house)

Here, we define a route for updating haunted houses using the @put decorator. The update_house method expects two arguments: house_id as a path parameter, and updated_house, which is deserialized from the request body.

If you come from FastAPI, you might find this quite familiar. pest is built on top of FastAPI, so it inherits many of its features. Importantly, pest request handlers are, essentially, FastAPI routes in the background. This means that everything you can do with FastAPI, you can seamlessly achieve with pest handlers. For example, you can leverage FastAPI's powerful features for handling body parameters, request validation, and more. Refer to the FastAPI documentation (opens in a new tab) for detailed insights on utilizing these capabilities.

The business logic for the update is delegated to the service, and the result is returned. As before, pest and FastAPI handle tasks like validation and serialization seamlessly.

Apparently, we found 2 more ghosts in our Spooky Manor haunted house. Let's update it!

PUT
Body
{
    "id": 3,
    "name": "Spooky Manor",
    "ghost_count": 7
}
Response200 OK
{
    "id": 3,
    "name": "Spooky Manor",
    "ghost_count": 7
}

Delete a haunted house

Finally, we need a route for deleting haunted houses (once all the ghosts are gone, of course 😛). We use the DELETE HTTP method for that.

haunted_houses/houses/haunted_houses_controller.py
from pest import controller, get, post, put, delete
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHouse
 
@controller("/haunted-houses")
class HauntedHousesController:
    """Haunted-houses routes"""
 
    service: HauntedHousesService  # 💉 injected
    
    ...
 
    @delete("/{house_id}")
    def delete_house(self, house_id: int) -> None:
        """Delete a haunted house by ID"""
        self.service.delete_house(house_id)

If we send a DELETE request to the /haunted-houses/1 route, the delete_house method will be called with the house_id argument set to 1. The service will then communicate with the repository to delete the haunted house.

Let's try it out!

DELETE
Response200 OK

If we try to retrieve the list of haunted houses again, we should see that the Spooky Manor is gone with all its ghosts 👻

There we go! We have all the routes we need to manage haunted houses.

The entire controller

At this point, our controller should look like this.

haunted_houses/houses/haunted_houses_controller.py
from pest import controller, get, post, put, delete
from .haunted_houses_service import HauntedHousesService
from .repository import HauntedHouse
 
@controller("/haunted-houses")
class HauntedHousesController:
    """Haunted-houses routes"""
    service: HauntedHousesService  # 💉 injected
 
    @get("/")
    def get_houses(self) -> list[HauntedHouse]:
        """Get all haunted houses"""
        return self.service.get_houses()
 
    @get("/{house_id}")
    def get_house_by_id(self, house_id: int) -> HauntedHouse:
        """Get a haunted house by ID"""
        return self.service.get_house_by_id(house_id)
 
    @post("/")
    def create_house(self, house: HauntedHouse) -> HauntedHouse:
        """Create a new haunted house"""
        return self.service.create_house(house)
 
    @put("/{house_id}")
    def update_house(self, house_id: int, updated_house: HauntedHouse) -> HauntedHouse:
        """Update an existing haunted house"""
        return self.service.update_house(house_id, updated_house)
 
    @delete("/{house_id}")
    def delete_house(self, house_id: int) -> None:
        """Delete a haunted house by ID"""
        self.service.delete_house(house_id)

All the CRUD operations for haunted houses are now implemented. Yay! 🎉

Conclusion

Across this course, we've explored the core concepts of creating APIs with the pest framework. From crafting controller classes to defining routes and handling HTTP requests, we've covered the essential aspects of API development with pest.

Our exploration included insights into dependency injection and the importance of delegating business logic to a service. These practices not only enhance modularity but also contribute to the maintainability of our API.

In the upcoming courses, we'll dive into more advanced features like authentication, authorization, testing, and more! Stay tuned!