A JSON API for managing movie records, with user registration and activation, bearer-token authentication, per-user permissions, IP rate limiting, CORS and expvar metrics.
- Go 1.26+ (see
go.mod) - PostgreSQL 12+ (the
citextextension is used for emails) golang-migrateCLI —brew install golang-migratejq, for the scripted curl snippets further down —brew install jq
The defaults everywhere (the -db-dsn flag and every make target) assume
postgres://greenlight:pa55word@localhost/greenlight?sslmode=disable:
psql postgres -c "CREATE ROLE greenlight WITH LOGIN PASSWORD 'pa55word';"
psql postgres -c "CREATE DATABASE greenlight OWNER greenlight;"
psql greenlight -c "CREATE EXTENSION IF NOT EXISTS citext;"The citext extension has no migration of its own, so it has to be created
before the migrations run.
make migrate # apply all migrations
make check-migration-version # print the current version
make migrate-roll-back # undo the last migration
make migrate-down # undo everything
make create-table # scaffold a new pair of .sql files in ./migrationsEach target hardcodes the DSN above — edit the Makefile if yours differs.
go run ./cmd/apiIt listens on :4000 and logs database connection pool established on a
successful start. Two variants are handy while working through the flows below:
# the default limiter is 2 rps / burst 4, which the scripted flows will trip:
go run ./cmd/api -limiter-enabled=false
# needed only for the CORS flow at the end:
go run ./cmd/api -cors-trusted-origins="http://localhost:9000"| Flag | Default |
|---|---|
-port |
4000 |
-env |
development |
-log-level |
info — debug also logs a line per CORS request |
-db-dsn |
postgres://greenlight:pa55word@localhost/greenlight?sslmode=disable |
-db-max-open-conns |
25 |
-db-max-idle-conns |
25 |
-db-max-idle-time |
15m |
-limiter-rps |
2 |
-limiter-burst |
4 |
-limiter-enabled |
true |
-smtp-host / -smtp-port |
sandbox.smtp.mailtrap.io / 2525 |
-smtp-username / -smtp-password |
Mailtrap sandbox credentials |
-smtp-sender |
Greenlight <no-reply@dima.maimesko.com> |
-cors-trusted-origins |
empty (space-separated list) |
The SMTP defaults point at a Mailtrap sandbox inbox, so welcome and activation emails land there rather than in a real mailbox — swap in your own credentials to send for real.
The full OpenAPI 3.1 spec lives in docs/openapi.yaml —
every endpoint, parameter, request body, response envelope and error shape. It
is hand-written rather than generated, so it needs updating alongside the
handlers. The API does not serve it; view it with any OpenAPI viewer, e.g.:
# render it as a browsable HTML page and open it
npx @redocly/cli build-docs docs/openapi.yaml -o docs/api.html
open docs/api.html
# check it still validates after an edit
npx @redocly/cli lint docs/openapi.yamldocs/api.html is generated output — regenerate it after editing the spec, or
delete it and view the YAML in editor.swagger.io
instead. The page inlines the spec but pulls the Redoc renderer from a CDN, so
it needs a network connection to display.
The rest of this file is the same API as a set of runnable curl flows.
Base URL assumes the API is running locally on the default port:
export BASE=http://localhost:4000Endpoints (from cmd/api/routes.go):
| Method | Path | Auth |
|---|---|---|
| GET | /v1/healthcheck |
none |
| POST | /v1/users |
none |
| PUT | /v1/users/activated |
none (activation token in body) |
| POST | /v1/tokens/authentication |
none (email + password in body) |
| GET | /v1/movies |
activated + movies:read |
| POST | /v1/movies |
activated + movies:write |
| GET | /v1/movies/:id |
activated + movies:read |
| PATCH | /v1/movies/:id |
activated + movies:write |
| DELETE | /v1/movies/:id |
activated + movies:write |
| GET | /debug/vars |
none |
curl -i $BASE/v1/healthcheckName is required (max 500 bytes), email must be valid, password 8–72 bytes.
Returns 202 Accepted; the account starts with "activated": false and is
automatically granted the movies:read permission.
curl -i -X POST $BASE/v1/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice Smith","email":"alice@example.com","password":"pa55word1234"}'An activation token (valid 3 days) is emailed in the background via SMTP — with the default flags that is the Mailtrap sandbox inbox. Only the hash is stored in the database, so the plaintext token has to come from that email.
export ACTIVATION_TOKEN=<26-char token from the welcome email>
curl -i -X PUT $BASE/v1/users/activated \
-H "Content-Type: application/json" \
-d "{\"token\":\"$ACTIVATION_TOKEN\"}"Response echoes the user with "activated": true. All activation tokens for
that user are then deleted, so replaying this call returns a 422.
Returns 201 with a 24-hour bearer token.
curl -i -X POST $BASE/v1/tokens/authentication \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"pa55word1234"}'Capture it straight into a variable (needs jq):
export TOKEN=$(curl -s -X POST $BASE/v1/tokens/authentication \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"pa55word1234"}' \
| jq -r '.authentication_token.token')
echo $TOKENEvery call below needs Authorization: Bearer <token> and an activated
account with movies:read (granted at registration).
curl -i -H "Authorization: Bearer $TOKEN" "$BASE/v1/movies"curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/v1/movies?title=black+panther" | jqcurl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/v1/movies?genres=adventure,action" | jqsort safelist: id, title, year, runtime and each prefixed with -
for descending. page_size max is 100, page max is 10 million.
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/v1/movies?page=2&page_size=5&sort=-year" | jq '.metadata'curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/v1/movies?title=panther&genres=action&page=1&page_size=10&sort=-runtime" | jqcurl -i -H "Authorization: Bearer $TOKEN" "$BASE/v1/movies/1"Registration only grants movies:read, so these calls return
403 "your user account doesn't have the necessary permissions" until
movies:write is granted. There is no endpoint for that — do it in psql:
psql "postgres://greenlight:pa55word@localhost/greenlight?sslmode=disable" -c \
"INSERT INTO users_permissions
SELECT users.id, permissions.id FROM users, permissions
WHERE users.email = 'alice@example.com' AND permissions.code = 'movies:write';"The existing token stays valid — permissions are read per request.
runtime must be the string "<n> mins". 1–5 unique genres, year between
1888 and the current year. Returns 201 plus a Location header.
curl -i -X POST $BASE/v1/movies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["action","adventure"]}'Grab the new id:
export MOVIE_ID=$(curl -s -X POST $BASE/v1/movies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Deadpool","year":2016,"runtime":"108 mins","genres":["action","comedy"]}' \
| jq -r '.movie.id')
echo $MOVIE_IDPATCH semantics: omitted fields are left alone.
curl -i -X PATCH $BASE/v1/movies/$MOVIE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"year":2017}'Replace the genres only:
curl -i -X PATCH $BASE/v1/movies/$MOVIE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"genres":["comedy","action","sci-fi"]}'curl -i -X DELETE $BASE/v1/movies/$MOVIE_ID \
-H "Authorization: Bearer $TOKEN"ID=$(curl -s -X POST $BASE/v1/movies \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Moana","year":2016,"runtime":"107 mins","genres":["animation","adventure"]}' \
| jq -r '.movie.id')
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/v1/movies/$ID" | jq
curl -s -X PATCH "$BASE/v1/movies/$ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"runtime":"110 mins"}' | jq
curl -s -X DELETE "$BASE/v1/movies/$ID" -H "Authorization: Bearer $TOKEN" | jqcurl -i $BASE/v1/moviescurl -i -H "Authorization: $TOKEN" $BASE/v1/movies # missing "Bearer "
curl -i -H "Authorization: Bearer not-a-real-token" $BASE/v1/moviescurl -s -X POST $BASE/v1/users -H "Content-Type: application/json" \
-d '{"name":"Bob Jones","email":"bob@example.com","password":"pa55word1234"}'
BOB=$(curl -s -X POST $BASE/v1/tokens/authentication -H "Content-Type: application/json" \
-d '{"email":"bob@example.com","password":"pa55word1234"}' | jq -r '.authentication_token.token')
curl -i -H "Authorization: Bearer $BOB" $BASE/v1/moviescurl -i -X POST $BASE/v1/movies \
-H "Authorization: Bearer $BOB_ACTIVATED" \
-H "Content-Type: application/json" \
-d '{"title":"Test","year":2020,"runtime":"90 mins","genres":["drama"]}'curl -i -X POST $BASE/v1/tokens/authentication \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"wrongpassword"}'curl -i -X POST $BASE/v1/users -H "Content-Type: application/json" \
-d '{"name":"Alice Again","email":"alice@example.com","password":"pa55word1234"}'# empty name, bad email, short password
curl -i -X POST $BASE/v1/users -H "Content-Type: application/json" \
-d '{"name":"","email":"not-an-email","password":"short"}'
# bad year / runtime format / too many genres
curl -i -X POST $BASE/v1/movies \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Too Old","year":1000,"runtime":"134 minutes","genres":["a","b","c","d","e","f"]}'
# invalid sort value
curl -i -H "Authorization: Bearer $TOKEN" "$BASE/v1/movies?sort=director"curl -i -X POST $BASE/v1/users -H "Content-Type: application/json" \
-d '{"name": "Alice",'
curl -i -X POST $BASE/v1/users -H "Content-Type: application/json" \
-d '{"name":"Alice","email":"a@b.com","password":"pa55word1234","rating":9}'curl -i $BASE/v1/foo
curl -i -X DELETE $BASE/v1/healthcheckDefaults are 2 rps with a burst of 4, keyed by IP:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code}\n" $BASE/v1/healthcheck; donecurl -s $BASE/debug/vars | jq
curl -s $BASE/debug/vars | jq '{version, goroutines, total_requests_received, total_responses_sent}'
curl -s $BASE/debug/vars | jq '.database'Only origins passed to -cors-trusted-origins get an
Access-Control-Allow-Origin header back:
curl -i -H "Origin: http://localhost:9000" $BASE/v1/healthcheck # allowed
curl -i -H "Origin: http://evil.example.com" $BASE/v1/healthcheck # no ACAO headercurl -i -X OPTIONS $BASE/v1/movies/1 \
-H "Origin: http://localhost:9000" \
-H "Access-Control-Request-Method: PATCH" \
-H "Access-Control-Request-Headers: Authorization, Content-Type"The browser-side demo page that exercises this lives in
cmd/examples/cors/simple (go run ./cmd/examples/cors/simple).