From e0c34aae9e4c989f9523f410ce5c1b3883b32942 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Sat, 20 Jul 2024 22:32:57 +0200 Subject: [PATCH 1/5] wip --- docs/index.md | 1 + docs/tutorials/rest-api-sqlmodel.md | 248 +++++++++++++++++++ examples/rest-api-sqlmodel/myapp/__init__.py | 0 examples/rest-api-sqlmodel/myapp/__main__.py | 12 + examples/rest-api-sqlmodel/myapp/database.db | Bin 0 -> 8192 bytes examples/rest-api-sqlmodel/myapp/resource.py | 94 +++++++ examples/rest-api-sqlmodel/requirements.txt | 2 + 7 files changed, 357 insertions(+) create mode 100644 docs/tutorials/rest-api-sqlmodel.md create mode 100644 examples/rest-api-sqlmodel/myapp/__init__.py create mode 100644 examples/rest-api-sqlmodel/myapp/__main__.py create mode 100644 examples/rest-api-sqlmodel/myapp/database.db create mode 100644 examples/rest-api-sqlmodel/myapp/resource.py create mode 100644 examples/rest-api-sqlmodel/requirements.txt 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..10f6501 --- /dev/null +++ b/docs/tutorials/rest-api-sqlmodel.md @@ -0,0 +1,248 @@ + +```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 +``` + +```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 +``` + +```python +from sqlalchemy 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 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 + + +### Authorization handler + +```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 + +Since we are using werkzeug exceptions, we will also need the `WerkzeugExceptionHandler` to serialize them correctly. +Let's put together an appropriate handler chain: + +```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"), + ] + ) +``` + + +## Complete program + +```python +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() +``` \ No newline at end of file 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 0000000000000000000000000000000000000000..19f3bda456b747a9b7114b401c79c7d4ad802e39 GIT binary patch literal 8192 zcmeI#!Aiq07zgmAZf-3|ylgVOeBfmR@!|`t9YLqHYOT(lB-^4ETqm_2cGQ!P;bZt1 zK8BBBdZ;ikc$fc2K9c+sa`@eHaUQ0HrrSk6R|V~}O~yIv6EVhI(~jw?xLMSyjhV~+ zU*)pB(b0yv#l?h~fr0=8AOHafKmY;|fB*y_009X6Q-POl-q>oj_-m(7lLvjT^F`Ut zAeMe2De;3)QkhWKbJH0OqeLFan4;5!qRTMcCC|;&T+@{w4-Wm+gC{-Bb#Y@oS9iL4 zuzcq%9-jE|n2zO`y6LRv^~%3_Q}mhm5})Ejyo)#SD6UO_f&c^{009U<00Izz00bZa y0SG|gN1)xTvz-nL^5?0hk;;}jP{mwj?Z!&J%&L6dlGiPH&62NKa>tVM-}7&X(`}~! literal 0 HcmV?d00001 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 From 78b398e000b398e9ef46d85bb9158205c1f724f1 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Wed, 24 Jul 2024 12:43:20 +0200 Subject: [PATCH 2/5] add a bit of more text --- docs/tutorials/rest-api-sqlmodel.md | 48 +++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/rest-api-sqlmodel.md b/docs/tutorials/rest-api-sqlmodel.md index 10f6501..2782465 100644 --- a/docs/tutorials/rest-api-sqlmodel.md +++ b/docs/tutorials/rest-api-sqlmodel.md @@ -1,3 +1,13 @@ +# Tutorial: REST API with SQLModel + +In this tutorial, we will explore how rolo can be used to build REST API servers with concepts you are familiar with from Flask or FastAPI, +and adding middleware using the [handler chain](../handler_chain.md). + +## Introduction + +TODO + +## Defining the SQLModel ```python from typing import Optional @@ -11,6 +21,8 @@ class Hero(SQLModel, table=True): age: Optional[int] = None ``` +## Defining the REST API + ```python from rolo import Request, route @@ -29,8 +41,12 @@ class HeroResource: return ``` +## Using SQLModel with rolo + + + ```python -from sqlalchemy import Engine +from sqlalchemy.engine import Engine class HeroResource: db_engine: Engine @@ -44,7 +60,7 @@ class HeroResource: ```python from typing import Optional -from sqlalchemy import Engine +from sqlalchemy.engine import Engine from sqlmodel import Field, SQLModel, Session, select from rolo import Request, Response, route @@ -93,9 +109,20 @@ class HeroResource: ## 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 @@ -122,8 +149,8 @@ class AuthorizationHandler: ### Handler chain -Since we are using werkzeug exceptions, we will also need the `WerkzeugExceptionHandler` to serialize them correctly. -Let's put together an appropriate 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(): @@ -147,13 +174,18 @@ def main(): ) ``` +TODO: breakdown + ## Complete program +Here's the complete program: + ```python from typing import Optional -from sqlalchemy import Engine, create_engine +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 @@ -245,4 +277,8 @@ def main(): if __name__ == '__main__': main() -``` \ No newline at end of file +``` + +## Conclusion + +TODO From 2b9794e33c564d3c2e652d339db0bcd55639a8e1 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Sat, 20 Jul 2024 22:32:57 +0200 Subject: [PATCH 3/5] wip --- docs/index.md | 1 + docs/tutorials/rest-api-sqlmodel.md | 248 +++++++++++++++++++ examples/rest-api-sqlmodel/myapp/__init__.py | 0 examples/rest-api-sqlmodel/myapp/__main__.py | 12 + examples/rest-api-sqlmodel/myapp/database.db | Bin 0 -> 8192 bytes examples/rest-api-sqlmodel/myapp/resource.py | 94 +++++++ examples/rest-api-sqlmodel/requirements.txt | 2 + 7 files changed, 357 insertions(+) create mode 100644 docs/tutorials/rest-api-sqlmodel.md create mode 100644 examples/rest-api-sqlmodel/myapp/__init__.py create mode 100644 examples/rest-api-sqlmodel/myapp/__main__.py create mode 100644 examples/rest-api-sqlmodel/myapp/database.db create mode 100644 examples/rest-api-sqlmodel/myapp/resource.py create mode 100644 examples/rest-api-sqlmodel/requirements.txt 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..10f6501 --- /dev/null +++ b/docs/tutorials/rest-api-sqlmodel.md @@ -0,0 +1,248 @@ + +```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 +``` + +```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 +``` + +```python +from sqlalchemy 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 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 + + +### Authorization handler + +```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 + +Since we are using werkzeug exceptions, we will also need the `WerkzeugExceptionHandler` to serialize them correctly. +Let's put together an appropriate handler chain: + +```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"), + ] + ) +``` + + +## Complete program + +```python +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() +``` \ No newline at end of file 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 0000000000000000000000000000000000000000..19f3bda456b747a9b7114b401c79c7d4ad802e39 GIT binary patch literal 8192 zcmeI#!Aiq07zgmAZf-3|ylgVOeBfmR@!|`t9YLqHYOT(lB-^4ETqm_2cGQ!P;bZt1 zK8BBBdZ;ikc$fc2K9c+sa`@eHaUQ0HrrSk6R|V~}O~yIv6EVhI(~jw?xLMSyjhV~+ zU*)pB(b0yv#l?h~fr0=8AOHafKmY;|fB*y_009X6Q-POl-q>oj_-m(7lLvjT^F`Ut zAeMe2De;3)QkhWKbJH0OqeLFan4;5!qRTMcCC|;&T+@{w4-Wm+gC{-Bb#Y@oS9iL4 zuzcq%9-jE|n2zO`y6LRv^~%3_Q}mhm5})Ejyo)#SD6UO_f&c^{009U<00Izz00bZa y0SG|gN1)xTvz-nL^5?0hk;;}jP{mwj?Z!&J%&L6dlGiPH&62NKa>tVM-}7&X(`}~! literal 0 HcmV?d00001 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 From 68be3be69b446d14c6be0c3b40e2a8d38985e033 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Wed, 24 Jul 2024 12:43:20 +0200 Subject: [PATCH 4/5] add a bit of more text --- docs/tutorials/rest-api-sqlmodel.md | 48 +++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/rest-api-sqlmodel.md b/docs/tutorials/rest-api-sqlmodel.md index 10f6501..2782465 100644 --- a/docs/tutorials/rest-api-sqlmodel.md +++ b/docs/tutorials/rest-api-sqlmodel.md @@ -1,3 +1,13 @@ +# Tutorial: REST API with SQLModel + +In this tutorial, we will explore how rolo can be used to build REST API servers with concepts you are familiar with from Flask or FastAPI, +and adding middleware using the [handler chain](../handler_chain.md). + +## Introduction + +TODO + +## Defining the SQLModel ```python from typing import Optional @@ -11,6 +21,8 @@ class Hero(SQLModel, table=True): age: Optional[int] = None ``` +## Defining the REST API + ```python from rolo import Request, route @@ -29,8 +41,12 @@ class HeroResource: return ``` +## Using SQLModel with rolo + + + ```python -from sqlalchemy import Engine +from sqlalchemy.engine import Engine class HeroResource: db_engine: Engine @@ -44,7 +60,7 @@ class HeroResource: ```python from typing import Optional -from sqlalchemy import Engine +from sqlalchemy.engine import Engine from sqlmodel import Field, SQLModel, Session, select from rolo import Request, Response, route @@ -93,9 +109,20 @@ class HeroResource: ## 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 @@ -122,8 +149,8 @@ class AuthorizationHandler: ### Handler chain -Since we are using werkzeug exceptions, we will also need the `WerkzeugExceptionHandler` to serialize them correctly. -Let's put together an appropriate 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(): @@ -147,13 +174,18 @@ def main(): ) ``` +TODO: breakdown + ## Complete program +Here's the complete program: + ```python from typing import Optional -from sqlalchemy import Engine, create_engine +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 @@ -245,4 +277,8 @@ def main(): if __name__ == '__main__': main() -``` \ No newline at end of file +``` + +## Conclusion + +TODO From 0bc65fd3815e5e4bb1be1683ec8ce128d7ac2373 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Thu, 17 Oct 2024 20:23:22 +0200 Subject: [PATCH 5/5] wip --- docs/tutorials/rest-api-sqlmodel.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/rest-api-sqlmodel.md b/docs/tutorials/rest-api-sqlmodel.md index 2782465..89a8e83 100644 --- a/docs/tutorials/rest-api-sqlmodel.md +++ b/docs/tutorials/rest-api-sqlmodel.md @@ -1,14 +1,17 @@ # Tutorial: REST API with SQLModel -In this tutorial, we will explore how rolo can be used to build REST API servers with concepts you are familiar with from Flask or FastAPI, +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 -TODO +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 @@ -23,6 +26,10 @@ class Hero(SQLModel, table=True): ## 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 @@ -44,7 +51,6 @@ class HeroResource: ## Using SQLModel with rolo - ```python from sqlalchemy.engine import Engine