falconry / falcon

The no-magic web data plane API and microservices framework for Python developers, with a focus on reliability, correctness, and performance at scale.
https://falcon.readthedocs.io/en/stable/
Apache License 2.0
9.53k stars 944 forks source link

Integration with Tortoise ORM. #2398

Closed 0x1618 closed 2 weeks ago

0x1618 commented 2 weeks ago

Hi, i'm wondering how to integrate falcon with Tortoise ORM. As far as i know, Tortoise ORM manages transactions on its own, but i'm not sure if it creates a transaction for each request (although i don't see why it would). I already managed to create process_startup and process_shutdown, and i think that to create a new transaction (where exceptions trigger a rollback), you need to use async with in_transaction() wherever you run database queries.

Here you can see an example. There’s one problem: entity_user is fetched in a separate transaction (which can lead to data mismatch), but it should be fetched within a single transaction using in_transaction(). As you can see, it's challenging to manage this without proper middleware. Without in_transaction(), if an exception occurs in verification(), the database will save progress up to that point, which could lead to bugs.

@before(authorize_user, is_logged=True, is_email_verified=False)
class VerifyEmailResource:
    async def on_get(self, req: Request, resp: Response):
        entity_user: UserType = req.context.entity_user # Got from authorize_user. (Used Tortoise ORM)

        async with in_transaction():
            await entity_user.verification().start_verification() # Tortoise ORM operations.

I'm thinking about creating a decorator to wrap on_get so it would run on_get within a async with in_transaction() block. However, i'm not sure how to do it properly to make it work well with Falcon and Tortoise ORM, also there may be a better solution.

Current middleware:

class TortoiseMiddleware:
    async def process_startup(self, scope, event):
        await Tortoise.init(config=TORTOISE_ORM)
        await Tortoise.generate_schemas()

    async def process_shutdown(self, scope, event):
        await Tortoise.close_connections()

I would really appreciate help with the integration. Also if you have a good solution for other ORM integration, please feel free to share it.

0x1618 commented 2 weeks ago

Ok, so i created a decorator, create_transaction(), which wraps the entire route in a with block, but it feels a bit nested...

def create_transaction():
    def decorator(func):
        @wraps(func)
        async def wrapped(self, req, resp, *args, **kwargs):
            async with in_transaction() as connection:
                req.context.connection = connection

                return await func(self, req, resp, *args, **kwargs)

        return wrapped

    return decorator
class VerifyEmailResource:
    @create_transaction()
    @before(authorize_user, is_logged=True, is_email_verified=False) # uses req.context.connection
    async def on_get(self, req: Request, resp: Response):
        entity_user: UserType = req.context.entity_user

        await entity_user.verification().start_verification()

        resp.status = 200
        resp.media = {
            "title": VERIFICATION_STARTED_TITLE,
            "description": VERIFICATION_STARTED_MSG
        }

    @create_transaction()
    @before(authorize_user, is_logged=True, is_email_verified=False) # uses req.context.connection
    @before(
        validate_request,
        token={
            'type': str,
            'validators': {
                'exact_length': VERIFICATION_TOKEN_LENGTH
            }
        }
    )
    async def on_post(self, req: Request, resp: Response):
        entity_user: UserType = req.context.entity_user

        await entity_user.verification().end_verification(
            token=req.context.data['token']
        )

        resp.status = 200
        resp.media = {
            "title": VERIFICATION_ENDED_TITLE,
            "description": VERIFICATION_ENDED_MSG
        }

A much better option would be to do something like this under. However, i'm not sure how to implement the decorator properly to map all routes, like @before do.

@create_transaction()
@before(authorize_user, is_logged=True, is_email_verified=False) # uses req.context.connection
class VerifyEmailResource:
    async def on_get(self, req: Request, resp: Response):
        entity_user: UserType = req.context.entity_user

        await entity_user.verification().start_verification()

        resp.status = 200
        resp.media = {
            "title": VERIFICATION_STARTED_TITLE,
            "description": VERIFICATION_STARTED_MSG
        }

    @before(
        validate_request,
        token={
            'type': str,
            'validators': {
                'exact_length': VERIFICATION_TOKEN_LENGTH
            }
        }
    )
    async def on_post(self, req: Request, resp: Response):
        entity_user: UserType = req.context.entity_user

        await entity_user.verification().end_verification(
            token=req.context.data['token']
        )

        resp.status = 200
        resp.media = {
            "title": VERIFICATION_ENDED_TITLE,
            "description": VERIFICATION_ENDED_MSG
        }
CaselIT commented 2 weeks ago

Hi,

This looks like a discussion, so I will move it there. Regarding your question, falcon has middleware methods that run before and after a responder method so you could use them to start a transaction then commit/rollback.

The method are documented here process_resource and process_response. See the docs here https://falcon.readthedocs.io/en/stable/api/middleware.html You could do something like the following:

class Middleware:
    async def process_resource(
        self,
        req: Request,
        resp: Response,
        resource: object,
        params: dict[str, Any],
    ) -> None:
        req.context.conn = await new_transaction() # create a transaction
    async def process_response(
        self,
        req: Request,
        resp: Response,
        resource: object,
        req_succeeded: bool
    ) -> None:
        if req_succeded:
           await req.context.conn.commit()
        else:
           await req.context.conn.rollback()