Skip to content

geospatial-api/fastapi-postgis-starter

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fastapi-postgis-starter

Scaffold a production-shaped FastAPI + PostGIS spatial API in one command, with the spatial parts already done properly.

What it does

create-geo-api generates a complete, runnable geospatial API project — not a skeleton with TODO comments, but working code you can pip install -e ., migrate and serve. Roughly 40–45 files depending on the options you pick:

  • A layered application package. api/ holds HTTP concerns only, domain/ holds spatial rules and query construction with no FastAPI imports (so it is unit-testable without an app), db/ holds the engine and ORM models, and schemas/ holds the wire contract kept deliberately separate from the ORM.
  • Async SQLAlchemy 2.0 + asyncpg + GeoAlchemy2, with statement_cache_size=0 set so the app does not break the day PgBouncer is put in front of it, and a server-side statement_timeout so one enormous bounding box cannot pin a connection forever.
  • Strict Pydantic v2 geometry models. Longitude first, latitude second, range checks on both, closed-ring validation for polygons, NaN/Infinity rejected — and a transposed [lat, lon] pair produces an error that says the coordinates look swapped rather than a generic range complaint.
  • Index-friendly bbox filtering. The generated query emits geom && ST_MakeEnvelope(...) followed by ST_Intersects(...): the cheap bounding-box operator selects candidates straight from the GiST index, and the exact predicate only runs on what survives.
  • Keyset pagination over (created_at, id) with opaque, versioned, URL-safe cursors and a matching composite index in the initial migration.
  • k-nearest-neighbour search ordered by the <-> operator, so PostGIS walks the index in distance order and stops after k rows instead of computing a distance for every row in the table.
  • GeoJSON FeatureCollection responses serialised by ST_AsGeoJSON inside Postgres at 6-decimal precision, so geometries are never decoded into Python objects only to be re-encoded as JSON.
  • OpenAPI customised for spatial types — the document declares CRS84, documents coordinate order explicitly, and defines reusable GeoJSONPosition and BoundingBox schemas.
  • A GiST index in the first migration, plus health/readiness endpoints, a multi-stage Dockerfile, a docker-compose.yml wired to postgis/postgis, a GitHub Actions workflow with a PostGIS service container, and a test suite that passes offline.

Optionally, depending on the flags you pass: a projected analysis column maintained by a trigger, API-key or JWT authentication with spatial scopes and row-level security, on-demand ST_AsMVT vector tiles or a precomputed-tile bake script, a Redis caching layer, and Alembic migrations.

Why

The interesting decisions in a spatial API are made in the first hour and are painful to revisit: whether the bbox filter can use the index, whether pagination survives past page fifty, whether coordinate order is enforced or merely hoped for, whether the projected geometry is maintained by the database or by whichever code path happens to write the row. A generic FastAPI template has no opinion on any of them. This one does, and writes the reasoning into the generated code as comments so the next person can disagree on purpose rather than by accident.

Install

Not published to PyPI — clone and install locally:

git clone https://github.com/geospatial-api/fastapi-postgis-starter.git
cd fastapi-postgis-starter
pip install -e .

Or with uv:

uv venv --python 3.12 .venv
uv pip install -e ".[dev]"

That puts create-geo-api on your path. Python 3.10+ is required.

Quickstart

create-geo-api

With no arguments it prompts for everything:

$ create-geo-api
create-geo-api — scaffold a FastAPI + PostGIS service

Project name [Geo API]: Fleet Tracker
Python package slug [fleet_tracker]:
Description [A spatial API built on FastAPI and PostGIS.]: Vehicle telemetry
Author [Unknown Author]: Ada Lovelace
CRS handling:
  1) 4326-only (default)
  2) 4326-with-projected-analysis
choice: 2
Authentication:
  1) none (default)
  2) api-key
  3) jwt-spatial-scopes
choice: 3
Tile strategy:
  1) none (default)
  2) db-mvt
  3) precomputed-cdn
choice: 2
Add Redis caching layer? [y/N]: y
Add Dockerfile + docker-compose? [Y/n]:
Add Alembic migrations? [Y/n]:

Every prompt has a flag, so the whole thing is scriptable:

$ create-geo-api --yes --name "Fleet Tracker" --auth api-key --tiles db-mvt ./ft
Wrote 42 files.

Generated Fleet Tracker in ft

Next steps:
  cd ft
  python -m venv .venv && . .venv/bin/activate
  pip install -e ".[dev]"
  cp .env.example .env        # then set DATABASE_URL
  docker compose up -d db     # PostGIS on localhost:5432
  alembic upgrade head        # creates the table + GiST index
  uvicorn fleet_tracker.main:app --reload

Then open http://127.0.0.1:8000/docs and try:
  GET /v1/features?bbox=-0.2,51.4,0.05,51.6&limit=50
  GET /v1/tiles/12/2047/1362.mvt

Auth: set FLEET_TRACKER_API_KEYS in .env and send a key as the X-API-Key header.

Run 'pytest -q' in the generated project to check the wiring.

Follow those steps and the generated project's own suite passes immediately:

$ cd ft && pip install -e ".[dev]" && pytest -q
......s........................                                          [100%]
30 passed, 1 skipped in 0.13s

The one skip is the readiness check that needs a live PostGIS; it runs automatically once DATABASE_URL is set.

Full usage

create-geo-api [target] [options]
Flag Values Default Effect
--name text Geo API Human-readable project name.
--package identifier derived from --name Python package slug; must be a valid identifier.
--description text generic Written into pyproject.toml and the OpenAPI description.
--author text Unknown Author Written into pyproject.toml.
--crs 4326-only, 4326-with-projected-analysis 4326-only See below.
--auth none, api-key, jwt-spatial-scopes none See below.
--tiles none, db-mvt, precomputed-cdn none See below.
--pagination cursor cursor Keyset only, on purpose.
--projected-srid EPSG code 3857 SRID of the projected analysis column.
--redis / --no-redis off Redis read-through cache for bbox and tile responses.
--docker / --no-docker on Dockerfile, docker-compose.yml, .dockerignore.
--alembic / --no-alembic on alembic.ini, migrations/, initial migration.
-y, --yes Non-interactive; unspecified options take their defaults.
--force Write into a non-empty directory.
--dry-run Print the file tree, write nothing.
--list-options Describe every option and exit.
--version Print the version and exit.

If target is omitted, the project is written to ./<package-slug>.

--crs

4326-only stores WGS84 longitude/latitude and nothing else. Simple, and correct for anything that only ever filters and displays.

4326-with-projected-analysis adds a second geometry column in a metre-based SRID, kept in sync by a BEFORE INSERT OR UPDATE trigger and given its own GiST index. The trigger matters: maintaining the reprojection in application code means rows inserted by psql, a COPY, or some future service silently miss it.

--auth

none leaves the API open — reasonable behind a gateway that already authenticates.

api-key adds an X-API-Key dependency using hmac.compare_digest, and accepts a comma-separated list of keys so rotation does not need a deployment window.

jwt-spatial-scopes adds bearer-token verification with explicit audience checking and per-resource scopes (features:read, tiles:read), plus a row-level-security module: the initial migration enables RLS on the table and creates a tenant-isolation policy, and the runtime sets app.tenant_id as a transaction-local GUC so the value cannot leak into the next request that borrows the same pooled connection.

--tiles

db-mvt generates GET /v1/tiles/{z}/{x}/{y}.mvt backed by ST_AsMVT, with ST_TileEnvelope bbox pruning, an ETag, a CDN-friendly Cache-Control, and a 204 for empty tiles.

precomputed-cdn generates a redirect endpoint plus scripts/bake_tiles.py, which walks only the tiles intersecting the data extent, skips empty ones, and writes gzipped .mvt files ready to sync to object storage.

--dry-run

$ create-geo-api --yes --name "Fleet Tracker" --crs 4326-with-projected-analysis \
    --auth jwt-spatial-scopes --tiles db-mvt --redis --dry-run
create-geo-api would write 45 files into /tmp/demo/fleet_tracker:
/tmp/demo/fleet_tracker
    .dockerignore
    .env.example
    .github/
        workflows/
            ci.yml
    .gitignore
    Dockerfile
    Makefile
    README.md
    alembic.ini
    docker-compose.yml
    migrations/
        env.py
        script.py.mako
        versions/
            0001_initial.py
    pyproject.toml
    src/
        fleet_tracker/
            __init__.py
            api/
                __init__.py
                deps.py
                errors.py
                routes/
                    __init__.py
                    features.py
                    health.py
                    tiles.py
            cache/
                __init__.py
                redis_cache.py
            config.py
            db/
                __init__.py
                models.py
                session.py
            domain/
                __init__.py
                bbox.py
                features.py
                pagination.py
                tiles.py
            main.py
            openapi.py
            schemas/
                __init__.py
                features.py
                geometry.py
            security/
                __init__.py
                jwt.py
                rls.py
    tests/
        conftest.py
        test_app.py
        test_bbox.py
        test_geometry_schema.py
        test_pagination.py

How it works

The scaffolder is intentionally small — about 800 lines across six modules — with no cookiecutter or copier dependency.

Module Responsibility
options.py The ProjectOptions frozen dataclass: every choice, its validation, and its derived values (projected, has_auth, env_prefix, …). One source of truth shared by the CLI, the prompts and the tests.
renderer.py A thin Jinja2 wrapper plus the filename conventions. StrictUndefined is on, so a typo in a template is an error rather than a silently blank line.
scaffold.py MANIFEST: an ordered tuple of TemplateSpec(source, when) pairs. Conditional inclusion lives in plain Python predicates, not in template magic, which is what makes it directly testable.
prompts.py The interactive flow, parameterised on input_fn/output_fn so tests drive it with a scripted list of answers.
cli.py Argument parsing, the overwrite guard, --dry-run tree rendering and the "next steps" block.

Templates live in src/fastapi_postgis_starter/templates/ and ship as package data. Three filename conventions translate template paths to output paths:

Template path Output path
pyproject.toml.j2 pyproject.toml
src/__pkg__/main.py.j2 src/<slug>/main.py
_dot_github/workflows/ci.yml.j2 .github/workflows/ci.yml

Dotfiles are stored undotted (_dot_gitignore) so packaging tools and git never quietly drop them from the built wheel.

Safety

  • A non-empty target directory is refused unless --force is passed; a directory containing only .git counts as empty, so generating into a fresh clone works.
  • A target that exists as a plain file is refused outright.
  • --force writes over the generated files but leaves unrelated files alone.
  • The package slug is validated as a Python identifier before anything is written, so generation never half-fails on an unimportable name.

Documentation links in generated code

Generated modules carry a single # Docs: comment pointing at the guide that explains the pattern in that file — the bbox module to the spatial-index-query guide, the pagination module to the cursor-strategies guide, the RLS module to the multi-tenant guide, and so on. One per module, and none of them are load-bearing.

Configuration

The scaffolder itself has no configuration file and reads no environment variables — everything is a flag or a prompt. Configuration in the generated project is environment-driven with a per-project prefix derived from the package slug (fleet_trackerFLEET_TRACKER_), documented in the generated .env.example and README.

Testing

uv venv --python 3.12 .venv
uv pip install -e ".[dev]"
.venv/bin/python -m pytest -q
$ .venv/bin/python -m pytest -q
........................................................................ [ 63%]
..........................................                               [100%]
114 passed in 1.38s

114 tests, no database and no network required. The suite covers:

  • option validation, slug derivation, and the fact that every derived slug is importable;
  • path-convention translation and StrictUndefined behaviour;
  • generation across the auth × tiles × CRS matrix, asserting per-option that the right files exist and the wrong ones do not;
  • compileall over every generated .py in each matrix cell;
  • tomllib parsing of the generated pyproject.toml and yaml.safe_load of the generated workflow and compose file;
  • golden-file snapshots of four key rendered outputs (UPDATE_SNAPSHOTS=1 pytest tests/test_snapshots.py to refresh them);
  • an assertion that no Jinja placeholder ever survives into output;
  • the overwrite guard, --force, --dry-run equivalence, and CLI argument parsing including the negated boolean flags;
  • the interactive prompt flow, including re-prompting on an invalid slug.

Lint with ruff check . and ruff format --check ..

Further reading

The patterns the generated project is built on are written up in more depth here:

License

MIT — see LICENSE.

About

Scaffold a production-shaped FastAPI + PostGIS spatial API: async SQLAlchemy, strict Pydantic geometry validation, keyset pagination, index-friendly bbox queries, MVT tiles and JWT spatial scopes.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors