A small CRUD task API with a TypeScript + Alpine.js front end, built to compare lightweight hypermedia-style front ends against heavyweight SPA frameworks.
- Backend — Flask + flask-smorest. The OpenAPI document is derived from marshmallow schemas and Blueprint routes, never hand-written, so it cannot drift from the implementation.
- Frontend — TypeScript compiled to a single bundle by esbuild, with Alpine.js for reactivity. No framework, no virtual DOM, no build-time templating.
exercise1/
├── README.md
├── Dockerfile multi-stage: node builds the bundle, python serves
├── .dockerignore
├── docker-entrypoint.sh
├── docker-compose.yml Postgres + the API
├── backend/ Flask API
│ ├── app.py application factory, OpenAPI config
│ ├── views.py routes (the Blueprint the spec is generated from)
│ ├── schemas.py marshmallow schemas
│ ├── models.py SQLAlchemy models
│ ├── db.py engine + per-request session
│ ├── alembic/ migrations
│ ├── alembic.ini migration config (holds no connection string)
│ ├── gunicorn.conf.py production server settings
│ ├── openapi.json generated — do not edit by hand
│ ├── requirements.txt runtime deps only
│ ├── requirements-dev.txt adds the test runner
│ └── tests/
├── docker/init-db/ runs once when the Postgres volume is created
├── frontend/ TypeScript sources
│ ├── src/
│ │ ├── main.ts registers the component, starts Alpine
│ │ ├── taskApp.ts the Alpine component
│ │ ├── api.ts typed HTTP client + error decoding
│ │ └── types.ts shapes mirroring openapi.json
│ ├── package.json
│ └── tsconfig.json
└── static/ served by Flask
├── index.html markup only, no inline JS
└── js/app.js build output (gitignored)
static/ is the handoff point between the two halves: the frontend build
writes into it, the backend serves from it.
Postgres runs in a container; nothing needs to be installed on the host
(psycopg[binary] bundles libpq).
docker compose up -d db # just the database, on 127.0.0.1:5432
docker compose ps # wait for "healthy"Note the explicit db: a bare docker compose up -d starts the API container
as well, which is what you want for running in a
container but not while developing against the
locally installed backend.
The test suite needs a second database, created once:
docker compose exec db createdb -U taskapi taskapi_testpython -m venv backend/.venv
backend/.venv/bin/pip install -r backend/requirements.txt
cd backend && .venv/bin/alembic upgrade head # create the schemacd frontend
npm install
npm run build # typechecks, then bundles to ../static/js/app.jsThe bundle is not committed, so the frontend must be built at least once or the page will load without behaviour.
cd backend
.venv/bin/python app.py| URL | What |
|---|---|
| http://127.0.0.1:5000/ | the demo front end |
| http://127.0.0.1:5000/docs | Swagger UI |
| http://127.0.0.1:5000/openapi.json | the generated spec |
The whole stack, built and wired together:
docker compose up -d --buildThat serves the same URLs on http://127.0.0.1:8000. The image is
multi-stage — Node builds the TypeScript bundle in the first stage and never
reaches the runtime image, which is Python only. npm run build typechecks
before bundling, so a type error fails the image build.
The entrypoint runs alembic upgrade head before starting gunicorn, so a
deploy migrates itself. Set RUN_MIGRATIONS=0 to skip that if you'd rather
run migrations as a separate release step (worth doing once you run more than
a couple of replicas).
Everything is environment-driven, so the same image runs unchanged locally and on a platform:
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
local compose db | connection string |
PORT |
8000 |
port gunicorn binds |
WEB_CONCURRENCY |
4 |
gunicorn worker count |
RUN_MIGRATIONS |
1 |
run alembic upgrade head on start |
GUNICORN_TIMEOUT |
30 |
seconds before a wedged worker is killed |
| Endpoint | Checks | Use for |
|---|---|---|
/healthz |
process is up; no database access | liveness probe |
/readyz |
SELECT 1 against Postgres |
readiness probe |
They are deliberately separate. A liveness probe that touched the database would have the orchestrator restart every container during a brief Postgres outage, turning a blip into a restart storm. Readiness failing just removes the container from the load balancer, which is the correct response.
If you'd rather not install Python or Node on the host, every step has a
container equivalent. These live under a dev compose profile, so a plain
docker compose up ignores them.
docker compose up -d --build # run the app on :8000
docker compose --profile dev run --rm test # run the tests
docker compose --profile dev run --rm frontend npm run build # production bundle
docker compose --profile dev up -d frontend # watch modeNotes on how this is wired:
testbuilds thedevimage target — the runtime image plusrequirements-dev.txt— so tests run against exactly the Python and dependency set production uses. Source is bind-mounted, so edits take effect without a rebuild.frontendbakesnode_modulesinto its image rather than mounting it, and mounts onlysrc/andtsconfig.json. It runs as uid 1000 so the bundle it writes intostatic/stays editable from the host.- Watch mode uses esbuild's
--watch=forever; plain--watchexits as soon as stdin closes, which is always the case for a detached container. npm run devemits an unminified bundle (~116 KB) andnpm run buildemits a minified one (~48 KB). Both are correct; just don't be surprised when the file size changes. The production image always runsbuildinternally, so what's instatic/on your host never affects the image.
The test database is created automatically when the Postgres volume is first
initialised (docker/init-db/). An existing volume predates that script, so
create it once by hand — see Database — or docker compose down -v
to start fresh.
cd frontend && npm run dev # esbuild watch mode; reload the page to pick up changes
cd frontend && npm run typecheck # tsc --noEmit, no bundling
cd backend && .venv/bin/python -m pytest tests/ -qThe tests hit a real Postgres, so docker compose up -d db must be running
and taskapi_test must exist (see Database) — otherwise the
suite fails at the migration step with a connection error.
Regenerate the spec after changing any route or schema — a test fails if the committed copy is stale:
cd backend && .venv/bin/python app.py --dump-openapicd backend
.venv/bin/alembic revision --autogenerate -m "what changed"
.venv/bin/alembic upgrade head
.venv/bin/alembic downgrade -1 # undo the last oneThe connection string comes from DATABASE_URL and is never committed;
alembic.ini deliberately leaves sqlalchemy.url blank and env.py falls
back to the environment. The default is the local compose database.
Tests run against a separate taskapi_test database (override with
TEST_DATABASE_URL) and apply the real migrations rather than create_all,
so a broken migration fails the suite instead of reaching production.
.github/workflows/ci.yml runs on every push and PR to main, in three
parallel jobs:
| Job | Does | Catches |
|---|---|---|
| Backend tests | pytest against a real Postgres service container | behaviour regressions, spec drift, a stale openapi.json, a broken migration chain |
| Frontend | npm ci, npm run typecheck, npm run build |
type errors, a lockfile out of sync with package.json |
| Image | builds the production target, then smoke-tests the running container | a broken Dockerfile, a container that can't reach Postgres or serve its assets |
The backend job migrates an empty database from zero, so it proves the whole
Alembic chain applies cleanly — something a locally migrated database can't
tell you. The image job asserts /healthz, /readyz, a real POST /tasks,
and that both index.html and the bundle are served from inside the image.
Base URL /. Every response is JSON, including errors.
| Method | Path | Purpose |
|---|---|---|
GET |
/tasks |
list all tasks |
POST |
/tasks |
create a task — {"title": "..."} |
GET |
/tasks/{id} |
fetch one task |
PUT |
/tasks/{id} |
update title and/or finished state |
DELETE |
/tasks/{id} |
delete a task |
A task is {"id": 1, "title": "Buy milk", "finished": false}.
PUT applies a partial update but requires at least one of title or
finished; a body-less request is rejected rather than silently succeeding.
Errors use flask-smorest's standard shape. Validation failures return 422 with per-field detail:
{
"code": 422,
"status": "Unprocessable Entity",
"errors": { "json": { "title": ["Title must not be blank."] } }
}Tasks are stored in Postgres. GET /tasks orders by id explicitly —
Postgres guarantees no row order without ORDER BY, and an UPDATE can
relocate a row.
The test suite covers the API's behaviour and guards the spec against drift:
every route must appear in the document, every documented path must be routed,
every operation needs an operationId, every error response needs a body, the
document must validate as OpenAPI 3.0.3, and the committed openapi.json must
match what the app generates.
flask-smorest only sees routes registered on a Blueprint, so a bare
@app.route would be invisible to the generator. That is the one remaining
drift vector, and test_every_route_is_documented catches it — routes that are
deliberately undocumented (/, /healthz, /readyz, static assets, and the
docs endpoints themselves) are listed in UNDOCUMENTED_ENDPOINTS.