Tutorial: REST API with SQLModel¶
In this tutorial, we will explore how rolo can be used to build RESTful API servers with a database backend, using concepts you are familiar with, such as from Flask or FastAPI, and adding middleware using the handler chain.
Introduction¶
A bread-and-butter use case of web frameworks is implementing resources using RESTful API design.
Mapping web API concepts (like a Request object) to an internal resource model (like a Simple Hero API described in the SQLModel docs).
Defining the SQLModel¶
Here’s a simple SQLModel class that will map to the Hero table in the database.
SQLModel uses SQLAlchemy to map pydantic classes to tables.
from typing import Optional
from sqlmodel import Field, SQLModel
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
Defining the REST API¶
Now let’s define the basic Create, Read, List, Delete API.
Each HTTP method will map to one of these operations.
Rolo allows you to declare the route’s signatures using pydantic BaseModel types.
This means, adding the attribute hero: Hero into your route signature tells rolo that this method accepts application/json payloads that are serialized into the Hero class using pydantic.
Since SQLModel is also a pydantic.BaseModel, we can use our Hero object directly.
from rolo import Request, route
class HeroResource:
@route("/heroes", methods=["GET"])
def list_heroes(self, request: Request, hero_id: int) -> list[Hero]:
return
@route("/heroes/<int:hero_id>", methods=["GET"])
def get_hero(self, request: Request, hero_id: int) -> Hero:
return
@route("/heroes", methods=["POST"])
def add_hero(self, request: Request, hero: Hero) -> Hero:
return
@route("/heroes/<int:hero_id>", methods=["DELETE"])
def delete_hero(self, request: Request, hero_id: int):
return
Using SQLModel with rolo¶
We pass to the HeroResource the sqlalchemy Engine object, that will allow us to perform database operations.
We will later inject it as a dependency when creating the HeroResource.
from sqlalchemy.engine import Engine
class HeroResource:
db_engine: Engine
def __init__(self, db_engine: Engine):
self.db_engine = db_engine
...
Let’s look at the full implementation of our resource. Every method now uses the appropriate SQLAlchemy database operations. You can see that database objects are serialized and deserialized automatically.
from typing import Optional
from sqlalchemy.engine import Engine
from sqlmodel import Field, SQLModel, Session, select, delete
from rolo import Request, Response, route
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
class HeroResource:
db_engine: Engine
def __init__(self, db_engine: Engine):
self.db_engine = db_engine
@route("/heroes", methods=["GET"])
def list_heroes(self, request: Request) -> list[Hero]:
with Session(self.db_engine) as session:
statement = select(Hero)
results = session.exec(statement)
return list(results)
@route("/heroes/<int:hero_id>", methods=["GET"])
def get_hero(self, request: Request, hero_id: int) -> Hero | Response:
with Session(self.db_engine) as session:
statement = select(Hero).where(Hero.id == hero_id)
results = session.exec(statement)
if hero := results.first():
return hero
return Response.for_json({"message": "not found"}, status=404)
@route("/heroes", methods=["POST"])
def add_hero(self, request: Request, hero: Hero) -> Hero:
with Session(self.db_engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@route("/heroes/<int:hero_id>", methods=["DELETE"])
def delete_hero(self, request: Request, hero_id: int) -> None:
with Session(self.db_engine) as session:
statement = delete(Hero).where(Hero.id == hero_id)
session.exec(statement)
session.commit()
For example, this CURL call will produce the following result:
curl -X POST
-H 'Content-Type: application/json' \
-d '{"name": "Superman", "secret_name": "Clark Kent", "age": 150}' \
http://localhost:8000/heroes
{"name": "Superman", "id": 1, "secret_name": "Clark Kent", "age": 150}
Complete program¶
Here’s the complete program:
import typing as t
from sqlalchemy import create_engine, Engine
from werkzeug import run_simple
from werkzeug.exceptions import Unauthorized
from rolo import Router, Request, Response, route
from rolo.dispatcher import handler_dispatcher
from rolo.gateway import Gateway, HandlerChain, RequestContext
from rolo.gateway.handlers import RouterHandler, WerkzeugExceptionHandler
from rolo.gateway.wsgi import WsgiGateway
if t.TYPE_CHECKING:
pass
from typing import Optional
from sqlmodel import Field, Session, SQLModel, select, delete
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
class HeroResource:
db_engine: Engine
def __init__(self, db_engine: Engine):
self.db_engine = db_engine
@route("/heroes", methods=["GET"])
def list_heroes(self, request: Request) -> list[Hero]:
with Session(self.db_engine) as session:
statement = select(Hero)
results = session.exec(statement)
return list(results)
@route("/heroes/<int:hero_id>", methods=["GET"])
def get_hero(self, request: Request, hero_id: int) -> Hero | Response:
with Session(self.db_engine) as session:
statement = select(Hero).where(Hero.id == hero_id)
results = session.exec(statement)
if hero := results.first():
return hero
return Response.for_json({"message": "not found"}, status=404)
@route("/heroes", methods=["POST"])
def add_hero(self, request: Request, hero: Hero) -> Hero:
with Session(self.db_engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@route("/heroes/<int:hero_id>", methods=["DELETE"])
def delete_hero(self, request: Request, hero_id: int) -> None:
with Session(self.db_engine) as session:
statement = delete(Hero).where(Hero.id == hero_id)
session.exec(statement)
session.commit()
class AuthorizationHandler:
authorized_tokens: set[str]
def __init__(self, authorized_tokens: set[str]):
self.authorized_tokens = authorized_tokens
def __call__(self, chain: HandlerChain, context: RequestContext, response: Response):
auth = context.request.authorization
if not auth:
raise Unauthorized("No authorization header")
if not auth.type == "bearer":
raise Unauthorized("Unknown authorization type %s" % auth.type)
if auth.token not in self.authorized_tokens:
raise Unauthorized("Invalid token")
def wsgi():
# create engine
engine = create_engine("sqlite:///database.db")
SQLModel.metadata.create_all(engine)
# create router with resource
router = Router(handler_dispatcher())
router.add(HeroResource(engine))
# gateway
gateway = Gateway(
request_handlers=[
AuthorizationHandler({"mysecret"}),
RouterHandler(router, respond_not_found=True),
],
exception_handlers=[
WerkzeugExceptionHandler(output_format="json"),
]
)
return WsgiGateway(gateway)
def main():
run_simple("localhost", 8000, wsgi())
if __name__ == '__main__':
main()
Running this program will output something like:
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://localhost:8000
Press CTRL+C to quit
You can also look in the examples/rest-api-sqlmodel directory for a more modularized version of the app.
Conclusion¶
The example shows how Rolo allows you to combine classic flask or FastAPI-styled routers, with an object-relational mapper through its pydantic integration, and serve it with custom middleware through Rolo’s handler chain concept.
Handler chains make it easy to write custom middleware like the authorization handler, or exception handlers, and layer them around your application logic. The pydantic integration allows you to write your resources in an object-oriented style, and abstracts away most serialization logic.
If you want to learn more about how to compose more complex logic in a handler chain, check out our tutorial on building a JSON RPC Server.