Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fast-jelly! the Gel auth extension bindings for FastAPI #3

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion python-fastapi/examples/basic-async/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Basic Async Example

```console
$ uv run main.py
$ uv run uvicorn app.main:fast_api
```

## Development
Expand Down
Empty file.
160 changes: 160 additions & 0 deletions python-fastapi/examples/basic-async/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from __future__ import annotations
from typing import Annotated

import json
import logging
import http

import fastapi
from fastapi import responses
from gel.auth import email_password as core
from gel_auth_fastapi import email_password as ext

from . import dependencies as deps
from .queries import create_user_async_edgeql as create_user_qry

logger = logging.getLogger("fast_jelly")
router = fastapi.APIRouter(tags=["Auth"])


@router.post(
"/register",
response_class=responses.RedirectResponse,
status_code=http.HTTPStatus.SEE_OTHER,
)
async def register(
sign_up_body: Annotated[ext.SignUpBody, fastapi.Form()],
client: deps.CleanGelClient,
auth: deps.EmailPassword,
request: fastapi.Request,
response: fastapi.Response,
):
sign_up_response = await auth.handle_sign_up(
sign_up_body,
verify_url=str(request.url_for("verify")),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generates the public URL for the "verify" endpoint at runtime.

response=response,
)
if not isinstance(sign_up_response, core.SignUpFailedResponse):
user = await create_user_qry.create_user(
client,
name=sign_up_body.email,
identity_id=sign_up_response.identity_id,
)
print(f"Created user: {json.dumps(user, default=str)}")

match sign_up_response:
case core.SignUpCompleteResponse():
return "/"
case core.SignUpVerificationRequiredResponse():
return "/signin?incomplete=verification_required"
case core.SignUpFailedResponse():
logger.error("sign up failed: %s", sign_up_response)
return "/signin?error=failure"
case _:
raise AssertionError("Invalid sign up response")


@router.post(
"/authenticate",
response_class=responses.RedirectResponse,
status_code=http.HTTPStatus.SEE_OTHER,
)
async def authenticate(
sign_in_body: Annotated[ext.SignInBody, fastapi.Form()],
auth: deps.EmailPassword,
response: fastapi.Response,
):
sign_in_response = await auth.handle_sign_in(
sign_in_body, response=response
)
match sign_in_response:
case core.SignInCompleteResponse():
return "/"
case core.SignInVerificationRequiredResponse():
return "/signin?incomplete=verification_required"
case core.SignInFailedResponse():
logger.error("sign in failed: %s", sign_in_response)
return "/signin?error=failure"
case _:
raise AssertionError("Invalid sign in response")


@router.get(
"/verify",
response_class=responses.RedirectResponse,
status_code=http.HTTPStatus.SEE_OTHER,
)
async def verify(
verify_body: Annotated[ext.VerifyBody, fastapi.Query()],
auth: deps.EmailPassword,
verifier: deps.PKCEVerifier,
):
verify_response = await auth.handle_verify_email(
verify_body, verifier=verifier
)

match verify_response:
case core.EmailVerificationCompleteResponse():
return "/"
case core.EmailVerificationMissingProofResponse():
return "/signin?incomplete=verify"
case core.EmailVerificationFailedResponse():
logger.error("verify email failed: %s", verify_response)
return "/signin?error=failure"
case _:
raise AssertionError("Invalid verify email response")


@router.post(
"/send-password-reset",
response_class=responses.RedirectResponse,
status_code=http.HTTPStatus.SEE_OTHER,
)
async def send_password_reset(
send_password_reset_body: Annotated[
ext.SendPasswordResetBody, fastapi.Form()
],
auth: deps.EmailPassword,
request: fastapi.Request,
response: fastapi.Response,
):
send_password_reset_response = await auth.handle_send_password_reset(
send_password_reset_body,
reset_url=str(request.url_for("reset_password_page")),
response=response,
)
match send_password_reset_response:
case core.SendPasswordResetEmailCompleteResponse():
return "/signin?incomplete=password_reset_sent"
case core.SendPasswordResetEmailFailedResponse():
logger.error(
"send password reset failed: %s", send_password_reset_response
)
return "/signin?error=failure"
case _:
raise Exception("Invalid send password reset response")


@router.post(
"/reset-password",
response_class=responses.RedirectResponse,
status_code=http.HTTPStatus.SEE_OTHER,
)
async def reset_password(
reset_password_body: Annotated[ext.PasswordResetBody, fastapi.Form()],
auth: deps.EmailPassword,
verifier: deps.PKCEVerifier,
):
reset_password_response = await auth.handle_reset_password(
reset_password_body, verifier=verifier
)
match reset_password_response:
case core.PasswordResetCompleteResponse():
return "/"
case core.PasswordResetMissingProofResponse():
return "/signin?incomplete=reset_password"
case core.PasswordResetFailedResponse():
logger.error("reset password failed: %s", reset_password_response)
return "/signin?error=failure"
case _:
raise Exception("Invalid reset password response")
54 changes: 54 additions & 0 deletions python-fastapi/examples/basic-async/app/configure_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import asyncio
import os
import secrets

from gel import create_async_client


async def main():
client = create_async_client()

auth_signing_key = os.getenv(
"GEL_AUTH_SIGNING_KEY", secrets.token_urlsafe(32)
)

await client.execute(
f"""
configure current branch reset ext::auth::AuthConfig;
configure current branch reset ext::auth::ProviderConfig;
configure current branch reset ext::auth::EmailPasswordProviderConfig;
configure current branch reset cfg::EmailProviderConfig;

configure current branch set
ext::auth::AuthConfig::auth_signing_key := "{auth_signing_key}";

configure current branch set
ext::auth::AuthConfig::app_name := "Fast Jelly";

configure current branch set
ext::auth::AuthConfig::allowed_redirect_urls := {{"http://localhost:8000"}};

configure current branch insert
ext::auth::EmailPasswordProviderConfig {{
require_verification := true,
}};

configure current branch insert
cfg::SMTPProviderConfig {{
name := "mailpit",
host := "localhost",
port := 1025,
username := "smtpuser",
password := "smtppassword",
sender := "[email protected]",
validate_certs := false,
}};

configure current branch set
cfg::current_email_provider_name := "mailpit";
"""
)


if __name__ == "__main__":
asyncio.run(main())
48 changes: 48 additions & 0 deletions python-fastapi/examples/basic-async/app/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Annotated

import fastapi
import gel
from gel.auth import email_password as core
from gel_auth_fastapi import email_password as ext, session


AUTH_COOKIE_NAME = "fastjelly-auth"
auth_token = session.AuthToken(AUTH_COOKIE_NAME)
AuthToken = Annotated[str | None, fastapi.Depends(auth_token)]
required_auth_token = session.AuthToken(AUTH_COOKIE_NAME, auto_error=True)
RequiredAuthToken = Annotated[str, fastapi.Depends(auth_token)]

VERIFIER_COOKIE_NAME = "fastjelly-verifier"
pkce_verifier = session.PKCEVerifier(VERIFIER_COOKIE_NAME)
PKCEVerifier = Annotated[str | None, fastapi.Depends(pkce_verifier)]


def gel_client(request: fastapi.Request) -> gel.AsyncIOClient:
return request.app.state.client


CleanGelClient = Annotated[gel.AsyncIOClient, fastapi.Depends(gel_client)]


def gel_client_with_auth(
client: CleanGelClient, token: AuthToken
) -> gel.AsyncIOClient:
return session.get_client_with_auth_token(client, auth_token=token)


GelClient = Annotated[gel.AsyncIOClient, fastapi.Depends(gel_client_with_auth)]


async def email_password(
request: fastapi.Request, client: CleanGelClient
) -> ext.EmailPassword:
return ext.EmailPassword(
await core.make_async(client),
secure_cookie=request.base_url.is_secure,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This detects if the app is visited under TLS or not - it's convenient if the cookie follows the security setup for development, but this should be enforced on production environments.

verifier_cookie_name=VERIFIER_COOKIE_NAME,
auth_cookie_name=AUTH_COOKIE_NAME,
)


EmailPassword = Annotated[ext.EmailPassword, fastapi.Depends(email_password)]
49 changes: 49 additions & 0 deletions python-fastapi/examples/basic-async/app/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

import datetime
from http import HTTPStatus

import gel
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from . import dependencies as deps
from .queries import (
create_event_async_edgeql as create_event_qry,
)

router = APIRouter(tags=["API"])


class RequestData(BaseModel):
name: str
address: str
schedule: datetime.datetime
host_name: str


@router.post("/events", status_code=HTTPStatus.CREATED)
async def post_event(
event: RequestData, client: deps.GelClient
) -> create_event_qry.CreateEventResult:
try:
created_event = await create_event_qry.create_event(
client,
name=event.name,
address=event.address,
schedule=event.schedule,
host_name=event.host_name,
)
except gel.errors.InvalidValueError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={"error": str(ex)},
)

except gel.errors.ConstraintViolationError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={"error": "Event '{event.name}' already exists"},
)

return created_event
45 changes: 45 additions & 0 deletions python-fastapi/examples/basic-async/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

import logging
import sys

import fastapi
import gel

from app import auth, users, events, ui


formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)

logger = logging.getLogger("fast_jelly")
logger.setLevel(logging.DEBUG)
logger.addHandler(stream_handler)

auth_core_logger = logging.getLogger("gel.auth")
auth_core_logger.setLevel(logging.DEBUG)
auth_core_logger.addHandler(stream_handler)


async def startup():
fast_api.state.client = await gel.create_async_client().ensure_connected()


async def shutdown():
await fast_api.state.client.aclose()


fast_api = fastapi.FastAPI(
title="Jellyrole",
on_startup=[startup],
on_shutdown=[shutdown],
)
fast_api.include_router(ui.router)
fast_api.include_router(auth.router, prefix="/auth")

api_router = fastapi.APIRouter()
api_router.include_router(users.router)
api_router.include_router(events.router)

fast_api.include_router(api_router, prefix="/api")
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.py linguist-generated=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
with
name := <str>$name,
address := <str>$address,
schedule := <datetime>$schedule,
host_name := <str>$host_name,

HOST := (select default::User filter .name = host_name),
CREATED := (
insert default::Event {
name := name,
address := address,
schedule := schedule,
host := HOST,
}
),
select CREATED { *, host: { * } };
Loading
Loading