├── .gitignore ├── 00-ultimate-fastapi-project-setup ├── .dockerignore ├── .env.local ├── .gitignore ├── README.md ├── docker-compose.yml ├── postgres │ ├── .gitkeep │ └── docker-entrypoint-initdb.d │ │ └── .gitkeep ├── pyproject.toml ├── pytest.ini ├── scripts │ ├── entrypoint-test.sh │ ├── run-dev.sh │ └── run-tests.sh ├── snippets │ ├── Dockerfile │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── gunicorn_config.py │ │ ├── routes │ │ │ ├── __init__.py │ │ │ └── v1 │ │ │ │ ├── __init__.py │ │ │ │ └── user.py │ │ └── server.py │ ├── core │ │ ├── __init__.py │ │ └── config.py │ ├── crud │ │ ├── __init__.py │ │ └── user.py │ ├── db │ │ ├── __init__.py │ │ ├── session.py │ │ └── utils.py │ ├── models │ │ ├── __init__.py │ │ ├── base.py │ │ └── user.py │ ├── requirements-dev.txt │ └── requirements.txt └── tests │ ├── __init__.py │ ├── conftest.py │ ├── crud │ ├── __init__.py │ └── test_user.py │ └── routes │ ├── __init__.py │ └── v1 │ ├── __init__.py │ └── test_user.py ├── 01-sqlalchemy-pydantic-crud-factory-pattern ├── .dockerignore ├── .env.test ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── pyproject.toml ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── run-tests.sh ├── snippets │ ├── .env.local │ ├── __init__.py │ ├── config.py │ ├── crud.py │ ├── database.py │ └── models.py └── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_post_crud.py │ └── test_user_crud.py ├── 02-fastapi-mvt-tileserver ├── .dockerignore ├── .env.sample ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── postgres │ └── initdb.d │ │ ├── 00-initdb.sql │ │ ├── 01-create-table.sql │ │ ├── 02-load-data.sh │ │ ├── 03-create-indexes.sql │ │ └── data │ │ ├── listings_1.csv.gz │ │ ├── listings_2.csv.gz │ │ ├── listings_3.csv.gz │ │ ├── listings_4.csv.gz │ │ ├── listings_5.csv.gz │ │ ├── listings_6.csv.gz │ │ └── listings_7.csv.gz ├── requirements.txt ├── scripts │ └── run.sh └── snippets │ ├── __init__.py │ ├── config.py │ ├── crud.py │ ├── db.py │ ├── gunicorn_config.py │ ├── models.py │ ├── server.py │ └── templates │ └── index.html └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | **/.env 3 | medium.md 4 | **/medium.md 5 | xx* 6 | **/.notes 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | cover/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | .pybuilder/ 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | # For a library or package, you might want to ignore these files since the code is 94 | # intended to run in multiple environments; otherwise, check them in: 95 | # .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # poetry 105 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 106 | # This is especially recommended for binary packages to ensure reproducibility, and is more 107 | # commonly ignored for libraries. 108 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 109 | #poetry.lock 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | #pdm.lock 114 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 115 | # in version control. 116 | # https://pdm.fming.dev/#use-with-ide 117 | .pdm.toml 118 | 119 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 120 | __pypackages__/ 121 | 122 | # Celery stuff 123 | celerybeat-schedule 124 | celerybeat.pid 125 | 126 | # SageMath parsed files 127 | *.sage.py 128 | 129 | # Environments 130 | .env 131 | .venv 132 | env/ 133 | venv/ 134 | ENV/ 135 | env.bak/ 136 | venv.bak/ 137 | 138 | # Spyder project settings 139 | .spyderproject 140 | .spyproject 141 | 142 | # Rope project settings 143 | .ropeproject 144 | 145 | # mkdocs documentation 146 | /site 147 | 148 | # mypy 149 | .mypy_cache/ 150 | .dmypy.json 151 | dmypy.json 152 | 153 | # Pyre type checker 154 | .pyre/ 155 | 156 | # pytype static type analyzer 157 | .pytype/ 158 | 159 | # Cython debug symbols 160 | cython_debug/ 161 | 162 | # PyCharm 163 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 164 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 165 | # and can be added to the global gitignore or merged into this file. For a more nuclear 166 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 167 | #.idea/ 168 | 169 | .vscode/ 170 | *.ruff_cache/ 171 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | README.md 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | __pycache__ 7 | .pytest_cache 8 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/.env.local: -------------------------------------------------------------------------------- 1 | ENV=test 2 | APP_HOST=0.0.0.0 3 | APP_PORT=8000 4 | GUNICORN_WORKERS=1 5 | POSTGRES_USER=postgres 6 | POSTGRES_PASSWORD=postgres 7 | POSTGRES_DB=postgres 8 | POSTGRES_PORT=5432 9 | POSTGRES_ECHO=false 10 | POSTGRES_POOL_SIZE=5 11 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .vscode/ 163 | *.ruff_cache/ 164 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/README.md: -------------------------------------------------------------------------------- 1 | # Ultimate FastAPI Project Setup 2 | 3 | See the Medium article [The Ultimate FastAPI Project Setup: FastAPI, Async Postgres, SQLModel, Pytest and Docker]() for more information. 4 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-postgres-base: &postgres-base 2 | image: postgis/postgis:15-3.3-alpine 3 | restart: always 4 | healthcheck: 5 | test: 6 | - CMD-SHELL 7 | - pg_isready -U postgres 8 | interval: 10s 9 | timeout: 5s 10 | retries: 5 11 | 12 | x-app-base: &app-base 13 | restart: always 14 | 15 | services: 16 | postgres-test: 17 | profiles: ["test"] 18 | <<: *postgres-base 19 | env_file: ".env.local" 20 | environment: 21 | - POSTGRES_HOST=postgres-test 22 | - GUNICORN_WORKERS=1 23 | networks: 24 | - test 25 | 26 | app-test: 27 | profiles: ["test"] 28 | <<: *app-base 29 | entrypoint: ./scripts/entrypoint-test.sh 30 | build: 31 | context: . 32 | dockerfile: ./snippets/Dockerfile 33 | args: 34 | ENV: test 35 | env_file: ".env.local" 36 | environment: 37 | - POSTGRES_DRIVERNAME=postgresql+asyncpg 38 | - POSTGRES_HOST=postgres-test 39 | - GUNICORN_WORKERS=1 40 | volumes: 41 | - ./:/code 42 | depends_on: 43 | postgres-test: 44 | condition: service_healthy 45 | networks: 46 | - test 47 | 48 | postgres-dev: 49 | profiles: ["dev"] 50 | <<: *postgres-base 51 | env_file: ".env.local" 52 | environment: 53 | - POSTGRES_HOST=postgres-dev 54 | - GUNICORN_WORKERS=4 55 | ports: 56 | - 5432:5432 57 | expose: 58 | - 5432 59 | volumes: 60 | - ./postgres/docker-entrypoint-initdb.d/:/docker-entrypoint-initdb.d/ 61 | - pgdata-dev:/var/lib/postgresql/data 62 | networks: 63 | - dev 64 | 65 | app-dev: 66 | profiles: ["dev"] 67 | <<: *app-base 68 | build: 69 | context: . 70 | dockerfile: ./snippets/Dockerfile 71 | command: 72 | bash -c " 73 | gunicorn snippets.api.server:app --config ./snippets/api/gunicorn_config.py" 74 | env_file: ".env.local" 75 | environment: 76 | - POSTGRES_HOST=postgres-dev 77 | - GUNICORN_WORKERS=4 78 | volumes: 79 | - ./snippets:/snippets 80 | ports: 81 | - 8000:8000 82 | expose: 83 | - 8000 84 | depends_on: 85 | postgres-dev: 86 | condition: service_healthy 87 | networks: 88 | - dev 89 | 90 | volumes: 91 | pgdata-dev: 92 | 93 | networks: 94 | test: 95 | dev: 96 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/postgres/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/postgres/.gitkeep -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/postgres/docker-entrypoint-initdb.d/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/postgres/docker-entrypoint-initdb.d/.gitkeep -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py312'] 4 | 5 | [tool.ruff.lint.isort] 6 | known-first-party = ["snippets", "tests"] 7 | 8 | [tool.isort] 9 | known-first-party = ["snippets", "tests"] 10 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | asyncio_mode = auto 5 | timeout = 100 6 | addopts = -vv --disable-warnings --durations=10 --durations-min=1.0 7 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/scripts/entrypoint-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python -m pytest -vv -s tests/ "$@" 4 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/scripts/run-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | DOCKER_ARGS="$*" 6 | 7 | trap ctrl_c INT 8 | trap ctrl_c SIGTERM 9 | 10 | function ctrl_c() { 11 | echo "Gracefully hutting down containers ..." 12 | docker compose --profile dev down --volumes 13 | exit 0 14 | } 15 | 16 | docker compose --profile dev up $DOCKER_ARGS 17 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/scripts/run-tests.sh: -------------------------------------------------------------------------------- 1 | pytest_args=$* 2 | docker compose -f docker-compose.yml run --rm app-test $pytest_args 3 | docker compose -f docker-compose.yml --profile test down --volumes 4 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim AS builder 2 | 3 | COPY ./snippets/requirements.txt requirements.txt 4 | COPY ./snippets/requirements-dev.txt requirements-dev.txt 5 | 6 | RUN pip install --upgrade pip && \ 7 | pip install --user --no-cache-dir -r requirements.txt 8 | 9 | ARG ENV 10 | RUN if [ "$ENV" = "test" ]; then \ 11 | pip install --user --no-cache-dir -r requirements-dev.txt; \ 12 | fi; 13 | 14 | FROM python:3.12-slim AS production 15 | 16 | ENV PYTHONDONTWRITEBYTECODE 1 17 | ENV PYTHONUNBUFFERED=1 18 | 19 | COPY --from=builder /root/.local /root/.local 20 | 21 | ENV PATH=/root/.local/bin:$PATH 22 | 23 | WORKDIR /code 24 | 25 | COPY ./snippets /code/snippets 26 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/snippets/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/snippets/api/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/api/gunicorn_config.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import cpu_count 2 | from os import environ 3 | 4 | _gunicorn_port = int(environ.get("APP_PORT", 8000)) 5 | _gunicorn_host = environ.get("APP_HOST", "0.0.0.0") 6 | bind = f"{_gunicorn_host}:{_gunicorn_port}" 7 | workers = int(environ.get("GUNICORN_WORKERS", 2 * cpu_count() + 1)) 8 | worker_class = "uvicorn.workers.UvicornWorker" 9 | keepalive = 600 10 | timeout = 3600 11 | reload = True 12 | accesslog = "-" 13 | errorlog = "-" 14 | capture_output = True 15 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from snippets.api.routes.v1 import router as v1_router 4 | 5 | router = APIRouter() 6 | router.include_router(v1_router) 7 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/api/routes/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from fastapi import APIRouter 4 | 5 | router = APIRouter(prefix="/v1") 6 | routes = ("user",) 7 | for module_name in routes: 8 | api_module = import_module(f"snippets.api.routes.v1.{module_name}") 9 | api_module_router = api_module.router 10 | router.include_router(api_module_router) 11 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/api/routes/v1/user.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastapi import APIRouter, Depends, status 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from snippets.crud.user import create_user, delete_user, get_user, update_user 7 | from snippets.db.session import get_session 8 | from snippets.models.base import DeleteResponse 9 | from snippets.models.user import UserCreate, UserResponse, UserUpdate 10 | 11 | router = APIRouter(prefix="/users", tags=["users"]) 12 | 13 | 14 | @router.post( 15 | "/", 16 | summary="Create a new user.", 17 | status_code=status.HTTP_201_CREATED, 18 | response_model=UserResponse, 19 | ) 20 | async def create_user_route( 21 | data: UserCreate, 22 | db: AsyncSession = Depends(get_session), 23 | ): 24 | return await create_user(session=db, user=data) 25 | 26 | 27 | @router.get( 28 | "/{id}", 29 | summary="Get a user.", 30 | status_code=status.HTTP_200_OK, 31 | response_model=UserResponse, 32 | ) 33 | async def get_user_route(id: UUID, db: AsyncSession = Depends(get_session)): 34 | return await get_user(session=db, id=id) 35 | 36 | 37 | @router.patch( 38 | "/{id}", 39 | summary="Update a user.", 40 | status_code=status.HTTP_200_OK, 41 | response_model=UserResponse, 42 | ) 43 | async def update_user_route( 44 | id: UUID, 45 | data: UserUpdate, 46 | db: AsyncSession = Depends(get_session), 47 | ): 48 | return await update_user(session=db, id=id, user=data) 49 | 50 | 51 | @router.delete( 52 | "/{id}", 53 | summary="Delete a user.", 54 | status_code=status.HTTP_200_OK, 55 | response_model=DeleteResponse, 56 | ) 57 | async def delete_user_route(id: UUID, db: AsyncSession = Depends(get_session)): 58 | deleted = await delete_user(session=db, id=id) 59 | return DeleteResponse(deleted=deleted) 60 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/api/server.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from contextlib import asynccontextmanager 3 | 4 | from fastapi import FastAPI 5 | 6 | from snippets.api.routes import router as api_router 7 | from snippets.core.config import settings 8 | from snippets.db.session import engine 9 | from snippets.db.utils import create_db_and_tables 10 | 11 | warnings.filterwarnings( 12 | "ignore", category=UserWarning, message=r".*PydanticJsonSchemaWarning.*" 13 | ) 14 | 15 | 16 | @asynccontextmanager 17 | async def lifespan(app: FastAPI): 18 | await create_db_and_tables(engine) 19 | yield 20 | 21 | 22 | def get_application(): 23 | app = FastAPI( 24 | title=settings.PROJECT_NAME, 25 | version=settings.VERSION, 26 | docs_url="/docs", 27 | lifespan=lifespan, 28 | ) 29 | app.include_router(api_router, prefix="/api") 30 | return app 31 | 32 | 33 | app = get_application() 34 | 35 | 36 | @app.get("/", tags=["health"]) 37 | async def health(): 38 | return dict( 39 | name=settings.PROJECT_NAME, 40 | version=settings.VERSION, 41 | status="OK", 42 | message="Visit /docs for more information.", 43 | ) 44 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/snippets/core/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/core/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic_settings import BaseSettings, SettingsConfigDict 3 | 4 | 5 | class Settings(BaseSettings): 6 | model_config = SettingsConfigDict(extra="ignore") 7 | 8 | # project settings 9 | VERSION: str = Field("0.0.1") 10 | PROJECT_NAME: str = Field("Ultimate FastAPI Project Setup") 11 | 12 | # postgres settings 13 | POSTGRES_DRIVERNAME: str = "postgresql" 14 | POSTGRES_USER: str = "postgres" 15 | POSTGRES_PASSWORD: str = "postgres" 16 | POSTGRES_DB: str = "postgres" 17 | POSTGRES_HOST: str = "localhost" 18 | POSTGRES_PORT: int | str = "5432" 19 | POSTGRES_ECHO: bool = False 20 | POSTGRES_POOL_SIZE: int = 5 21 | 22 | 23 | settings = Settings() 24 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/snippets/crud/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/crud/user.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastapi import HTTPException 4 | from sqlalchemy.exc import IntegrityError 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from sqlmodel import delete, select 7 | 8 | from snippets.models.user import User, UserCreate, UserUpdate 9 | 10 | 11 | async def create_user(session: AsyncSession, user: UserCreate) -> User: 12 | db_user = User.model_validate(user) 13 | try: 14 | session.add(db_user) 15 | await session.commit() 16 | await session.refresh(db_user) 17 | return db_user 18 | except IntegrityError: 19 | await session.rollback() 20 | raise HTTPException( 21 | status_code=409, 22 | detail="User already exists", 23 | ) 24 | 25 | 26 | async def get_user(session: AsyncSession, id: UUID) -> User: 27 | query = select(User).where(User.id == id) 28 | response = await session.execute(query) 29 | return response.scalar_one_or_none() 30 | 31 | 32 | async def get_user_by_email(session: AsyncSession, email: str) -> User: 33 | query = select(User).where(User.email == email) 34 | response = await session.execute(query) 35 | return response.scalar_one_or_none() 36 | 37 | 38 | async def update_user(session: AsyncSession, id: UUID, user: UserUpdate) -> User: 39 | db_user = await get_user(session, id) 40 | if not db_user: 41 | raise HTTPException(status_code=404, detail="User not found") 42 | 43 | for k, v in user.model_dump(exclude_unset=True).items(): 44 | setattr(db_user, k, v) 45 | 46 | try: 47 | await session.commit() 48 | await session.refresh(db_user) 49 | return db_user 50 | except IntegrityError: 51 | await session.rollback() 52 | raise HTTPException( 53 | status_code=409, 54 | detail="Updated user collides with other users", 55 | ) 56 | 57 | 58 | async def delete_user(session: AsyncSession, id: UUID) -> int: 59 | query = delete(User).where(User.id == id) 60 | response = await session.execute(query) 61 | await session.commit() 62 | return response.rowcount 63 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/snippets/db/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/db/session.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | from sqlalchemy.engine import URL 4 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 5 | 6 | from snippets.core.config import settings 7 | 8 | 9 | def create_pg_url( 10 | drivername: str = "postgresql", 11 | username: str = "postgres", 12 | password: str = "postgres", 13 | host: str = "localhost", 14 | port: str = "5432", 15 | database: str = "postgres", 16 | ) -> URL: 17 | return URL.create( 18 | drivername=drivername, 19 | username=username, 20 | password=password, 21 | host=host, 22 | port=port, 23 | database=database, 24 | ) 25 | 26 | 27 | def create_pg_url_from_env( 28 | drivername: str | None = None, 29 | username: str | None = None, 30 | password: str | None = None, 31 | host: str | None = None, 32 | port: str | None = None, 33 | database: str | None = None, 34 | ) -> URL: 35 | return create_pg_url( 36 | drivername=drivername or environ.get("POSTGRES_DRIVERNAME", "postgresql"), 37 | username=username or environ.get("POSTGRES_USER", "postgres"), 38 | password=password or environ.get("POSTGRES_PASSWORD", "postgres"), 39 | host=host or environ.get("POSTGRES_HOST", "localhost"), 40 | port=port or environ.get("POSTGRES_PORT", "5432"), 41 | database=database or environ.get("POSTGRES_DB", "postgres"), 42 | ) 43 | 44 | 45 | url = create_pg_url_from_env(drivername="postgresql+asyncpg") 46 | engine = create_async_engine( 47 | url, 48 | echo=settings.POSTGRES_ECHO, 49 | future=True, 50 | pool_size=max(5, settings.POSTGRES_POOL_SIZE), 51 | ) 52 | 53 | SessionLocal = async_sessionmaker( 54 | bind=engine, 55 | autocommit=False, 56 | autoflush=False, 57 | expire_on_commit=False, 58 | ) 59 | 60 | 61 | async def get_session(): 62 | async with SessionLocal() as session: 63 | try: 64 | yield session 65 | except Exception as e: 66 | await session.rollback() 67 | raise e 68 | finally: 69 | await session.close() 70 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/db/utils.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel 2 | 3 | 4 | async def create_db_and_tables(engine): 5 | async with engine.begin() as conn: 6 | await conn.run_sync(SQLModel.metadata.create_all) 7 | 8 | 9 | async def drop_db_and_tables(engine): 10 | async with engine.begin() as conn: 11 | await conn.run_sync(SQLModel.metadata.drop_all) 12 | 13 | 14 | async def recreate_db_and_tables(engine): 15 | async with engine.begin() as conn: 16 | await conn.run_sync(SQLModel.metadata.drop_all) 17 | await conn.run_sync(SQLModel.metadata.create_all) 18 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/snippets/models/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/models/base.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from uuid import UUID 3 | 4 | from sqlmodel import Field, SQLModel 5 | from uuid_extensions import uuid7 6 | 7 | 8 | class IdMixin(SQLModel): 9 | id: UUID | None = Field( 10 | default_factory=uuid7, 11 | primary_key=True, 12 | index=True, 13 | nullable=False, 14 | ) 15 | 16 | 17 | def utc_now(): 18 | return datetime.now(UTC).replace(tzinfo=None) 19 | 20 | 21 | class TimestampMixin(SQLModel): 22 | created_at: datetime | None = Field(default_factory=utc_now, nullable=False) 23 | updated_at: datetime | None = Field( 24 | default_factory=utc_now, 25 | sa_column_kwargs={"onupdate": utc_now()}, 26 | ) 27 | 28 | 29 | class DeleteResponse(SQLModel): 30 | deleted: int 31 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/models/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import EmailStr 2 | from sqlmodel import Field, SQLModel 3 | 4 | from snippets.models.base import IdMixin, TimestampMixin 5 | 6 | 7 | class UserBase(SQLModel): 8 | email: EmailStr = Field( 9 | nullable=False, index=True, sa_column_kwargs={"unique": True} 10 | ) 11 | first_name: str | None = None 12 | last_name: str | None = None 13 | is_active: bool = True 14 | 15 | 16 | class UserCreate(UserBase): 17 | pass 18 | 19 | 20 | class UserUpdate(SQLModel): 21 | first_name: str | None = None 22 | last_name: str | None = None 23 | email: EmailStr | None = None 24 | is_active: bool | None = None 25 | 26 | 27 | class User(IdMixin, TimestampMixin, UserBase, table=True): 28 | __tablename__ = "users" 29 | 30 | 31 | class UserResponse(User, table=False): 32 | pass 33 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # formatting 2 | ruff==0.5.6 3 | black==24.8.0 4 | isort 5 | 6 | # testing 7 | httpx==0.27.0 8 | pytest==8.3.2 9 | pytest-asyncio==0.23.8 10 | pytest-timeout==2.3.1 11 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/snippets/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.112.0 2 | pydantic[email]==2.8.2 3 | pydantic-settings==2.4.0 4 | uvicorn==0.30.5 5 | sqlmodel==0.0.21 6 | gunicorn==22.0.0 7 | asyncpg==0.29.0 8 | SQLAlchemy==2.0.31 9 | uuid7==0.1.0 10 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/tests/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | import pytest_asyncio 4 | from fastapi import FastAPI 5 | from httpx import ASGITransport, AsyncClient 6 | from sqlalchemy.ext.asyncio import ( 7 | AsyncEngine, 8 | AsyncSession, 9 | async_sessionmaker, 10 | create_async_engine, 11 | ) 12 | from sqlalchemy.pool import NullPool 13 | from sqlmodel import SQLModel 14 | 15 | from snippets.db.session import create_pg_url_from_env, get_session 16 | 17 | 18 | @pytest_asyncio.fixture(scope="session") 19 | async def engine() -> AsyncGenerator[AsyncEngine, None]: 20 | url = create_pg_url_from_env() 21 | engine = create_async_engine( 22 | url, 23 | echo=False, 24 | future=True, 25 | poolclass=NullPool, 26 | ) 27 | 28 | async with engine.begin() as conn: 29 | await conn.run_sync(SQLModel.metadata.drop_all) 30 | await conn.run_sync(SQLModel.metadata.create_all) 31 | 32 | yield engine 33 | 34 | async with engine.begin() as conn: 35 | await conn.run_sync(SQLModel.metadata.drop_all) 36 | 37 | await engine.dispose() 38 | 39 | 40 | @pytest_asyncio.fixture(scope="function") 41 | async def session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: 42 | SessionLocal = async_sessionmaker( 43 | bind=engine, 44 | autoflush=False, 45 | expire_on_commit=False, 46 | autocommit=False, 47 | ) 48 | 49 | async with engine.connect() as conn: 50 | tsx = await conn.begin() 51 | try: 52 | async with SessionLocal(bind=conn) as session: 53 | nested_tsx = await conn.begin_nested() 54 | yield session 55 | 56 | if nested_tsx.is_active: 57 | await nested_tsx.rollback() 58 | 59 | await tsx.rollback() 60 | finally: 61 | await tsx.close() 62 | 63 | await conn.close() 64 | 65 | 66 | class BaseTestRouter: 67 | router = None 68 | 69 | @pytest_asyncio.fixture(scope="function") 70 | async def client(self, session): 71 | app = FastAPI() 72 | app.include_router(self.router) 73 | app.dependency_overrides[get_session] = lambda: session 74 | 75 | transport = ASGITransport(app=app) 76 | async with AsyncClient(transport=transport, base_url="http://test") as c: 77 | yield c 78 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/tests/crud/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/crud/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | from uuid_extensions import uuid7 4 | 5 | from snippets.crud.user import ( 6 | create_user, 7 | delete_user, 8 | get_user, 9 | get_user_by_email, 10 | update_user, 11 | ) 12 | from snippets.models.user import UserCreate, UserUpdate 13 | 14 | 15 | async def test_create_user(session: AsyncSession): 16 | user = UserCreate(email="test@example.com") 17 | created_user = await create_user(session, user) 18 | assert created_user.id is not None 19 | assert created_user.email == user.email 20 | assert created_user.created_at is not None 21 | assert created_user.updated_at is not None 22 | 23 | 24 | async def test_create_duplicate_user(session: AsyncSession): 25 | user = UserCreate(email="test@example.com") 26 | await create_user(session, user) 27 | try: 28 | await create_user(session, user) 29 | except HTTPException as e: 30 | assert e.status_code == 409 31 | assert e.detail == "User already exists" 32 | 33 | 34 | async def test_get_user(session: AsyncSession): 35 | user = UserCreate(email="test@example.com") 36 | created_user = await create_user(session, user) 37 | retrieved_user = await get_user(session, created_user.id) 38 | assert retrieved_user == created_user 39 | 40 | 41 | async def test_get_nonexistent_user(session: AsyncSession): 42 | retrieved_user = await get_user(session, uuid7()) 43 | assert retrieved_user is None 44 | 45 | 46 | async def test_get_user_by_email(session: AsyncSession): 47 | user = UserCreate(email="test@example.com") 48 | created_user = await create_user(session, user) 49 | retrieved_user = await get_user_by_email(session, user.email) 50 | assert retrieved_user == created_user 51 | 52 | 53 | async def test_get_nonexistent_user_by_email(session: AsyncSession): 54 | retrieved_user = await get_user_by_email(session, "nonexistent@example.com") 55 | assert retrieved_user is None 56 | 57 | 58 | async def test_update_user(session: AsyncSession): 59 | created_user = await create_user( 60 | session, UserCreate(first_name="alice", email="test@example.com") 61 | ) 62 | updated_user = await update_user( 63 | session, created_user.id, UserUpdate(first_name="bob") 64 | ) 65 | assert updated_user.id == created_user.id 66 | assert updated_user.email == "test@example.com" 67 | assert updated_user.first_name == "bob" 68 | 69 | 70 | async def test_update_nonexistent_user(session: AsyncSession): 71 | try: 72 | await update_user(session, uuid7(), UserUpdate(first_name="alice")) 73 | except HTTPException as e: 74 | assert e.status_code == 404 75 | assert e.detail == "User not found" 76 | 77 | 78 | async def test_delete_user(session: AsyncSession): 79 | created_user = await create_user(session, UserCreate(email="test@example.com")) 80 | deleted_count = await delete_user(session, created_user.id) 81 | assert deleted_count == 1 82 | retrieved_user = await get_user(session, created_user.id) 83 | assert retrieved_user is None 84 | 85 | 86 | async def test_delete_nonexistent_user(session: AsyncSession): 87 | deleted_count = await delete_user(session, uuid7()) 88 | assert deleted_count == 0 89 | -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/tests/routes/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/routes/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/00-ultimate-fastapi-project-setup/tests/routes/v1/__init__.py -------------------------------------------------------------------------------- /00-ultimate-fastapi-project-setup/tests/routes/v1/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from snippets.api.routes.v1.user import router as user_router 4 | from snippets.crud.user import create_user, get_user 5 | from snippets.models.user import UserCreate 6 | from tests.conftest import BaseTestRouter 7 | 8 | 9 | @pytest.mark.asyncio 10 | class TestUserRouter(BaseTestRouter): 11 | router = user_router 12 | 13 | async def test_create_user(self, client): 14 | data = {"email": "test@example.com", "password": "password"} 15 | response = await client.post("/users/", json=data) 16 | assert response.status_code == 201 17 | assert response.json()["email"] == data["email"] 18 | 19 | async def test_get_user(self, session, client): 20 | user = await create_user(session, UserCreate(email="test@example.com")) 21 | response = await client.get(f"/users/{user.id}") 22 | assert response.status_code == 200 23 | assert response.json()["email"] == user.email 24 | 25 | async def test_update_user(self, session, client): 26 | user = await create_user(session, UserCreate(email="test@example.com")) 27 | response = await client.patch( 28 | f"/users/{user.id}", json=dict(email="test1@example.com") 29 | ) 30 | assert response.status_code == 200 31 | assert response.json()["email"] == user.email 32 | 33 | user_updated = await get_user(session, id=user.id) 34 | assert user_updated.email == "test1@example.com" 35 | 36 | async def test_delete_user(self, session, client): 37 | user = await create_user(session, UserCreate(email="test@example.com")) 38 | response = await client.delete(f"/users/{user.id}") 39 | assert response.status_code == 200 40 | assert response.json() == dict(deleted=1) 41 | 42 | user_deleted = await get_user(session, id=user.id) 43 | assert user_deleted is None 44 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | README.md 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | __pycache__ 7 | .pytest_cache 8 | medium.md -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/.env.test: -------------------------------------------------------------------------------- 1 | POSTGRES_DRIVERNAME=postgresql+asyncpg 2 | POSTGRES_USER=postgres 3 | POSTGRES_PASSWORD=postgres 4 | POSTGRES_DB=postgres 5 | POSTGRES_HOST=postgres-test 6 | POSTGRES_PORT=5432 7 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .vscode/ 163 | *.ruff_cache/ 164 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim AS builder 2 | 3 | RUN apt-get update && apt-get install -y gcc \ 4 | && apt-get clean && rm -rf /var/lib/apt/lists/* 5 | 6 | COPY requirements.txt requirements.txt 7 | COPY requirements-dev.txt requirements-dev.txt 8 | 9 | RUN pip install --upgrade pip && \ 10 | pip install --user --no-cache-dir -r requirements.txt 11 | 12 | ARG ENV 13 | RUN if [ "$ENV" = "test" ]; then \ 14 | pip install --user --no-cache-dir -r requirements-dev.txt; \ 15 | fi; 16 | 17 | FROM python:3.12-slim AS production 18 | 19 | ENV PYTHONDONTWRITEBYTECODE 1 20 | ENV PYTHONUNBUFFERED=1 21 | 22 | COPY --from=builder /root/.local /root/.local 23 | 24 | ENV PATH=/root/.local/bin:$PATH 25 | 26 | WORKDIR /app 27 | 28 | COPY . /app 29 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Repository Pattern 2 | 3 | See the [Medium article](https://medium.com/@lawsontaylor/the-factory-and-repository-pattern-with-sqlalchemy-and-pydantic-33cea9ae14e0) for more information. 4 | 5 | To see how the factory/repository pattern works, go to `./tests`. 6 | 7 | Run the tests with the following command: 8 | 9 | ```bash 10 | ./run-tests.sh 11 | ``` 12 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-postgres-base: &postgres-base 2 | image: postgis/postgis:16-3.4-alpine 3 | restart: always 4 | healthcheck: 5 | test: 6 | - CMD-SHELL 7 | - pg_isready -U postgres 8 | interval: 10s 9 | timeout: 5s 10 | retries: 5 11 | 12 | x-app-base: &app-base 13 | restart: "no" 14 | 15 | services: 16 | # test 17 | postgres-test: 18 | profiles: ["test"] 19 | <<: *postgres-base 20 | env_file: ".env.test" 21 | networks: 22 | - test 23 | 24 | snippet-test: 25 | profiles: ["test"] 26 | <<: *app-base 27 | command: ["pytest"] 28 | build: 29 | context: . 30 | args: 31 | ENV: test 32 | env_file: ".env.test" 33 | environment: 34 | ENV: test 35 | volumes: 36 | - ./:/app 37 | depends_on: 38 | postgres-test: 39 | condition: service_healthy 40 | networks: 41 | - test 42 | 43 | networks: 44 | test: 45 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py312'] 4 | 5 | [tool.ruff.lint.isort] 6 | known-first-party = ["snippets", "tests"] 7 | 8 | [tool.isort] 9 | known-first-party = ["snippets", "tests"] 10 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | asyncio_mode = auto 5 | timeout = 100 6 | addopts = -vv --disable-warnings --durations=10 --durations-min=1.0 7 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest==7.4.3 2 | pytest-asyncio==0.21.1 3 | pytest-mock==3.12.0 4 | pytest-timeout==2.2.0 5 | ruff==0.1.8 6 | black==23.12.0 7 | isort==5.12.0 8 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/requirements.txt: -------------------------------------------------------------------------------- 1 | SQLAlchemy==2.0.25 2 | asyncpg==0.29.0 3 | psycopg2-binary==2.9.9 4 | pydantic==2.5.3 5 | pydantic-settings==2.1.0 6 | uuid6==2024.1.12 7 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/run-tests.sh: -------------------------------------------------------------------------------- 1 | pytest_args=$* 2 | docker compose run --rm snippet-test $pytest_args 3 | docker compose --profile test down --volumes 4 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/snippets/.env.local: -------------------------------------------------------------------------------- 1 | ENV="test" 2 | APP_HOST="0.0.0.0" 3 | APP_PORT="8000" 4 | POSTGRES_USER="postgres" 5 | POSTGRES_PASSWORD="postgres" 6 | POSTGRES_DB="postgres" 7 | POSTGRES_PORT="5432" 8 | POSTGRES_ECHO="false" 9 | POSTGRES_POOL_SIZE="5" 10 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/snippets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/01-sqlalchemy-pydantic-crud-factory-pattern/snippets/__init__.py -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/snippets/config.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | from pydantic import Field 4 | from pydantic_settings import BaseSettings, SettingsConfigDict 5 | 6 | ENV = environ.get("ENV", None) 7 | ENV_FILE = {"dev": ".env.dev", "prod": ".env", "test": ".env.test"} 8 | 9 | try: 10 | env_file = ENV_FILE[ENV] 11 | except KeyError: 12 | raise ValueError("ENV must be either 'dev', 'prod' or 'test'") 13 | 14 | 15 | class Settings(BaseSettings): 16 | model_config = SettingsConfigDict(env_file=env_file, extra="ignore") 17 | 18 | # project 19 | VERSION: str = Field("0.0.1") 20 | PROJECT_NAME: str = Field( 21 | "FastAPI Snippets: Sqlalchemy/Pydantic CRUD Factory Pattern" 22 | ) 23 | # postgres 24 | POSTGRES_USER: str = Field("postgres") 25 | POSTGRES_PASSWORD: str = Field("postgres") 26 | POSTGRES_DB: str = Field("postgres") 27 | POSTGRES_HOST: str = Field("localhost") 28 | POSTGRES_PORT: int | str = Field("5432") 29 | POSTGRES_ECHO: bool = Field(False) 30 | POSTGRES_POOL_SIZE: int = Field(3) 31 | 32 | 33 | settings = Settings() 34 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/snippets/crud.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import delete, select 4 | from sqlalchemy.exc import IntegrityError 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from snippets.models import ( 8 | SnippetModel, 9 | SnippetSchema, 10 | User, 11 | UserInSchema, 12 | UserSchema, 13 | UserUpdateSchema, 14 | ) 15 | 16 | 17 | class SnippetException(Exception): 18 | pass 19 | 20 | 21 | class IntegrityConflictException(Exception): 22 | pass 23 | 24 | 25 | class NotFoundException(Exception): 26 | pass 27 | 28 | 29 | def CrudFactory(model: SnippetModel): 30 | class AsyncCrud: 31 | @classmethod 32 | async def create( 33 | cls, 34 | session: AsyncSession, 35 | data: SnippetSchema, 36 | ) -> SnippetModel: 37 | """Accepts a Pydantic model, creates a new record in the database, catches 38 | any integrity errors, and returns the record. 39 | 40 | Args: 41 | session (AsyncSession): SQLAlchemy async session 42 | data (SnippetSchema): Pydantic model 43 | 44 | Raises: 45 | IntegrityConflictException: if creation conflicts with existing data 46 | SnippetException: if an unknown error occurs 47 | 48 | Returns: 49 | SnippetModel: created SQLAlchemy model 50 | """ 51 | try: 52 | db_model = model(**data.model_dump()) 53 | session.add(db_model) 54 | await session.commit() 55 | await session.refresh(db_model) 56 | return db_model 57 | except IntegrityError: 58 | raise IntegrityConflictException( 59 | f"{model.__tablename__} conflicts with existing data.", 60 | ) 61 | except Exception as e: 62 | raise SnippetException(f"Unknown error occurred: {e}") from e 63 | 64 | @classmethod 65 | async def create_many( 66 | cls, 67 | session: AsyncSession, 68 | data: list[SnippetSchema], 69 | return_models: bool = False, 70 | ) -> list[SnippetModel] | bool: 71 | """_summary_ 72 | 73 | Args: 74 | session (AsyncSession): SQLAlchemy async session 75 | data (list[SnippetSchema]): list of Pydantic models 76 | return_models (bool, optional): Should the created models be returned 77 | or a boolean indicating they have been created. Defaults to False. 78 | 79 | Raises: 80 | IntegrityConflictException: if creation conflicts with existing data 81 | SnippetException: if an unknown error occurs 82 | 83 | Returns: 84 | list[SnippetModel] | bool: list of created SQLAlchemy models or boolean 85 | """ 86 | db_models = [model(**d.model_dump()) for d in data] 87 | try: 88 | session.add_all(db_models) 89 | await session.commit() 90 | except IntegrityError: 91 | raise IntegrityConflictException( 92 | f"{model.__tablename__} conflict with existing data.", 93 | ) 94 | except Exception as e: 95 | raise SnippetException(f"Unknown error occurred: {e}") from e 96 | 97 | if not return_models: 98 | return True 99 | 100 | for m in db_models: 101 | await session.refresh(m) 102 | 103 | return db_models 104 | 105 | @classmethod 106 | async def get_one_by_id( 107 | cls, 108 | session: AsyncSession, 109 | id_: str | UUID, 110 | column: str = "uuid", 111 | with_for_update: bool = False, 112 | ) -> SnippetModel: 113 | """Fetches one record from the database based on a column value and returns 114 | it, or returns None if it does not exist. Raises an exception if the column 115 | doesn't exist. 116 | 117 | Args: 118 | session (AsyncSession): SQLAlchemy async session 119 | id_ (str | UUID): value to search for in `column`. 120 | column (str, optional): the column name in which to search. 121 | Defaults to "uuid". 122 | with_for_update (bool, optional): Should the returned row be locked 123 | during the lifetime of the current open transactions. 124 | Defaults to False. 125 | 126 | Raises: 127 | SnippetException: if the column does not exist on the model 128 | 129 | Returns: 130 | SnippetModel: SQLAlchemy model or None 131 | """ 132 | try: 133 | q = select(model).where(getattr(model, column) == id_) 134 | except AttributeError: 135 | raise SnippetException( 136 | f"Column {column} not found on {model.__tablename__}.", 137 | ) 138 | 139 | if with_for_update: 140 | q = q.with_for_update() 141 | 142 | results = await session.execute(q) 143 | return results.unique().scalar_one_or_none() 144 | 145 | @classmethod 146 | async def get_many_by_ids( 147 | cls, 148 | session: AsyncSession, 149 | ids: list[str | UUID] = None, 150 | column: str = "uuid", 151 | with_for_update: bool = False, 152 | ) -> list[SnippetModel]: 153 | """Fetches multiple records from the database based on a column value and 154 | returns them. Raises an exception if the column doesn't exist. 155 | 156 | Args: 157 | session (AsyncSession): SQLAlchemy async session 158 | ids (list[str | UUID], optional): list of values to search for in 159 | `column`. Defaults to None. 160 | column (str, optional): the column name in which to search 161 | Defaults to "uuid". 162 | with_for_update (bool, optional): Should the returned rows be locked 163 | during the lifetime of the current open transactions. 164 | Defaults to False. 165 | 166 | Raises: 167 | SnippetException: if the column does not exist on the model 168 | 169 | Returns: 170 | list[SnippetModel]: list of SQLAlchemy models 171 | """ 172 | q = select(model) 173 | if ids: 174 | try: 175 | q = q.where(getattr(model, column).in_(ids)) 176 | except AttributeError: 177 | raise SnippetException( 178 | f"Column {column} not found on {model.__tablename__}.", 179 | ) 180 | 181 | if with_for_update: 182 | q = q.with_for_update() 183 | 184 | rows = await session.execute(q) 185 | return rows.unique().scalars().all() 186 | 187 | @classmethod 188 | async def update_by_id( 189 | cls, 190 | session: AsyncSession, 191 | data: SnippetSchema, 192 | id_: str | UUID, 193 | column: str = "uuid", 194 | ) -> SnippetModel: 195 | """Updates a record in the database based on a column value and returns the 196 | updated record. Raises an exception if the record isn't found or if the 197 | column doesn't exist. 198 | 199 | Args: 200 | session (AsyncSession): SQLAlchemy async session 201 | data (SnippetSchema): Pydantic schema for the updated data. 202 | id_ (str | UUID): value to search for in `column` 203 | column (str, optional): the column name in which to search 204 | Defaults to "uuid". 205 | Raises: 206 | NotFoundException: if the record isn't found 207 | IntegrityConflictException: if the update conflicts with existing data 208 | 209 | Returns: 210 | SnippetModel: updated SQLAlchemy model 211 | """ 212 | db_model = await cls.get_one_by_id( 213 | session, id_, column=column, with_for_update=True 214 | ) 215 | if not db_model: 216 | raise NotFoundException( 217 | f"{model.__tablename__} {column}={id_} not found.", 218 | ) 219 | 220 | values = data.model_dump(exclude_unset=True) 221 | for k, v in values.items(): 222 | setattr(db_model, k, v) 223 | 224 | try: 225 | await session.commit() 226 | await session.refresh(db_model) 227 | return db_model 228 | except IntegrityError: 229 | raise IntegrityConflictException( 230 | f"{model.__tablename__} {column}={id_} conflict with existing data.", 231 | ) 232 | 233 | @classmethod 234 | async def update_many_by_ids( 235 | cls, 236 | session: AsyncSession, 237 | updates: dict[str | UUID, SnippetSchema], 238 | column: str = "uuid", 239 | return_models: bool = False, 240 | ) -> list[SnippetModel] | bool: 241 | """Updates multiple records in the database based on a column value and 242 | returns the updated records. Raises an exception if the column doesn't 243 | exist. 244 | 245 | Args: 246 | session (AsyncSession): SQLAlchemy async session 247 | updates (dict[str | UUID, SnippetSchema]): dictionary of id_ to 248 | Pydantic update schema 249 | column (str, optional): the column name in which to search. 250 | Defaults to "uuid". 251 | return_models (bool, optional): Should the created models be returned 252 | or a boolean indicating they have been created. Defaults to False. 253 | Defaults to False. 254 | 255 | Raises: 256 | IntegrityConflictException: if the update conflicts with existing data 257 | 258 | Returns: 259 | list[SnippetModel] | bool: list of updated SQLAlchemy models or boolean 260 | """ 261 | updates = {str(id): update for id, update in updates.items() if update} 262 | ids = list(updates.keys()) 263 | db_models = await cls.get_many_by_ids( 264 | session, ids=ids, column=column, with_for_update=True 265 | ) 266 | 267 | for db_model in db_models: 268 | values = updates[str(getattr(db_model, column))].model_dump( 269 | exclude_unset=True 270 | ) 271 | for k, v in values.items(): 272 | setattr(db_model, k, v) 273 | session.add(db_model) 274 | 275 | try: 276 | await session.commit() 277 | except IntegrityError: 278 | raise IntegrityConflictException( 279 | f"{model.__tablename__} conflict with existing data.", 280 | ) 281 | 282 | if not return_models: 283 | return True 284 | 285 | for db_model in db_models: 286 | await session.refresh(db_model) 287 | 288 | return db_models 289 | 290 | @classmethod 291 | async def remove_by_id( 292 | cls, 293 | session: AsyncSession, 294 | id_: str | UUID, 295 | column: str = "uuid", 296 | ) -> int: 297 | """Removes a record from the database based on a column value. Raises an 298 | exception if the column doesn't exist. 299 | 300 | Args: 301 | session (AsyncSession): SQLAlchemy async session 302 | id (str | UUID): value to search for in `column` and delete 303 | column (str, optional): the column name in which to search. 304 | Defaults to "uuid". 305 | 306 | Raises: 307 | SnippetException: if the column does not exist on the model 308 | 309 | Returns: 310 | int: number of rows removed, 1 if successful, 0 if not. Can be greater 311 | than 1 if id_ is not unique in the column. 312 | """ 313 | try: 314 | query = delete(model).where(getattr(model, column) == id_) 315 | except AttributeError: 316 | raise SnippetException( 317 | f"Column {column} not found on {model.__tablename__}.", 318 | ) 319 | 320 | rows = await session.execute(query) 321 | await session.commit() 322 | return rows.rowcount 323 | 324 | @classmethod 325 | async def remove_many_by_ids( 326 | cls, 327 | session: AsyncSession, 328 | ids: list[str | UUID], 329 | column: str = "uuid", 330 | ) -> int: 331 | """Removes multiple records from the database based on a column value. 332 | Raises an exception if the column doesn't exist. 333 | 334 | Args: 335 | session (AsyncSession): SQLAlchemy async session 336 | ids (list[str | UUID]): list of values to search for in `column` and 337 | column (str, optional): the column name in which to search. 338 | Defaults to "uuid". 339 | 340 | Raises: 341 | SnippetException: if ids is empty to stop deleting an entire table 342 | SnippetException: if column does not exist on the model 343 | 344 | Returns: 345 | int: _description_ 346 | """ 347 | if not ids: 348 | raise SnippetException("No ids provided.") 349 | 350 | try: 351 | query = delete(model).where(getattr(model, column).in_(ids)) 352 | except AttributeError: 353 | raise SnippetException( 354 | f"Column {column} not found on {model.__tablename__}.", 355 | ) 356 | 357 | rows = await session.execute(query) 358 | await session.commit() 359 | return rows.rowcount 360 | 361 | AsyncCrud.model = model 362 | return AsyncCrud 363 | 364 | 365 | class UserCrud(CrudFactory(User)): 366 | 367 | @classmethod 368 | async def create_many(cls, *args, **kwargs) -> list[User]: 369 | raise NotImplementedError("Create many not implemented for users.") 370 | 371 | @classmethod 372 | async def update_many_by_ids(cls, *args, **kwargs) -> list[User] | bool: 373 | raise NotImplementedError("Update many not implemented for users.") 374 | 375 | @classmethod 376 | def format_user_with_password(cls, user: UserInSchema) -> UserSchema: 377 | """Take a Pydantic UserInSchema and return a UserSchema with the password 378 | hashed. 379 | 380 | Args: 381 | user (UserInSchema): Pydantic UserInSchema holding the user information 382 | 383 | Returns: 384 | UserSchema: Pydantic UserSchema with the password hashed 385 | """ 386 | user_data = user.model_dump() 387 | password = user_data.pop("password") 388 | db_user = UserSchema( 389 | **user_data, hashed_password=cls.get_password_hash(password) 390 | ) 391 | return db_user 392 | 393 | @classmethod 394 | def get_password_hash(cls, password: str) -> str: 395 | """Perform hashing of passwords. This is a simple example and should not be used 396 | in production. A simple example: 397 | 398 | ```python 399 | from passlib.context import CryptContext 400 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 401 | 402 | def verify_password(plain_password: str, hashed_password: str) -> bool: 403 | return pwd_context.verify(plain_password, hashed_password) 404 | 405 | def get_password_hash(password: str) -> str: 406 | return pwd_context.hash(password) 407 | ``` 408 | 409 | Args: 410 | password (str): user's password 411 | 412 | Returns: 413 | str: user's hashed password 414 | """ 415 | # NOTE: do not ever do this in production 416 | return password[::-1] 417 | 418 | @classmethod 419 | async def create(cls, session: AsyncSession, data: UserInSchema) -> User: 420 | """Create a user in the database. This method is overridden to hash the password 421 | and then calls the parent create method, with the hashed password. 422 | 423 | Args: 424 | session (AsyncSession): SQLAlchemy async session 425 | data (UserInSchema): Pydantic UserInSchema holding the user information 426 | 427 | Returns: 428 | User: SQLAlchemy User model 429 | """ 430 | db_user = cls.format_user_with_password(data) 431 | return await super(cls, cls).create(session, data=db_user) 432 | 433 | @classmethod 434 | async def update_by_id( 435 | cls, 436 | session: AsyncSession, 437 | data: UserUpdateSchema, 438 | id_: str | UUID, 439 | column: str = "uuid", 440 | ) -> User: 441 | """Updates a user in the database based on a column value and returns the 442 | updated user. Raises an exception if the user isn't found or if the column 443 | doesn't exist. 444 | 445 | Overrides the parent method to hash the password if it is included in the 446 | update. 447 | 448 | Args: 449 | session (AsyncSession): SQLAlchemy async session 450 | data (UserUpdateSchema): Pydantic schema for the updated data. 451 | id_ (str | UUID): value to search for in `column` 452 | column (str, optional): the column name in which to search. 453 | Defaults to "uuid". 454 | 455 | Raises: 456 | NotFoundException: user not found in database given id_ and column 457 | IntegrityConflictException: update conflicts with existing data 458 | 459 | Returns: 460 | User: updated SQLAlchemy model 461 | """ 462 | db_model = await cls.get_one_by_id( 463 | session, id_, column=column, with_for_update=True 464 | ) 465 | if not db_model: 466 | raise NotFoundException( 467 | f"{User.__tablename__} id={id_} not found.", 468 | ) 469 | 470 | values = data.model_dump(exclude_unset=True, exclude={"password"}) 471 | for k, v in values.items(): 472 | setattr(db_model, k, v) 473 | 474 | if data.password is not None: 475 | db_model.hashed_password = cls.get_password_hash(data.password) 476 | 477 | try: 478 | await session.commit() 479 | await session.refresh(db_model) 480 | return db_model 481 | except IntegrityError as e: 482 | raise IntegrityConflictException( 483 | f"{User.__tablename__} {column}={id_} conflict with existing data.", 484 | ) from e 485 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/snippets/database.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | from sqlalchemy import MetaData 4 | from sqlalchemy import create_engine as _create_engine 5 | from sqlalchemy.engine import URL, Engine 6 | from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncEngine 7 | from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine 8 | from sqlalchemy.orm import DeclarativeBase 9 | from sqlalchemy.sql import text 10 | 11 | EXTENSIONS = ["uuid-ossp", "postgis", "postgis_topology"] 12 | 13 | 14 | naming_convention = { 15 | "ix": "ix_ct_%(table_name)s_%(column_0_N_name)s", 16 | "uq": "uq_ct_%(table_name)s_%(column_0_N_name)s", 17 | "ck": "ck_ct_%(table_name)s_%(constraint_name)s", 18 | "fk": "fk_ct_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 19 | "pk": "pk_ct_%(table_name)s", 20 | } 21 | 22 | 23 | class Base(DeclarativeBase, AsyncAttrs): 24 | metadata = MetaData(naming_convention=naming_convention) 25 | 26 | 27 | def create_pg_url( 28 | drivername: str = "postgresql", 29 | username: str = "postgres", 30 | password: str = "postgres", 31 | host: str = "localhost", 32 | port: str = "5432", 33 | database: str = "postgres", 34 | ) -> URL: 35 | return URL.create( 36 | drivername=drivername, 37 | username=username, 38 | password=password, 39 | host=host, 40 | port=port, 41 | database=database, 42 | ) 43 | 44 | 45 | def create_pg_url_from_env( 46 | drivername: str | None = None, 47 | username: str | None = None, 48 | password: str | None = None, 49 | host: str | None = None, 50 | port: str | None = None, 51 | database: str | None = None, 52 | ) -> URL: 53 | return create_pg_url( 54 | drivername=drivername or environ.get("POSTGRES_DRIVERNAME", "postgresql"), 55 | username=username or environ.get("POSTGRES_USER", "postgres"), 56 | password=password or environ.get("POSTGRES_PASSWORD", "postgres"), 57 | host=host or environ.get("POSTGRES_HOST", "localhost"), 58 | port=port or environ.get("POSTGRES_PORT", "5432"), 59 | database=database or environ.get("POSTGRES_DATABASE", "postgres"), 60 | ) 61 | 62 | 63 | def create_engine(url: URL, **kwargs) -> Engine: 64 | return _create_engine(url, **kwargs) 65 | 66 | 67 | def create_async_engine(url: URL, **kwargs) -> AsyncEngine: 68 | pool_size_env = environ.get("POSTGRES_POOL_SIZE", 5) 69 | pool_size = int(kwargs.pop("pool_size", pool_size_env)) 70 | return _create_async_engine( 71 | url, 72 | future=True, 73 | pool_size=pool_size, 74 | **kwargs, 75 | ) 76 | 77 | 78 | async def create_db_and_tables_async(engine: AsyncEngine): 79 | async with engine.begin() as conn: 80 | await conn.run_sync(Base.metadata.create_all) 81 | 82 | 83 | def create_db_and_tables_sync(engine: Engine): 84 | with engine.begin() as conn: 85 | Base.metadata.create_all(conn) 86 | 87 | 88 | async def create_extensions(engine): 89 | async with engine.connect() as conn: 90 | for extension in EXTENSIONS: 91 | await conn.execute(text(f'CREATE EXTENSION IF NOT EXISTS "{extension}"')) 92 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/snippets/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import TypeAlias 3 | from uuid import UUID as UuidType 4 | 5 | from pydantic import BaseModel 6 | from sqlalchemy import DateTime, Text 7 | from sqlalchemy.dialects.postgresql import UUID as UuidColumn 8 | from sqlalchemy.ext.compiler import compiles 9 | from sqlalchemy.orm import Mapped, mapped_column 10 | from sqlalchemy.sql.expression import FunctionElement 11 | from uuid6 import uuid7 12 | 13 | from snippets.database import Base 14 | 15 | # Some generic types for the SQLAlchemy and Pydantic models 16 | SnippetModel: TypeAlias = Base 17 | SnippetSchema: TypeAlias = BaseModel 18 | 19 | 20 | # some mixins to make our life easier 21 | class UuidMixin: 22 | uuid: Mapped[UuidType] = mapped_column( 23 | "uuid", 24 | UuidColumn(as_uuid=True), 25 | primary_key=True, 26 | default=uuid7, 27 | nullable=False, 28 | sort_order=-1000, 29 | ) 30 | 31 | 32 | class UuidMixinSchema(BaseModel): 33 | uuid: UuidType = None 34 | 35 | 36 | class utcnow(FunctionElement): 37 | type = DateTime() 38 | inherit_cache = True 39 | 40 | 41 | @compiles(utcnow, "postgresql") 42 | def pg_utcnow(element, compiler, **kw): 43 | return "TIMEZONE('utc', CURRENT_TIMESTAMP)" 44 | 45 | 46 | class TimestampMixin: 47 | created_at: Mapped[datetime] = mapped_column( 48 | DateTime, 49 | nullable=False, 50 | server_default=utcnow(), 51 | sort_order=9999, 52 | ) 53 | updated_at: Mapped[datetime] = mapped_column( 54 | DateTime, 55 | nullable=True, 56 | index=True, 57 | server_default=utcnow(), 58 | server_onupdate=utcnow(), 59 | sort_order=10000, 60 | ) 61 | 62 | 63 | class TimestampMixinSchema(BaseModel): 64 | created_at: datetime | None = None 65 | updated_at: datetime | None = None 66 | 67 | 68 | # now the real models 69 | # first posts 70 | class Post(Base, UuidMixin, TimestampMixin): 71 | __tablename__ = "post" 72 | 73 | title: Mapped[str] = mapped_column(nullable=False, unique=True) 74 | content: Mapped[str] = mapped_column(Text, nullable=False) 75 | published: Mapped[bool] = mapped_column(nullable=False, server_default="False") 76 | views: Mapped[int] = mapped_column(nullable=False, server_default="0") 77 | 78 | 79 | class PostSchema(UuidMixinSchema, TimestampMixinSchema): 80 | title: str 81 | content: str 82 | published: bool = False 83 | views: int = 0 84 | 85 | 86 | class PostUpdateSchema(BaseModel): 87 | title: str | None = None 88 | content: str | None = None 89 | published: bool | None = None 90 | views: int | None = None 91 | 92 | 93 | # the users 94 | class User(Base, UuidMixin, TimestampMixin): 95 | __tablename__ = "user" 96 | 97 | username: Mapped[str] = mapped_column(nullable=False, unique=True, index=True) 98 | email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False) 99 | hashed_password: Mapped[str] = mapped_column(nullable=False) 100 | is_active: Mapped[bool] = mapped_column(default=True, server_default="TRUE") 101 | 102 | 103 | class UserBaseSchema(BaseModel): 104 | username: str 105 | email: str 106 | is_active: bool = True 107 | 108 | 109 | class UserSchema(UserBaseSchema, UuidMixinSchema, TimestampMixinSchema): 110 | hashed_password: str 111 | 112 | 113 | class UserInSchema(UserBaseSchema): 114 | password: str 115 | 116 | 117 | class UserUpdateSchema(BaseModel): 118 | username: str | None = None 119 | email: str | None = None 120 | password: str | None = None 121 | is_active: bool | None = None 122 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/01-sqlalchemy-pydantic-crud-factory-pattern/tests/__init__.py -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | import pytest_asyncio 5 | from snippets.database import ( 6 | Base, 7 | create_async_engine, 8 | create_extensions, 9 | create_pg_url_from_env, 10 | ) 11 | from sqlalchemy.ext.asyncio import async_sessionmaker 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def event_loop(request): 16 | loop = asyncio.get_event_loop_policy().new_event_loop() 17 | yield loop 18 | loop.close() 19 | 20 | 21 | @pytest_asyncio.fixture(scope="session") 22 | async def engine(event_loop): 23 | url = create_pg_url_from_env() 24 | engine = create_async_engine(url, echo=False) 25 | await create_extensions(engine) 26 | async with engine.begin() as conn: 27 | await conn.run_sync(Base.metadata.drop_all) 28 | await conn.run_sync(Base.metadata.create_all) 29 | 30 | yield engine 31 | 32 | async with engine.begin() as conn: 33 | await conn.run_sync(Base.metadata.drop_all) 34 | 35 | await engine.dispose() 36 | 37 | 38 | @pytest_asyncio.fixture(scope="function") 39 | async def session(engine): 40 | SessionLocal = async_sessionmaker( 41 | bind=engine, 42 | autoflush=False, 43 | expire_on_commit=False, 44 | autocommit=False, 45 | ) 46 | 47 | async with engine.connect() as conn: 48 | tsx = await conn.begin() 49 | async with SessionLocal(bind=conn) as session: 50 | nested_tsx = await conn.begin_nested() 51 | yield session 52 | 53 | if nested_tsx.is_active: 54 | await nested_tsx.rollback() 55 | await tsx.rollback() 56 | 57 | await conn.close() 58 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/tests/test_post_crud.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from snippets.crud import CrudFactory, IntegrityConflictException 4 | from snippets.models import Post, PostSchema, PostUpdateSchema 5 | 6 | PostCrud = CrudFactory(Post) 7 | 8 | 9 | @pytest.mark.asyncio 10 | class TestPostCrud: 11 | async def test_create_post(self, session): 12 | post_create = PostSchema(title="my first post", content="hello world") 13 | post = await PostCrud.create(session, post_create) 14 | assert post.uuid is not None 15 | assert post.created_at is not None 16 | assert post.updated_at is not None 17 | assert post.title == "my first post" 18 | assert post.content == "hello world" 19 | 20 | async def test_create_many_posts(self, session): 21 | posts_create = [ 22 | PostSchema(title="1: my first post", content="hello world"), 23 | PostSchema(title="2: my second post", content="hello world again"), 24 | ] 25 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 26 | posts = sorted(posts, key=lambda x: x.title) 27 | 28 | assert posts[0].uuid is not None 29 | assert posts[0].created_at is not None 30 | assert posts[0].updated_at is not None 31 | assert posts[0].title == "1: my first post" 32 | assert posts[0].content == "hello world" 33 | 34 | assert posts[1].uuid is not None 35 | assert posts[1].created_at is not None 36 | assert posts[1].updated_at is not None 37 | assert posts[1].title == "2: my second post" 38 | assert posts[1].content == "hello world again" 39 | 40 | async def test_create_many_posts_unique_name_error(self, session): 41 | posts_create = [ 42 | PostSchema(title="1: my first post", content="hello world"), 43 | PostSchema(title="1: my first post", content="a different hello world"), 44 | ] 45 | 46 | with pytest.raises(IntegrityConflictException): 47 | _ = await PostCrud.create_many(session, posts_create) 48 | 49 | async def test_get_one_by_id(self, session): 50 | posts_create = [ 51 | PostSchema(title="1: my first post", content="hello world"), 52 | PostSchema(title="2: my second post", content="hello world again"), 53 | ] 54 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 55 | posts = sorted(posts, key=lambda x: x.title) 56 | 57 | post = await PostCrud.get_one_by_id(session, posts[0].uuid) 58 | assert post.title == "1: my first post" 59 | assert post.content == "hello world" 60 | 61 | async def test_get_many_by_ids(self, session): 62 | posts_create = [ 63 | PostSchema(title="1: my first post", content="hello world"), 64 | PostSchema(title="2: my second post", content="hello world again"), 65 | PostSchema(title="3: my third post", content="foo bar baz"), 66 | ] 67 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 68 | posts = sorted(posts, key=lambda x: x.title) 69 | 70 | posts_selected = await PostCrud.get_many_by_ids( 71 | session, [posts[0].uuid, posts[2].uuid] 72 | ) 73 | posts_selected = sorted(posts_selected, key=lambda x: x.title) 74 | 75 | assert posts_selected[0].title == "1: my first post" 76 | assert posts_selected[1].title == "3: my third post" 77 | 78 | async def test_update_by_id(self, session): 79 | posts_create = [ 80 | PostSchema(title="1: my first post", content="hello world"), 81 | PostSchema(title="2: my second post", content="hello world again"), 82 | ] 83 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 84 | posts = sorted(posts, key=lambda x: x.title) 85 | 86 | post_update = PostUpdateSchema(content="i changed my mind") 87 | post1 = await PostCrud.update_by_id(session, post_update, posts[0].uuid) 88 | assert post1.content == "i changed my mind" 89 | 90 | async def test_update_many_by_ids(self, session): 91 | posts_create = [ 92 | PostSchema(title="1: my first post", content="hello world"), 93 | PostSchema(title="2: my second post", content="hello world again"), 94 | PostSchema(title="3: my third post", content="foo bar baz"), 95 | ] 96 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 97 | posts = sorted(posts, key=lambda x: x.title) 98 | 99 | updates = { 100 | posts[0].uuid: PostUpdateSchema(published=True, views=1), 101 | posts[2].uuid: PostUpdateSchema(published=True, views=2), 102 | } 103 | posts_updated = await PostCrud.update_many_by_ids( 104 | session, updates, return_models=True 105 | ) 106 | posts = sorted(posts, key=lambda x: x.title) 107 | 108 | assert posts_updated[0].uuid == posts[0].uuid 109 | assert posts_updated[0].published is True 110 | assert posts_updated[0].views == 1 111 | 112 | assert posts_updated[1].uuid == posts[2].uuid 113 | assert posts_updated[1].published is True 114 | assert posts_updated[1].views == 2 115 | 116 | async def test_remove_by_id(self, session): 117 | posts_create = [ 118 | PostSchema(title="1: my first post", content="hello world"), 119 | PostSchema(title="2: my second post", content="hello world again"), 120 | ] 121 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 122 | posts = sorted(posts, key=lambda x: x.title) 123 | 124 | row_count = await PostCrud.remove_by_id(session, posts[0].uuid) 125 | assert row_count == 1 126 | 127 | all_posts = await PostCrud.get_many_by_ids( 128 | session, [posts[0].uuid, posts[1].uuid] 129 | ) 130 | assert len(all_posts) == 1 131 | 132 | row_count = await PostCrud.remove_by_id(session, posts[0].uuid) 133 | assert row_count == 0 134 | 135 | async def test_remove_many_by_ids(self, session): 136 | posts_create = [ 137 | PostSchema(title="1: my first post", content="hello world"), 138 | PostSchema(title="2: my second post", content="hello world again"), 139 | PostSchema(title="3: my third post", content="foo bar baz"), 140 | ] 141 | posts = await PostCrud.create_many(session, posts_create, return_models=True) 142 | posts = sorted(posts, key=lambda x: x.title) 143 | 144 | row_count = await PostCrud.remove_many_by_ids( 145 | session, [posts[0].uuid, posts[1].uuid] 146 | ) 147 | assert row_count == 2 148 | 149 | row_count = await PostCrud.remove_many_by_ids( 150 | session, [posts[0].uuid, posts[1].uuid, posts[2].uuid] 151 | ) 152 | assert row_count == 1 153 | -------------------------------------------------------------------------------- /01-sqlalchemy-pydantic-crud-factory-pattern/tests/test_user_crud.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from snippets.crud import IntegrityConflictException, UserCrud 3 | from snippets.models import UserInSchema, UserUpdateSchema 4 | 5 | 6 | @pytest.mark.asyncio 7 | class TestUserCrud: 8 | async def test_create_user(self, session): 9 | user_create = UserInSchema( 10 | username="test", 11 | email="test@test.com", 12 | password="password", 13 | ) 14 | user = await UserCrud.create(session, user_create) 15 | assert user.uuid is not None 16 | assert user.created_at is not None 17 | assert user.updated_at is not None 18 | assert user.username == "test" 19 | assert user.email == "test@test.com" 20 | assert user.is_active is True 21 | assert user.hashed_password == "drowssap" 22 | 23 | async def test_create_user_conflict_username(self, session): 24 | user_create = UserInSchema( 25 | username="test", 26 | email="test@test.com", 27 | password="password", 28 | ) 29 | _ = await UserCrud.create(session, user_create) 30 | 31 | user_conflict = UserInSchema( 32 | username="test", 33 | email="test1@test.com", 34 | password="password", 35 | ) 36 | with pytest.raises(IntegrityConflictException): 37 | _ = await UserCrud.create(session, user_conflict) 38 | 39 | async def test_create_user_conflict_email(self, session): 40 | user_create = UserInSchema( 41 | username="test", 42 | email="test@test.com", 43 | password="password", 44 | ) 45 | _ = await UserCrud.create(session, user_create) 46 | 47 | user_conflict = UserInSchema( 48 | username="test1", 49 | email="test@test.com", 50 | password="password", 51 | ) 52 | with pytest.raises(IntegrityConflictException): 53 | _ = await UserCrud.create(session, user_conflict) 54 | 55 | async def test_update_user(self, session): 56 | user_create = UserInSchema( 57 | username="test", 58 | email="test@test.com", 59 | password="password", 60 | ) 61 | user = await UserCrud.create(session, user_create) 62 | 63 | user_update = await UserCrud.update_by_id( 64 | session, id_=user.uuid, data=UserUpdateSchema(password="new_password") 65 | ) 66 | assert user_update.username == "test" 67 | assert user_update.email == "test@test.com" 68 | assert user_update.hashed_password == "drowssap_wen" 69 | 70 | async def test_update_user_conflict(self, session): 71 | _ = await UserCrud.create( 72 | session, 73 | UserInSchema( 74 | username="test", 75 | email="test@test.com", 76 | password="password", 77 | ), 78 | ) 79 | _ = await UserCrud.create( 80 | session, 81 | UserInSchema( 82 | username="test1", 83 | email="test1@test.com", 84 | password="password", 85 | ), 86 | ) 87 | 88 | with pytest.raises(IntegrityConflictException): 89 | _ = await UserCrud.update_by_id( 90 | session, 91 | id_="test1", 92 | column="username", 93 | data=UserUpdateSchema(email="test@test.com"), 94 | ) 95 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/.dockerignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/.dockerignore -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/.env.sample: -------------------------------------------------------------------------------- 1 | POSTGRES_DRIVERNAME=postgresql 2 | POSTGRES_USER=postgres 3 | POSTGRES_PASSWORD=postgres 4 | POSTGRES_DB=postgres 5 | POSTGRES_HOST=localhost 6 | POSTGRES_PORT=5432 7 | POSTGRES_POOL_SIZE=10 8 | APP_PORT=8000 9 | APP_HOST=0.0.0.0 10 | MAPBOX_ACCESS_TOKEN=SUPERSECRET 11 | GUNICORN_WORKERS=4 12 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .venv 3 | .notes 4 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim AS builder 2 | 3 | RUN apt-get update && \ 4 | apt-get -y install gcc && \ 5 | rm -rf /var/lib/apt/lists/* 6 | 7 | COPY requirements.txt requirements.txt 8 | 9 | RUN pip install --upgrade pip && \ 10 | pip install --user --no-cache-dir -r requirements.txt 11 | 12 | FROM python:3.12-slim AS production 13 | 14 | ENV PYTHONDONTWRITEBYTECODE 1 15 | ENV PYTHONUNBUFFERED=1 16 | 17 | COPY --from=builder /root/.local /root/.local 18 | 19 | ENV PATH=/root/.local/bin:$PATH 20 | 21 | WORKDIR /code 22 | 23 | COPY ./snippets /code/snippets 24 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/README.md: -------------------------------------------------------------------------------- 1 | # DIY Vector Tile Server with FastAPI and PostGIS 2 | 3 | Build your own dynamic vector tile server with FastAPI, PostGIS and Async SQLAlchemy quickly and easily. 4 | 5 | Full explanation of the code found on [Medium.com](https://medium.com/@lawsontaylor/diy-vector-tile-server-with-postgis-and-fastapi-b8514c95267c). 6 | 7 | To run the services do `./scripts/run.sh dev`, this will: 8 | 9 | - start the Postgres Docker container 10 | - create the database and `listing`, import the data and create the indexes 11 | - start the FastAPI server 12 | 13 | Once the services are running, you can access HTML Map template at at `http://localhost:8000/` that fetches the vector tiles at `http://localhost:8000/listings/tiles/{z}/{x}/{y}.mvt`. -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | profiles: ["dev"] 4 | image: postgis/postgis:16-3.4-alpine 5 | restart: always 6 | healthcheck: 7 | test: 8 | - CMD-SHELL 9 | - pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB 10 | interval: 5s 11 | timeout: 5s 12 | retries: 5 13 | env_file: ".env" 14 | ports: 15 | - 5432:5432 16 | expose: 17 | - 5432 18 | volumes: 19 | - ./postgres/initdb.d/:/docker-entrypoint-initdb.d/ 20 | - pgdata-dev:/var/lib/postgresql/data 21 | networks: 22 | - dev 23 | 24 | app: 25 | profiles: ["dev"] 26 | build: 27 | context: . 28 | command: > 29 | bash -c " 30 | gunicorn snippets.server:app --config ./snippets/gunicorn_config.py" 31 | env_file: ".env" 32 | environment: 33 | - POSTGRES_HOST=postgres 34 | volumes: 35 | - ./:/code 36 | ports: 37 | - 8000:8000 38 | expose: 39 | - 8000 40 | depends_on: 41 | postgres: 42 | condition: service_healthy 43 | networks: 44 | - dev 45 | 46 | volumes: 47 | pgdata-dev: 48 | 49 | networks: 50 | dev: 51 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/00-initdb.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS postgis; -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/01-create-table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE listing ( 2 | listing_id VARCHAR PRIMARY KEY, 3 | longitude DOUBLE PRECISION NULL, 4 | latitude DOUBLE PRECISION NULL, 5 | geometry public.geometry(point, 4326) GENERATED ALWAYS AS ( 6 | st_setsrid(st_makepoint(longitude, latitude), 4326) 7 | ) STORED NULL, 8 | property_type VARCHAR NULL, 9 | property_sub_type VARCHAR NULL, 10 | room_count INTEGER NULL, 11 | bathroom_count INTEGER NULL, 12 | sleep_count INTEGER NULL, 13 | rating DOUBLE PRECISION NULL, 14 | review_count INTEGER NULL, 15 | instant_book BOOLEAN NULL, 16 | is_superhost BOOLEAN NULL, 17 | is_hotel BOOLEAN NULL, 18 | children_allowed BOOLEAN NULL, 19 | events_allowed BOOLEAN NULL, 20 | smoking_allowed BOOLEAN NULL, 21 | pets_allowed BOOLEAN NULL, 22 | has_family_friendly BOOLEAN NULL, 23 | has_wifi BOOLEAN NULL, 24 | has_pool BOOLEAN NULL, 25 | has_air_conditioning BOOLEAN NULL, 26 | has_views BOOLEAN NULL, 27 | has_hot_tub BOOLEAN NULL, 28 | has_parking BOOLEAN NULL, 29 | has_patio_or_balcony BOOLEAN NULL, 30 | has_kitchen BOOLEAN NULL, 31 | nightly_price INTEGER NULL, 32 | "name" TEXT NULL, 33 | main_image_url TEXT NULL, 34 | recently_active BOOLEAN NULL 35 | ); 36 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/02-load-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Directory containing your gzip CSV files 4 | DATA_DIR="/docker-entrypoint-initdb.d/data" 5 | 6 | # Loop through all gzip files in the data directory 7 | for file in "$DATA_DIR"/*.gz; do 8 | echo "Loading data from $file into listing ..." 9 | gzip -dc "$file" | psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "COPY listing ( listing_id, longitude, latitude, property_type, property_sub_type, room_count, bathroom_count, sleep_count, rating, review_count, instant_book, is_superhost, is_hotel, children_allowed, events_allowed, smoking_allowed, pets_allowed, has_family_friendly, has_wifi, has_pool, has_air_conditioning, has_views, has_hot_tub, has_parking, has_patio_or_balcony, has_kitchen, nightly_price, name, main_image_url, recently_active ) FROM STDIN DELIMITER ',' CSV HEADER" 10 | done 11 | 12 | echo "Data loading complete." 13 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/03-create-indexes.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX ix_snippets_listing_property_type ON listing USING BTREE (property_type); 2 | CREATE INDEX ix_snippets_listing_geometry ON listing USING GIST (geometry); 3 | CREATE INDEX ix_snippets_listing_property_sub_type ON listing USING BTREE (property_sub_type); 4 | CREATE INDEX ix_snippets_listing_room_count ON listing USING BTREE (room_count); 5 | CREATE INDEX ix_snippets_listing_bathroom_count ON listing USING BTREE (bathroom_count); 6 | CREATE INDEX ix_snippets_listing_sleep_count ON listing USING BTREE (sleep_count); 7 | CREATE INDEX ix_snippets_listing_rating ON listing USING BTREE (rating); 8 | CREATE INDEX ix_snippets_listing_review_count ON listing USING BTREE (review_count); 9 | CREATE INDEX ix_snippets_listing_instant_book ON listing USING BTREE (instant_book); 10 | CREATE INDEX ix_snippets_listing_is_superhost ON listing USING BTREE (is_superhost); 11 | CREATE INDEX ix_snippets_listing_is_hotel ON listing USING BTREE (is_hotel); 12 | CREATE INDEX ix_snippets_listing_children_allowed ON listing USING BTREE (children_allowed); 13 | CREATE INDEX ix_snippets_listing_events_allowed ON listing USING BTREE (events_allowed); 14 | CREATE INDEX ix_snippets_listing_smoking_allowed ON listing USING BTREE (smoking_allowed); 15 | CREATE INDEX ix_snippets_listing_pets_allowed ON listing USING BTREE (pets_allowed); 16 | CREATE INDEX ix_snippets_listing_has_family_friendly ON listing USING BTREE (has_family_friendly); 17 | CREATE INDEX ix_snippets_listing_has_wifi ON listing USING BTREE (has_wifi); 18 | CREATE INDEX ix_snippets_listing_has_pool ON listing USING BTREE (has_pool); 19 | CREATE INDEX ix_snippets_listing_has_air_conditioning ON listing USING BTREE (has_air_conditioning); 20 | CREATE INDEX ix_snippets_listing_has_views ON listing USING BTREE (has_views); 21 | CREATE INDEX ix_snippets_listing_has_hot_tub ON listing USING BTREE (has_hot_tub); 22 | CREATE INDEX ix_snippets_listing_has_parking ON listing USING BTREE (has_parking); 23 | CREATE INDEX ix_snippets_listing_has_patio_or_balcony ON listing USING BTREE (has_patio_or_balcony); 24 | CREATE INDEX ix_snippets_listing_has_kitchen ON listing USING BTREE (has_kitchen); 25 | CREATE INDEX ix_snippets_listing_nightly_price ON listing USING BTREE (nightly_price); 26 | CREATE INDEX ix_snippets_listing_recently_active ON listing USING BTREE (recently_active); 27 | ANALYZE listing; -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_1.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_1.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_2.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_2.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_3.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_3.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_4.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_4.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_5.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_5.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_6.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_6.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_7.csv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/postgres/initdb.d/data/listings_7.csv.gz -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/requirements.txt: -------------------------------------------------------------------------------- 1 | sqlalchemy==2.0.31 2 | geoalchemy2==0.15.2 3 | psycopg2-binary==2.9.9 4 | asyncpg==0.29.0 5 | pydantic==2.8.2 6 | pydantic-settings==2.3.4 7 | fastapi==0.111.1 8 | uvicorn==0.30.1 9 | gunicorn==22.0.0 10 | jinja2==3.1.4 11 | orjson==3.10.5 12 | aiocache==0.12.2 -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ENV=$1 6 | shift 7 | DOCKER_ARGS="$*" 8 | 9 | trap ctrl_c INT 10 | trap ctrl_c SIGTERM 11 | 12 | function ctrl_c() { 13 | echo "Gracefully hutting down containers ..." 14 | docker compose --profile $ENV down --volumes 15 | exit 0 16 | } 17 | 18 | docker compose --profile $ENV up $DOCKER_ARGS 19 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LTMullineux/fastapi-snippets/9d3f2fc45cb02a3c014da870f78cc8e2c44cf316/02-fastapi-mvt-tileserver/snippets/__init__.py -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic_settings import BaseSettings, SettingsConfigDict 3 | 4 | 5 | class Config(BaseSettings): 6 | model_config = SettingsConfigDict(extra="ignore") 7 | 8 | # project 9 | VERSION: str = Field("0.0.1") 10 | PROJECT_NAME: str = Field("FastAPI + SQLAlchemy Tile Server") 11 | SNIPPETS_PORT: int = 8000 12 | SNIPPETS_HOST: str = "0.0.0.0" 13 | 14 | # postgres 15 | POSTGRES_USER: str = "postgres" 16 | POSTGRES_PASSWORD: str = "postgres" 17 | POSTGRES_DB: str = "postgres" 18 | POSTGRES_HOST: str = "localhost" 19 | POSTGRES_PORT: int = 5432 20 | POSTGRES_ECHO: bool = False 21 | POSTGRES_POOL_SIZE: int = 5 22 | 23 | # keys 24 | MAPBOX_ACCESS_TOKEN: str = "YOUR_MAPBOX_ACCESS_TOKEN" 25 | 26 | 27 | config = Config() 28 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/crud.py: -------------------------------------------------------------------------------- 1 | from aiocache import Cache, cached 2 | from aiocache.serializers import PickleSerializer 3 | from geoalchemy2.functions import ( 4 | ST_AsMVT, 5 | ST_AsMVTGeom, 6 | ST_Intersects, 7 | ST_TileEnvelope, 8 | ST_Transform, 9 | ) 10 | from sqlalchemy import and_, select, text 11 | from sqlalchemy.ext.asyncio import AsyncSession 12 | from sqlalchemy.sql.expression import literal 13 | 14 | from snippets.models import Listing 15 | 16 | 17 | @cached( 18 | ttl=600, 19 | cache=Cache.MEMORY, 20 | serializer=PickleSerializer(), 21 | ) 22 | async def get_listing_tiles_bytes( 23 | session: AsyncSession, 24 | z: int, 25 | x: int, 26 | y: int, 27 | recently_active: bool | None = True, 28 | ) -> bytes | None: 29 | tile_bounds_cte = select( 30 | ST_TileEnvelope(z, x, y).label("geom_3857"), 31 | ST_Transform(ST_TileEnvelope(z, x, y), 4326).label("geom_4326"), 32 | ).cte("tile_bounds_cte") 33 | 34 | mvt_table_cte = ( 35 | select( 36 | ST_AsMVTGeom( 37 | ST_Transform(Listing.geometry, 3857), tile_bounds_cte.c.geom_3857 38 | ).label("geom"), 39 | Listing.listing_id, 40 | Listing.longitude, 41 | Listing.latitude, 42 | Listing.property_type, 43 | Listing.property_sub_type, 44 | Listing.room_count, 45 | Listing.bathroom_count, 46 | Listing.sleep_count, 47 | Listing.rating, 48 | Listing.review_count, 49 | Listing.instant_book, 50 | Listing.is_superhost, 51 | Listing.is_hotel, 52 | Listing.children_allowed, 53 | Listing.events_allowed, 54 | Listing.smoking_allowed, 55 | Listing.pets_allowed, 56 | Listing.has_family_friendly, 57 | Listing.has_wifi, 58 | Listing.has_pool, 59 | Listing.has_air_conditioning, 60 | Listing.has_views, 61 | Listing.has_hot_tub, 62 | Listing.has_parking, 63 | Listing.has_patio_or_balcony, 64 | Listing.has_kitchen, 65 | Listing.nightly_price, 66 | Listing.name, 67 | Listing.main_image_url, 68 | Listing.recently_active, 69 | ) 70 | .select_from(Listing) 71 | .join(tile_bounds_cte, literal(True)) # cross join 72 | .filter( 73 | and_( 74 | Listing.geometry.is_not(None), 75 | ST_Intersects(Listing.geometry, tile_bounds_cte.c.geom_4326), 76 | ) 77 | ) 78 | ) 79 | 80 | if recently_active is not None: 81 | mvt_table_cte = mvt_table_cte.filter(Listing.recently_active == recently_active) 82 | 83 | mvt_table_cte = mvt_table_cte.cte("mvt_table_cte") 84 | stmt = select(ST_AsMVT(text("mvt_table_cte.*"))).select_from(mvt_table_cte) 85 | result = await session.execute(stmt) 86 | return result.scalar() 87 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/db.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from typing import TypeAlias 3 | 4 | import orjson 5 | from pydantic import BaseModel 6 | from sqlalchemy import MetaData 7 | from sqlalchemy.engine import URL 8 | from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncEngine, async_sessionmaker 9 | from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine 10 | from sqlalchemy.orm import DeclarativeBase 11 | 12 | from snippets.config import config 13 | 14 | 15 | def orjson_serializer(obj): 16 | """ 17 | Note that `orjson.dumps()` return byte array, 18 | while sqlalchemy expects string, thus `decode()` call. 19 | """ 20 | return orjson.dumps( 21 | obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NAIVE_UTC 22 | ).decode() 23 | 24 | 25 | naming_convention = { 26 | "ix": "ix_snippets_%(table_name)s_%(column_0_N_name)s", 27 | "uq": "uq_snippets_%(table_name)s_%(column_0_N_name)s", 28 | "ck": "ck_snippets_%(table_name)s_%(constraint_name)s", 29 | "fk": "fk_snippets_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 30 | "pk": "pk_snippets_%(table_name)s", 31 | } 32 | 33 | 34 | class Base(DeclarativeBase, AsyncAttrs): 35 | metadata = MetaData(naming_convention=naming_convention) 36 | 37 | 38 | SnippetModel: TypeAlias = Base 39 | SnippetSchema: TypeAlias = BaseModel 40 | 41 | 42 | def create_pg_url( 43 | drivername: str = "postgresql", 44 | username: str = "postgres", 45 | password: str = "postgres", 46 | host: str = "localhost", 47 | port: str = "5432", 48 | database: str = "postgres", 49 | ) -> URL: 50 | return URL.create( 51 | drivername=drivername, 52 | username=username, 53 | password=password, 54 | host=host, 55 | port=port, 56 | database=database, 57 | ) 58 | 59 | 60 | def create_pg_url_from_env( 61 | drivername: str | None = None, 62 | username: str | None = None, 63 | password: str | None = None, 64 | host: str | None = None, 65 | port: str | None = None, 66 | database: str | None = None, 67 | ) -> URL: 68 | return create_pg_url( 69 | drivername=drivername or environ.get("POSTGRES_DRIVERNAME", "postgresql"), 70 | username=username or environ.get("POSTGRES_USER", "postgres"), 71 | password=password or environ.get("POSTGRES_PASSWORD", "postgres"), 72 | host=host or environ.get("POSTGRES_HOST", "localhost"), 73 | port=port or environ.get("POSTGRES_PORT", "5432"), 74 | database=database or environ.get("POSTGRES_DB", "postgres"), 75 | ) 76 | 77 | 78 | def create_async_engine(url: URL, **kwargs) -> AsyncEngine: 79 | return _create_async_engine( 80 | url, 81 | json_serializer=orjson_serializer, 82 | json_deserializer=orjson.loads, 83 | future=True, 84 | **kwargs, 85 | ) 86 | 87 | 88 | async def create_db_and_tables_async(engine: AsyncEngine): 89 | async with engine.begin() as conn: 90 | await conn.run_sync(Base.metadata.create_all) 91 | 92 | 93 | url = create_pg_url_from_env(drivername="postgresql+asyncpg") 94 | engine = create_async_engine( 95 | url, 96 | echo=False, 97 | pool_size=max(5, config.POSTGRES_POOL_SIZE), 98 | max_overflow=2 * max(5, config.POSTGRES_POOL_SIZE), 99 | pool_pre_ping=True, 100 | pool_recycle=900, 101 | pool_timeout=60, 102 | ) 103 | 104 | 105 | SessionLocal = async_sessionmaker( 106 | bind=engine, 107 | autocommit=False, 108 | autoflush=False, 109 | expire_on_commit=False, 110 | ) 111 | 112 | 113 | async def get_session(): 114 | async with SessionLocal() as session: 115 | try: 116 | yield session 117 | except Exception as e: 118 | await session.rollback() 119 | raise e 120 | finally: 121 | await session.close() 122 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/gunicorn_config.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import cpu_count 2 | from os import environ 3 | 4 | _gunicorn_port = int(environ.get("SNIPPETS_PORT", 8000)) 5 | _gunicorn_host = environ.get("SNIPPETS_HOST", "0.0.0.0") 6 | bind = f"{_gunicorn_host}:{_gunicorn_port}" 7 | workers = int(environ.get("GUNICORN_WORKERS", 2 * cpu_count() + 1)) 8 | worker_class = "uvicorn.workers.UvicornWorker" 9 | keepalive = 60 10 | timeout = 900 11 | reload = True 12 | accesslog = "-" 13 | errorlog = "-" 14 | capture_output = True 15 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/models.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from geoalchemy2 import Geometry 4 | from sqlalchemy import Computed, Index 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from snippets.db import Base 8 | 9 | 10 | class Listing(Base): 11 | __tablename__ = "listing" 12 | __table_args__ = (Index(None, "geometry", postgresql_using="gist"),) 13 | 14 | listing_id: Mapped[str] = mapped_column(primary_key=True) 15 | longitude: Mapped[float] = mapped_column(nullable=True) 16 | latitude: Mapped[float] = mapped_column(nullable=True) 17 | geometry: Mapped[Any] = mapped_column( 18 | Geometry( 19 | "POINT", 20 | srid=4326, 21 | spatial_index=False, 22 | from_text="ST_GeogFromText", 23 | name="geometry", 24 | ), 25 | Computed( 26 | """ 27 | public.geometry(point, 4326) GENERATED ALWAYS AS ( 28 | st_setsrid(st_makepoint(longitude, latitude), 4326) 29 | ) 30 | """, 31 | persisted=True, 32 | ), 33 | nullable=True, 34 | ) 35 | property_type: Mapped[str] = mapped_column(nullable=True, index=True) 36 | property_sub_type: Mapped[str] = mapped_column(nullable=True, index=True) 37 | room_count: Mapped[int] = mapped_column(nullable=True, index=True) 38 | bathroom_count: Mapped[int] = mapped_column(nullable=True, index=True) 39 | sleep_count: Mapped[int] = mapped_column(nullable=True, index=True) 40 | rating: Mapped[float] = mapped_column(nullable=True, index=True) 41 | review_count: Mapped[int] = mapped_column(nullable=True, index=True) 42 | instant_book: Mapped[bool] = mapped_column(nullable=True, index=True) 43 | is_superhost: Mapped[bool] = mapped_column(nullable=True, index=True) 44 | is_hotel: Mapped[bool] = mapped_column(nullable=True, index=True) 45 | children_allowed: Mapped[bool] = mapped_column(nullable=True, index=True) 46 | events_allowed: Mapped[bool] = mapped_column(nullable=True, index=True) 47 | smoking_allowed: Mapped[bool] = mapped_column(nullable=True, index=True) 48 | pets_allowed: Mapped[bool] = mapped_column(nullable=True, index=True) 49 | has_family_friendly: Mapped[bool] = mapped_column(nullable=True, index=True) 50 | has_wifi: Mapped[bool] = mapped_column(nullable=True, index=True) 51 | has_pool: Mapped[bool] = mapped_column(nullable=True, index=True) 52 | has_air_conditioning: Mapped[bool] = mapped_column(nullable=True, index=True) 53 | has_views: Mapped[bool] = mapped_column(nullable=True, index=True) 54 | has_hot_tub: Mapped[bool] = mapped_column(nullable=True, index=True) 55 | has_parking: Mapped[bool] = mapped_column(nullable=True, index=True) 56 | has_patio_or_balcony: Mapped[bool] = mapped_column(nullable=True, index=True) 57 | has_kitchen: Mapped[bool] = mapped_column(nullable=True, index=True) 58 | nightly_price: Mapped[int] = mapped_column(nullable=True, index=True) 59 | name: Mapped[str] = mapped_column(nullable=True) 60 | main_image_url: Mapped[str] = mapped_column(nullable=True) 61 | recently_active: Mapped[bool] = mapped_column(nullable=True, index=True) 62 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/server.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | 3 | from fastapi import Depends, FastAPI, Path, Request, Response, status 4 | from fastapi.responses import HTMLResponse 5 | from fastapi.templating import Jinja2Templates 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from snippets.config import config 9 | from snippets.crud import get_listing_tiles_bytes 10 | from snippets.db import get_session 11 | 12 | templates = Jinja2Templates(directory="./snippets/templates") 13 | 14 | 15 | async def tile_args( 16 | z: int = Path(..., ge=0, le=24), 17 | x: int = Path(..., ge=0), 18 | y: int = Path(..., ge=0), 19 | ) -> dict[str, str]: 20 | return dict(z=z, x=x, y=y) 21 | 22 | 23 | app = FastAPI(title=config.PROJECT_NAME, version=config.VERSION) 24 | 25 | 26 | @app.get("/", response_class=HTMLResponse) 27 | async def homepage(request: Request): 28 | return templates.TemplateResponse( 29 | request=request, 30 | name="index.html", 31 | context={ 32 | "title": config.PROJECT_NAME, 33 | "host": "localhost", 34 | "port": config.SNIPPETS_PORT, 35 | "mapbox_access_token": config.MAPBOX_ACCESS_TOKEN, 36 | }, 37 | ) 38 | 39 | 40 | @app.get( 41 | "/listings/tiles/{z}/{x}/{y}.mvt", 42 | summary="Get listing tiles", 43 | ) 44 | async def get_listing_tiles( 45 | tile: dict[str, int] = Depends(tile_args), 46 | session: AsyncSession = Depends(get_session), 47 | ) -> Response: 48 | byte_tile = await get_listing_tiles_bytes( 49 | session=session, recently_active=True, **tile 50 | ) 51 | byte_tile = b"" if byte_tile is None else byte_tile 52 | return Response( 53 | content=gzip.compress(byte_tile), 54 | media_type="application/vnd.mapbox-vector-tile", 55 | headers={"Content-Encoding": "gzip"}, 56 | status_code=status.HTTP_200_OK, 57 | ) 58 | -------------------------------------------------------------------------------- /02-fastapi-mvt-tileserver/snippets/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 10 | 11 | 15 | 87 | 88 | 89 |
90 |
91 |

Listing Filters

92 |

Max Price $

93 | 101 | 104 |
105 | 252 | 253 | 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-snippets 2 | 3 | A selection of snippets to make working with FastAPI a dream ❤️. 4 | 5 | The snippets are split into sub-projects, each of which solves a particular problem: 6 | 7 | - [**00-ultimate-fastapi-project-setup**](https://medium.com/@lawsontaylor/the-ultimate-fastapi-project-setup-fastapi-async-postgres-sqlmodel-pytest-and-docker-ed0c6afea11b) using FastAPI, SQLModel, Async Postgres, Pytest and Docker to create the project structure for development, testing and deployment. 8 | 9 | - [**01-sqlalchemy-pydantic-crud-factory-pattern**](https://medium.com/@lawsontaylor/the-factory-and-repository-pattern-with-sqlalchemy-and-pydantic-33cea9ae14e0) implementing the factory and repository pattern in a SQLAlchemy and Pydantic application to simplify everything. 10 | 11 | - [**02-fastapi-mvt-tileserver**](https://medium.com/@lawsontaylor/diy-vector-tile-server-with-postgis-and-fastapi-b8514c95267c) building a dynamic vector tile server with FastAPI, PostGIS and Async SQLAlchemy. 12 | --------------------------------------------------------------------------------