A small, production-shaped REST API for managing tasks, written in Go with PostgreSQL.
It is deliberately dependency-light: net/http with chi for routing,
pgx for the database, sqlc for type-safe
queries generated from plain SQL, and goose for migrations.
No ORM, no framework.
An OpenAPI spec is generated from handler comments by swag and
served as Swagger UI at /swagger/index.html.
A full documentation site — this README's content plus an interactive API reference built from
the same spec — lives in docs-site/ and is built with Blume.
Run it with make docs-dev.
- Features
- Requirements
- Quick start
- Configuration
- API reference
- Data model
- Project layout
- Development
- Docker
- Troubleshooting
- Full CRUD for tasks over a versioned
/v1API - Filtering by status and offset/limit pagination with a total count
- Request validation with per-field error messages (HTTP 422)
- Type-safe database access — Go code generated from
.sqlfiles by sqlc - Versioned SQL migrations via goose
- Structured JSON logging (
log/slog) - Graceful shutdown on
SIGINT/SIGTERMwith a 10s drain window - Request IDs, panic recovery, and a 15s request timeout middleware
/healthzliveness endpoint- Multi-stage Docker build producing a distroless, non-root image
| Tool | Version | Needed for |
|---|---|---|
| Docker + Compose | recent | Running Postgres and/or the API |
| Go | 1.26+ | Running or building the API locally |
goose, sqlc, air
and swag are not separate installs — they're tracked as Go tool
dependencies (tool block in go.mod), pinned to v3.27.3, v1.30.0, v1.67.3 and v1.16.6
respectively. go tool goose / go tool sqlc / go tool air / go tool swag builds and caches
the exact pinned version on first use — no go install or PATH setup required.
git clone https://github.com/denysdovzhenko/task-api.git
cd task-api
cp .env.example .envBring up Postgres and the API:
make upApply migrations (the containers do not run migrations automatically):
make migrate-upVerify:
curl -i http://localhost:8080/healthzCreate your first task:
curl -s -X POST http://localhost:8080/v1/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"Write the README","priority":2,"status":"in_progress"}'Tear everything down (the postgres_data volume survives):
make downUseful when you want fast rebuilds and a debugger. Start only the database, then run the
binary on the host — make run points DATABASE_URL at localhost:5432.
docker compose up -d db
make migrate-up
make run.env is read by Docker Compose and by the Makefile. Copy .env.example and adjust.
| Variable | Required | Default | Description |
|---|---|---|---|
POSTGRES_USER |
yes | — | Postgres role for the db container |
POSTGRES_PASSWORD |
yes | — | Password for that role |
POSTGRES_DB |
yes | — | Database name |
API_PORT |
no | 8080 |
Host port mapped to the API container's 8080 |
The API binary itself reads only these two variables from its own environment:
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
yes | — | Postgres connection string; startup fails without it |
HTTP_ADDR |
no | :8080 |
Listen address |
Compose builds DATABASE_URL for the api service from the POSTGRES_* values, pointing at
the db service on the internal network. make run builds the same URL against localhost.
.env is git-ignored; .env.example is the template that is committed.
Base URL: http://localhost:${API_PORT:-8080}
All request and response bodies are JSON. Timestamps are RFC 3339.
| Method | Path | Description | Success |
|---|---|---|---|
GET |
/healthz |
Liveness probe (plain text ok) |
200 |
GET |
/swagger/index.html |
Swagger UI | 200 |
POST |
/v1/tasks |
Create a task | 201 |
GET |
/v1/tasks |
List tasks (filter + paginate) | 200 |
GET |
/v1/tasks/{id} |
Fetch one task | 200 |
PUT |
/v1/tasks/{id} |
Replace a task | 200 |
DELETE |
/v1/tasks/{id} |
Delete a task | 204 |
Interactive docs, generated from the handler comments in task.go, are
served at /swagger/index.html; the raw spec is at
/swagger/doc.json.
{
"id": 1,
"title": "Write the README",
"description": "",
"status": "in_progress",
"priority": 2,
"due_date": null,
"created_at": "2026-07-27T10:31:04.512338Z",
"updated_at": "2026-07-27T10:31:04.512338Z"
}Request fields:
| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
title |
string | yes | — | non-blank, ≤ 200 characters |
description |
string | no | "" |
— |
status |
string | no | "todo" |
one of todo, in_progress, done |
priority |
integer | no | 0 |
0–3 |
due_date |
string | null | no | null |
RFC 3339 timestamp |
curl -s -X POST http://localhost:8080/v1/tasks \
-H 'Content-Type: application/json' \
-d '{
"title": "Ship v1",
"description": "Cut the first tagged release",
"status": "todo",
"priority": 3,
"due_date": "2026-08-15T17:00:00Z"
}'Query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
status |
string | — | Exact match; omit to return every status |
page |
integer | 1 |
Values below 1 fall back to 1 |
page_size |
integer | 20 |
Clamped to a maximum of 100 |
Unparseable numeric values fall back to the default rather than erroring.
Results are ordered by created_at descending.
curl -s 'http://localhost:8080/v1/tasks?status=todo&page=1&page_size=10'{
"tasks": [
/* … */
],
"page": 1,
"page_size": 10,
"total_items": 42
}total_items counts every row matching the status filter, not just the current page.
A full replacement, not a patch. The body takes the same fields as POST, and every
omitted field is reset to its default — omitting status sets the task back to todo, and
omitting due_date clears it. Send the complete object.
curl -s -X PUT http://localhost:8080/v1/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"title":"Ship v1","description":"Cut the first tagged release","status":"done","priority":3,"due_date":null}'Returns 204 No Content with an empty body, or 404 if the id does not exist.
Errors are JSON with an error message. Validation failures add a fields map.
| Status | When |
|---|---|
400 Bad Request |
Non-numeric {id}, or a malformed JSON body |
404 Not Found |
No task with that id |
422 Unprocessable Entity |
Body parsed but failed validation |
500 Internal Server Error |
Database or unexpected server failure |
{
"error": "validation failed",
"fields": {
"title": "title is required",
"priority": "priority must be between 0 and 3"
}
}Server-side failures log the underlying error and return a generic message — internal details are never leaked to clients.
Single table, defined in 00001_create_tasks.sql:
CREATE TABLE tasks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo'
CHECK (status IN ('todo', 'in_progress', 'done')),
priority SMALLINT NOT NULL DEFAULT 0,
due_date TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Indexes back the two access patterns the API has: idx_tasks_status for the status filter
and idx_tasks_created_at for the default ordering. The status CHECK constraint mirrors
the handler-side validation, so bad data cannot reach the table even if it bypasses the API.
.
├── cmd
│ └── api
│ └── main.go wiring: config, pgx pool, HTTP server, graceful shutdown
├── docs GENERATED — swagger.json/yaml + docs.go, via `make swagger`
├── internal
│ ├── config
│ │ └── config.go environment loading and validation
│ ├── db
│ │ ├── migrations goose SQL migrations (the sqlc schema source)
│ │ │ └── 00001_create_tasks.sql
│ │ ├── query hand-written SQL; the input to sqlc
│ │ │ └── tasks.sql
│ │ └── sqlc GENERATED — do not edit by hand
│ │ ├── db.go
│ │ ├── models.go
│ │ ├── querier.go
│ │ └── tasks.sql.go
│ ├── handler HTTP handlers, request/response DTOs, validation, error writers
│ │ ├── errors.go
│ │ ├── handler.go
│ │ └── task.go
│ └── server
│ └── server.go chi router, middleware, route table
├── Dockerfile multi-stage build → distroless non-root image
├── Makefile day-to-day commands
├── docker-compose.yaml postgres + api services
└── sqlc.yaml codegen configuration
Regenerate with tree -I '.git|.claude' --dirsfirst.
Two deliberate boundaries are worth knowing about:
- DTOs are separate from DB models.
internal/handlerdefines its owntaskResponseandtaskInputtypes so the wire format can evolve independently of the schema, and so a new column is never accidentally exposed. - Handlers depend on
sqlc.Querier, an interface, not on the concrete pool — which keeps the handlers substitutable for a fake in tests.
All commands come from the Makefile:
| Command | What it does |
|---|---|
make up |
Build and start db + api in the background |
make down |
Stop and remove the containers |
make run |
Run the API on the host against localhost:5432 |
make dev |
Same as make run, but live-reloading via air |
make migrate-up |
Apply all pending migrations |
make migrate-down |
Roll back the most recent migration |
make new-migration name=add_foo |
Scaffold a new timestamped SQL migration |
make sqlc |
Regenerate internal/db/sqlc from schema + queries |
make swagger |
Regenerate docs/ from handler comments |
make tidy |
go mod tidy |
make test |
go test ./... |
make db-backup |
Dump the Compose Postgres database to backups/ |
make db-restore file=... |
Restore a custom-format dump into Compose Postgres |
make docs-dev |
Run the docs-site/ documentation site locally |
make docs-build |
Static production build of docs-site/ into dist/ |
Commit messages should follow
Conventional Commits:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Use one of these commit types:
build
chore
ci
docs
feat
fix
perf
refactor
revert
style
test
Examples:
feat: allow provided config object to extend other configs
BREAKING CHANGE: `extends` key in config file is now used for extending other config files
Keep the type lowercase, write a non-empty subject, and omit the trailing period. Keep the commit header to 100 characters or fewer.
make new-migration name=add_somethingand fill in theUp/Downblocks.make migrate-upto apply it.- Add or edit queries in internal/db/query/tasks.sql.
make sqlcto regenerate the typed Go code.- Update the handlers and DTOs to match, including the
@Param/@Successannotations above each handler in task.go. make swaggerto regeneratedocs/from those annotations.
sqlc reads the migrations directory as its schema, so step 1 must happen before step 4 — otherwise codegen will not know about the new columns.
The timestamptz overrides in sqlc.yaml map timestamps to time.Time and
nullable ones to *time.Time, instead of the default pgtype.Timestamptz. A db_type
override applies to nullable or non-nullable columns but not both, hence the two entries.
internal/handler has unit tests (make test / go test ./...) covering taskInput
validation and the HTTP handlers. They run against a fakeQuerier
(fake_querier_test.go) implementing the
sqlc.Querier interface from querier.go, so no live database
is needed. Requests are dispatched through a small chi router in
task_test.go that mirrors the real /v1/tasks routes, so
{id} URL params resolve the same way they do in production.
Coverage is intentionally shallow — one or two cases per handler (happy path plus the most
important edge case), not an exhaustive matrix. There's no integration test against a real
Postgres instance yet; if that's added later, it should live alongside the sqlc.Queries type
and run against a migrated test database (e.g. via testcontainers-go or the db Compose
service), separate from these fast in-memory unit tests.
The Dockerfile is a two-stage build. Stage one compiles a static binary on
golang:1.26-alpine with CGO_ENABLED=0 and -ldflags="-s -w", using BuildKit cache mounts
for the module and build caches so rebuilds stay fast. Stage two copies that single binary
into gcr.io/distroless/static-debian12:nonroot — no shell, no package manager, running as
a non-root user.
The container always listens on 8080; API_PORT only changes the host-side port mapping.
Postgres data lives in the named postgres_data volume, so make down preserves your rows.
To start genuinely clean:
docker compose down -vDATABASE_URL is required — the API exits immediately when the variable is unset. With
Compose this means .env is missing or incomplete; running the binary directly means you
need make run (which sets it) rather than a bare go run ./cmd/api.
relation "tasks" does not exist — migrations have not been applied. Run make migrate-up.
Nothing in the container startup path applies them for you.
make up fails with POSTGRES_USER is required — Compose requires the POSTGRES_*
variables and fails fast rather than silently defaulting. Copy .env.example to .env.
Port 5432 already in use — another Postgres is running on the host. Stop it, or change
the published port in docker-compose.yaml (and DB_URL in the
Makefile to match).
make run fails with address already in use on :8080 — an api container from a
previous make up is still running and holding the port. make down (or
docker stop task-api-api-1) frees it before make run can bind the same port on the host.
go: github.com/pressly/goose/v3/cmd/goose@...: no matching versions (or similar for
sqlc) — the module cache can't reach the network on first go tool goose/go tool sqlc
invocation. Both are pinned as tool dependencies in go.mod; run go mod download
with network access once and the pinned binaries build from the local module cache after
that.