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.
When a handler is passed instead of a Gateway/WebsocketGateway, it will be automatically mapped to the corresponding wrapper and added to the routes.
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.
This allows overwriting options of the specific handler route. If not required, you can just pass the handler, it is automatically wrapped in a plain Gateway.
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
.
This allows overwriting options of the specific WebsocketHandler route. If not required, you can just pass the handler, it is automatically wrapped in a plain WebSocketGateway.
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:
- Scalability without issues (thanks to Lilya).
- Clean routing design.
- Separation of concerns.
- Separation of routes.
- Reduction of the level of imports needed through files.
- 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.
from esmerald import Include
route_patterns = [Include(namespace="myapp.accounts.urls")]
from myapp.accounts.urls import route_patterns
from esmerald import Include
route_patterns = [Include(routes=route_patterns)]
Using a different pattern¶
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(self, request: Request, url: str) -> Response:
return Response(f"URL: {url}")
@post(path="/{url}", status_code=status.HTTP_201_CREATED)
async def mars(self, 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()
from esmerald import Gateway, WebSocketGateway
from .controllers 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),
]
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:
from esmerald import Include
route_patterns = [Include(namespace="myapp.accounts.urls", pattern="my_urls")]
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?
- Importing the
src.urls
without path, it will default to/
. - 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 Pythonuuid.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:
- Default application built-in middleware.
BaseRequestLoggingMiddleware
.ExampleMiddleware
.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:
- Default application built-in exception handlers.
EsmeraldException : http_esmerald_handler
.InternalServerError : http_internal_server_error_handler
.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:
first : first_dependency
.second : second_dependency
.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:
AllowAny
- From the application level.DenyAll
- From the Gateway.AllowAny
,IsAdmin
- From the handlers.
More than one permission can be added to each list.