Skip to content

Repository files navigation

task-api

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.


Table of contents


Features

  • Full CRUD for tasks over a versioned /v1 API
  • 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 .sql files by sqlc
  • Versioned SQL migrations via goose
  • Structured JSON logging (log/slog)
  • Graceful shutdown on SIGINT/SIGTERM with a 10s drain window
  • Request IDs, panic recovery, and a 15s request timeout middleware
  • /healthz liveness endpoint
  • Multi-stage Docker build producing a distroless, non-root image

Requirements

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.

Quick start

git clone https://github.com/denysdovzhenko/task-api.git
cd task-api
cp .env.example .env

Bring up Postgres and the API:

make up

Apply migrations (the containers do not run migrations automatically):

make migrate-up

Verify:

curl -i http://localhost:8080/healthz

Create 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 down

Running the API outside Docker

Useful 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

Configuration

.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.

API reference

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.

Task object

{
  "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"
}

POST /v1/tasks

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 03
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"
      }'

GET /v1/tasks

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.

PUT /v1/tasks/{id}

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}'

DELETE /v1/tasks/{id}

Returns 204 No Content with an empty body, or 404 if the id does not exist.

Errors

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.

Data model

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.

Project layout

.
├── 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/handler defines its own taskResponse and taskInput types 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.

Development

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 conventions

Conventional Commits 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.

Changing the database

  1. make new-migration name=add_something and fill in the Up/Down blocks.
  2. make migrate-up to apply it.
  3. Add or edit queries in internal/db/query/tasks.sql.
  4. make sqlc to regenerate the typed Go code.
  5. Update the handlers and DTOs to match, including the @Param/@Success annotations above each handler in task.go.
  6. make swagger to regenerate docs/ 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.

Tests

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.

Docker

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 -v

Troubleshooting

DATABASE_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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages