From ec5c6a8a2f78c2cc7b4b88f9afa194f002f492a8 Mon Sep 17 00:00:00 2001 From: Aleksandra Date: Thu, 25 Jul 2024 14:34:49 +0200 Subject: [PATCH] add fastapi example with edgedb auth (#135) --- README.md | 13 +- fastapi-crud-auth/.flake8 | 25 +++ fastapi-crud-auth/.gitignore | 1 + fastapi-crud-auth/Makefile | 138 ++++++++++++++++ fastapi-crud-auth/README.md | 7 + fastapi-crud-auth/app/__init__.py | 6 + fastapi-crud-auth/app/auth.py | 107 +++++++++++++ fastapi-crud-auth/app/events.py | 151 ++++++++++++++++++ fastapi-crud-auth/app/main.py | 48 ++++++ .../app/queries/create_event.edgeql | 15 ++ .../app/queries/create_user.edgeql | 1 + .../app/queries/delete_event.edgeql | 3 + .../app/queries/delete_user.edgeql | 3 + .../app/queries/get_event_by_name.edgeql | 4 + .../app/queries/get_events.edgeql | 1 + .../app/queries/get_user_by_name.edgeql | 1 + .../app/queries/get_users.edgeql | 1 + .../app/queries/update_event.edgeql | 15 ++ .../app/queries/update_user.edgeql | 4 + fastapi-crud-auth/app/users.py | 124 ++++++++++++++ fastapi-crud-auth/dbschema/default.esdl | 38 +++++ .../dbschema/migrations/00001.edgeql | 23 +++ .../dbschema/migrations/00002.edgeql | 14 ++ .../dbschema/migrations/00003.edgeql | 9 ++ .../dbschema/migrations/00004.edgeql | 13 ++ .../dbschema/migrations/00005.edgeql | 9 ++ .../dbschema/migrations/00006.edgeql | 5 + .../dbschema/migrations/00007.edgeql | 9 ++ .../dbschema/migrations/00008.edgeql | 10 ++ .../dbschema/migrations/00009-m1co2rs.edgeql | 6 + .../dbschema/migrations/00010-m1gme45.edgeql | 17 ++ fastapi-crud-auth/edgedb.toml | 2 + fastapi-crud-auth/pyproject.toml | 30 ++++ fastapi-crud-auth/scripts/health_check | 23 +++ fastapi-crud-auth/tests/__init__.py | 0 fastapi-crud-auth/tests/conftest.py | 35 ++++ fastapi-crud-auth/tests/fixture.edgeql | 9 ++ fastapi-crud-auth/tests/test_events.py | 90 +++++++++++ fastapi-crud-auth/tests/test_users.py | 58 +++++++ 39 files changed, 1062 insertions(+), 6 deletions(-) create mode 100644 fastapi-crud-auth/.flake8 create mode 100644 fastapi-crud-auth/.gitignore create mode 100644 fastapi-crud-auth/Makefile create mode 100644 fastapi-crud-auth/README.md create mode 100644 fastapi-crud-auth/app/__init__.py create mode 100644 fastapi-crud-auth/app/auth.py create mode 100644 fastapi-crud-auth/app/events.py create mode 100644 fastapi-crud-auth/app/main.py create mode 100644 fastapi-crud-auth/app/queries/create_event.edgeql create mode 100644 fastapi-crud-auth/app/queries/create_user.edgeql create mode 100644 fastapi-crud-auth/app/queries/delete_event.edgeql create mode 100644 fastapi-crud-auth/app/queries/delete_user.edgeql create mode 100644 fastapi-crud-auth/app/queries/get_event_by_name.edgeql create mode 100644 fastapi-crud-auth/app/queries/get_events.edgeql create mode 100644 fastapi-crud-auth/app/queries/get_user_by_name.edgeql create mode 100644 fastapi-crud-auth/app/queries/get_users.edgeql create mode 100644 fastapi-crud-auth/app/queries/update_event.edgeql create mode 100644 fastapi-crud-auth/app/queries/update_user.edgeql create mode 100644 fastapi-crud-auth/app/users.py create mode 100644 fastapi-crud-auth/dbschema/default.esdl create mode 100644 fastapi-crud-auth/dbschema/migrations/00001.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00002.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00003.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00004.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00005.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00006.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00007.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00008.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00009-m1co2rs.edgeql create mode 100644 fastapi-crud-auth/dbschema/migrations/00010-m1gme45.edgeql create mode 100644 fastapi-crud-auth/edgedb.toml create mode 100644 fastapi-crud-auth/pyproject.toml create mode 100755 fastapi-crud-auth/scripts/health_check create mode 100644 fastapi-crud-auth/tests/__init__.py create mode 100644 fastapi-crud-auth/tests/conftest.py create mode 100644 fastapi-crud-auth/tests/fixture.edgeql create mode 100644 fastapi-crud-auth/tests/test_events.py create mode 100644 fastapi-crud-auth/tests/test_users.py diff --git a/README.md b/README.md index 0ab02f7..a6a46e8 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ This repo contains starter projects that demonstrate usage of EdgeDB with differ ## Python -| Framework | Link | -| ------------------ | ------------------------------------------------------------------------------------ | -| FastAPI | [fastapi-crud](https://github.com/edgedb/edgedb-examples/tree/main/fastapi-crud) | -| Flask CRUD | [flask-crud](https://github.com/edgedb/edgedb-examples/tree/main/flask-crud) | -| Flask Proxy | [flask-proxy](https://github.com/edgedb/edgedb-examples/tree/main/flask-proxy) | -| Strawberry GraphQL | [strawberry-gql](https://github.com/edgedb/edgedb-examples/tree/main/strawberry-gql) | +| Framework | Link | +| ------------------ | -------------------------------------------------------------------------------------------| +| FastAPI | [fastapi-crud](https://github.com/edgedb/edgedb-examples/tree/main/fastapi-crud) | +| FastAPI with Auth | [fastapi-crud-auth](https://github.com/edgedb/edgedb-examples/tree/main/fastapi-crud-auth) | +| Flask CRUD | [flask-crud](https://github.com/edgedb/edgedb-examples/tree/main/flask-crud) | +| Flask Proxy | [flask-proxy](https://github.com/edgedb/edgedb-examples/tree/main/flask-proxy) | +| Strawberry GraphQL | [strawberry-gql](https://github.com/edgedb/edgedb-examples/tree/main/strawberry-gql) | ## Go diff --git a/fastapi-crud-auth/.flake8 b/fastapi-crud-auth/.flake8 new file mode 100644 index 0000000..1d56cad --- /dev/null +++ b/fastapi-crud-auth/.flake8 @@ -0,0 +1,25 @@ +[flake8] +extend-exclude = + .git, + __pycache__, + docs/source/conf.py, + old, + build, + dist, + .venv, + venv, + myvenv, + app/queries + +extend-ignore = E203, E266, E501, W605 + +# Black's default line length. +max-line-length = 88 + +max-complexity = 18 + +# Specify the list of error codes you wish Flake8 to report. +select = B,C,E,F,W,T4,B9 + +# Parallelism +jobs = 4 diff --git a/fastapi-crud-auth/.gitignore b/fastapi-crud-auth/.gitignore new file mode 100644 index 0000000..7d9a07e --- /dev/null +++ b/fastapi-crud-auth/.gitignore @@ -0,0 +1 @@ +myvenv \ No newline at end of file diff --git a/fastapi-crud-auth/Makefile b/fastapi-crud-auth/Makefile new file mode 100644 index 0000000..fd226cb --- /dev/null +++ b/fastapi-crud-auth/Makefile @@ -0,0 +1,138 @@ +path := ./ + +define Comment + - Run `make help` to see all the available options. + - Run `make setup` to run first-time project setup. + - Run `make lint` to run the linter. + - Run `make lint-check` to check linter conformity. +endef + + +.PHONY: lint +lint: black isort flake mypy ## Apply all the linters. + + +.PHONY: lint-check +lint-check: ## Check whether the codebase satisfies the linter rules. + @echo "Checking linter rules..." + @echo "========================" + @echo + @. ./myvenv/bin/activate && black --fast --check $(path) + @. ./myvenv/bin/activate && isort --check $(path) + @. ./myvenv/bin/activate && flake8 $(path) + @. ./myvenv/bin/activate && (echo 'y' | mypy $(path) --install-types) + + +.PHONY: black +black: ## Apply black. + @echo + @echo "Applying black..." + @echo "=================" + @echo + @. ./myvenv/bin/activate && black --fast $(path) + @echo + + +.PHONY: isort +isort: ## Apply isort. + @echo "Applying isort..." + @echo "=================" + @echo + @. ./myvenv/bin/activate && isort $(path) + + +.PHONY: flake +flake: ## Apply flake8. + @echo + @echo "Applying flake8..." + @echo "=================" + @echo + @. ./myvenv/bin/activate && flake8 $(path) + + +.PHONY: mypy +mypy: ## Apply mypy. + @echo + @echo "Applying mypy..." + @echo "=================" + @echo + @. ./myvenv/bin/activate && mypy $(path) + + +.PHONY: help +help: ## Show this help message. + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + + +.PHONY: test +test: ## Run the tests against the current version of Python. + @echo "Resetting test database..." + @edgedb query "drop database edgedb_test" > /dev/null 2>&1 || true && edgedb query "create database edgedb_test" > /dev/null 2>&1 && edgedb migrate -d edgedb_test && edgedb query -d edgedb_test -f tests/fixture.edgeql + @. ./myvenv/bin/activate && EDGEDB_DATABASE=edgedb_test pytest + + +.PHONY: dep-install +dep-install: ## Install latest versions of prod dependencies + @echo + @echo "Installing dependencies..." + @echo "==========================" + @. ./myvenv/bin/activate && pip install edgedb fastapi uvicorn + + +.PHONY: dep-install-dev +dep-install-dev: ## Install latest versions of dev dependencies + @echo + @echo "Installing dev dependencies..." + @echo "==============================" + @. ./myvenv/bin/activate && pip install 'httpx[cli]' black flake8 isort mypy pytest pytest-mock + +.PHONY: install-edgedb +install-edgedb: ## Install the EdgeDB CLI + @echo + @echo "Installing EdgeDB..." + @echo "====================" + @which edgedb > /dev/null 2>&1 || (curl --proto '=https' --tlsv1.2 -sSf https://sh.edgedb.com | sh -s -- -y) + + +.PHONY: init-project +init-project: ## Install the EdgeDB CLI + @echo + @echo "Initializing EdgeDB project..." + @echo "==============================" + @edgedb project init --non-interactive + + +.PHONY: dev-server +dev-server: ## Spin up local dev server. + @. ./myvenv/bin/activate && uvicorn app.main:fast_api --port 5001 --reload + + +.PHONY: health-check +health-check: ## Perfrom health check on the uvicorn server. + @chmod +x ./scripts/health_check + @./scripts/health_check + + +.PHONY: generate +generate: ## Generate code from .edgeql files + @echo + @echo "Generating code..." + @echo "==================" + @. ./myvenv/bin/activate && edgedb-py + + +.PHONY: create-venv +create-venv: ## Create a virtual environment for the project + @echo "Creating virtual environment..." + @python -m venv myvenv + + +.PHONY: activate-venv +activate-venv: ## Activate the project's virtual environment + @echo "This cannot be done from Make. Run \`source myvenv/bin/activate\` in your shell to activate." + + +.PHONY: setup +setup: create-venv dep-install dep-install-dev install-edgedb init-project generate ## Run first-time setup + @echo + @echo "${Green}All set!${NC} Run \`make dev-server\` to start a uvicorn dev server on port 5001." diff --git a/fastapi-crud-auth/README.md b/fastapi-crud-auth/README.md new file mode 100644 index 0000000..7fa77a7 --- /dev/null +++ b/fastapi-crud-auth/README.md @@ -0,0 +1,7 @@ +## FastAPI + +Before using the app, run `make setup` to prep environment, install dependencies, and generate code. Run `make dev-server` to start a development server. + +To switch to the app's virtual environment in an interactive terminal session, run `source myvenv/bin/activate`. + +To learn how to build this app yourself, check out [our guide](https://www.edgedb.com/docs/guides/tutorials/rest_apis_with_fastapi). diff --git a/fastapi-crud-auth/app/__init__.py b/fastapi-crud-auth/app/__init__.py new file mode 100644 index 0000000..bc0596a --- /dev/null +++ b/fastapi-crud-auth/app/__init__.py @@ -0,0 +1,6 @@ +import edgedb +from fastapi import Request + + +def get_edgedb_client(request: Request) -> edgedb.AsyncIOClient: + return request.app.state.edgedb diff --git a/fastapi-crud-auth/app/auth.py b/fastapi-crud-auth/app/auth.py new file mode 100644 index 0000000..27d78df --- /dev/null +++ b/fastapi-crud-auth/app/auth.py @@ -0,0 +1,107 @@ +import secrets +import hashlib +import base64 +import os + +import edgedb +import httpx + +from fastapi import APIRouter, HTTPException, Request, Cookie, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from .queries import create_user_async_edgeql as create_user_qry + +router = APIRouter() + +client = edgedb.create_async_client() + +class RequestData(BaseModel): + name: str + +EDGEDB_AUTH_BASE_URL = os.getenv("EDGEDB_AUTH_BASE_URL") + +@router.post("/auth/signup") +async def handle_signup(request: Request): + body = await request.json() + email = body.get("email") + name = body.get("name") + password = body.get("password") + + if not email or not password or not name: + raise HTTPException(status_code=400, detail="Missing email, password, or name.") + + verifier, challenge = generate_pkce() + register_url = f"{EDGEDB_AUTH_BASE_URL}/register" + register_response = httpx.post(register_url, json={ + "challenge": challenge, + "email": email, + "password": password, + "provider": "builtin::local_emailpassword", + "verify_url": "http://localhost:8000/auth/verify", + }) + + if register_response.status_code != 200 and response.status_code != 201: + return JSONResponse(status_code=400, content={"message": "Registration failed"}) + + code = register_response.json().get("code") + token_url = f"{EDGEDB_AUTH_BASE_URL}/token" + token_response = httpx.get(token_url, params={"code": code, "verifier": verifier}) + + if token_response.status_code != 200: + return JSONResponse(status_code=400, content={"message": "Token exchange failed"}) + + auth_token = token_response.json().get("auth_token") + identity_id = token_response.json().get("identity_id") + try: + created_user = await create_user_qry.create_user(client, name=name, identity_id=identity_id) + except edgedb.errors.ConstraintViolationError: + raise HTTPException( + status_code=400, + detail={"error": f"Username '{name}' already exists."}, + ) + + response = JSONResponse(content={"message": "User registered"}) + response.set_cookie(key="edgedb-auth-token", value=auth_token, httponly=True, secure=True, samesite='strict') + return response + +@router.post("/auth/signin") +async def handle_signin(request: Request): + body = await request.json() + email = body.get("email") + password = body.get("password") + provider = body.get("provider") + + if not email or not password or not provider: + raise HTTPException(status_code=400, detail="Missing email, password, or provider.") + + verifier, challenge = generate_pkce() + authenticate_url = f"{EDGEDB_AUTH_BASE_URL}/authenticate" + response = httpx.post(authenticate_url, json={ + "challenge": challenge, + "email": email, + "password": password, + "provider": provider, + }) + + if response.status_code != 200: + return JSONResponse(status_code=400, content={"message": "Authentication failed"}) + + code = response.json().get("code") + token_url = f"{EDGEDB_AUTH_BASE_URL}/token" + token_response = httpx.get(token_url, params={"code": code, "verifier": verifier}) + + if token_response.status_code != 200: + return JSONResponse(status_code=400, content={"message": "Token exchange failed"}) + + auth_token = token_response.json().get("auth_token") + response = JSONResponse(content={"message": "Authentication successful"}) + response.set_cookie(key="edgedb-auth-token", value=auth_token, httponly=True, secure=True, samesite='strict') + return response + +def generate_pkce(): + verifier = secrets.token_urlsafe(32) + challenge = hashlib.sha256(verifier.encode()).digest() + challenge_base64 = base64.urlsafe_b64encode(challenge).decode('utf-8').rstrip('=') + return verifier, challenge_base64 + diff --git a/fastapi-crud-auth/app/events.py b/fastapi-crud-auth/app/events.py new file mode 100644 index 0000000..fc923e6 --- /dev/null +++ b/fastapi-crud-auth/app/events.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from http import HTTPStatus +from typing import List + +import edgedb +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from . import get_edgedb_client +from .queries import create_event_async_edgeql as create_event_qry +from .queries import delete_event_async_edgeql as delete_event_qry +from .queries import get_event_by_name_async_edgeql as get_event_by_name_qry +from .queries import get_events_async_edgeql as get_events_qry +from .queries import update_event_async_edgeql as update_event_qry + +router = APIRouter() + + +class RequestData(BaseModel): + name: str + address: str + schedule: str + host_name: str + + +################################ +# Get events +################################ + + +@router.get("/events") +async def get_events( + name: str = Query(None, max_length=50), + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> List[get_events_qry.GetEventsResult] | get_event_by_name_qry.GetEventByNameResult: + if not name: + events = await get_events_qry.get_events(client) + return events + else: + event = await get_event_by_name_qry.get_event_by_name(client, name=name) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail={"error": f"Event '{name}' does not exist."}, + ) + return event + + +# ################################ +# Create events +# ################################ + + +@router.post("/events", status_code=HTTPStatus.CREATED) +async def post_event( + event: RequestData, + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> 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 edgedb.errors.InvalidValueError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={ + "error": "Invalid datetime format. " + "Datetime string must look like this: " + "'2010-12-27T23:59:59-07:00'", + }, + ) + + except edgedb.errors.ConstraintViolationError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Event name '{event.name}' already exists,", + ) + + return created_event + + +# ################################ +# Update events +# ################################ + + +@router.put("/events") +async def put_event( + event: RequestData, + current_name: str, + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> update_event_qry.UpdateEventResult: + try: + updated_event = await update_event_qry.update_event( + client, + current_name=current_name, + name=event.name, + address=event.address, + schedule=event.schedule, + host_name=event.host_name, + ) + + except edgedb.errors.InvalidValueError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={ + "error": "Invalid datetime format. " + "Datetime string must look like this: '2010-12-27T23:59:59-07:00'", + }, + ) + + except edgedb.errors.ConstraintViolationError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={"error": f"Event name '{event.name}' already exists."}, + ) + + if not updated_event: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail={"error": f"Update event '{event.name}' failed."}, + ) + + return updated_event + + +# ################################ +# Delete events +# ################################ + + +@router.delete("/events") +async def delete_event( + name: str, + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> delete_event_qry.DeleteEventResult: + deleted_event = await delete_event_qry.delete_event(client, name=name) + + if not deleted_event: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail={"error": f"Delete event '{name}' failed."}, + ) + + return deleted_event diff --git a/fastapi-crud-auth/app/main.py b/fastapi-crud-auth/app/main.py new file mode 100644 index 0000000..cff9b7d --- /dev/null +++ b/fastapi-crud-auth/app/main.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import functools + +import edgedb +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from app import events, users, auth + + +async def setup_edgedb(app): + client = app.state.edgedb = edgedb.create_async_client() + await client.ensure_connected() + + +async def shutdown_edgedb(app): + client, app.state.edgedb = app.state.edgedb, None + await client.aclose() + + +def make_app(): + app = FastAPI() + + app.on_event("startup")(functools.partial(setup_edgedb, app)) + app.on_event("shutdown")(functools.partial(shutdown_edgedb, app)) + + @app.get("/health_check", include_in_schema=False) + async def health_check() -> dict[str, str]: + return {"status": "Ok"} + + # Set all CORS enabled origins + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(events.router) + app.include_router(users.router) + fast_api.include_router(auth.router) + + return app + + +fast_api = make_app() diff --git a/fastapi-crud-auth/app/queries/create_event.edgeql b/fastapi-crud-auth/app/queries/create_event.edgeql new file mode 100644 index 0000000..edb44de --- /dev/null +++ b/fastapi-crud-auth/app/queries/create_event.edgeql @@ -0,0 +1,15 @@ +with name := $name, + address := $address, + schedule := $schedule, + host_name := $host_name + +select ( + insert Event { + name := name, + address := address, + schedule := schedule, + host := assert_single( + (select detached User filter .name = host_name) + ) + } +) {name, address, schedule, host: {name}}; diff --git a/fastapi-crud-auth/app/queries/create_user.edgeql b/fastapi-crud-auth/app/queries/create_user.edgeql new file mode 100644 index 0000000..81af0a7 --- /dev/null +++ b/fastapi-crud-auth/app/queries/create_user.edgeql @@ -0,0 +1 @@ +select (insert User {name:=$name}) {name, created_at}; diff --git a/fastapi-crud-auth/app/queries/delete_event.edgeql b/fastapi-crud-auth/app/queries/delete_event.edgeql new file mode 100644 index 0000000..c55d12a --- /dev/null +++ b/fastapi-crud-auth/app/queries/delete_event.edgeql @@ -0,0 +1,3 @@ +select ( + delete Event filter .name = $name +) {name, address, schedule, host : {name}}; diff --git a/fastapi-crud-auth/app/queries/delete_user.edgeql b/fastapi-crud-auth/app/queries/delete_user.edgeql new file mode 100644 index 0000000..389c4a4 --- /dev/null +++ b/fastapi-crud-auth/app/queries/delete_user.edgeql @@ -0,0 +1,3 @@ +select ( + delete User filter .name = $name +) {name, created_at}; diff --git a/fastapi-crud-auth/app/queries/get_event_by_name.edgeql b/fastapi-crud-auth/app/queries/get_event_by_name.edgeql new file mode 100644 index 0000000..160a588 --- /dev/null +++ b/fastapi-crud-auth/app/queries/get_event_by_name.edgeql @@ -0,0 +1,4 @@ +select Event { + name, address, schedule, + host : {name} +} filter .name=$name; diff --git a/fastapi-crud-auth/app/queries/get_events.edgeql b/fastapi-crud-auth/app/queries/get_events.edgeql new file mode 100644 index 0000000..387dc09 --- /dev/null +++ b/fastapi-crud-auth/app/queries/get_events.edgeql @@ -0,0 +1 @@ +select Event {name, address, schedule, host : {name}}; diff --git a/fastapi-crud-auth/app/queries/get_user_by_name.edgeql b/fastapi-crud-auth/app/queries/get_user_by_name.edgeql new file mode 100644 index 0000000..edad423 --- /dev/null +++ b/fastapi-crud-auth/app/queries/get_user_by_name.edgeql @@ -0,0 +1 @@ +select User {name, created_at} filter User.name=$name diff --git a/fastapi-crud-auth/app/queries/get_users.edgeql b/fastapi-crud-auth/app/queries/get_users.edgeql new file mode 100644 index 0000000..de2dbed --- /dev/null +++ b/fastapi-crud-auth/app/queries/get_users.edgeql @@ -0,0 +1 @@ +select User {name, created_at}; diff --git a/fastapi-crud-auth/app/queries/update_event.edgeql b/fastapi-crud-auth/app/queries/update_event.edgeql new file mode 100644 index 0000000..3673431 --- /dev/null +++ b/fastapi-crud-auth/app/queries/update_event.edgeql @@ -0,0 +1,15 @@ +with current_name := $current_name, + new_name := $name, + address := $address, + schedule := $schedule, + host_name := $host_name + +select ( + update Event filter .name = current_name + set { + name := new_name, + address := address, + schedule := schedule, + host := (select User filter .name = host_name) + } +) {name, address, schedule, host: {name}}; diff --git a/fastapi-crud-auth/app/queries/update_user.edgeql b/fastapi-crud-auth/app/queries/update_user.edgeql new file mode 100644 index 0000000..e9b799c --- /dev/null +++ b/fastapi-crud-auth/app/queries/update_user.edgeql @@ -0,0 +1,4 @@ +select ( + update User filter .name = $current_name + set {name := $new_name} +) {name, created_at}; diff --git a/fastapi-crud-auth/app/users.py b/fastapi-crud-auth/app/users.py new file mode 100644 index 0000000..9d7a321 --- /dev/null +++ b/fastapi-crud-auth/app/users.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from http import HTTPStatus +from typing import List + +import edgedb +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from . import get_edgedb_client +from .queries import create_user_async_edgeql as create_user_qry +from .queries import delete_user_async_edgeql as delete_user_qry +from .queries import get_user_by_name_async_edgeql as get_user_by_name_qry +from .queries import get_users_async_edgeql as get_users_qry +from .queries import update_user_async_edgeql as update_user_qry + +router = APIRouter() + + +class RequestData(BaseModel): + name: str + + +################################ +# Get users +################################ + + +@router.get("/users") +async def get_users( + name: str = Query(None, max_length=50), + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> List[get_users_qry.GetUsersResult] | get_user_by_name_qry.GetUserByNameResult: + if not name: + users = await get_users_qry.get_users(client) + return users + else: + user = await get_user_by_name_qry.get_user_by_name(client, name=name) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail={"error": f"Username '{name}' does not exist."}, + ) + return user + + +################################ +# Create users +################################ + + +@router.post("/users", status_code=HTTPStatus.CREATED) +async def post_user( + user: RequestData, + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> create_user_qry.CreateUserResult: + try: + created_user = await create_user_qry.create_user(client, name=user.name) + except edgedb.errors.ConstraintViolationError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={"error": f"Username '{user.name}' already exists."}, + ) + return created_user + + +################################ +# Update users +################################ + + +@router.put("/users") +async def put_user( + user: RequestData, + current_name: str, + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> update_user_qry.UpdateUserResult: + try: + updated_user = await update_user_qry.update_user( + client, + new_name=user.name, + current_name=current_name, + ) + except edgedb.errors.ConstraintViolationError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={"error": f"Username '{user.name}' already exists."}, + ) + + if not updated_user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail={"error": f"User '{current_name}' was not found."}, + ) + return updated_user + + +################################ +# Delete users +################################ + + +@router.delete("/users") +async def delete_user( + name: str, + client: edgedb.AsyncIOClient = Depends(get_edgedb_client), +) -> delete_user_qry.DeleteUserResult: + try: + deleted_user = await delete_user_qry.delete_user( + client, + name=name, + ) + except edgedb.errors.ConstraintViolationError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={"error": "User attached to an event. Cannot delete."}, + ) + + if not deleted_user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail={"error": f"User '{name}' was not found."}, + ) + return deleted_user diff --git a/fastapi-crud-auth/dbschema/default.esdl b/fastapi-crud-auth/dbschema/default.esdl new file mode 100644 index 0000000..4219a61 --- /dev/null +++ b/fastapi-crud-auth/dbschema/default.esdl @@ -0,0 +1,38 @@ +using extension auth; + +module default { + global current_user := ( + assert_single(( + select User { id, name } + filter .identity = global ext::auth::ClientTokenIdentity + )) + ); + + abstract type Auditable { + annotation description := "Add 'created_at' property to all types."; + required property created_at -> datetime { + readonly := true; + default := datetime_current(); + } + } + + type User extending Auditable { + required identity: ext::auth::Identity; + annotation description := "Event host."; + required property name -> str { + constraint exclusive; + constraint max_len_value(50); + }; + } + + type Event extending Auditable { + annotation description := "Some grand event."; + required property name -> str { + constraint exclusive; + constraint max_len_value(50); + } + property address -> str; + property schedule -> datetime; + link host -> User; + } +} diff --git a/fastapi-crud-auth/dbschema/migrations/00001.edgeql b/fastapi-crud-auth/dbschema/migrations/00001.edgeql new file mode 100644 index 0000000..428a96b --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00001.edgeql @@ -0,0 +1,23 @@ +CREATE MIGRATION m1ztaehexxjhdnba7m7xbmdvmfeir6z37ehrnxkylg7obpdo6gqzfa + ONTO initial +{ + CREATE ABSTRACT TYPE default::AuditLog { + CREATE ANNOTATION std::description := "Add 'create_at' and 'update_at' properties to all types."; + CREATE PROPERTY created_at -> std::datetime { + SET default := (std::datetime_current()); + }; + }; + CREATE TYPE default::User EXTENDING default::AuditLog { + CREATE ANNOTATION std::description := 'Event host.'; + CREATE REQUIRED PROPERTY name -> std::str; + }; + CREATE TYPE default::Event EXTENDING default::AuditLog { + CREATE ANNOTATION std::description := 'Some grand event.'; + CREATE LINK host -> default::User; + CREATE PROPERTY address -> std::str; + CREATE REQUIRED PROPERTY name -> std::str { + CREATE CONSTRAINT std::exclusive; + }; + CREATE PROPERTY schedule -> std::datetime; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00002.edgeql b/fastapi-crud-auth/dbschema/migrations/00002.edgeql new file mode 100644 index 0000000..2508292 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00002.edgeql @@ -0,0 +1,14 @@ +CREATE MIGRATION m1o44fg65rqgafacgqgue2hlahzddj6ilapw6gmmgf6ckx56n7bxuq + ONTO m1ztaehexxjhdnba7m7xbmdvmfeir6z37ehrnxkylg7obpdo6gqzfa +{ + ALTER TYPE default::Event { + ALTER PROPERTY name { + CREATE CONSTRAINT std::max_len_value(50); + }; + }; + ALTER TYPE default::User { + ALTER PROPERTY name { + CREATE CONSTRAINT std::max_len_value(50); + }; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00003.edgeql b/fastapi-crud-auth/dbschema/migrations/00003.edgeql new file mode 100644 index 0000000..ee2e9b3 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00003.edgeql @@ -0,0 +1,9 @@ +CREATE MIGRATION m1dubpo33vtnoby5ynceil2qir2rede56kfx5n2pmpgw7xob2p6k5q + ONTO m1o44fg65rqgafacgqgue2hlahzddj6ilapw6gmmgf6ckx56n7bxuq +{ + ALTER TYPE default::User { + ALTER PROPERTY name { + CREATE CONSTRAINT std::exclusive; + }; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00004.edgeql b/fastapi-crud-auth/dbschema/migrations/00004.edgeql new file mode 100644 index 0000000..ab63d32 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00004.edgeql @@ -0,0 +1,13 @@ +CREATE MIGRATION m1oe66ebzkfe3pjwiq2yxr6pmfdp5ltnv3hdhzb5snicxaiz3yjvaq + ONTO m1dubpo33vtnoby5ynceil2qir2rede56kfx5n2pmpgw7xob2p6k5q +{ + ALTER TYPE default::Event { + ALTER LINK host { + SET REQUIRED USING (SELECT + default::User + FILTER + (.name = 'string') + ); + }; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00005.edgeql b/fastapi-crud-auth/dbschema/migrations/00005.edgeql new file mode 100644 index 0000000..7e10e18 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00005.edgeql @@ -0,0 +1,9 @@ +CREATE MIGRATION m1dbl7go74lax7a3a7knyduto3ivka4jxfyzcexvzzhozc7ycftada + ONTO m1oe66ebzkfe3pjwiq2yxr6pmfdp5ltnv3hdhzb5snicxaiz3yjvaq +{ + ALTER TYPE default::Event { + ALTER LINK host { + RESET OPTIONALITY; + }; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00006.edgeql b/fastapi-crud-auth/dbschema/migrations/00006.edgeql new file mode 100644 index 0000000..5c20f98 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00006.edgeql @@ -0,0 +1,5 @@ +CREATE MIGRATION m16r77gz27jqnlos6std4s2ich5ecmxkidbe5vrel2jef23trmtpia + ONTO m1dbl7go74lax7a3a7knyduto3ivka4jxfyzcexvzzhozc7ycftada +{ + ALTER TYPE default::AuditLog RENAME TO default::Auditable; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00007.edgeql b/fastapi-crud-auth/dbschema/migrations/00007.edgeql new file mode 100644 index 0000000..5d76a0a --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00007.edgeql @@ -0,0 +1,9 @@ +CREATE MIGRATION m12kcgdxpnqoh7uekkvmp3wvhhqwod3qyz7psn7y5wtne3wln6yefa + ONTO m16r77gz27jqnlos6std4s2ich5ecmxkidbe5vrel2jef23trmtpia +{ + ALTER TYPE default::Auditable { + ALTER PROPERTY created_at { + SET readonly := true; + }; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00008.edgeql b/fastapi-crud-auth/dbschema/migrations/00008.edgeql new file mode 100644 index 0000000..2043ff8 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00008.edgeql @@ -0,0 +1,10 @@ +CREATE MIGRATION m1iql6m25k74sq2o4432ettotupltggdib5q7b54svxzcfycfi3xia + ONTO m12kcgdxpnqoh7uekkvmp3wvhhqwod3qyz7psn7y5wtne3wln6yefa +{ + ALTER TYPE default::Auditable { + ALTER ANNOTATION std::description := "Add 'created_at' property to all types."; + ALTER PROPERTY created_at { + SET REQUIRED USING (std::datetime_current()); + }; + }; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00009-m1co2rs.edgeql b/fastapi-crud-auth/dbschema/migrations/00009-m1co2rs.edgeql new file mode 100644 index 0000000..1dc55fe --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00009-m1co2rs.edgeql @@ -0,0 +1,6 @@ +CREATE MIGRATION m1co2rsosgepnjltxdv47gcdsawsfrjang4kwebko5c5oqugxrtnua + ONTO m1iql6m25k74sq2o4432ettotupltggdib5q7b54svxzcfycfi3xia +{ + CREATE EXTENSION pgcrypto VERSION '1.3'; + CREATE EXTENSION auth VERSION '1.0'; +}; diff --git a/fastapi-crud-auth/dbschema/migrations/00010-m1gme45.edgeql b/fastapi-crud-auth/dbschema/migrations/00010-m1gme45.edgeql new file mode 100644 index 0000000..b9e5310 --- /dev/null +++ b/fastapi-crud-auth/dbschema/migrations/00010-m1gme45.edgeql @@ -0,0 +1,17 @@ +CREATE MIGRATION m1gme45vmspnh7htkkqp627x4ksu7g2hgwchbpzcf6vvlu2fcie3yq + ONTO m1co2rsosgepnjltxdv47gcdsawsfrjang4kwebko5c5oqugxrtnua +{ + ALTER TYPE default::User { + CREATE REQUIRED LINK identity: ext::auth::Identity { + SET REQUIRED USING ({}); + }; + }; + CREATE GLOBAL default::current_user := (std::assert_single((SELECT + default::User { + id, + name + } + FILTER + (.identity = GLOBAL ext::auth::ClientTokenIdentity) + ))); +}; diff --git a/fastapi-crud-auth/edgedb.toml b/fastapi-crud-auth/edgedb.toml new file mode 100644 index 0000000..a52f054 --- /dev/null +++ b/fastapi-crud-auth/edgedb.toml @@ -0,0 +1,2 @@ +[edgedb] +server-version = "4.4" diff --git a/fastapi-crud-auth/pyproject.toml b/fastapi-crud-auth/pyproject.toml new file mode 100644 index 0000000..9fc7b50 --- /dev/null +++ b/fastapi-crud-auth/pyproject.toml @@ -0,0 +1,30 @@ +# Linter configuruation. +[tool.isort] +profile = "black" +atomic = true +extend_skip_glob = "migrations,scripts,app/queries,myvenv,tests" +line_length = 88 + + +[tool.black] +extend-exclude = "migrations,scripts" +force-exclude = "app/queries/.*|tests" + + +[tool.mypy] +follow_imports = "skip" +ignore_missing_imports = true +warn_no_return = false +warn_unused_ignores = true +allow_untyped_globals = true +allow_redefinition = true +pretty = true +exclude = "myvenv" + + +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/fastapi-crud-auth/scripts/health_check b/fastapi-crud-auth/scripts/health_check new file mode 100755 index 0000000..9254a40 --- /dev/null +++ b/fastapi-crud-auth/scripts/health_check @@ -0,0 +1,23 @@ +#!/bin/bash + +set -euo pipefail + +source myvenv/bin/activate +# Run the uvicorn server in the background. + +nohup uvicorn app.main:fast_api --port 5001 --reload >> /dev/null & + +# Give the server enough time to be ready before accepting requests. +sleep 2 + +# Run the healthcheck. +if [[ $(httpx -m GET http://localhost:5001/health_check 2>&1) =~ "200 OK" ]]; then + echo "Health check passed!" + exit 0 +else + echo "Health check failed!" + exit 1 +fi + +# Cleanup. +pkill -9 -ecfi python diff --git a/fastapi-crud-auth/tests/__init__.py b/fastapi-crud-auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-crud-auth/tests/conftest.py b/fastapi-crud-auth/tests/conftest.py new file mode 100644 index 0000000..4a92143 --- /dev/null +++ b/fastapi-crud-auth/tests/conftest.py @@ -0,0 +1,35 @@ +import edgedb +import pytest +from fastapi.testclient import TestClient + +from app.main import make_app + + +@pytest.fixture +def test_client(): + with TestClient(make_app()) as client: + yield client + + +@pytest.fixture +def tx_test_client(mocker): + mocker.patch("app.main.setup_edgedb", tx_setup_edgedb) + mocker.patch("app.main.shutdown_edgedb", tx_shutdown_edgedb) + with TestClient(make_app()) as client: + yield client + + +async def tx_setup_edgedb(app): + client = app.state.edgedb_client = edgedb.create_async_client() + await client.ensure_connected() + async for tx in client.with_retry_options(edgedb.RetryOptions(0)).transaction(): + await tx.__aenter__() + app.state.edgedb = tx + break + + +async def tx_shutdown_edgedb(app): + client, app.state.edgedb_client = app.state.edgedb_client, None + tx, app.state.edgedb = app.state.edgedb, None + await tx.__aexit__(Exception, Exception(), None) + await client.aclose() diff --git a/fastapi-crud-auth/tests/fixture.edgeql b/fastapi-crud-auth/tests/fixture.edgeql new file mode 100644 index 0000000..9486456 --- /dev/null +++ b/fastapi-crud-auth/tests/fixture.edgeql @@ -0,0 +1,9 @@ +insert User {name := 'Jonathan Harker'}; +insert User {name := 'Count Dracula'}; +insert User {name := 'Mina Murray'}; +insert Event { + name := 'Resuscitation', + host := (select User filter .name = 'Mina Murray'), + address := 'Britain', + schedule := '1889-07-28T06:59:59+00:00' +}; diff --git a/fastapi-crud-auth/tests/test_events.py b/fastapi-crud-auth/tests/test_events.py new file mode 100644 index 0000000..9b09e25 --- /dev/null +++ b/fastapi-crud-auth/tests/test_events.py @@ -0,0 +1,90 @@ +""" +Currently these tests use the same database that the app uses. +In a real application, you'd want to patch the 'client' to use a +separate database. + +""" + +import datetime +import uuid +from http import HTTPStatus + +from app.queries import get_events_async_edgeql as get_events_qry + + +def test_get_events(test_client): + response = test_client.get("/events") + assert response.status_code == HTTPStatus.OK + + +def test_get_events_with_single_event(mocker, test_client): + event_name = "Test" + event_address = "Address" + event_host = "Test Host" + mocker.patch( + "app.events.get_events_qry.get_events", + return_value=[ + get_events_qry.GetEventsResult( + id=uuid.uuid4(), + name=event_name, + address=event_address, + host=get_events_qry.GetEventsResultHost( + id=uuid.uuid4(), name=event_host + ), + schedule=datetime.datetime.now(), + ) + ], + ) + response = test_client.get("/events") + assert response.status_code == HTTPStatus.OK + assert response.json()[0]["name"] == event_name + + +def test_get_events_with_multiple_events(mocker, test_client): + event_1_name = "Test 1" + event_2_name = "Test 2" + event_1_address = "Address" + event_2_address = "Address" + event_1_host = "Test Host" + event_2_host = "Test Host" + mocker.patch( + "app.events.get_events_qry.get_events", + return_value=[ + get_events_qry.GetEventsResult( + id=uuid.uuid4(), + name=event_1_name, + address=event_1_address, + host=get_events_qry.GetEventsResultHost( + id=uuid.uuid4(), name=event_1_host + ), + schedule=datetime.datetime.now(), + ), + get_events_qry.GetEventsResult( + id=uuid.uuid4(), + name=event_2_name, + address=event_2_address, + host=get_events_qry.GetEventsResultHost( + id=uuid.uuid4(), name=event_2_host + ), + schedule=datetime.datetime.now(), + ), + ], + ) + response = test_client.get("/events") + assert response.status_code == HTTPStatus.OK + assert response.json()[0]["name"] == event_1_name + assert response.json()[1]["name"] == event_2_name + + +def test_post_event(tx_test_client): + response = tx_test_client.post( + "/events", + json={ + "name": "test", + "address": "test address", + "host_name": "Test", + "schedule": "2010-12-27T23:59:59-07:00", + }, + ) + assert response.status_code == HTTPStatus.CREATED + assert response.json()["name"] == "test" diff --git a/fastapi-crud-auth/tests/test_users.py b/fastapi-crud-auth/tests/test_users.py new file mode 100644 index 0000000..9cc01a8 --- /dev/null +++ b/fastapi-crud-auth/tests/test_users.py @@ -0,0 +1,58 @@ +""" +Currently these tests use the same database that the app uses. +In a real application, you'd want to patch the 'client' to use a +separate database. + +""" + +import datetime +import uuid +from http import HTTPStatus + +from app.queries import get_users_async_edgeql as get_users_qry + + +def test_get_users(test_client): + response = test_client.get("/users") + assert response.status_code == HTTPStatus.OK + + +def test_get_users_with_single_user(mocker, test_client): + user_name = "Test" + mocker.patch( + "app.users.get_users_qry.get_users", + return_value=[ + get_users_qry.GetUsersResult( + id=uuid.uuid4(), name=user_name, created_at=datetime.datetime.now() + ) + ], + ) + response = test_client.get("/users") + assert response.status_code == HTTPStatus.OK + assert response.json()[0]["name"] == user_name + + +def test_get_users_with_multiple_users(mocker, test_client): + user_1_name = "Test 1" + user_2_name = "Test 2" + mocker.patch( + "app.users.get_users_qry.get_users", + return_value=[ + get_users_qry.GetUsersResult( + id=uuid.uuid4(), name=user_1_name, created_at=datetime.datetime.now() + ), + get_users_qry.GetUsersResult( + id=uuid.uuid4(), name=user_2_name, created_at=datetime.datetime.now() + ), + ], + ) + response = test_client.get("/users") + assert response.status_code == HTTPStatus.OK + assert response.json()[0]["name"] == user_1_name + assert response.json()[1]["name"] == user_2_name + + +def test_post_user(tx_test_client): + response = tx_test_client.post("/users", json={"name": "test"}) + assert response.status_code == HTTPStatus.CREATED + assert response.json()["name"] == "test"