I cannot find a clean way to structure a python API project that splits the routes of a module (/invoices
for this example) into different files, when using a decorator approach for the routing strategy.
The problem is the following:
api.py:
from invoices.adapters.api.routes import invoices_routes
app = APIGatewayRestResolver(enable_validation=True)
app.include_router(invoices_routes.invoices_router, prefix="/invoices")
Each Router
is defined as a variable in a separate file that holds all methods linked to this router, which is as follows:
invoices_routes.py:
invoices_router = Router()
@invoices_router.post(
"/",
)
def handle_post_invoice(...)
The issue is that, as the methods under prefix /invoices
grow, the file that holds all methods will grow too, so it would be necessary to split the methods into multiple files, one per method.
I have tried a few solutions, but I dont like any of them, because the code gets more dirty:
I move the methods to separate files, and keep only the Router
instantiation in the routes file. It does not work, because in order for the methods to be loaded in the router, their files have to imported, and thus, loaded.
Importing the file in the routes file (to solve previous problem) doesnt help, because I end up having circular dependencies:
invoices_routes.py:
from .get_invoice import handle_get_invoice # fix to load the method in the router, causes circular dep
invoices_router = Router()
get_invoice.py:
from invoices.adapters.api.routes.invoices_routes import (
invoices_router # circular dep
)
@invoices_router.get("/<invoice_id>", tags=["Invoices"])
def handle_get_invoice(...)
Note: Im using aws-lambda-powertools
, but this problem happens if you implement a simple router decorator that works in a similar way, adding the routing function to a Router when importing the function, properly decorated.
I dont like this one, because the code gets very ugly, and it separates logic that should be close:
invoices_routes.py:
from .get_invoice import handle_get_invoice
invoices_router = Router()
decorated_handle_get_invoice = router.post(
"/",
)(handle_get_invoice)
get_invoice.py:
def handle_get_invoice(...)
This one is a bit cleaner, but still not that much. The method file keeps the same, but we need to alter the routes, and the API entrypoint:
invoices_routes.py:
invoices_router = Router()
def load_routes(app):
# Note the local import to avoid circular deps
from .get_invoice import handle_get_invoice
app.include_router(invoices_router, prefix="/invoices")
api.py:
from invoices.adapters.api.routes import invoices_routes
app = APIGatewayRestResolver(enable_validation=True)
invoices_routes.load_routes(app)
I havent find a cleaner way to do it, but Im sure there has to be some standard, better way to do this, given that implementing an API with separated route files is very common.
You can make the code that creates a Router
instance a separate module (named invoices_router.py
in the example below) so that all modules that need the Router
instance can import the module without a reverse dependency (the long paths in your example are omitted for brevity):
invoices_router.py
invoices_router = Router()
get_invoice.py
from invoices_router import invoices_router
@invoices_router.get("/<invoice_id>", tags=["Invoices"])
def handle_get_invoice(...): ...
invoices_routes.py
import get_invoice
from invoices_router import invoices_router
api.py
from invoices_routes import invoices_router
app = APIGatewayRestResolver(enable_validation=True)
app.include_router(invoices_router, prefix="/invoices")