Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.vscode
4 changes: 0 additions & 4 deletions .vscode/settings.json

This file was deleted.

192 changes: 181 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
# Full featured FastAPI template, all boring and tedious things are covered
## Minimal async FastAPI + postgresql template

![OpenAPIexample](./docs/OpenAPI_example.png)

- SQLAlchemy using new 2.0 API + async queries
- Postgresql database under `asyncpg`
- Alembic migrations
- Very minimal project structure yet ready for quick start building new api
- Refresh token endpoint (not only access like in official template)
- Two databases in docker-compose.yml (second for tests)
- Two databases in docker-compose.yml (second one for tests)
- poetry
- `pre-push.sh` script with poetry export, autoflake, black, isort and flake8
- Setup for tests, one big test for token flow and very extensible `conftest.py`
- Setup for async tests, one func test for token flow and very extensible `conftest.py`

## What this repo is

This is a minimal template for FastAPI backend + postgresql db as of 2021.11, `async` style for database sessions, endpoints and tests. It provides basic codebase that almost every application has, but nothing more.

## What this repo is not

# Quickstart
It is not complex, full featured solutions for all human kind problems. It doesn't include any third party that isn't necessary for most of apps (dashboards, queues) or implementation differs so much in every project that it's pointless (complex User model, emails, RBAC, permissions).

## Quickstart

```bash
# You can install it globally
# Install cookiecutter globally
pip install cookiecutter

# And cookiecutter this project :)
Expand All @@ -22,24 +32,184 @@ cookiecutter https://github.com/rafsaf/minimal-fastapi-postgres-template
cd project_name
# Poetry install (and activate environment!)
poetry install
# Databases
# Setup two databases
docker-compose up -d
# Alembic migrations upgrade and initial_data.py script
bash init.sh
# And this is it:
uvicorn app.main:app
uvicorn app.main:app --reload
```

tests:

```bash
# Note, it will use second database declared in docker-compose.yml, not default one
pytest
# Note, it will use second database declared in docker-compose.yml, not default one like
# in official template

```

# About
## About

This project is heavily base on official template https://github.com/tiangolo/full-stack-fastapi-postgresql (and on my previous work: [link1](https://github.com/rafsaf/fastapi-plan), [link2](https://github.com/rafsaf/docker-fastapi-projects)), but as it is 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).

`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, hence less than more. Similarly with the `User` model, it is very modest, because it will be adapted to the project anyway (and there are no tests for these endpoints)
`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, because it will be adapted to the project anyway (and there are no tests for these endpoints, you would remove them probably).

## Step by step example

I always enjoy to to have some kind of example in templates (even if I don't like it much, _some_ parts may be useful and save my time...), so let's create `POST` endpoint for creating dogs.

### 1. Add `HappyDog` model

```python
# /app/models.py
(...)

class HappyDog(Base):
__tablename__ = "happy_dog"
id = Column(Integer, primary_key=True, index=True)
puppy_name = Column(String(500))
puppy_age = Column(Integer)
```

### 2. Create and apply alembic migrations

```bash
# Run
alembic revision --autogenerate -m "add_happy_dog"

# Somethig like `YYYY-MM-DD-....py` will appear in `/alembic/versions` folder

alembic upgrade head

# (...)
# INFO [alembic.runtime.migration] Running upgrade cefce371682e -> 038f530b0e9b, add_happy_dog
```

PS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes.

### 3. Create schemas

```python
# /app/schemas/happy_dog.py

from typing import Optional

from pydantic import BaseModel


class BaseHappyDog(BaseModel):
puppy_name: str
puppy_age: Optional[int]


class CreateHappyDog(BaseHappyDog):
pass


class HappyDog(BaseHappyDog):
id: int

```

Then add it to schemas `__init__.py`

```python
# /app/schemas/__init__.py

from .token import Token, TokenPayload, TokenRefresh
from .user import User, UserCreate, UserUpdate
from .happy_dog import HappyDog, CreateHappyDog
```

### 4. Create endpoint

```python
# /app/api/endpoints/dogs.py

from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app import models, schemas
from app.api import deps

router = APIRouter()


@router.post("/", response_model=schemas.HappyDog, status_code=201)
async def create_happy_dog(
dog_create: schemas.CreateHappyDog,
session: AsyncSession = Depends(deps.get_session),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Creates new happy dog. Only for logged users.
"""
new_dog = models.HappyDog(
puppy_name=dog_create.puppy_name, puppy_age=dog_create.puppy_age
)

session.add(new_dog)
await session.commit()
await session.refresh(new_dog)

return new_dog

```

Also, add it to router

```python
# /app/api/api.py

from fastapi import APIRouter

from app.api.endpoints import auth, users, dogs

api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
# new content below
api_router.include_router(dogs.router, prefix="/dogs", tags=["dogs"])

```

### 5. Test it simply

```python
# /app/tests/test_dogs.py

import pytest
from httpx import AsyncClient
from app.models import User

pytestmark = pytest.mark.asyncio


async def test_dog_endpoint(client: AsyncClient, default_user: User):
# better to create fixture auth_client or similar than repeat code with access_token
access_token = await client.post(
"/auth/access-token",
data={
"username": "user@email.com",
"password": "password",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert access_token.status_code == 200
access_token = access_token.json()["access_token"]

puppy_name = "Sonia"
puppy_age = 6

create_dog = await client.post(
"/dogs/",
json={"puppy_name": puppy_name, "puppy_age": puppy_age},
headers={"Authorization": f"Bearer {access_token}"},
)
assert create_dog.status_code == 201
create_dog_json = create_dog.json()
assert create_dog_json["puppy_name"] == puppy_name
assert create_dog_json["puppy_age"] == puppy_age

```
Binary file added docs/OpenAPI_example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 17 additions & 16 deletions {{cookiecutter.project_name}}/.env.example
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
DEBUG=true
SECRET_KEY=string
ACCESS_TOKEN_EXPIRE_MINUTES=11520
SECRET_KEY="DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG"
ENVIRONMENT="DEV"
ACCESS_TOKEN_EXPIRE_MINUTES="11520"
REFRESH_TOKEN_EXPIRE_MINUTES="40320"
BACKEND_CORS_ORIGINS="http://localhost:3000,http://localhost:8001"

DEFAULT_DATABASE_HOSTNAME="localhost"
DEFAULT_DATABASE_USER="rDGJeEDqAz"
DEFAULT_DATABASE_PASSWORD="XsPQhCoEfOQZueDjsILetLDUvbvSxAMnrVtgVZpmdcSssUgbvs"
DEFAULT_DATABASE_PORT="5387"
DEFAULT_DATABASE_DB="default_db"

VERSION="0.1.0"
DESCRIPTION=string
PROJECT_NAME=string
API_STR=
BACKEND_CORS_ORIGINS=http://localhost:3000,http://localhost:8001
TEST_DATABASE_HOSTNAME="localhost"
TEST_DATABASE_USER="test"
TEST_DATABASE_PASSWORD="ywRCUjJijmQoBmWxIfLldOoITPzajPSNvTvHyugQoSqGwNcvQE"
TEST_DATABASE_PORT="37270"
TEST_DATABASE_DB="test_db"

POSTGRES_USER=postgres
POSTGRES_PASSWORD=strong_password
POSTGRES_SERVER=db
POSTGRES_DB=db
POSTGRES_PORT=4999

FIRST_SUPERUSER_EMAIL=example@example.com
FIRST_SUPERUSER_PASSWORD=string_password
FIRST_SUPERUSER_EMAIL="example@example.com"
FIRST_SUPERUSER_PASSWORD="OdLknKQJMUwuhpAVHvRC"
31 changes: 16 additions & 15 deletions {{cookiecutter.project_name}}/.env.template
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
DEBUG=true
SECRET_KEY="{{ random_ascii_string(50) }}"
ACCESS_TOKEN_EXPIRE_MINUTES=11520
ENVIRONMENT="DEV"
ACCESS_TOKEN_EXPIRE_MINUTES="11520"
REFRESH_TOKEN_EXPIRE_MINUTES="40320"
BACKEND_CORS_ORIGINS="http://localhost:3000,http://localhost:8001"

DEFAULT_DATABASE_HOSTNAME="localhost"
DEFAULT_DATABASE_USER="{{ random_ascii_string(10) }}"
DEFAULT_DATABASE_PASSWORD="{{ random_ascii_string(50) }}"
DEFAULT_DATABASE_PORT="{{ range(4000, 7000) | random }}"
DEFAULT_DATABASE_DB="default_db"

VERSION="0.1.0"
DESCRIPTION="{{ cookiecutter.project_name }}"
PROJECT_NAME="{{ cookiecutter.project_name }}"
API_STR=
BACKEND_CORS_ORIGINS=http://localhost:3000,http://localhost:8001
TEST_DATABASE_HOSTNAME="localhost"
TEST_DATABASE_USER="test"
TEST_DATABASE_PASSWORD="{{ random_ascii_string(50) }}"
TEST_DATABASE_PORT="{{ range(30000, 40000) | random }}"
TEST_DATABASE_DB="test_db"

POSTGRES_USER=postgres
POSTGRES_PASSWORD="{{ random_ascii_string(50) }}"
POSTGRES_SERVER=db
POSTGRES_DB=db
POSTGRES_PORT=4999

FIRST_SUPERUSER_EMAIL=example@example.com
FIRST_SUPERUSER_PASSWORD="{{ random_ascii_string(20) }}"
FIRST_SUPERUSER_EMAIL="example@example.com"
FIRST_SUPERUSER_PASSWORD="{{ random_ascii_string(20) }}"
5 changes: 1 addition & 4 deletions {{cookiecutter.project_name}}/.flake8
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ ignore = E203, E501, W503
exclude =
__init__.py,
.venv,
venv,
__pycache__,
.github,
.vscode,
config,
locale,
webhook
app/tests/conftest.py
7 changes: 2 additions & 5 deletions {{cookiecutter.project_name}}/.gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
# vscode
.vscode

# postgresql
data
data-debug
tests_data
default_database_data
test_database_data

# Byte-compiled / optimized / DLL files
__pycache__/
Expand All @@ -14,7 +12,6 @@ __pycache__/

# C extensions
*.so
.env

# Distribution / packaging
.Python
Expand Down
17 changes: 9 additions & 8 deletions {{cookiecutter.project_name}}/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
# See https://unit.nginx.org/installation/#docker-images

FROM nginx/unit:1.25.0-python3.9

# Our Debian with Python and Nginx for python apps.
# See https://hub.docker.com/r/nginx/unit/
ENV PYTHONUNBUFFERED 1

COPY ./app/initial.sh /docker-entrypoint.d/initial.sh
COPY ./config/config.json /docker-entrypoint.d/config.json
# Nginx unit config and init.sh will be consumed at container startup.
COPY ./app/init.sh /docker-entrypoint.d/init.sh
COPY ./nginx-unit-config.json /docker-entrypoint.d/config.json
RUN chmod +x /docker-entrypoint.d/init.sh

# Build folder for our app, only stuff that matters copied.
RUN mkdir build

# We create folder named build for our app.
WORKDIR /build

COPY ./app ./app
COPY ./alembic ./alembic
COPY ./alembic.ini .
COPY ./requirements.txt .

# We copy our app folder to the /build

# Update, install requirements and then cleanup.
RUN apt update && apt install -y python3-pip \
&& pip3 install -r requirements.txt \
&& apt remove -y python3-pip \
Expand Down
2 changes: 1 addition & 1 deletion {{cookiecutter.project_name}}/alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
script_location = alembic

# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s__%%(rev)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
Expand Down
Loading