diff --git a/docs/index.md b/docs/index.md index decd395..bdf5db4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,5 +45,6 @@ serving :maxdepth: 1 tutorials/jsonrpc-server +tutorials/rest-api-sqlmodel ``` diff --git a/docs/tutorials/rest-api-sqlmodel.md b/docs/tutorials/rest-api-sqlmodel.md new file mode 100644 index 0000000..89a8e83 --- /dev/null +++ b/docs/tutorials/rest-api-sqlmodel.md @@ -0,0 +1,290 @@ +# 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 from Flask or FastAPI, +and adding middleware using the [handler chain](../handler_chain.md). + +## Introduction + +A bread-and-butter use case of web frameworks is implementing [resources](https://restful-api-design.readthedocs.io/en/latest/resources.html) using RESTful API design. +Mapping web API concepts (like a `Request` object) to an internal resource model (like a `Hero` in ) + + +## Defining the SQLModel + + +```python +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 + +You can declare the body using pydantic BaseModel types. +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. + +```python +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/", 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): + return +``` + +## Using SQLModel with rolo + + +```python +from sqlalchemy.engine import Engine + +class HeroResource: + db_engine: Engine + + def __init__(self, db_engine: Engine): + self.db_engine = db_engine + + ... +``` + +```python +from typing import Optional + +from sqlalchemy.engine import Engine +from sqlmodel import Field, SQLModel, Session, select + +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.all()) + + @route("/heroes/", 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) + for hero in results: + 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 + + +``` + +## Add simple authorization middleware + +Next, we're going to add a simple authorization middleware that uses the Bearer token [HTTP authentication scheme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). +The basic idea is that there is an authorization database, which holds a set of valid auth tokens. +The clients sends the auth token through the `Authorization: Bearer ` header. +For every request, we want to check whether the header is present and check the token against the database. +If not, then we want to respond with a `401 Unauthorized` error. +To that end, we will introduce a [handler chain](../handler_chain.md) handler. + +### Authorization handler + +Here is the example handler code. +Notice how you can use the [`authorization`](https://werkzeug.palletsprojects.com/en/2.3.x/wrappers/#werkzeug.wrappers.Request.authorization) attribute of the werkzeug request object, to access the header directly. +You are working with the [`Authorization`](https://werkzeug.palletsprojects.com/en/2.3.x/datastructures/#werkzeug.datastructures.Authorization) data structure. +Next, you can raise werkzeug `Unauthorized` exceptions, which we will then handle with the builtin `WerkzeugExceptionHandler`. + +```python +from werkzeug.exceptions import Unauthorized + +from rolo import Response +from rolo.gateway import HandlerChain, RequestContext + + +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") +``` + +### Handler chain + +Let's put together an appropriate handler chain using both our `AuthorizationHandler`, and the builtin handlers `RouterHandler` and `WerkzeugExceptionHandler`, +as well as all SQLModel resources we need: + +```python +def main(): + # create database 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"), + ] + ) +``` + +TODO: breakdown + + +## Complete program + +Here's the complete program: + +```python +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlmodel import Field, Session, SQLModel, select +from werkzeug import run_simple +from werkzeug.exceptions import Unauthorized + +from rolo import Request, Response, Router, 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 + + +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/", 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) + for hero in results: + 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 + + +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 main(): + # 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"), + ] + ) + + run_simple("localhost", 8000, WsgiGateway(gateway)) + + +if __name__ == '__main__': + main() +``` + +## Conclusion + +TODO diff --git a/examples/rest-api-sqlmodel/myapp/__init__.py b/examples/rest-api-sqlmodel/myapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/rest-api-sqlmodel/myapp/__main__.py b/examples/rest-api-sqlmodel/myapp/__main__.py new file mode 100644 index 0000000..e676b4e --- /dev/null +++ b/examples/rest-api-sqlmodel/myapp/__main__.py @@ -0,0 +1,12 @@ +from werkzeug import run_simple + +from rolo.gateway.wsgi import WsgiGateway + + +def main(): + gateway = MyAppGateway() + run_simple("localhost", 8000, WsgiGateway(gateway)) + + +if __name__ == '__main__': + main() diff --git a/examples/rest-api-sqlmodel/myapp/database.db b/examples/rest-api-sqlmodel/myapp/database.db new file mode 100644 index 0000000..19f3bda Binary files /dev/null and b/examples/rest-api-sqlmodel/myapp/database.db differ diff --git a/examples/rest-api-sqlmodel/myapp/resource.py b/examples/rest-api-sqlmodel/myapp/resource.py new file mode 100644 index 0000000..bb06b78 --- /dev/null +++ b/examples/rest-api-sqlmodel/myapp/resource.py @@ -0,0 +1,94 @@ +from typing import Optional + +from sqlalchemy import Engine, create_engine +from sqlmodel import Field, Session, SQLModel, select +from werkzeug import run_simple +from werkzeug.exceptions import Unauthorized + +from rolo import Request, Response, Router, 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 + + +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/", 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) + for hero in results: + 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 + + +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 main(): + # 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"), + ] + ) + + run_simple("localhost", 8000, WsgiGateway(gateway)) + + +if __name__ == '__main__': + main() diff --git a/examples/rest-api-sqlmodel/requirements.txt b/examples/rest-api-sqlmodel/requirements.txt new file mode 100644 index 0000000..ba82e05 --- /dev/null +++ b/examples/rest-api-sqlmodel/requirements.txt @@ -0,0 +1,2 @@ +rolo +sqlmodel