Skip to content

Routes

Esmerald has a simple but highly effective routing system capable of handling from simple routes to the most complex ones.

Using an enterprise application as example, the routing system surely will not be something simple with 20 or 40 direct routes, maybe it will have 200 or 300 routes where those are split by responsabilities, components and packages and imported also inside complex design systems. Esmerald handles with those cases without any kind of issues at all.

Lilya routing system alone wasn't enough to serve all the complexities and cases for all sort of different APIs and systems, so Esmerald created its own.

Gateway

A Gateway is an extension of the Route, really, but adds its own logic and handling capabilities, as well as its own validations, without compromising the core.

Gateway and application

In simple terms, a Gateway is not a direct route but instead is a "wrapper" of a handler and maps that same handler with the application routing system.

Parameters

All the parameters and defaults are available in the Gateway Reference.

from esmerald import Esmerald, Gateway, Request, get


@get()
async def homepage(request: Request) -> str:
    return "Hello, home!"


app = Esmerald(routes=[Gateway(handler=homepage)])

WebSocketGateway

Same principle as Gateway with one particularity. Due to the nature of Lilya and websockets we decided not to interfere (for now) with what already works and therefore the only supported websockets are async.

WebSocketGateway and application

In simple terms, a WebSocketGateway is not a direct route but instead is a "wrapper" of a websocket handler and maps that same handler with the application routing system.

Parameters

All the parameters and defaults are available in the WebSocketGateway Reference.

from esmerald import Esmerald, Websocket, WebSocketGateway, websocket


@websocket(path="/{path_param:str}")
async def world_socket(socket: Websocket) -> None:
    await socket.accept()
    msg = await socket.receive_json()
    assert msg
    assert socket
    await socket.close()


app = Esmerald(
    routes=[
        WebSocketGateway(handler=world_socket),
    ]
)

Include

Includes are unique to Esmerald, very similar to the Include of Lilya but more powerful and with more control and feature and allows:

  1. Scalability without issues (thanks to Lilya).
  2. Clean routing design.
  3. Separation of concerns.
  4. Separation of routes.
  5. Reduction of the level of imports needed through files.
  6. Less human lead bugs.

Warning

Includes DO NOT take path parameters. E.g.: Include('/include/{id:int}, routes=[...]).

Include and application

This is a very special object that allows the import of any routes from anywhere in the application. Include accepts the import via namespace or via routes list but not both.

When using a namespace, the Include will look for the default route_patterns list in the imported namespace (object) unless a different pattern is specified.

The patten only works if the imports are done via namespace and not via routes object.

Parameters

All the parameters and defaults are available in the Include Reference.

myapp/urls.py
from esmerald import Include

route_patterns = [Include(namespace="myapp.accounts.urls")]
src/myapp/urls.py
from myapp.accounts.urls import route_patterns

from esmerald import Include

route_patterns = [Include(routes=route_patterns)]

Using a different pattern

src/myapp/accounts/controllers.py
from pydantic import BaseModel

from esmerald import (
    APIView,
    JSONResponse,
    Request,
    Response,
    WebSocket,
    get,
    post,
    put,
    status,
    websocket,
)


class Product(BaseModel):
    name: str
    sku: str
    price: float


@put("/product/{product_id}")
def update_product(product_id: int, data: Product) -> dict:
    return {"product_id": product_id, "product_name": data.name}


@get(status_code=status.HTTP_200_OK)
async def home() -> JSONResponse:
    return JSONResponse({"detail": "Hello world"})


@get()
async def another(request: Request) -> dict:
    return {"detail": "Another world!"}


@websocket(path="/{path_param:str}")
async def world_socket(socket: WebSocket) -> None:
    await socket.accept()
    msg = await socket.receive_json()
    assert msg
    assert socket
    await socket.close()


class World(APIView):
    @get(path="/{url}")
    async def home(request: Request, url: str) -> Response:
        return Response(f"URL: {url}")

    @post(path="/{url}", status_code=status.HTTP_201_CREATED)
    async def mars(request: Request, url: str) -> JSONResponse: ...

    @websocket(path="/{path_param:str}")
    async def pluto(self, socket: WebSocket) -> None:
        await socket.accept()
        msg = await socket.receive_json()
        assert msg
        assert socket
        await socket.close()
src/myapp/accounts/urls.py
from esmerald import Gateway, WebSocketGateway

from .views import World, another, home, world_socket

my_urls = [
    Gateway(handler=update_product),
    Gateway(handler=home),
    Gateway(handler=another),
    Gateway(handler=World),
    WebSocketGateway(handler=world_socket),
]
src/myapp/urls.py
from esmerald import Include

route_patterns = [Include(namespace="myapp.accounts.urls", pattern="my_urls")]

Include and application instance

The Include can be very helpful mostly when the goal is to avoid a lot of imports and massive list of objects to be passed into one single object. This can be particularly useful to make a clean start Esmerald object as well.

Example:

src/urls.py
from esmerald import Include

route_patterns = [Include(namespace="myapp.accounts.urls", pattern="my_urls")]
src/app.py
from esmerald import Esmerald, Include

app = Esmerald(routes=[Include(namespace="src.urls")])

Nested Routes

When complexity increses and the level of routes increases as well, Include allows nested routes in a clean fashion.

from esmerald import Esmerald, Gateway, Include, get


@get()
async def me() -> None: ...


app = Esmerald(routes=[Include("/", routes=[Gateway(path="/me", handler=me)])])
from esmerald import Esmerald, Gateway, Include, get


@get()
async def me() -> None: ...


app = Esmerald(
    routes=[
        Include(
            "/",
            routes=[
                Include(
                    "/another",
                    routes=[
                        Include(
                            "/multi",
                            routes=[
                                Include(
                                    "/nested",
                                    routes=[
                                        Include(
                                            "/routing",
                                            routes=[
                                                Gateway(path="/me", handler=me),
                                                Include(
                                                    path="/imported",
                                                    namespace="myapp.routes",
                                                ),
                                            ],
                                        )
                                    ],
                                )
                            ],
                        )
                    ],
                )
            ],
        )
    ]
)

Include supports as many nested routes with different paths and Gateways, WebSocketGateways and Includes as you desire to have. Once the application starts, the routes are assembled and it will not impact the performance, thanks to Lilya.

Nested routes also allows all the functionalities on each level, from middleware, permissions and exception handlers to dependencies.

Application routes

Warning

Be very careful when using the Include directly in the Esmerald(routes[]), importing without a path may incur in some routes not being properly mapped.

Only applied to the application routes:

If you decide to do this:

from esmerald import Esmerald, Include

app = Esmerald(
    routes=[
        Include(namespace="src.urls", name="root"),
        Include(namespace="accounts.v1.urls", name="accounts"),
    ]
)

Be careful!

What is actually happening?

  1. Importing the src.urls without path, it will default to /.
  2. Importing the accounts.v1.urls without path, it will default to /.

Because accounts.v1.urls was the last being imported without a path and matching the same path / as src.urls, internally the system by the time of loading up the routes, it will only register the src.urls ignoring completely the accounts.v1.urls.

One possible solution:

from esmerald import Esmerald, Include

app = Esmerald(
    routes=[
        Include(namespace="src.urls", name="root"),
        Include(path="/api/v1", namespace="accounts.v1.urls", name="accounts"),
    ]
)

The same is applied to the nested routes nested routes.

Example:

from esmerald import Esmerald, Include

app = Esmerald(
    routes=[
        Include(
            "/",
            routes=[
                Include(path="/one", namespace="src.urls"),
                Include(path="/two", namespace="accounts.v1.urls", name="accounts"),
            ],
            name="root",
        ),
    ]
)

Another Example:

from flask import Flask, escape, request

from esmerald import Esmerald, Include
from esmerald.middleware.wsgi import WSGIMiddleware

flask_app = Flask(__name__)
another_flask_app = Flask(__name__)


@flask_app.route("/")
def flask_main():
    name = request.args.get("name", "Esmerald")
    return f"Hello, {escape(name)} from Flask!"


app = Esmerald(
    routes=[
        Include(
            "/",
            routes=[
                Include(path="/one", namespace="src.urls"),
                Include(path="/two", namespace="accounts.v1.urls", name="accounts"),
                Include("/flask", WSGIMiddleware(flask_app)),
                Include("/flask/v2", WSGIMiddleware(another_flask_app)),
            ],
            name="root",
        ),
        Include(
            "/external",
            routes=[
                Include(WSGIMiddleware(flask_app)),
            ],
        ),
    ]
)

The path is / for both src.urls and accounts.v1.urls and unique with their prefixes.

Info

If you are wondering why Flask in the examples then the answer is simple. Esmerald supports the integration with other wsgi frameworks but more details can be found here.

Tip

If you encounter a scenario where you need to have the same prefix for many paths (as per examples), simply create a nested route and that's it.

Check

Remember, the route paths are registered only once and there is no "override". First in, first registered. This is feature came from Lilya and there is a reason why it is like this and we decided not to break it since it was designed to be hierarchical, from the top to bottom.

Routes priority

The application routes in simple terms are simply prioritised. Since Esmerald uses Lilya under the hood that also means that the incoming paths are matched agains each Gateway, WebSocketGateway and Include in order.

In cases where more than one, let's say Gateway could match an incoming path, you should ensure that more specifc routes are listed before general cases.

Example:

from esmerald import Esmerald, Gateway, get


@get()
async def user() -> dict: ...


@get()
async def active_user() -> dict: ...


# Don't do this: `/users/me`` will never match the incoming requests.
app = Esmerald(
    routes=[
        Gateway("/users/{username}", handler=user),
        Gateway("/users/me", handler=active_user),
    ]
)

# Do this: `/users/me` is tested first and both cases will work.
app = Esmerald(
    routes=[
        Gateway("/users/me", handler=active_user),
        Gateway("/users/{username}", handler=user),
    ]
)

Warning

The way the routes are assembled is very important and you always need to pay attention. Esmerald in a very high level does some sorting on the base routes of the application making sure that the routes where the only path is /, are the last ones being evaluated but this might be updated in the future and it does not stop you from following the routes priority in any way from the beginning.

Path parameters

Paths can use templating style for path components. The path params are only applied to Gateway and WebSocketGateway and not applied to Include.

@get('/example')
async def customer(customer_id: Union[int, str]) -> None:
    ...


@get('/')
async def floating_point(number: float) -> None:
    ...

Gateway('/customers/{customer_id}', handler=customer)

By default this will capture characters up to the end of the path of the next '/' and also are joint to the path of a handler. In the example above, it will become /customers/{customer_id}/example.

Transformers can be used to modify what is being captured. The current available transformers are the same ones used by Lilya as well.

  • str returns a string, and is the default.
  • int returns a Python integer.
  • float returns a Python float.
  • uuid returns a Python uuid.UUID instance.
  • path returns the rest of the path, including any additional / characters.

As per standard, the transformers are used by prefixing them with a colon:

Gateway('/customers/{customer_id:int}', handler=customer)
Gateway('/floating-point/{number:float}', handler=floating_point)
Gateway('/uploaded/{rest_of_path:path}', handler=uploaded)

Custom transformers

If a need for a different transformer that is not defined or available, you can also create your own. Using the same example as Lilya since it works with Esmerald.

from datetime import datetime

from lilya.transformers import Transformer, register_path_transformer


class DateTimeTransformer(Transformer):
    regex = "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(.[0-9]+)?"

    def transform(self, value: str) -> datetime:
        return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S")

    def normalise(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%dT%H:%M:%S")


register_path_transformer("datetime", DateTimeTransformer())

With the custom transformer created you can now use it.

Gateway('/sells/{date:datetime}', handler=sell)

Info

The request parameters are available also in the request, via request.path_params dictionary.

Middleware, exception Handlers, dependencies and permissions

Examples

The following examples are applied to Gateway, WebSocketGateway and Include.

We will be using Gateway for it can be replaced by any of the above as it is common among them.

Middleware

As specified before, the middleware of a Gateway are read from top down, from the parent to the very handler and the same is applied to exception handlers, dependencies and permissions.

from esmerald import Esmerald, Gateway, MiddlewareProtocol, get
from esmerald.types import ASGIApp


class RequestLoggingMiddlewareProtocol(MiddlewareProtocol):
    def __init__(self, app: "ASGIApp", kwargs: str = "") -> None:
        self.app = app
        self.kwargs = kwargs


class ExampleMiddleware(MiddlewareProtocol):
    def __init__(self, app: "ASGIApp") -> None:
        self.app = app


@get(path="/home", middleware=[RequestLoggingMiddlewareProtocol])
async def homepage() -> dict:
    return {"page": "ok"}


app = Esmerald(routes=[Gateway(handler=homepage, middleware=[ExampleMiddleware])])

The above example illustrates the various levels where a middleware can be implemented and because it follows an parent order, the order is:

  1. Default application built-in middleware.
  2. BaseRequestLoggingMiddleware.
  3. ExampleMiddleware.
  4. RequestLoggingMiddlewareProtocol.

More than one middleware can be added to each list.

Exception Handlers

from esmerald import Esmerald, Gateway, JSONResponse, Request, get
from esmerald.exceptions import EsmeraldAPIException, InternalServerError, NotAuthorized


async def http_esmerald_handler(_: Request, exc: EsmeraldAPIException) -> JSONResponse:
    return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)


async def http_internal_server_error_handler(_: Request, exc: InternalServerError) -> JSONResponse:
    return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)


async def http_not_authorized_handler(_: Request, exc: NotAuthorized) -> JSONResponse:
    return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)


@get(path="/home", exception_handlers={NotAuthorized: http_not_authorized_handler})
async def homepage() -> dict:
    return {"page": "ok"}


app = Esmerald(
    routes=[
        Gateway(
            handler=homepage,
            exception_handlers={InternalServerError: http_internal_server_error_handler},
        )
    ],
    exception_handlers={EsmeraldAPIException: http_esmerald_handler},
)

The above example illustrates the various levels where the exception handlers can be implemented and follows a parent order where the order is:

  1. Default application built-in exception handlers.
  2. EsmeraldException : http_esmerald_handler.
  3. InternalServerError : http_internal_server_error_handler.
  4. NotAuthorized: http_not_authorized_handler.

More than one exception handler can be added to each mapping.

Dependencies

from esmerald import Esmerald, Gateway, get


def first_dependency() -> bool:
    return True


async def second_dependency() -> str:
    return "Second dependency"


async def third_dependency() -> dict:
    return {"third": "dependency"}


@get(path="/home", dependencies={"third": third_dependency})
async def homepage(first: bool, second: str, third: dict) -> dict:
    return {"page": "ok"}


app = Esmerald(
    routes=[Gateway(handler=homepage, dependencies={"second": second_dependency})],
    dependencies={"first": first_dependency},
)

The above example illustrates the various levels where the dependencies can be implemented and follows an parent order where the order is:

  1. first : first_dependency.
  2. second : second_dependency.
  3. third: third_dependency.

More than one dependency can be added to each mapping.

Permissions

Permissions are a must in every application. It is very hard to control flows of APIs only with dependency injection as that can be very hard to maintain in the future whereas with a permission based system, that can be done in the cleanest way possible. More on permissions and how to use them.

from esmerald import APIView, Esmerald, Gateway, Request, get
from esmerald.permissions import AllowAny, BasePermission, DenyAll


class IsAdmin(BasePermission):
    def has_permission(
        self,
        request: "Request",
        apiview: "APIView",
    ) -> bool:
        return bool(request.path_params["admin"] is True)


@get(path="/home", permissions=[AllowAny])
async def homepage() -> dict:
    return {"page": "ok"}


@get(path="/admin", permissions=[IsAdmin])
async def admin() -> dict:
    return {"page": "ok"}


@get(path="/deny")
async def deny() -> dict:
    return {"page": "tis payload will never be reached"}


app = Esmerald(
    routes=[
        Gateway(handler=homepage),
        Gateway(handler=admin),
        Gateway(handler=deny, permissions=[DenyAll]),
    ],
    permissions=[AllowAny],
)

The above example illustrates the various levels where the permissions can be implemented and follows an parent order where the order is:

  1. AllowAny- From the application level.
  2. DenyAll- From the Gateway.
  3. AllowAny, IsAdmin - From the handlers.

More than one permission can be added to each list.