Minimal async FastAPI + PostgreSQL template
- Minimal async FastAPI + PostgreSQL template
Features
- SQLAlchemy 2.0 only, async queries, best possible autocompletion support (SQLAlchemy 2.0.0 was released January 26, 2023)
- Postgresql database under
asyncpg
- Alembic migrations
- Very minimal project structure yet ready for quick start building new apps
- Refresh token endpoint (not only access like in official template)
- Two databases in docker-compose.yml (second one for tests) and ready to go Dockerfile with Uvicorn webserver
- Poetry and Python 3.11 based
-
pre-commit
with poetry export, autoflake, black, isort and flake8 - Rich setup for pytest async tests with few included and extensible
conftest.py
Check out also online example: https://minimal-fastapi-postgres-template.rafsaf.pl, it's 100% code used in template (docker image) with added domain and https only.
Quickstart
1. Install cookiecutter globally and cookiecutter this project
pip install cookiecutter
# And cookiecutter this project :)
cookiecutter https://github.com/rafsaf/minimal-fastapi-postgres-template
2. Install dependecies with poetry or without it
cd project_name
### Poetry install (python3.11)
poetry install
### Optionally there is also `requirements-dev.txt` file
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements-dev.txt
Note, be sure to use python3.11
with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example str | int
for python < 3.10 or tomllib
for python < 3.11)
3. Setup databases
### Setup two databases
docker-compose up -d
### Alembic migrations upgrade and initial_data.py script
bash init.sh
4. Now you can run app
### And this is it:
uvicorn app.main:app --reload
You should then use git init
to initialize git repository and access OpenAPI spec at http://localhost:8000/ by default. To customize docs url, cors and allowed hosts settings, read section about it.
5. Activate pre-commit
pre-commit is de facto standard now for pre push activities like isort or black.
Refer to .pre-commit-config.yaml
file to see my opinionated choices.
# Install pre-commit
pre-commit install
# First initialization and run on all files
pre-commit run --all-files
6. Running tests
# Note, it will use second database declared in docker-compose.yml, not default one
pytest
# collected 7 items
# app/tests/test_auth.py::test_auth_access_token PASSED [ 14%]
# app/tests/test_auth.py::test_auth_access_token_fail_no_user PASSED [ 28%]
# app/tests/test_auth.py::test_auth_refresh_token PASSED [ 42%]
# app/tests/test_users.py::test_read_current_user PASSED [ 57%]
# app/tests/test_users.py::test_delete_current_user PASSED [ 71%]
# app/tests/test_users.py::test_reset_current_user_password PASSED [ 85%]
# app/tests/test_users.py::test_register_new_user PASSED [100%]
#
# ======================================================== 7 passed in 1.75s ========================================================
About
This project is heavily based on the official template https://github.com/tiangolo/full-stack-fastapi-postgresql (and on my previous work: link1, link2), but as it now not too much up-to-date, it is much easier to create new one than change official. I didn't like some of conventions over there also (crud
and db
folders for example or schemas
with bunch of files). This template aims to be as much up-to-date as possible, using only newest python versions and libraries versions.
2.0
style SQLAlchemy API is good enough so there is no need to write everything in crud
and waste our time... The core
folder was also rewritten. There is great base for writting tests in tests
, but I didn't want to write hundreds of them, I noticed that usually after changes in the structure of the project, auto tests are useless and you have to write them from scratch anyway (delete old ones...), hence less than more. Similarly with the User
model, it is very modest, with just id
(uuid), email
and password_hash
, because it will be adapted to the project anyway.
Step by step example - POST and GET endpoints
I always enjoy to have some kind of an example in templates (even if I don't like it much, some parts may be useful and save my time...), so let's create two example endpoints:
POST
endpoint/pets/create
for creatingPets
with relation to currently loggedUser
GET
endpoint/pets/me
for fetching all user's pets.
1. Create SQLAlchemy model
We will add Pet model to app/models.py
. To keep things clear, below is full result of models.py file.
# app/models.py
import uuid
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user_model"
id: Mapped[str] = mapped_column(
UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
)
email: Mapped[str] = mapped_column(
String(254), nullable=False, unique=True, index=True
)
hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
class Pet(Base):
__tablename__ = "pet"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[str] = mapped_column(
ForeignKey("user_model.id", ondelete="CASCADE"),
)
pet_name: Mapped[str] = mapped_column(String(50), nullable=False)
Note, we are using super powerful SQLAlchemy feature here - Mapped and mapped_column were first introduced in SQLAlchemy 2.0 on Feb 26, if this syntax is new for you, read carefully "what's new" part of documentation https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html.
2. Create and apply alembic migration
### Use below commands in root folder in virtualenv ###
# if you see FAILED: Target database is not up to date.
# first use alembic upgrade head
# Create migration with alembic revision
alembic revision --autogenerate -m "create_pet_model"
# File similar to "2022050949_create_pet_model_44b7b689ea5f.py" should appear in `/alembic/versions` folder
# Apply migration using alembic upgrade
alembic upgrade head
# (...)
# INFO [alembic.runtime.migration] Running upgrade d1252175c146 -> 44b7b689ea5f, create_pet_model
PS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes.
3. Create request and response schemas
I personally lately (after seeing clear benefits at work in Samsung) prefer less files than a lot of them for things like schemas.
Thats why there are only 2 files: requests.py
and responses.py
in schemas
folder and I would keep it that way even for few dozen of endpoints. Not to mention this is opinionated.
# app/schemas/requests.py
(...)
class PetCreateRequest(BaseRequest):
pet_name: str
# app/schemas/responses.py
(...)
class PetResponse(BaseResponse):
id: int
pet_name: str
user_id: str
4. Create endpoints
# /app/api/endpoints/pets.py
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.models import Pet, User
from app.schemas.requests import PetCreateRequest
from app.schemas.responses import PetResponse
router = APIRouter()
@router.post("/create", response_model=PetResponse, status_code=201)
async def create_new_pet(
new_pet: PetCreateRequest,
session: AsyncSession = Depends(deps.get_session),
current_user: User = Depends(deps.get_current_user),
):
"""Creates new pet. Only for logged users."""
pet = Pet(user_id=current_user.id, pet_name=new_pet.pet_name)
session.add(pet)
await session.commit()
return pet
@router.get("/me", response_model=list[PetResponse], status_code=200)
async def get_all_my_pets(
session: AsyncSession = Depends(deps.get_session),
current_user: User = Depends(deps.get_current_user),
):
"""Get list of pets for currently logged user."""
stmt = select(Pet).where(Pet.user_id == current_user.id).order_by(Pet.pet_name)
pets = await session.execute(stmt)
return pets.scalars().all()
Also, we need to add newly created endpoints to router.
# /app/api/api.py
from fastapi import APIRouter
from app.api.endpoints import auth, pets, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(pets.router, prefix="/pets", tags=["pets"])
5. Write tests
# /app/tests/test_pets.py
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.models import Pet, User
async def test_create_new_pet(
client: AsyncClient, default_user_headers, default_user: User
):
response = await client.post(
app.url_path_for("create_new_pet"),
headers=default_user_headers,
json={"pet_name": "Tadeusz"},
)
assert response.status_code == 201
result = response.json()
assert result["user_id"] == default_user.id
assert result["pet_name"] == "Tadeusz"
async def test_get_all_my_pets(
client: AsyncClient, default_user_headers, default_user: User, session: AsyncSession
):
pet1 = Pet(user_id=default_user.id, pet_name="Pet_1")
pet2 = Pet(user_id=default_user.id, pet_name="Pet_2")
session.add(pet1)
session.add(pet2)
await session.commit()
response = await client.get(
app.url_path_for("get_all_my_pets"),
headers=default_user_headers,
)
assert response.status_code == 200
assert response.json() == [
{
"user_id": pet1.user_id,
"pet_name": pet1.pet_name,
"id": pet1.id,
},
{
"user_id": pet2.user_id,
"pet_name": pet2.pet_name,
"id": pet2.id,
},
]
Deployment strategies - via Docker image
This template has by default included Dockerfile
with Uvicorn webserver, because it's simple and just for showcase purposes, with direct relation to FastAPI and great ease of configuration. You should be able to run container(s) (over :8000 port) and then need to setup the proxy, loadbalancer, with https enbaled, so the app stays behind it.
If you prefer other webservers for FastAPI, check out Nginx Unit, Daphne, Hypercorn.
Docs URL, CORS and Allowed Hosts
There are some opinionated default settings in /app/main.py
for documentation, CORS and allowed hosts.
-
Docs
app = FastAPI( title=config.settings.PROJECT_NAME, version=config.settings.VERSION, description=config.settings.DESCRIPTION, openapi_url="/openapi.json", docs_url="/", )
Docs page is simpy
/
(by default in FastAPI it is/docs
). Title, version and description are taken directly fromconfig
and then directly frompyproject.toml
file. You can change it completely for the project, remove or use environment variablesPROJECT_NAME
,VERSION
,DESCRIPTION
. -
CORS
app.add_middleware( CORSMiddleware, allow_origins=[str(origin) for origin in config.settings.BACKEND_CORS_ORIGINS], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )
If you are not sure what are CORS for, follow https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS. React and most frontend frameworks nowadays operate on
http://localhost:3000
thats why it's included inBACKEND_CORS_ORIGINS
in .env file, before going production be sure to include your frontend domain here, likehttps://my-fontend-app.example.com
. -
Allowed Hosts
app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)
Prevents HTTP Host Headers attack, you shoud put here you server IP or (preferably) full domain under it's accessible like
example.com
. By default in .env there are two most popular records:ALLOWED_HOSTS=["localhost", "127.0.0.1"]