first commit

This commit is contained in:
2026-07-16 10:13:46 +03:30
commit 423c528b2f
42 changed files with 7298 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Keeps the Docker build context small and prevents local secrets/state
# from ever being copied into an image layer.
.git
.env
cookies.txt
docs/
*.md
+42
View File
@@ -0,0 +1,42 @@
# Copy this file to .env and fill in real values.
# cp .env.example .env
#
# .env is read automatically by `docker compose` when it sits next to
# docker-compose.yml. For running the app directly with `go run ./cmd/api`
# (outside Docker), export these variables in your shell instead, or use a
# tool like `direnv` / `godotenv` to load them (not included in this
# project, but a natural addition later).
# --- Server ---
PORT=8080
# "development" or "production" - controls whether the session cookie
# requires HTTPS (Secure flag). Keep this as "development" for local work.
ENV=development
# "debug" enables verbose debug-level log lines; anything else hides them.
LOG_LEVEL=info
# --- MySQL ---
# When running via docker-compose, DB_HOST must be "mysql" (the service
# name) instead of 127.0.0.1 - see docker-compose.yml for why.
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=devpass
DB_NAME=go_simple_api
# --- Redis ---
# Same rule as DB_HOST: "redis" when running via docker-compose.
REDIS_ADDR=127.0.0.1:6379
# --- Google OAuth2 ---
# Get these from https://console.cloud.google.com/apis/credentials
# Register the redirect URI below EXACTLY as your app's "Authorized
# redirect URI" in the Google Cloud Console, or the OAuth flow will fail.
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback
# --- CORS ---
# Comma-separated list of frontend origins allowed to call this API from
# browser JavaScript with credentials (cookies) attached.
ALLOWED_ORIGINS=http://localhost:3000
+21
View File
@@ -0,0 +1,21 @@
# Environment files - never commit real secrets
.env
# Build artifacts
/bin/
/dist/
*.exe
*.test
# Go workspace / tooling files
go.work
go.work.sum
# curl cookie jars used while testing the API by hand
cookies.txt
# OS / editor cruft
.DS_Store
.idea/
.vscode/
*.swp
+50
View File
@@ -0,0 +1,50 @@
# ---- Stage 1: build ----
# This stage has the full Go toolchain and is only used to compile the
# binary. It is discarded entirely after the build - none of its ~800MB+
# footprint ends up in the final image.
FROM golang:1.26 AS builder
WORKDIR /app
# Copy just the module files first and download dependencies before
# copying the rest of the source. Docker caches each instruction as a
# layer; as long as go.mod/go.sum don't change, this layer (and the
# downloads it triggers) is reused on every subsequent build, even if
# application code changes constantly. If we copied all source first, any
# code edit would invalidate this cache and force a full re-download every
# single build.
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 produces a fully static binary with no dynamic library
# dependencies, which is what lets it run on the minimal Alpine base image
# in stage 2 below without missing shared libraries. GOOS=linux ensures we
# cross-compile for Linux even if you're building this image on macOS or
# Windows.
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server ./cmd/api
# ---- Stage 2: run ----
# A tiny (~7MB) base image that receives ONLY the compiled binary from
# stage 1 - no compiler, no source code, no build tools. Smaller image,
# smaller attack surface.
FROM alpine:3.20
# Alpine's minimal base doesn't include root CA certificates by default.
# Without these, any outbound HTTPS call this app makes (Google's OAuth2
# token exchange and userinfo endpoints) would fail with a certificate
# verification error. --no-cache avoids leaving package-manager cache
# files behind in the image.
RUN apk add --no-cache ca-certificates
WORKDIR /app
# Pull just the compiled binary out of the builder stage - this is the
# actual multi-stage mechanism: --from=builder reaches back into the FIRST
# image just for this one file.
COPY --from=builder /app/bin/server .
EXPOSE 8080
CMD ["./server"]
+156
View File
@@ -0,0 +1,156 @@
# go-simple-api
A small, heavily-commented Go REST API built as a learning project. It
implements user authentication two ways - email/password and "Sign in with
Google" - using server-side sessions stored in Redis, backed by MySQL for
user data.
This project was built incrementally, lesson by lesson, specifically to
teach Go web-service fundamentals. Every file has generous inline comments
explaining *why* the code is written the way it is, not just what it does.
See [`docs/LESSONS.md`](docs/LESSONS.md) for the full course this project
was built from, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a
deeper explanation of how the pieces fit together.
## Stack
| Concern | Technology |
|---|---|
| HTTP routing | [chi](https://github.com/go-chi/chi) v5 |
| Structured logging | `log/slog` (Go standard library), JSON output |
| Database | MySQL, via `database/sql` + `go-sql-driver/mysql` |
| Sessions | [scs](https://github.com/alexedwards/scs) v2, backed by Redis |
| Password hashing | `golang.org/x/crypto/bcrypt` |
| Google login | `golang.org/x/oauth2` (Authorization Code flow) |
| Rate limiting | [httprate](https://github.com/go-chi/httprate) |
| CORS | [go-chi/cors](https://github.com/go-chi/cors) |
## Project layout
```
go-simple-api/
├── cmd/api/main.go # entrypoint: wires everything, runs the server
├── internal/
│ ├── config/ # env var loading
│ ├── logging/ # structured JSON logger (slog)
│ ├── database/ # MySQL connection + migrations
│ ├── models/ # User struct + UserRepository (all SQL lives here)
│ ├── session/ # scs session manager, backed by Redis
│ ├── oauth/ # Google oauth2.Config builder
│ ├── handlers/ # HTTP handlers (health, auth, google oauth)
│ ├── middleware/ # request logging + auth-guard middleware
│ └── router/ # wires routes + middleware together
├── docs/ # architecture, API reference, course notes
├── Dockerfile # multi-stage build
├── docker-compose.yml # app + MySQL + Redis
├── .env.example # every config variable, documented
└── go.mod
```
## Running locally (without Docker)
Requires Go 1.26+, and MySQL + Redis reachable somewhere.
```bash
# 1. Start MySQL and Redis (or point at existing instances)
docker run --name mysql-api -e MYSQL_ROOT_PASSWORD=devpass \
-e MYSQL_DATABASE=go_simple_api -p 3306:3306 -d mysql:9
docker run --name redis-api -p 6379:6379 -d redis:8
# 2. Set up environment
cp .env.example .env
# edit .env - at minimum, fill in GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
# if you want to test Google login. Password login works without them.
export $(grep -v '^#' .env | xargs) # or use a tool like direnv
# 3. Fetch dependencies (go.sum is not included - see go.mod for why)
go mod tidy
# 4. Run
go run ./cmd/api
```
The server listens on `:8080` by default. Try:
```bash
curl http://localhost:8080/health
```
## Running with Docker Compose (recommended)
```bash
cp .env.example .env
# fill in GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET if you want Google login
docker compose up --build
```
This starts the API, MySQL, and Redis together, with the API waiting on
the other two. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how
service networking works inside Compose.
Stop everything:
```bash
docker compose down # stops containers, keeps the MySQL volume
docker compose down -v # also wipes the MySQL volume (fresh start)
```
## API reference
See [`docs/API.md`](docs/API.md) for every endpoint, request/response
shapes, and example `curl` commands.
Quick overview:
| Method | Path | Auth required? | Purpose |
|---|---|---|---|
| GET | `/health` | no | Liveness check |
| POST | `/register` | no | Create a password-based account |
| POST | `/login` | no | Log in with email + password, starts a session |
| POST | `/logout` | no (needs a session to destroy) | Ends the current session |
| GET | `/me` | **yes** | Returns the currently logged-in user |
| GET | `/auth/google/login` | no | Redirects the browser to Google |
| GET | `/auth/google/callback` | no | Google redirects here after login |
## Google OAuth setup
1. Go to the [Google Cloud Console credentials page](https://console.cloud.google.com/apis/credentials).
2. Create an **OAuth 2.0 Client ID** (Application type: Web application).
3. Add an **Authorized redirect URI**: `http://localhost:8080/auth/google/callback`
(must match `GOOGLE_REDIRECT_URL` in your `.env` exactly).
4. Copy the Client ID and Client Secret into `.env`.
## Security notes
This project deliberately implements several production-appropriate
security practices, explained in detail in the code comments where they
appear:
- Passwords are hashed with bcrypt, never stored or logged in plaintext.
- Login returns an identical, generic error for both "no such email" and
"wrong password", to avoid leaking which emails are registered.
- Sessions are server-side (Redis-backed) - the browser only ever holds a
random token, never the actual session data.
- `sessions.RenewToken()` is called on every successful login (password or
Google) to prevent session fixation.
- The session cookie is `HttpOnly` (JS can't read it) and `SameSite=Lax`
(mitigates CSRF); `Secure` is enabled automatically when `ENV=production`.
- The OAuth2 flow uses a random `state` value, checked on callback, to
prevent CSRF against the login flow itself.
- `/login` and `/register` have a much stricter rate limit than the rest
of the API, to slow down credential-stuffing / brute-force attempts.
- CORS is an explicit origin allowlist (`ALLOWED_ORIGINS`), never a
wildcard, since the API uses credentialed (cookie-based) requests.
## What's not included (possible next steps)
- Automated tests (`httptest`, table-driven tests, a mockable repository interface)
- A real migration tool (e.g. `golang-migrate`) instead of `CREATE TABLE IF NOT EXISTS`
- CSRF tokens for a same-origin HTML form frontend (SameSite=Lax already
covers the cookie-based JSON API case)
- Refresh/renewal flow for sessions beyond their fixed 24h lifetime
- Machine-readable error codes in API responses (currently just a message string)
- Shipping logs to Grafana Loki via Grafana Alloy (the JSON log shape
produced by this app is already Alloy/Loki-friendly - see
`internal/logging` and `internal/middleware/request_logger.go`)
+102
View File
@@ -0,0 +1,102 @@
// Command api is the entrypoint of the application. Its job is narrow and
// deliberate: load configuration, construct every shared dependency
// exactly once (logger, database, session manager), build the router, and
// run an HTTP server with a graceful shutdown sequence. It contains no
// business logic itself - that all lives in the packages it wires
// together.
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/database"
"git.hamidsoltani.com/hamid/go-simple-api/internal/logging"
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
func main() {
// 1. Configuration - read once, pass down everywhere as a value.
cfg := config.Load()
// 2. Logger - built before anything else so even early startup
// failures (DB connection, etc.) get logged as structured JSON,
// consistent with every other log line the app produces.
logger := logging.New()
ctx := context.Background()
// 3. Database - connect and verify with a real ping before doing
// anything else. If this fails, there's no point starting an HTTP
// server that can't actually serve any authenticated route.
db, err := database.NewMySQL(ctx, cfg)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
// Closes the connection pool once main() returns - i.e. after the
// graceful shutdown sequence below completes.
defer db.Close()
logger.Info("connected to database", "host", cfg.DBHost, "db", cfg.DBName)
// 4. Migrate - ensure required tables exist. See
// internal/database/migrate.go for why this simple approach is a
// deliberate shortcut for a learning project.
if err := database.Migrate(ctx, db); err != nil {
logger.Error("failed to migrate database", "error", err)
os.Exit(1)
}
logger.Info("database migrated")
// 5. Sessions - builds the scs.SessionManager backed by Redis.
sessions := session.New(cfg)
logger.Info("session manager configured", "redis_addr", cfg.RedisAddr)
// 6. Router - assembles every handler/middleware, using the shared
// dependencies constructed above.
r := router.New(logger, db, sessions, cfg)
// We build http.Server explicitly (instead of calling
// http.ListenAndServe directly) specifically so we can call
// srv.Shutdown(ctx) on it later, for a graceful shutdown.
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
// ListenAndServe blocks forever (until the server stops or errors), so
// it must run in its own goroutine - otherwise the code below that
// listens for OS shutdown signals would never get a chance to run.
go func() {
logger.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
// Block the main goroutine until we receive an interrupt (Ctrl+C) or
// termination (e.g. `docker stop`) signal from the OS.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down gracefully")
// Give any in-flight requests up to 5 seconds to finish before we
// force-close them.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("forced shutdown", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
+62
View File
@@ -0,0 +1,62 @@
# Brings up the full stack with one command: the Go API, MySQL, and Redis.
#
# Usage:
# cp .env.example .env # fill in real values, especially Google OAuth
# docker compose up --build
#
# docker compose automatically loads a file literally named ".env" sitting
# next to this file, and substitutes ${VAR} references below from it.
services:
app:
build: .
ports:
- "8070:8080"
depends_on:
- mysql
- redis
environment:
PORT: 8080
ENV: development
# NOTE: these hostnames are NOT "127.0.0.1" - inside the compose
# network, each service's NAME becomes its hostname. Compose runs an
# internal DNS that resolves "mysql" and "redis" to the correct
# container's IP address automatically. This is exactly why
# internal/config reads these from environment variables instead of
# hardcoding 127.0.0.1 - the same compiled binary works unchanged
# both locally and inside Docker, just by changing env vars.
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: devpass
DB_NAME: go_simple_api
REDIS_ADDR: redis:6379
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URL: http://localhost:8080/auth/google/callback
ALLOWED_ORIGINS: http://localhost:3000
mysql:
image: mysql:9
environment:
MYSQL_ROOT_PASSWORD: devpass
MYSQL_DATABASE: go_simple_api
ports:
- "13306:3306"
volumes:
# Named volume: MySQL's data directory is persisted on the host,
# independent of the container's lifecycle. Without this, all data
# would be lost every time you run `docker compose down`.
- mysql_data:/var/lib/mysql
redis:
image: redis:8
ports:
- "16379:6379"
volumes:
mysql_data:
+184
View File
@@ -0,0 +1,184 @@
# API Reference
Base URL (local dev): `http://localhost:8080`
All request/response bodies are JSON. Authenticated endpoints rely on the
`session_id` cookie set by `/login` or the Google OAuth callback - include
it automatically by using a cookie-aware HTTP client (browsers do this
natively; with `curl`, use `-c cookies.txt -b cookies.txt`).
---
## `GET /health`
Liveness check. No authentication, no rate limiting beyond the global
limit.
**Response `200`**
```json
{ "status": "ok" }
```
```bash
curl http://localhost:8080/health
```
---
## `POST /register`
Creates a new password-based account.
Rate limited to **5 requests/minute per IP** (shared with `/login`).
**Request body**
```json
{ "email": "hamid@example.com", "password": "secret123" }
```
- `email` - required, must be unique across all accounts.
- `password` - required, minimum 8 characters.
**Response `201`**
```json
{ "id": 1, "email": "hamid@example.com" }
```
**Errors**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"invalid request body"}` | Malformed JSON |
| 400 | `{"error":"email and password are required"}` | Missing field |
| 400 | `{"error":"password must be at least 8 characters"}` | Password too short |
| 409 | `{"error":"email already registered"}` | Email already taken |
| 429 | (rate limit response) | Too many requests from this IP |
| 500 | `{"error":"internal error"}` | Unexpected server/database failure |
```bash
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
```
---
## `POST /login`
Authenticates with email + password and starts a server-side session.
Rate limited to **5 requests/minute per IP** (shared with `/register`).
**Request body**
```json
{ "email": "hamid@example.com", "password": "secret123" }
```
**Response `200`**
```json
{ "id": 1, "email": "hamid@example.com" }
```
Also sets a `session_id` cookie (`HttpOnly`, `SameSite=Lax`, `Secure` in
production).
**Errors**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"invalid request body"}` | Malformed JSON |
| 401 | `{"error":"invalid email or password"}` | No such email, OR wrong password (identical message for both, deliberately - see Security notes in the README) |
| 429 | (rate limit response) | Too many requests from this IP |
| 500 | `{"error":"internal error"}` | Unexpected server/database failure |
```bash
curl -c cookies.txt -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
```
---
## `POST /logout`
Destroys the current session (deletes it from Redis, expires the cookie).
Not rate-limited beyond the global limit - deliberately excluded from the
strict `/login`/`/register` limit so a legitimate user can always log out.
**Response `200`**
```json
{ "message": "logged out" }
```
```bash
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/logout
```
---
## `GET /me`
**Requires authentication** (a valid `session_id` cookie from a prior
login). Returns the currently logged-in user.
**Response `200`**
```json
{ "id": 1, "email": "hamid@example.com" }
```
**Errors**
| Status | Body | Cause |
|---|---|---|
| 401 | `{"error":"unauthorized"}` | No session, expired session, or the session's user no longer exists |
```bash
curl -b cookies.txt http://localhost:8080/me
```
---
## `GET /auth/google/login`
Redirects the browser to Google's OAuth2 consent screen. **Must be opened
in an actual browser** - this endpoint returns an HTTP redirect, and the
subsequent Google login page cannot be driven via `curl`.
**Response**: `307 Temporary Redirect` to `accounts.google.com`.
```
open http://localhost:8080/auth/google/login
```
---
## `GET /auth/google/callback`
Google redirects here automatically after the user approves access. Not
meant to be called directly - `state` and `code` query parameters are
supplied by Google.
On success: creates a new user (or links Google to an existing
email-matched account), starts a session exactly like `/login` does, and
returns:
**Response `200`**
```json
{ "id": 2, "email": "hamid@gmail.com" }
```
**Errors**
| Status | Body | Cause |
|---|---|---|
| 400 | `{"error":"invalid oauth state"}` | Missing/mismatched CSRF state - usually means the flow wasn't started via `/auth/google/login`, or the session expired mid-flow |
| 400 | `{"error":"missing code"}` | Google didn't include an authorization code |
| 500 | `{"error":"internal error"}` | Token exchange, Google API call, or database failure |
---
## General notes
- **CORS**: browser-based requests from an origin not listed in
`ALLOWED_ORIGINS` will be blocked by the browser itself, before this
API's own logic ever runs. Non-browser clients (curl, mobile apps,
server-to-server) are unaffected by CORS entirely.
- **Rate limiting**: exceeding a limit returns HTTP `429 Too Many
Requests`. The global limit (100/min/IP) applies to every route; the
strict limit (5/min/IP) applies only to `/register` and `/login`, and
stacks with the global limit.
- All error responses share the same shape: `{"error": "<message>"}`.
+205
View File
@@ -0,0 +1,205 @@
# Architecture
This document explains how the pieces of `go-simple-api` fit together, and
*why* they're structured this way - useful both as a reference and as a
guide if you extend the project.
## High-level request flow
Every incoming HTTP request passes through the same pipeline, built in
`internal/router/router.go`:
```
request
chimw.RequestID -- tags the request with a unique ID
middleware.RequestLogger -- records start time, wraps the response writer
chimw.Recoverer -- catches panics, converts them to a 500
chimw.Timeout(60s) -- cancels the request context if it runs too long
cors.Handler -- validates cross-origin requests (browser only)
httprate.LimitByIP(100/min) -- global rate limit
sessions.LoadAndSave -- loads session data from Redis into context
[ per-route middleware, e.g. httprate strict limit, or requireAuth ]
handler -- e.g. handlers.Login, handlers.Me
response written
(back through the stack) middleware.RequestLogger logs the final status/duration
```
Each middleware is a function shaped `func(http.Handler) http.Handler`: it
wraps the *next* thing in the chain, does something before calling
`next.ServeHTTP(w, r)`, and optionally does something after. This is why
ordering matters - `RequestLogger` wraps everything registered after it,
so it can measure the full duration including all of those inner layers.
## Dependency construction (`cmd/api/main.go`)
`main.go` is intentionally the only place that constructs the "big"
shared resources - the logger, the database pool, the session manager -
and it constructs each of them exactly once, then passes them down as
explicit function arguments (`router.New(logger, db, sessions, cfg)`).
This is a form of **dependency injection**: nothing deep in the call stack
reaches for a global variable to get a database connection or a logger.
Every package that needs one receives it explicitly, either as a
constructor argument (`NewUserRepository(db)`) or a struct field
(`AuthHandler.userRepo`). The benefit: you can trace exactly what any given
piece of code depends on just by reading its constructor signature, and
(if you add tests later) you can substitute a fake/mock dependency without
any global state to fight with.
## Package responsibilities
| Package | Responsibility | Should NOT contain |
|---|---|---|
| `config` | Read env vars into a typed struct | Any logic beyond defaults/parsing |
| `logging` | Build the shared `*slog.Logger` | Per-request logging logic (that's `middleware`) |
| `database` | Open the MySQL pool, run migrations | Table-specific queries (that's `models`) |
| `models` | Domain structs + repositories (all SQL) | HTTP concerns (status codes, JSON) |
| `session` | Build the `*scs.SessionManager` | Route-specific session key names beyond `session.UserIDKey` |
| `oauth` | Build provider `*oauth2.Config` values | Handling the actual HTTP callback (that's `handlers`) |
| `handlers` | Parse requests, call into models/session, write responses | Raw SQL, direct Redis calls |
| `middleware` | Cross-cutting HTTP behavior (logging, auth) | Business logic specific to one route |
| `router` | Wire dependencies + register routes | Any actual request handling logic |
If you're ever unsure where a new piece of code belongs, this table is the
first place to check.
## The repository pattern (`internal/models`)
`UserRepository` is the *only* place in the entire codebase that writes
SQL for the `users` table. Handlers call methods like `FindByEmail` or
`Create` - they never see a raw `*sql.DB` or write a query themselves.
Why this matters in practice:
- If you swap MySQL for PostgreSQL later, you change `user_repository.go`
only - no handler code changes.
- SQL injection risk is contained to one file, and that file consistently
uses parameterized queries (`?` placeholders), never string concatenation.
- Errors are translated at the boundary: `sql.ErrNoRows` (a
database/sql-specific sentinel) becomes `models.ErrUserNotFound` (an
application-specific sentinel), so callers reason about "not found" as a
concept, not a SQL implementation detail.
## Sessions: how "server-side" actually works
1. `session.New(cfg)` builds a `*scs.SessionManager` whose `.Store` is
Redis-backed (`internal/session/session.go`).
2. `sessions.LoadAndSave` (applied as middleware in `router.go`) runs on
every request: it reads the `session_id` cookie, loads the
corresponding session data from Redis into the request's `context.Context`,
lets the handler run, then - after the handler returns - saves any
changes back to Redis and sets/refreshes the cookie on the response.
3. Handlers never touch cookies or Redis directly. They call
`sessions.Put(ctx, key, value)` / `sessions.GetInt(ctx, key)` /
`sessions.Destroy(ctx)`, and the manager handles the rest via the
context it already loaded in step 2.
4. Only the user's numeric ID is stored in the session
(`session.UserIDKey`) - never the full user object. This keeps the
session tiny and guarantees `/me` and `middleware.RequireAuth` always
see fresh data from the database, never a stale cached copy.
## Authentication middleware and `context.Context`
`middleware.RequireAuth` (`internal/middleware/require_auth.go`) is the
single place that decides "is this request authenticated?" It:
1. Reads `session.UserIDKey` from the session.
2. Looks the user up in the database via `UserRepository.FindByID`.
3. On success, stores the `*models.User` in the request's `context.Context`
under a private key, and calls `next.ServeHTTP` with the *new* request
(contexts and requests are immutable - `context.WithValue` and
`r.WithContext` both return new values rather than mutating in place).
4. On any failure, responds 401 immediately and `next.ServeHTTP` is never
called - the wrapped handler doesn't run at all.
Handlers that need the current user call `middleware.CurrentUser(r)`,
which does the type assertion back out of the context. They never see or
touch the context key itself, which is intentionally unexported.
To protect a new route, add it inside the `r.Group(func(r chi.Router) {
r.Use(requireAuth); ... })` block in `router.go`.
## Google OAuth2 flow in detail
```
Browser This API Google
│ │ │
│ GET /auth/google/login │ │
├───────────────────────────►│ │
│ │ generate random `state`, │
│ │ store it in session │
│ 302 redirect to Google │ │
│◄───────────────────────────┤ │
│ │
│ user logs in / approves, entirely on Google's own site │
│────────────────────────────────────────────────────────────►
│ │
│ 302 redirect back with ?state=...&code=... │
│◄────────────────────────────────────────────────────────────
│ │ │
│ GET /auth/google/callback │ │
├───────────────────────────►│ │
│ │ verify state matches │
│ │ POST code -> exchange for token │
│ ├──────────────────────────────►│
│ │◄──────────────────────────────┤
│ │ GET userinfo with token │
│ ├──────────────────────────────►│
│ │◄──────────────────────────────┤
│ │ find-or-create local user, │
│ │ renew session token, │
│ │ store user ID in session │
│ 200 OK { id, email } │ │
│◄───────────────────────────┤ │
```
The `state` parameter exists purely as CSRF protection for the login flow
itself - without it, an attacker could craft a callback URL using their
own Google account and trick a victim's browser into using it.
## Docker networking
Inside `docker-compose.yml`, each service's *name* becomes its hostname on
the internal Docker network Compose creates automatically. That's why the
`app` service is configured with `DB_HOST: mysql` and `REDIS_ADDR:
redis:6379` instead of `127.0.0.1` - Compose's built-in DNS resolves
`mysql` and `redis` to the correct container IPs. This is also exactly why
`internal/config` reads these values from environment variables instead of
hardcoding them: the same compiled binary works unchanged whether it's
running on your laptop directly or inside this Compose network - only the
environment variables differ.
## Logging shape (for Grafana Loki / Alloy)
Every log line the app writes is a single JSON object to stdout, e.g.:
```json
{"time":"2026-07-15T10:00:05Z","level":"INFO","msg":"http_request","request_id":"...","method":"GET","path":"/health","status":200,"bytes":16,"duration_ms":123000,"remote_addr":"127.0.0.1:54321"}
```
This shape is deliberately Alloy/Loki-friendly: consistent JSON keys mean
Alloy can scrape container stdout and ship structured log lines without
custom parsing rules, and you can filter/query in Loki on fields like
`status`, `path`, or `request_id` directly.
+65
View File
@@ -0,0 +1,65 @@
# The course this project was built from
This project was built incrementally across 10 lessons, each adding one
concept on top of the last. This file is a map from "concept" to "where it
lives in the code" - useful if you want to revisit how/why something was
built the way it was.
| # | Lesson | New concepts | Where it lives |
|---|---|---|---|
| 1 | Project skeleton & chi routing | Standard Go project layout, `chi.Mux`, middleware basics, graceful shutdown via `http.Server` + `srv.Shutdown()` | `cmd/api/main.go`, `internal/router/router.go`, `internal/handlers/health.go` |
| 2 | Structured JSON logging | `log/slog`, `slog.NewJSONHandler`, log levels, the three-layer middleware-factory pattern | `internal/logging/logger.go`, `internal/middleware/request_logger.go` |
| 3 | Config & MySQL connection | `database/sql`, connection pooling (`SetMaxOpenConns` etc.), DSNs, `context.WithTimeout` for a hard deadline on the initial ping | `internal/config/config.go`, `internal/database/mysql.go` |
| 4 | User model & repository pattern | Pointers (`*`/`&`) in depth, pointer receivers, the repository pattern, sentinel errors + `errors.Is` | `internal/models/user.go`, `internal/models/user_repository.go` |
| 5 | Password login | `bcrypt` hashing/salting, decoding JSON request bodies, struct tags, generic error messages to avoid user enumeration | `internal/handlers/auth.go` (Register/Login), `internal/handlers/respond.go` |
| 6 | Server-side sessions (scs + Redis) | `scs.SessionManager`, swapping storage backends via `.Store`, cookie flags (`HttpOnly`, `SameSite`), `RenewToken` to prevent session fixation | `internal/session/session.go`, `internal/session/keys.go`, `Login`/`Logout`/`Me` in `internal/handlers/auth.go` |
| 7 | Login with Google (OAuth2) | Authorization Code flow, `oauth2.Config`, CSRF `state` parameter, account linking by email | `internal/oauth/google.go`, `internal/handlers/oauth_google.go` |
| 8 | Auth middleware & route protection | `context.Context` (`WithValue`/`Value`), private context-key types, type assertions, chi route groups | `internal/middleware/require_auth.go`, `r.Group(...)` in `internal/router/router.go` |
| 9 | Rate limiting & security hardening | `httprate.LimitByIP`, CORS (`go-chi/cors`), environment-aware `Secure` cookie flag | `internal/router/router.go`, `internal/session/session.go`, `internal/config/config.go` |
| 10 | Docker & wrap-up | Multi-stage Docker builds, `docker-compose`, service-name-as-hostname networking, named volumes | `Dockerfile`, `docker-compose.yml` |
## Core Go ideas that recur throughout the codebase
These aren't tied to a single lesson - once introduced, they show up
repeatedly, and are worth having solid:
- **Pointers (`*` / `&`)** - sharing one instance of something stateful
(`*sql.DB`, `*scs.SessionManager`, `*slog.Logger`) across the whole app
instead of copying it; writing a result back into a caller's variable
(`rows.Scan(&x)`, `u.ID = int(id)` inside `Create(ctx, u *User)`).
- **Interfaces satisfied implicitly** - `*chi.Mux` satisfies `http.Handler`
just by having a `ServeHTTP` method; there's no `implements` keyword in Go.
- **Closures / the three-layer middleware pattern** - seen in both
`RequestLogger(logger)` and `RequireAuth(sessions, userRepo, logger)`:
an outer function captures dependencies, returns a
`func(http.Handler) http.Handler`, which itself returns the actual
per-request handler - three layers, each running at a different time.
- **`context.Context`** - carrying request-scoped values (the current
user, a request ID) and deadlines (timeouts) through a call chain
without adding extra parameters to every function signature.
- **Error wrapping and sentinel errors** - `fmt.Errorf("...: %w", err)` to
add context while preserving the original error; `var ErrUserNotFound =
errors.New(...)` plus `errors.Is(err, ErrUserNotFound)` to let callers
branch on error *kind* without string-matching messages.
- **Dependency injection via structs** - `AuthHandler{userRepo, sessions,
logger}` instead of global variables, so every handler's requirements
are explicit and visible in its constructor.
## Suggested next steps
If you want to keep extending this project as further practice:
1. **Testing** - `httptest.NewRequest`/`NewRecorder` for handler tests,
table-driven test cases, and extracting a `UserStore` interface so
`UserRepository` can be swapped for an in-memory fake in tests.
2. **A real migration tool** (e.g. `golang-migrate/migrate`) instead of
`CREATE TABLE IF NOT EXISTS` on every boot.
3. **CSRF tokens** if you ever add a same-origin HTML form frontend
(the current `SameSite=Lax` cookie already covers the JSON-API case).
4. **Refresh/renewal** so an active user's session doesn't hard-expire
after 24 hours regardless of activity.
5. **Machine-readable error codes** (`{"error_code": "invalid_credentials"}`)
so a frontend can branch on a stable code instead of parsing message text.
6. **Grafana Alloy + Loki** - point Alloy at this container's stdout; the
JSON shape from `internal/logging` and `internal/middleware/request_logger.go`
is already structured for it.
+55
View File
@@ -0,0 +1,55 @@
// go.mod declares this project as a Go module.
//
// The module path (below) is used as the import prefix for every package
// inside this repo - that's why every internal import in this project starts
// with "git.hamidsoltani.com/hamid/go-simple-api/...".
module git.hamidsoltani.com/hamid/go-simple-api
go 1.26
// NOTE FOR THE STUDENT:
// This file intentionally does NOT list dependency versions or include a
// go.sum file, because they were generated without network access to the
// Go module proxy. The very first time you set up the project, run:
//
// go mod tidy
//
// from the project root. That command will:
// 1. Scan all .go files for imports it doesn't recognize yet
// 2. Download the latest compatible version of each one
// 3. Add "require" lines here automatically
// 4. Generate go.sum (a checksum file that locks exact versions,
// so builds are reproducible and can be verified as untampered)
//
// You need the following packages - `go mod tidy` will fetch all of them
// automatically just by scanning the imports in the code:
//
// github.com/go-chi/chi/v5
// github.com/go-chi/httprate
// github.com/go-chi/cors
// github.com/go-sql-driver/mysql
// github.com/alexedwards/scs/v2
// github.com/alexedwards/scs/redisstore
// github.com/gomodule/redigo
// golang.org/x/crypto
// golang.org/x/oauth2
require (
github.com/alexedwards/scs/redisstore v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.16.0
github.com/go-sql-driver/mysql v1.10.0
github.com/gomodule/redigo v1.9.3
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+44
View File
@@ -0,0 +1,44 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/alexedwards/scs/redisstore v0.0.0-20251002162104-209de6e426de h1:qum3fLI/hxIRCvHv54vMb6UgWBAIGIWsYR1vVF5Vg2A=
github.com/alexedwards/scs/redisstore v0.0.0-20251002162104-209de6e426de/go.mod h1:ceKFatoD+hfHWWeHOAYue1J+XgOJjE7dw8l3JtIRTGY=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/gomodule/redigo v1.8.0/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+87
View File
@@ -0,0 +1,87 @@
// Package config is responsible for one thing only: reading configuration
// from environment variables and handing back a single, typed Config struct
// that the rest of the app can use.
//
// Why centralize this instead of calling os.Getenv() all over the codebase?
// - One place to see every setting the app needs.
// - One place to define sane defaults for local development.
// - Easy to swap the *source* later (e.g. read from a file, from Vault,
// from AWS Secrets Manager) without touching any other package.
package config
import (
"os"
"strings"
)
// Config holds every piece of runtime configuration the application needs.
// It is built once, in main(), and then passed down (by value - it's a
// small, read-only struct) into whichever package needs it: the router,
// the database connector, the session manager, the OAuth config, etc.
type Config struct {
// Port is the TCP port the HTTP server listens on.
Port string
// Env distinguishes "development" from "production". Currently used
// to decide whether the session cookie requires HTTPS (Secure flag).
Env string
// --- MySQL connection settings ---
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
// --- Redis connection settings (used for session storage) ---
RedisAddr string
// --- Google OAuth2 settings ---
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
// AllowedOrigins is the CORS allowlist: which frontend origins are
// permitted to call this API from browser JavaScript.
AllowedOrigins []string
}
// Load reads every setting from the process environment, falling back to
// sensible local-development defaults when a variable isn't set. In
// production you would set all of these explicitly (e.g. via
// docker-compose "environment:", a systemd unit, or your orchestrator's
// secret/config mechanism) rather than relying on the defaults.
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
Env: getEnv("ENV", "development"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", "devpass"),
DBName: getEnv("DB_NAME", "go_simple_api"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
// ALLOWED_ORIGINS is a comma-separated list, e.g.:
// ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com
// strings.Split on a single-value default still works fine and
// yields a one-element slice.
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
}
}
// getEnv reads a single environment variable, returning fallback if it's
// unset or empty. It's unexported (lowercase) because nothing outside this
// package needs to read raw env vars directly - everyone else should go
// through the Config struct instead.
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+32
View File
@@ -0,0 +1,32 @@
package database
import (
"context"
"database/sql"
"fmt"
)
// Migrate creates the tables this application needs, if they don't already
// exist. This is intentionally the simplest possible approach
// (CREATE TABLE IF NOT EXISTS run on every startup) and is fine for a
// learning project with a single table.
//
// For a real production project you'd want a proper migration tool (e.g.
// golang-migrate/migrate) that tracks a version number, supports
// incremental "up"/"down" migrations, and can safely evolve a schema that
// already has production data in it. This function is a deliberate
// shortcut around that complexity for now.
func Migrate(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
google_id VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
)`)
if err != nil {
return fmt.Errorf("migrate users table: %w", err)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
// Package database owns everything related to talking to MySQL: opening
// the connection pool and running startup migrations. Nothing outside this
// package (and the repositories in internal/models) should import the
// MySQL driver directly - that keeps the database technology an
// implementation detail hidden behind a plain *sql.DB.
package database
import (
"context"
"database/sql"
"fmt"
"time"
// Blank import: we never call anything from this package by name.
// Importing it purely for its side effect - its init() function
// registers "mysql" as a driver name with database/sql, which is what
// lets sql.Open("mysql", ...) below work at all.
_ "github.com/go-sql-driver/mysql"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
// NewMySQL opens a connection pool to MySQL, configures reasonable pool
// limits, and verifies connectivity with a real ping before returning -
// so a bad connection string / unreachable database fails loudly at
// startup instead of silently on the first real query.
func NewMySQL(ctx context.Context, cfg config.Config) (*sql.DB, error) {
// DSN = Data Source Name. Format: user:password@tcp(host:port)/dbname?options
// parseTime=true tells the driver to convert MySQL DATETIME/TIMESTAMP
// columns into Go time.Time values automatically.
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?parseTime=true",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName,
)
// sql.Open does NOT connect yet - it just validates the DSN and
// returns a *sql.DB, which represents a pool of connections managed
// for you, not a single live connection.
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
// Pool tuning:
// MaxOpenConns - hard ceiling on simultaneous connections, protects
// the database from being overwhelmed by this app.
// MaxIdleConns - how many connections to keep warm/ready even when
// idle, so we don't pay reconnect cost constantly.
// ConnMaxLifetime - force connections to be recycled periodically,
// useful behind load balancers or if MySQL itself
// closes long-lived idle connections.
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Give the ping a hard deadline instead of letting it hang forever if
// the database host is unreachable.
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return nil, fmt.Errorf("ping mysql: %w", err)
}
return db, nil
}
+207
View File
@@ -0,0 +1,207 @@
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"golang.org/x/crypto/bcrypt"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
// AuthHandler groups every password-authentication-related handler
// (Register, Login, Logout, Me) together, and holds the dependencies they
// all share as struct fields: the user repository (to read/write users),
// the session manager (to start/end sessions), and the logger.
//
// This is Go's version of "dependency injection": instead of handlers
// reaching for global variables, every dependency they need is explicit,
// passed in once at construction time via NewAuthHandler, and stored on
// the struct. That makes each handler's requirements obvious from the
// struct definition, and makes the whole thing straightforward to test
// later (swap in a fake UserRepository, etc).
type AuthHandler struct {
userRepo *models.UserRepository
sessions *scs.SessionManager
logger *slog.Logger
}
// NewAuthHandler is the constructor - see the same NewXxx convention used
// throughout this project (NewUserRepository, NewMySQL, ...).
func NewAuthHandler(userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *AuthHandler {
return &AuthHandler{userRepo: userRepo, sessions: sessions, logger: logger}
}
// registerRequest is the expected JSON body for POST /register.
// It's intentionally a separate, small struct from models.User - the wire
// format of an API request should not be tightly coupled to the database
// model. For example, a register request should never be able to set
// PasswordHash or ID directly.
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Register handles POST /register: creates a new user account with a
// bcrypt-hashed password.
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
// Check whether this email is already registered. err == nil means we
// FOUND a user - i.e. the email is taken - which is the failure case
// here.
_, err := h.userRepo.FindByEmail(r.Context(), req.Email)
if err == nil {
writeError(w, http.StatusConflict, "email already registered")
return
}
// Any error OTHER than "not found" is unexpected (e.g. the database is
// down) and deserves a 500 + a log line, not a generic 400.
if !errors.Is(err, models.ErrUserNotFound) {
h.logger.Error("find user by email failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// bcrypt.GenerateFromPassword hashes the password with a random salt
// baked into the output, using DefaultCost rounds of internal hashing
// (intentionally slow, to resist brute-force attacks). We NEVER store
// the plaintext password anywhere past this point.
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
h.logger.Error("hash password failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
user := &models.User{
Email: req.Email,
PasswordHash: string(hash),
}
if err := h.userRepo.Create(r.Context(), user); err != nil {
h.logger.Error("create user failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
// loginRequest is the expected JSON body for POST /login.
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Login handles POST /login: verifies email + password, and if correct,
// starts a new server-side session for the user.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
user, err := h.userRepo.FindByEmail(r.Context(), req.Email)
if errors.Is(err, models.ErrUserNotFound) {
// Deliberately the SAME generic message as a wrong password below.
// If we said "no such email" here and something different for a
// bad password, an attacker could use that difference to figure
// out which emails are registered (an "enumeration" attack).
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err != nil {
h.logger.Error("find user by email failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// bcrypt.CompareHashAndPassword re-derives the hash using the salt
// embedded in the stored hash, and compares. This is the ONLY correct
// way to check a password - there is no way to "unhash" it back to
// plaintext, which is the entire point.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
// Session fixation defense: issue a brand new session token now that
// the user's privilege level is about to change (anonymous ->
// authenticated), while keeping any existing session data intact.
// This should be called right before any privilege change (login here;
// the same applies to e.g. password changes).
if err := h.sessions.RenewToken(r.Context()); err != nil {
h.logger.Error("renew token failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// We store ONLY the user's ID in the session - not their email or any
// other data. Everything else about the user is looked up fresh from
// the database whenever needed (see Me, and middleware.RequireAuth),
// which avoids ever serving stale cached user data from the session.
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
// Logout handles POST /logout: destroys the current session, which both
// deletes the session data from Redis and tells the browser (via response
// headers) to remove the session cookie.
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if err := h.sessions.Destroy(r.Context()); err != nil {
h.logger.Error("destroy session failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
// Me handles GET /me: returns the currently authenticated user.
//
// Note this handler does NOT check the session itself - that work is done
// once, generically, by middleware.RequireAuth, which is applied to this
// route in router.go. By the time Me runs, the user has already been
// looked up and stashed in the request's context; Me just reads it back
// out via middleware.CurrentUser.
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
user := middleware.CurrentUser(r)
if user == nil {
// Defensive fallback only - this should never actually trigger as
// long as RequireAuth is correctly applied to this route in the
// router. It protects against a future refactor accidentally
// wiring this handler up without the middleware.
writeError(w, http.StatusUnauthorized, "not logged in")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
+16
View File
@@ -0,0 +1,16 @@
// Package handlers contains all HTTP handlers - the functions/methods that
// actually receive a request and write a response. Handlers are kept thin:
// they parse input, call into repositories/other packages to do real work,
// and format the output. Business logic that isn't purely "HTTP plumbing"
// generally belongs elsewhere (e.g. in the models package).
package handlers
import "net/http"
// Health is a simple liveness check endpoint: GET /health.
// Useful for load balancers, container orchestrators (Docker/Kubernetes),
// and uptime monitors to confirm the process is up and responding, without
// needing to touch the database or any other dependency.
func Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+209
View File
@@ -0,0 +1,209 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"golang.org/x/oauth2"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
// GoogleOAuthHandler implements "Sign in with Google" using the OAuth2
// Authorization Code flow:
//
// 1. GET /auth/google/login - we redirect the browser to Google.
// 2. User logs into Google and approves access, entirely on Google's own
// site - our server is not involved in that step at all.
// 3. GET /auth/google/callback - Google redirects the browser back to us
// with a temporary ?code=..., which we exchange (server-to-server,
// never visible to the browser) for an access token, then use that
// token to ask Google who the user is.
type GoogleOAuthHandler struct {
config *oauth2.Config
userRepo *models.UserRepository
sessions *scs.SessionManager
logger *slog.Logger
}
func NewGoogleOAuthHandler(config *oauth2.Config, userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *GoogleOAuthHandler {
return &GoogleOAuthHandler{config: config, userRepo: userRepo, sessions: sessions, logger: logger}
}
// oauthStateSessionKey is where we temporarily stash the CSRF-protection
// "state" value between the /login redirect and the /callback request.
const oauthStateSessionKey = "oauth_state"
// Login handles GET /auth/google/login: builds Google's consent-screen URL
// and redirects the browser there.
func (h *GoogleOAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
state, err := generateState()
if err != nil {
h.logger.Error("generate oauth state failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// We store "state" in the visitor's session (not a package-level
// variable!) so it's correctly scoped per-visitor even with many
// concurrent users starting the login flow at the same time. It
// survives because the session cookie is already set on the browser
// before login - sessions work for anonymous visitors too, they just
// don't have a UserIDKey set yet.
h.sessions.Put(r.Context(), oauthStateSessionKey, state)
// AuthCodeURL builds the full URL to Google's consent screen,
// embedding our client ID, redirect URL, requested scopes, and state.
url := h.config.AuthCodeURL(state)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// Callback handles GET /auth/google/callback: Google redirects the browser
// here with ?state=...&code=... after the user approves access.
func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
// CSRF protection: confirm the state Google sent back matches the one
// WE generated for this specific login attempt. Without this check, an
// attacker could craft their own callback link using their own
// Google account and trick a victim into using it, potentially linking
// the attacker's Google account to the victim's session.
expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
if expectedState == "" || r.URL.Query().Get("state") != expectedState {
writeError(w, http.StatusBadRequest, "invalid oauth state")
return
}
// The state value is single-use - remove it from the session now that
// we've validated it, so it can't be replayed.
h.sessions.Remove(r.Context(), oauthStateSessionKey)
code := r.URL.Query().Get("code")
if code == "" {
writeError(w, http.StatusBadRequest, "missing code")
return
}
// Server-to-server call to Google: exchange the temporary, single-use
// code for a real access token. This request includes our
// ClientSecret, proving to Google that it's really our registered
// application making the request.
token, err := h.config.Exchange(r.Context(), code)
if err != nil {
h.logger.Error("oauth exchange failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// h.config.Client returns a regular *http.Client, pre-configured to
// automatically attach the access token as an "Authorization: Bearer
// ..." header on every request it makes - no manual header handling
// needed.
client := h.config.Client(r.Context(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
h.logger.Error("fetch google userinfo failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
h.logger.Error("read google userinfo failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
}
if err := json.Unmarshal(body, &googleUser); err != nil {
h.logger.Error("parse google userinfo failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
user, err := h.findOrCreateGoogleUser(r, googleUser.ID, googleUser.Email)
if err != nil {
h.logger.Error("find or create google user failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// Same session-fixation defense and "store only the ID" pattern as
// the password-based Login handler in auth.go.
if err := h.sessions.RenewToken(r.Context()); err != nil {
h.logger.Error("renew token failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
// findOrCreateGoogleUser links a Google identity to a local user account.
// There are three possible cases:
//
// 1. No user exists with this email yet -> create a brand new user,
// Google-only (empty PasswordHash).
// 2. A user already exists with this email AND already has this
// Google account linked -> just return them.
// 3. A user already exists with this email but registered via password
// (no GoogleID yet) -> link this Google account to that existing
// user, so they can log in either way going forward.
func (h *GoogleOAuthHandler) findOrCreateGoogleUser(r *http.Request, googleID, email string) (*models.User, error) {
existing, err := h.userRepo.FindByEmail(r.Context(), email)
if errors.Is(err, models.ErrUserNotFound) {
newUser := &models.User{
Email: email,
GoogleID: googleID,
// PasswordHash intentionally left empty - this user can only
// log in via Google unless they later set a password (not
// implemented in this learning project, but would be a
// natural next feature).
}
if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
return nil, createErr
}
return newUser, nil
}
if err != nil {
return nil, err
}
// A user with this email already exists. If they haven't linked
// Google yet, link it now.
if existing.GoogleID == "" {
if linkErr := h.userRepo.SetGoogleID(r.Context(), existing.ID, googleID); linkErr != nil {
return nil, linkErr
}
existing.GoogleID = googleID
}
return existing, nil
}
// generateState creates a cryptographically random, URL-safe string used
// as the OAuth2 "state" CSRF-protection parameter.
//
// Note this uses crypto/rand, NOT math/rand - crypto/rand is suitable for
// security-sensitive randomness (unpredictable even to an attacker who
// knows previous outputs), while math/rand is not.
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
+28
View File
@@ -0,0 +1,28 @@
package handlers
import (
"encoding/json"
"net/http"
)
// writeJSON is a tiny shared helper used by every handler in this package
// to avoid repeating the "set Content-Type header, write status code,
// encode body as JSON" sequence over and over.
//
// data is typed `any` (Go's built-in alias for interface{}, since Go 1.18)
// so this one function can serialize maps, structs, slices - anything
// encoding/json knows how to handle.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// Encoder writes JSON directly to the ResponseWriter (which is just an
// io.Writer under the hood) - no need to build the JSON bytes in a
// separate variable first.
json.NewEncoder(w).Encode(data)
}
// writeError is a thin wrapper around writeJSON for the extremely common
// case of returning a single {"error": "..."} body.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
+43
View File
@@ -0,0 +1,43 @@
// Package logging builds the single *slog.Logger instance shared across the
// whole application. Every log line the app writes goes through this
// logger, formatted as JSON to stdout.
//
// Why JSON to stdout specifically? This is the standard "12-factor app"
// approach to logging: the application doesn't know or care where its logs
// end up (a file, Loki, Elasticsearch, ...) - it just writes structured
// lines to stdout, and an external agent (in your case, Grafana Alloy)
// takes care of collecting, parsing, and shipping them. Because every line
// is valid JSON with consistent keys, Alloy/Loki can index and query on
// fields like "status", "path", or "request_id" without any custom parsing
// rules or regexes.
package logging
import (
"log/slog"
"os"
)
// New builds and returns the application's structured logger.
//
// The minimum log level is controlled by the LOG_LEVEL environment
// variable: set LOG_LEVEL=debug to see verbose Debug()-level output;
// anything else (or unset) defaults to Info level, which hides Debug logs.
func New() *slog.Logger {
level := slog.LevelInfo
if os.Getenv("LOG_LEVEL") == "debug" {
level = slog.LevelDebug
}
// slog.NewJSONHandler formats every log record as a single-line JSON
// object and writes it to the given io.Writer (here, os.Stdout).
// Swapping this for slog.NewTextHandler would switch to human-readable
// text output instead - useful for local dev if you ever want it - but
// every other part of the app that calls logger.Info/Error/etc would
// stay completely unchanged, since they only depend on *slog.Logger,
// not on which Handler is behind it.
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
+71
View File
@@ -0,0 +1,71 @@
// Package middleware contains chi-compatible HTTP middleware: functions
// matching the shape func(http.Handler) http.Handler, each wrapping the
// next handler in the chain to add some cross-cutting behavior (logging,
// authentication, ...) before and/or after the real handler runs.
package middleware
import (
"log/slog"
"net/http"
"time"
// Aliased to chimw so it doesn't collide with this package's own name
// ("middleware") when referenced from other files/packages.
chimw "github.com/go-chi/chi/v5/middleware"
)
// RequestLogger is a middleware FACTORY: a function that takes the
// dependencies it needs (here, just a logger) and returns the actual
// middleware function chi expects. This extra layer exists because chi's
// r.Use() only accepts func(http.Handler) http.Handler - there's no room
// to pass in a logger directly, so we wrap it in an outer function that
// captures the logger in a closure instead.
//
// There are three layers of function here, each running at a different
// time:
//
// RequestLogger(logger) -> runs ONCE, when building the router
// func(next http.Handler) ... -> runs ONCE, when chi wires up the chain
// func(w, r) { ... } -> runs on EVERY request
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture the start time BEFORE the request is handled, so we
// can measure total duration afterward.
start := time.Now()
// A plain http.ResponseWriter only lets you WRITE a status
// code/body - it doesn't let you read back what was written
// afterward. WrapResponseWriter adds that: once the handler
// below has run, ww.Status() and ww.BytesWritten() become
// available. We must pass ww (not w) to next.ServeHTTP so the
// wrapping actually captures what gets written downstream.
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
// Run the rest of the middleware chain / the final handler.
// Everything above this line happens BEFORE the request is
// handled; everything below happens AFTER the response has
// been written.
next.ServeHTTP(ww, r)
// Emit one structured JSON log line per request, with typed
// fields (slog.String, slog.Int, slog.Duration) so each one
// becomes an independently queryable key once these logs land
// in Loki via Grafana Alloy.
logger.Info("http_request",
// GetReqID reads back the request ID that chi's own
// RequestID middleware (registered earlier in the chain,
// see router.go) attached to the request's context - this
// lets you correlate every log line belonging to one
// specific request.
slog.String("request_id", chimw.GetReqID(r.Context())),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int("bytes", ww.BytesWritten()),
slog.Duration("duration_ms", time.Since(start)),
slog.String("remote_addr", r.RemoteAddr),
)
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
// contextKey is a private, unexported type used as the type of our context
// key below. This is a well-known Go idiom to avoid key collisions: since
// context.WithValue keys are compared by BOTH type and value, using our
// own named type (instead of a plain string) guarantees userContextKey can
// never accidentally collide with a key defined by another package, even
// if the underlying text happened to be identical.
type contextKey string
const userContextKey contextKey = "current_user"
// RequireAuth is a middleware factory (same three-layer shape as
// RequestLogger) that protects a route: it checks the caller's session for
// a logged-in user ID, loads the full user from the database, and - only
// if that all succeeds - stores the user in the request's context and lets
// the request continue. If anything fails, it responds 401 immediately and
// the wrapped handler never runs at all.
func RequireAuth(sessions *scs.SessionManager, userRepo *models.UserRepository, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// GetInt returns the zero value (0) if the key was never set
// in the session - which is exactly what happens for a
// visitor who never logged in.
userID := sessions.GetInt(r.Context(), session.UserIDKey)
if userID == 0 {
writeUnauthorized(w)
return
}
user, err := userRepo.FindByID(r.Context(), userID)
if err != nil {
// Covers both "no such user" (e.g. the account was
// deleted after this session was created) and genuine
// database errors - either way, this request cannot
// proceed as authenticated.
logger.Error("require auth: find user failed", "error", err, "user_id", userID)
writeUnauthorized(w)
return
}
// context.WithValue returns a NEW context wrapping the old
// one plus our key/value pair - contexts are immutable, you
// can't add to an existing one in place. Similarly,
// r.WithContext returns a NEW *http.Request carrying that
// context; we pass that new request onward so downstream
// handlers can read the user back out.
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CurrentUser lets handlers retrieve the authenticated user that
// RequireAuth already loaded and stashed in the request's context.
// Handlers never need to know about userContextKey directly (it's
// unexported - only this file can create or read that specific key) - they
// just call this function.
func CurrentUser(r *http.Request) *models.User {
// Value() returns `any`, so we need a type assertion to get back a
// concrete *models.User. The two-value form (`user, ok := ...`) is the
// SAFE version: ok is false if the assertion fails (wrong type, or the
// key simply isn't present) instead of panicking - always prefer this
// form when the value's presence isn't 100% guaranteed.
user, ok := r.Context().Value(userContextKey).(*models.User)
if !ok {
return nil
}
return user
}
// writeUnauthorized writes a plain {"error":"unauthorized"} 401 response.
// Written by hand (instead of reusing handlers.writeError) because
// internal/middleware and internal/handlers are separate packages, and
// writeError is unexported in the handlers package - a deliberate package
// boundary, not an oversight.
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
}
+24
View File
@@ -0,0 +1,24 @@
// Package models contains the application's domain types (plain data
// structs like User) and their repositories (the code that knows how to
// load/save them from/to the database).
package models
import "time"
// User represents one account in our system. It can be authenticated in
// two different ways, both represented on the same row:
// - Password login: PasswordHash is set, GoogleID is empty.
// - Google login: GoogleID is set, PasswordHash is empty.
// - Both: a password user later links a Google account (or
// vice versa), and both fields end up populated.
//
// Deliberately NOT included here: a plaintext Password field. This struct
// should never hold a plaintext password in memory beyond the brief moment
// it's hashed during registration - see handlers.Register.
type User struct {
ID int
Email string
PasswordHash string
GoogleID string
CreatedAt time.Time
}
+111
View File
@@ -0,0 +1,111 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// ErrUserNotFound is a sentinel error returned by any lookup method below
// when no matching row exists. Callers use errors.Is(err, ErrUserNotFound)
// to distinguish "not found" (an expected, normal outcome - e.g. checking
// if an email is already registered) from a real database failure.
var ErrUserNotFound = errors.New("user not found")
// UserRepository is the ONLY place in the entire application that writes
// SQL queries for the users table. Every handler that needs to read or
// write user data goes through here instead of touching *sql.DB directly.
//
// Why bother with this layer instead of just calling db.Query in handlers?
// - If you ever swap MySQL for Postgres (or add a cache in front), you
// change this one file - no handler code needs to know or care.
// - It gives handlers a small, purpose-built vocabulary (Create,
// FindByEmail, FindByID, SetGoogleID) instead of raw SQL strings
// scattered across the codebase.
type UserRepository struct {
db *sql.DB
}
// NewUserRepository is the constructor. Go doesn't have classes/constructors
// built into the language - "NewXxx returns a *Xxx" is just a naming
// convention the whole ecosystem follows.
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
// Create inserts a new user row. It takes a *User (pointer) specifically so
// it can write the newly generated auto-increment ID back into the
// caller's struct after the insert succeeds - the caller passes in a User
// with ID == 0, and walks away with u.ID populated.
func (r *UserRepository) Create(ctx context.Context, u *User) error {
res, err := r.db.ExecContext(ctx,
"INSERT INTO users (email, password_hash, google_id, created_at) VALUES (?, ?, ?, ?)",
u.Email, u.PasswordHash, u.GoogleID, time.Now(),
)
if err != nil {
return fmt.Errorf("create user: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("get last insert id: %w", err)
}
u.ID = int(id)
return nil
}
// FindByEmail looks up a user by their email address. This is what the
// login flow uses: the user submits an email, we look up the matching row,
// then compare the submitted password against the stored PasswordHash.
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE email = ?", email,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
// sql.ErrNoRows is the driver's own sentinel for "query matched
// zero rows". We translate it into OUR sentinel (ErrUserNotFound)
// so callers never need to know or care that the underlying
// storage is SQL at all.
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("find user by email: %w", err)
}
return &u, nil
}
// FindByID looks up a user by their numeric ID. This is used after a
// session is validated: the session only stores the user's ID, so we look
// the rest of the user up fresh on every authenticated request (see
// middleware.RequireAuth).
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE id = ?", id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("find user by id: %w", err)
}
return &u, nil
}
// SetGoogleID links a Google account to an existing user row - used the
// first time a user who originally registered with a password logs in via
// "Sign in with Google" using the same email address.
func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
)
if err != nil {
return fmt.Errorf("set google id: %w", err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
// Package oauth builds provider-specific OAuth2 configurations. Right now
// there's only Google, but the package name is deliberately generic (not
// "google") so a second provider (GitHub, Microsoft, ...) could live
// alongside it later as its own file/function without a rename.
package oauth
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
// NewGoogleConfig builds the *oauth2.Config describing how to talk to
// Google's OAuth2 system: our app's identity (ClientID/ClientSecret),
// where Google should redirect the user back to after login
// (RedirectURL - this MUST exactly match what's registered in the Google
// Cloud Console), what permission we're requesting (Scopes), and which
// provider's specific auth/token URLs to use (Endpoint).
func NewGoogleConfig(cfg config.Config) *oauth2.Config {
return &oauth2.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
RedirectURL: cfg.GoogleRedirectURL,
// We only ask for the user's email - the minimum needed to
// identify/create an account. Requesting more scopes than you
// actually need is both a privacy and a security smell.
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
// google.Endpoint is a predefined value from
// golang.org/x/oauth2/google pointing at Google's real
// authorization and token URLs - we never hardcode those
// ourselves.
Endpoint: google.Endpoint,
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package router is where the whole application gets wired together: it
// receives already-constructed shared dependencies (logger, db, sessions,
// config) from main.go, builds the handlers and middleware that need them,
// and registers every route on a chi.Mux.
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)
// New builds and returns the fully configured chi router for the
// application. *chi.Mux implements http.Handler (it has a ServeHTTP
// method), so the return value can be passed directly to http.Server as
// its Handler - see cmd/api/main.go.
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
r := chi.NewRouter()
// --- Global middleware stack ---
// Order matters here: each middleware wraps everything registered
// after it, so requests flow through this list top-to-bottom on the
// way in, and bottom-to-top on the way out.
// Tags every request with a unique ID, retrievable later via
// chimw.GetReqID(ctx) - used by our RequestLogger below, and useful
// for correlating log lines to one specific request once shipped to
// Loki.
r.Use(chimw.RequestID)
// Our own structured JSON request logger (internal/middleware),
// replacing chi's built-in plain-text Logger.
r.Use(middleware.RequestLogger(logger))
// Recovers from panics in any handler, returning a 500 instead of
// crashing the whole server process for one bad request.
r.Use(chimw.Recoverer)
// Cancels a request's context if it runs longer than this, so a slow
// downstream call (e.g. a hung database query) can't hold a
// connection open forever.
r.Use(chimw.Timeout(60 * time.Second))
// CORS: controls which browser-based frontends (running on a
// different origin than this API) are allowed to call it with
// credentials (cookies) attached. This does NOT protect against
// non-browser callers (curl, mobile apps, server-to-server) - CORS is
// a browser-enforced rule, not a server-side security boundary.
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true, // required for the session cookie to be sent cross-origin
}))
// A generous, global rate limit - mostly a safety net against
// runaway scripts/bots hitting the API in general. 100 requests per
// IP per minute.
r.Use(httprate.LimitByIP(100, time.Minute))
// scs's own middleware: loads session data (from Redis) into the
// request's context before the handler runs, and saves any changes
// back (plus sets/refreshes the cookie) after the handler finishes.
// Every route below this line can use sessions.Get/Put/Destroy.
r.Use(sessions.LoadAndSave)
// --- Public routes ---
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
// Register/Login get a MUCH stricter rate limit than the global one,
// since these are exactly the endpoints a credential-stuffing /
// brute-force script would target: 5 requests per IP per minute.
// r.Group scopes this middleware to only the routes registered inside
// the closure - it does not affect any route registered outside it.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(5, time.Minute))
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
})
// Deliberately OUTSIDE the strict-rate-limit group above - we don't
// want to rate-limit a legitimate logged-in user trying to log out.
r.Post("/logout", authHandler.Logout)
// --- Protected routes ---
// Every route registered inside this group first passes through
// requireAuth; if the caller isn't authenticated, requireAuth responds
// 401 and the route handler never runs at all. Add future
// authenticated-only routes inside this same group.
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Get("/me", authHandler.Me)
})
// --- Google OAuth2 routes ---
googleConfig := oauth.NewGoogleConfig(cfg)
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
r.Get("/auth/google/login", googleHandler.Login)
r.Get("/auth/google/callback", googleHandler.Callback)
return r
}
+10
View File
@@ -0,0 +1,10 @@
package session
// UserIDKey is the string key under which we store the logged-in user's
// numeric ID inside the session (via sessions.Put(ctx, UserIDKey, id)).
//
// This constant exists so the key is spelled exactly once, in one place -
// every other file that touches this key (login, logout, the auth
// middleware) imports this constant instead of retyping the raw string
// "user_id", which would risk a typo silently breaking authentication.
const UserIDKey = "user_id"
+74
View File
@@ -0,0 +1,74 @@
// Package session builds and configures the application's server-side
// session manager. We use github.com/alexedwards/scs (SCS) for the session
// framework itself, backed by Redis for storage.
//
// "Server-side session" means: the browser only ever holds a random,
// meaningless token in a cookie. All the actual session DATA (which user
// is logged in, etc.) lives in Redis, keyed by that token. This is in
// contrast to storing data directly inside a signed/encrypted cookie -
// server-side sessions can be instantly revoked (just delete the Redis
// key), don't grow the cookie size as you store more data, and never
// expose their contents to the browser at all.
package session
import (
"net/http"
"time"
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gomodule/redigo/redis"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
// New builds a *scs.SessionManager wired up to Redis, with cookie settings
// appropriate for an authentication system.
func New(cfg config.Config) *scs.SessionManager {
// redigo's connection pool - conceptually identical to *sql.DB's pool
// from the database package, just for Redis instead of MySQL. Dial is
// a function the pool calls whenever it needs to open a new
// connection.
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", cfg.RedisAddr)
},
}
manager := scs.New()
// By default scs stores session data IN MEMORY, which would be lost on
// every restart and wouldn't work at all if you ever ran more than one
// instance of this app behind a load balancer. Setting .Store swaps
// the storage backend to Redis while keeping the exact same
// Put/Get/Destroy API - the rest of the app never needs to know Redis
// is involved at all.
manager.Store = redisstore.New(pool)
// How long a session stays valid after creation.
manager.Lifetime = 24 * time.Hour
// --- Cookie security settings ---
manager.Cookie.Name = "session_id"
// HttpOnly: JavaScript in the browser cannot read this cookie via
// document.cookie. Blocks a large class of XSS-based session theft.
manager.Cookie.HttpOnly = true
// SameSite=Lax: the cookie IS sent on normal top-level navigation
// (e.g. clicking a link to this site, or Google redirecting back to
// our OAuth callback), but is NOT sent on cross-site requests
// triggered by another page (e.g. a malicious auto-submitting <form>
// pointed at our /logout). This is our primary CSRF defense for the
// cookie-based session.
manager.Cookie.SameSite = http.SameSiteLaxMode
// Secure: when true, the browser will refuse to send this cookie over
// plain HTTP, only HTTPS. We only turn this on in production, because
// enabling it during local development (over http://localhost) would
// silently break the cookie entirely.
manager.Cookie.Secure = cfg.Env == "production"
return manager
}
+64
View File
@@ -0,0 +1,64 @@
# Go Web API Course — Full Index
This course teaches you Go by building a real authentication API: chi
router, MySQL, Redis-backed sessions, password login, "Sign in with
Google", rate limiting, structured logging, and Docker — from an empty
folder to a containerized, production-shaped service.
It assumes **zero prior Go knowledge**. If you've never written a line of
Go before, start with the three "Go Basics" lessons below — everything
after that leans on them constantly.
## Go Basics (do these first if you're new to Go)
| File | Covers |
|---|---|
| `00-go-basics-1-syntax-and-types.md` | Installing Go, `go run`/`go build`, variables, basic types, `if`/`for`/`switch`, `fmt.Println` |
| `00-go-basics-2-functions-structs-pointers.md` | Functions, multiple return values, structs, methods, pointers (`*`/`&`) |
| `00-go-basics-3-interfaces-errors-concurrency-packages.md` | Interfaces, error handling, slices & maps, packages & modules, goroutines, JSON basics |
## The Project Lessons
| # | File | Builds |
|---|---|---|
| 1 | `lesson-01-project-skeleton-chi-routing.md` | Project layout, chi router, graceful shutdown |
| 2 | `lesson-02-structured-json-logging.md` | `log/slog` JSON logging, request-logging middleware |
| 3 | `lesson-03-config-and-mysql.md` | Env config, MySQL connection pooling |
| 4 | `lesson-04-user-model-repository-pattern.md` | Domain models, the repository pattern |
| 5 | `lesson-05-password-login-bcrypt.md` | bcrypt password hashing, register/login handlers |
| 6 | `lesson-06-sessions-scs-redis.md` | Server-side sessions backed by Redis |
| 7 | `lesson-07-google-oauth.md` | "Sign in with Google" (OAuth2 Authorization Code flow) |
| 8 | `lesson-08-auth-middleware.md` | `context.Context`, reusable auth-guard middleware |
| 9 | `lesson-09-rate-limiting-security.md` | Rate limiting, CORS, cookie hardening |
| 10 | `lesson-10-docker-wrapup.md` | Docker, docker-compose, full course review |
## How each lesson is structured
Every project lesson (110) has two parts:
- **Part A — standalone playground.** A tiny, throwaway program that
teaches the *new* concept in isolation, with nothing else going on. You
build and run this in its own scratch folder.
- **Part B — apply it to the project.** The same concept, now wired into
the real, growing `go-simple-api` project, building on the previous
lesson's code.
Each lesson also has a **"New Go concepts in this lesson"** box near the
top, pointing back at the specific Go Basics section you should understand
first. If something feels unfamiliar, that's the place to go check.
## What you'll have by the end
A real, working Go web service with:
- Password-based registration/login (bcrypt-hashed passwords)
- "Sign in with Google" (OAuth2)
- Server-side sessions stored in Redis
- MySQL-backed user storage via a repository pattern
- Structured JSON logging (ready for Grafana Loki / Alloy)
- Rate limiting and basic security hardening
- A Docker Compose setup running the whole stack with one command
Go at your own pace. Each lesson builds directly on the file state left by
the previous one — if you get lost, the companion `go-simple-api` code zip
(from earlier) has the final, correct state of every file at the end of
the whole course.
+291
View File
@@ -0,0 +1,291 @@
# Go Basics, Part 1 — Syntax, Types, and Control Flow
This is the first of three "Go Basics" lessons. If you've never written Go
before, do all three before starting Lesson 1 of the main course. If you
already know another programming language (Python, JavaScript, PHP, Java,
C, etc.), you'll move through this fast — Go is a small, simple language
on purpose.
## 1. Installing Go and running your first program
Download and install Go from [go.dev/dl](https://go.dev/dl/). Confirm it worked:
```bash
go version
```
You should see something like `go version go1.26.x`.
Make a folder and your first program:
```bash
mkdir hello && cd hello
go mod init hello
```
`go mod init hello` creates a `go.mod` file — this marks the folder as a
**Go module** (a self-contained project with its own dependencies). We'll
explain modules properly in Part 3; for now, just know every Go project
needs one.
**`main.go`**
```go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
```
Run it:
```bash
go run .
```
You should see `hello, world` printed. Let's break down every single piece
of that file, since you'll type this pattern constantly:
- `package main` — every Go file starts by declaring which **package** it
belongs to. A package is just a folder of `.go` files that are compiled
together and can freely call each other's code. `package main` is
special: it means "this produces a runnable program," not a reusable
library.
- `import "fmt"` — pulls in the standard library's `fmt` package (short
for "format"), which has functions for printing text and formatting
strings.
- `func main()` — the function named `main`, inside `package main`, is the
**entry point**. When you run the compiled program, execution starts
here. There must be exactly one `main` function in `package main`.
- `fmt.Println("hello, world")` — calls the `Println` function from the
`fmt` package (note the dot: `package.Function`), passing it the string
`"hello, world"`. `Println` prints its arguments followed by a newline.
Two ways to run Go code:
- `go run .` (or `go run main.go`) — compiles and runs immediately,
doesn't leave a binary behind. Good for development.
- `go build .` — compiles into an actual executable file (e.g. `hello` or
`hello.exe`) that you can run directly (`./hello`) without Go installed
on the machine that runs it. This is what you'd do to ship the program.
## 2. Variables and basic types
Go is **statically typed**: every variable has a fixed type, decided
either explicitly or by inference, and that type never changes.
```go
package main
import "fmt"
func main() {
// Explicit type
var age int = 30
// Type inferred from the value (int, since 30 has no decimal point)
var name = "Hamid"
// The short declaration operator ":=" — declares AND assigns in one
// step, inferring the type. This is by far the most common way to
// declare variables inside a function body.
city := "Tehran"
height := 1.78 // inferred as float64
fmt.Println(age, name, city, height)
// Reassignment - no "var" or ":=" needed, the variable already exists
age = 31
fmt.Println(age)
// Multiple variables at once
var x, y int = 1, 2
a, b := 3, 4
fmt.Println(x, y, a, b)
}
```
Key rules:
- `:=` can ONLY be used to declare a **new** variable (usually inside a
function). It's shorthand for `var x = value` with the type inferred.
- `var` can be used with or without an initial value: `var count int`
declares `count` as an `int` with the **zero value** `0` (Go always
initializes variables — there's no "undefined" or garbage value).
- You cannot declare a variable and never use it — Go's compiler will
refuse to build code with an unused local variable. This trips up
everyone coming from other languages at first.
### The built-in types you'll use constantly
| Type | Example | Notes |
|---|---|---|
| `int` | `42` | Platform-dependent size (64-bit on modern machines); use this by default for whole numbers |
| `string` | `"hello"` | UTF-8 text, immutable |
| `bool` | `true`, `false` | |
| `float64` | `3.14` | Default type for decimal numbers |
| `byte` | | Alias for `uint8`, used for raw binary data |
| `[]byte` | | A "slice of bytes" — how Go represents raw binary data (we cast strings to this constantly, e.g. for password hashing) |
Zero values (what a variable holds if declared without an initial value):
`int``0`, `string``""` (empty string), `bool``false`, pointers →
`nil` (explained in Part 2).
### Type conversion
Go **never** silently converts between types (unlike JavaScript or PHP).
You must convert explicitly:
```go
var i int = 42
var f float64 = float64(i) // must explicitly convert int -> float64
var s string = fmt.Sprintf("%d", i) // int -> string via formatting
id := "123"
// s, err := strconv.Atoi(id) // string -> int, using the strconv package
```
You'll see this constantly in the main course, e.g. `int(id)` when
converting a database's `int64` auto-increment ID into our own `int`
field.
## 3. `if`, `for`, and `switch`
Go has exactly one looping construct — `for` — no `while`, no `do-while`.
It also does **not** use parentheses around conditions, but curly braces
`{ }` are always required (even for a single-line body).
### `if`
```go
age := 20
if age >= 18 {
fmt.Println("adult")
} else if age >= 13 {
fmt.Println("teenager")
} else {
fmt.Println("child")
}
```
A very common Go idiom: declaring a variable that's scoped ONLY to the
`if`/`else` block, often used with error handling (you'll see this
constantly starting in Part 2):
```go
if err := doSomething(); err != nil {
fmt.Println("failed:", err)
}
// err doesn't exist out here — it was scoped to the if statement
```
### `for` — Go's only loop
```go
// Classic three-part loop (like C's for)
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// "while" style - just the condition
count := 0
for count < 3 {
fmt.Println("counting:", count)
count++
}
// Infinite loop - break out manually
for {
fmt.Println("runs forever until break")
break
}
// Looping over a collection (slices, maps - covered in Part 3)
names := []string{"alice", "bob", "carol"}
for index, name := range names {
fmt.Println(index, name)
}
// If you don't need the index, use _ (the "blank identifier") to
// explicitly discard it - Go's compiler complains about unused
// variables, and _ is the escape hatch:
for _, name := range names {
fmt.Println(name)
}
```
You'll see `_` constantly throughout the main course — any time a
function returns something you genuinely don't need, `_` discards it
without triggering an "unused variable" error.
### `switch`
```go
day := "Monday"
switch day {
case "Saturday", "Sunday":
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
```
Unlike C/Java, Go's `switch` cases do **not** fall through by default —
each case automatically breaks after its block, no `break` statement
needed.
## 4. Strings, formatting, and comments
```go
name := "Hamid"
age := 31
// Println - space-separated, newline at the end
fmt.Println("name:", name, "age:", age)
// Printf - C-style format string, YOU add the newline with \n
fmt.Printf("name: %s, age: %d\n", name, age)
// Sprintf - same as Printf but returns a string instead of printing it
message := fmt.Sprintf("hello %s, you are %d years old", name, age)
fmt.Println(message)
```
Common format verbs you'll use throughout the course:
| Verb | Meaning |
|---|---|
| `%s` | string |
| `%d` | integer |
| `%f` | float |
| `%v` | "default" representation of any value — great for debugging |
| `%+v` | like `%v` but includes struct field names |
| `%w` | wraps an error (covered in Part 3) — only valid with `fmt.Errorf` |
Comments:
```go
// A single-line comment
/*
A multi-line comment.
*/
// A comment directly above a function/type, with no blank line between,
// is a DOC comment - tools display it as that function's documentation.
// This project's code uses these constantly.
func DoSomething() {}
```
## 5. Try it yourself
Before moving to Part 2, write a small standalone program (new folder,
`go mod init practice`, `main.go`) that:
1. Declares a `string` name and an `int` age using `:=`.
2. Uses `if`/`else if`/`else` to print a different message depending on
the age (e.g. "minor", "adult", "senior" for under 18 / under 65 / 65+).
3. Uses a `for` loop to print the numbers 1 through 10.
4. Uses `fmt.Printf` to print the name and age formatted into one sentence.
Run it with `go run .`. Once this feels natural, move to Part 2 —
functions, structs, and pointers, which is where Go starts looking like
the code you'll write in the actual course.
@@ -0,0 +1,339 @@
# Go Basics, Part 2 — Functions, Structs, Methods, and Pointers
This continues directly from Part 1. By the end of this lesson you'll
understand every syntactic shape used in the main course's handler and
repository code.
## 1. Functions
```go
package main
import "fmt"
// A function with two parameters (both int) and one return value (int).
// Parameters sharing a type can share the type annotation: "a, b int"
// means both a and b are int.
func add(a, b int) int {
return a + b
}
func main() {
sum := add(3, 4)
fmt.Println(sum) // 7
}
```
### Multiple return values — used constantly in Go
This is one of Go's most distinctive features, and you'll see it on
almost every line of real Go code, especially for error handling:
```go
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
```
The pattern `value, err := someFunc()` followed immediately by
`if err != nil { ... }` is THE dominant idiom in Go. You will type this
exact shape hundreds of times in the main course. There are no exceptions
/ try-catch in Go (with one narrow exception, `panic`/`recover`, which
we'll touch on briefly later) — errors are just regular return values that
you're expected to check every time.
`nil` is Go's "no value" — similar to `null` in other languages. It's the
zero value for pointers, interfaces, slices, maps, channels, and function
types. `error` is an interface (explained in Part 3), so `nil` is its zero
value too — "no error occurred."
### Named return values (used occasionally, good to recognize)
```go
func divide(a, b int) (result int, err error) {
if b == 0 {
err = fmt.Errorf("cannot divide by zero")
return
}
result = a / b
return
}
```
`result` and `err` are declared as part of the function signature; a bare
`return` sends back their current values. You won't write much code this
way in this course, but you'll see it in standard-library source if you
ever go looking.
### Anonymous functions and closures
A function can be defined without a name and assigned to a variable, or
passed directly as an argument:
```go
square := func(n int) int {
return n * n
}
fmt.Println(square(5)) // 25
```
A **closure** is an anonymous function that "remembers" variables from the
scope it was created in, even after that outer function has returned:
```go
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3 - count persists between calls!
}
```
This is important: `makeCounter` returns a function, and that returned
function still has access to `count`, which technically belongs to
`makeCounter`'s own (finished) execution. This exact mechanism is what
makes Go's HTTP middleware pattern work — you'll see functions that take
some setup arguments and return another function, three layers deep, all
throughout the main course (starting in Lesson 2). Understanding this
closure example is the key to understanding that pattern.
## 2. Structs — Go's way of grouping data
Go doesn't have classes. Instead, it has **structs**: named groups of
fields.
```go
package main
import "fmt"
type User struct {
Name string
Age int
}
func main() {
// Construct a struct with a "struct literal"
u := User{
Name: "Hamid",
Age: 31,
}
fmt.Println(u.Name, u.Age) // access fields with dot notation
u.Age = 32 // fields are mutable
fmt.Println(u.Age)
// You can also build one without field names, in declared order
// (works, but fragile - prefer named fields)
u2 := User{"Sara", 28}
fmt.Println(u2)
}
```
### Capitalization matters: exported vs. unexported
This is one of Go's most important and most beginner-surprising rules:
> **Any identifier (variable, function, type, struct field...) that
> starts with an UPPERCASE letter is "exported" — visible outside its
> package. Anything starting lowercase is "unexported" — private to its
> own package.**
There's no `public`/`private` keyword. Capitalization IS the access
control.
```go
type User struct {
Name string // exported - visible to other packages
age int // unexported - only visible inside THIS package
}
```
You'll see this constantly in the main course: struct fields like
`models.User.Email` are capitalized (need to be readable/settable from
`handlers`), while helper functions like `getEnv` in the config package
are lowercase (only used internally, no other package needs them).
### Struct tags — metadata attached to fields
```go
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
```
The text in backticks after a field is a **struct tag** — a string of
metadata that other packages can read via reflection. `encoding/json`
(the standard library's JSON package) reads the `json:"..."` tag to know
"the JSON key `email` maps to this Go field," regardless of the Go field
name's capitalization. We use this on nearly every request/response struct
in the main course.
## 3. Pointers (`*` and `&`) — the single most important concept to nail down
Every variable lives somewhere in memory, at an address. A **pointer** is
a variable whose value IS a memory address — it "points to" where another
variable lives.
- `&x` — "give me the address of `x`" (turns a value into a pointer to it)
- `*T` (in a type position) — means "a pointer to a `T`", e.g. `*int`,
`*User`
- `*p` (in an expression) — "dereference `p`": go to the address it holds
and read/write the value stored there
```go
package main
import "fmt"
func main() {
x := 10
p := &x // p is a pointer to x; p holds x's memory address
fmt.Println(x) // 10
fmt.Println(p) // something like 0xc0000140a0 - an address
fmt.Println(*p) // 10 - dereferencing p gives x's value back
*p = 20 // dereference p, then assign through it - changes x itself!
fmt.Println(x) // 20
}
```
### Why pointers matter: Go passes everything by value
When you pass a variable to a function, the function receives a **copy**.
If you want a function to actually modify the caller's variable, you must
pass a pointer, and the function must dereference it to write through.
```go
func double(n int) {
n = n * 2 // only changes the LOCAL COPY
}
func doublePtr(n *int) {
*n = *n * 2 // dereferences and changes the ORIGINAL
}
func main() {
x := 5
double(x)
fmt.Println(x) // 5 - unchanged!
doublePtr(&x)
fmt.Println(x) // 10 - changed
}
```
### Pointers to structs, and why the main course uses them everywhere
```go
type Book struct {
Title string
Pages int
}
func addPages(b *Book, extra int) {
b.Pages += extra // note: no need to write (*b).Pages, Go allows b.Pages directly
}
func main() {
book := Book{Title: "Go 101", Pages: 100}
addPages(&book, 50)
fmt.Println(book.Pages) // 150
}
```
Note `b.Pages` instead of `(*b).Pages` — Go automatically dereferences
struct pointers for field access, as a convenience. Both work; everyone
writes `b.Pages`.
Two big reasons the main course uses pointers to structs constantly:
1. **Writing a result back into the caller's variable.** E.g. after
inserting a new row into the database, we want to write the newly
generated ID back into the struct the caller already has — that only
works if the function received a pointer.
2. **Sharing one instance instead of copying it.** Things like a database
connection pool or a logger should be ONE shared instance used
everywhere, not copied every time they're passed around. That's why
`sql.Open` returns `*sql.DB`, not `sql.DB` — every part of the app
needs to share the exact same pool.
### Methods and receivers
A **method** is a function attached to a specific type, via a **receiver**
declared between `func` and the method name:
```go
type Counter struct {
count int
}
// Value receiver - c is a COPY of the Counter this method was called on.
func (c Counter) Value() int {
return c.count
}
// Pointer receiver - c is the ADDRESS of the real Counter.
func (c *Counter) Increment() {
c.count++ // modifies the REAL struct, not a copy
}
func main() {
c := Counter{}
c.Increment()
c.Increment()
fmt.Println(c.Value()) // 2
}
```
**Rule of thumb, used throughout the main course:** if a method needs to
modify the struct, or the struct holds a resource like a database
connection, use a pointer receiver (`*Counter`). If the struct is small
and the method is purely read-only, a value receiver is fine — but
pointer receivers are the overwhelming default for anything nontrivial,
and that's what you'll see almost everywhere in this project (e.g. every
method on `*UserRepository`, `*AuthHandler`).
Go automatically inserts the `&` for you when calling a pointer-receiver
method on an addressable value — `c.Increment()` is really
`(&c).Increment()` behind the scenes. You don't need to write that `&`
yourself; just know it's happening.
## 4. Try it yourself
New scratch folder, `go mod init practice2`:
1. Define a `Book` struct with `Title string`, `Author string`, and
`Read bool`.
2. Write a function `NewBook(title, author string) *Book` that constructs
and returns a pointer to a `Book` (this is the exact "constructor"
pattern used throughout the main course — `NewXxx` returning `*Xxx`).
3. Write a method `func (b *Book) MarkAsRead()` that sets `Read = true`.
4. In `main`, create a book with `NewBook`, call `MarkAsRead()` on it, and
print the struct with `fmt.Printf("%+v\n", book)` to confirm `Read` is
now `true`.
Once this feels solid, move to Part 3 — interfaces, error handling,
slices/maps, packages, and a first look at goroutines and JSON, which
rounds out everything you need for Lesson 1.
@@ -0,0 +1,472 @@
# Go Basics, Part 3 — Interfaces, Errors, Collections, Packages, and Concurrency
This is the last basics lesson. It covers everything else the main course
leans on: interfaces (how `http.Handler` and similar types work),
proper error handling patterns, slices and maps, how packages/modules/imports
actually work, a first look at goroutines (needed for graceful shutdown),
and JSON encoding/decoding.
## 1. Interfaces — Go's version of "any type that can do X"
An **interface** is a type defined purely by a set of method signatures.
Any type that has those methods automatically satisfies the interface —
there's no `implements` keyword, no explicit declaration. This is called
**structural typing** or "duck typing, but checked at compile time."
```go
package main
import "fmt"
// Any type with a Speak() string method satisfies Speaker - automatically.
type Speaker interface {
Speak() string
}
type Dog struct{}
func (d Dog) Speak() string { return "Woof!" }
type Cat struct{}
func (c Cat) Speak() string { return "Meow!" }
func announce(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
announce(Dog{}) // Woof!
announce(Cat{}) // Meow!
}
```
`Dog` and `Cat` never mention `Speaker` anywhere in their code. They just
happen to have a method with the right name and signature, which is
enough. This is why, in the main course, `*chi.Mux` can be passed directly
to `http.Server{Handler: r}``http.Handler` is defined (in the standard
library) as:
```go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
```
`*chi.Mux` happens to have a `ServeHTTP` method, so it automatically
satisfies `http.Handler`, with zero extra code. Same story for our own
handlers wrapped via `http.HandlerFunc(...)` — a small built-in adapter
type that turns any function shaped `func(w, r)` into something with a
`ServeHTTP` method, satisfying the interface.
### `any` (a.k.a. `interface{}`)
The empty interface — one with zero required methods — is satisfied by
**every** type, since every type trivially has "at least zero" methods.
Go has a built-in alias for this: `any` (added in Go 1.18; older code
uses the equivalent `interface{}`).
```go
func describe(v any) {
fmt.Printf("value: %v, type: %T\n", v, v)
}
describe(42) // value: 42, type: int
describe("hello") // value: hello, type: string
describe(User{}) // value: {}, type: main.User
```
You'll see `any` used for things like generic JSON response helpers
(`map[string]any`) where the value could be a string, a number, a nested
object — anything.
### Type assertions
If you have a value typed as an interface (or `any`) and need the
concrete type back out, use a **type assertion**:
```go
var v any = "hello"
s := v.(string) // single-value form - PANICS if v isn't actually a string
s, ok := v.(string) // two-value form - SAFE: ok is false on mismatch, no panic
if !ok {
fmt.Println("v was not a string")
}
```
**Always prefer the two-value form** unless you're absolutely certain of
the type — a failed single-value assertion crashes your program. This
shows up in the main course when reading a value back out of a
`context.Context` (Lesson 8) — the value is stored as `any`, so you need a
type assertion to get a concrete struct back.
## 2. Error handling, properly
Go's `error` is just an interface:
```go
type error interface {
Error() string
}
```
Any type with an `Error() string` method IS an error. The standard library
gives you two easy ways to create one:
```go
import (
"errors"
"fmt"
)
err1 := errors.New("something went wrong")
err2 := fmt.Errorf("failed to process user %d", 42)
```
### The `if err != nil` pattern
```go
func readConfig() (string, error) {
// pretend this can fail
return "", errors.New("config file not found")
}
func main() {
config, err := readConfig()
if err != nil {
fmt.Println("error:", err)
return // stop here - don't continue using `config`, it's meaningless
}
fmt.Println("config:", config)
}
```
Checking `err != nil` after every call that can fail, and handling it
immediately, is the single most repeated pattern in idiomatic Go — and in
the entire main course.
### Wrapping errors with `%w`
When an error crosses through several layers of your program, it's useful
to add context at each layer without losing the original error:
```go
func openFile() error {
return errors.New("file not found")
}
func loadConfig() error {
if err := openFile(); err != nil {
return fmt.Errorf("load config: %w", err) // %w WRAPS, preserving err
}
return nil
}
```
`%w` (as opposed to `%v` or `%s`) specifically **wraps** the original
error, meaning code further up the chain can still inspect what the
original error actually was, using `errors.Is` or `errors.As`.
### Sentinel errors and `errors.Is`
A **sentinel error** is a specific, predefined error value that callers
can check for by identity, not by comparing message strings (which is
fragile — messages change, causes bugs).
```go
var ErrNotFound = errors.New("not found")
func findUser(id int) (string, error) {
if id != 1 {
return "", ErrNotFound
}
return "Hamid", nil
}
func main() {
_, err := findUser(99)
if errors.Is(err, ErrNotFound) {
fmt.Println("no such user!")
}
}
```
`errors.Is` works correctly even if the error was wrapped with `%w`
several layers deep — it "unwraps" automatically to check. This exact
pattern (`var ErrUserNotFound = errors.New(...)`, then
`errors.Is(err, ErrUserNotFound)`) is used throughout the main course's
repository layer.
## 3. Slices and maps — Go's core collection types
### Slices — dynamically-sized lists
```go
// A slice literal
names := []string{"alice", "bob", "carol"}
fmt.Println(names[0]) // "alice" - zero-indexed
fmt.Println(len(names)) // 3
names = append(names, "dave") // append returns a NEW slice - reassign it!
fmt.Println(names) // [alice bob carol dave]
// An empty slice, grown later
var scores []int
scores = append(scores, 10)
scores = append(scores, 20)
// Looping (seen in Part 1, repeated here for completeness)
for i, name := range names {
fmt.Println(i, name)
}
```
Important: `append` may or may not modify the original underlying array —
you should always use the return value (`names = append(names, ...)`),
never assume the original variable was updated in place.
### Maps — key/value lookups
```go
ages := map[string]int{
"alice": 30,
"bob": 25,
}
fmt.Println(ages["alice"]) // 30
ages["carol"] = 28 // add/update a key
delete(ages, "bob") // remove a key
// Reading a key that doesn't exist returns the TYPE'S ZERO VALUE, not an
// error or nil-equivalent crash:
fmt.Println(ages["nobody"]) // 0 (the zero value for int)
// The "comma ok" idiom - check whether a key actually exists:
age, ok := ages["nobody"]
if !ok {
fmt.Println("no such key")
}
// Looping over a map (order is NOT guaranteed - it's randomized each run)
for name, age := range ages {
fmt.Println(name, age)
}
```
You'll see `map[string]any` used constantly in the main course for
building ad-hoc JSON responses, e.g. `map[string]any{"id": user.ID,
"email": user.Email}`.
## 4. Packages, imports, and modules — how a real project is organized
You already saw `package main` in Part 1. Any other folder full of `.go`
files declares its own package name (usually matching the folder name),
and can be imported by other code.
```
myproject/
├── go.mod
├── main.go -- package main
└── greeter/
└── greeter.go -- package greeter
```
**`greeter/greeter.go`**
```go
package greeter
func Hello(name string) string {
return "Hello, " + name + "!"
}
```
**`main.go`**
```go
package main
import (
"fmt"
"myproject/greeter" // import path = module path + folder path
)
func main() {
fmt.Println(greeter.Hello("Hamid"))
}
```
The import path `"myproject/greeter"` is built from the module's name
(declared in `go.mod` via `module myproject`) plus the folder path. This
is exactly the pattern behind every internal import you'll see in the main
course, e.g.:
```go
import "git.hamidsoltani.com/hamid/go-simple-api/internal/config"
```
— the module is `git.hamidsoltani.com/hamid/go-simple-api` (declared once,
at the top of the project's `go.mod`), and `internal/config` is the folder
path to that specific package.
### The special `internal/` folder
Any package inside a folder literally named `internal/` can ONLY be
imported by code within the same module (specifically, code rooted at the
parent of `internal/`). This is a compiler-enforced way to say "this code
is a private implementation detail of this project, not a public library
for others to import." The main course's entire codebase lives under
`internal/` for exactly this reason.
### External packages and `go.mod`
To use code someone else published (like the chi router), you add it as a
dependency:
```bash
go get github.com/go-chi/chi/v5@latest
```
This downloads the package, records it in `go.mod` (a "require" line with
a specific version), and records exact checksums in `go.sum` (so builds
are reproducible and verifiably untampered). After that, you import it
just like any other package:
```go
import "github.com/go-chi/chi/v5"
```
`go mod tidy` is a command you'll run often — it scans your code for
imports it doesn't yet know about, fetches them, and also removes
`go.mod` entries for anything you've stopped importing.
## 5. A first look at goroutines (needed for Lesson 1's graceful shutdown)
A **goroutine** is a lightweight, independently-running function — Go's
built-in concurrency primitive. You start one with the `go` keyword:
```go
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("hello from a goroutine")
}
func main() {
go sayHello() // starts sayHello running CONCURRENTLY, doesn't block
fmt.Println("this may print before OR after 'hello from a goroutine'")
time.Sleep(100 * time.Millisecond) // give the goroutine time to run
// without this Sleep, main() might exit before sayHello ever runs -
// when main() returns, the WHOLE PROGRAM exits immediately, goroutines
// and all.
}
```
The key thing to understand: `go someFunction()` starts `someFunction`
running in the background and immediately continues to the next line —
it does **not** wait for `someFunction` to finish. This is exactly why the
main course wraps `srv.ListenAndServe()` in a goroutine in Lesson 1: that
call blocks forever (serving requests) — running it as a goroutine frees
up `main()`'s main line of execution to move on and listen for shutdown
signals (Ctrl+C) instead of getting stuck forever inside `ListenAndServe`.
We won't go deeper into concurrency (channels, `sync.WaitGroup`, etc.) in
this course — the main project only needs this one goroutine pattern.
## 6. JSON basics with `encoding/json`
Go's standard library can convert between Go values and JSON text
automatically, using struct tags (from Part 2) to control field naming.
### Encoding (Go value → JSON)
```go
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
u := User{Name: "Hamid", Age: 31}
// Marshal converts a Go value into a []byte of JSON text
data, err := json.Marshal(u)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(data)) // {"name":"Hamid","age":31}
}
```
### Decoding (JSON → Go value)
```go
jsonText := `{"name":"Sara","age":28}`
var u User
err := json.Unmarshal([]byte(jsonText), &u) // note the & - Unmarshal WRITES into u
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(u.Name, u.Age) // Sara 28
```
Note the `&u` — just like `rows.Scan(&x)` from database code, `Unmarshal`
needs to *write into* your variable, so it needs its address.
### Streaming versions: `Encoder`/`Decoder`
When working with HTTP requests/responses (which are streams, not
in-memory byte slices), you'll more often see the streaming forms:
```go
// Writing JSON directly to an io.Writer (e.g. http.ResponseWriter)
json.NewEncoder(w).Encode(u)
// Reading JSON directly from an io.Reader (e.g. an HTTP request body)
var u User
json.NewDecoder(r.Body).Decode(&u)
```
These do the same job as `Marshal`/`Unmarshal` but write/read directly to
a stream instead of requiring a full `[]byte` up front. You'll use
`NewDecoder(r.Body).Decode(...)` and `NewEncoder(w).Encode(...)` on nearly
every handler in the main course, starting in Lesson 1.
## 7. You're ready
That's everything the main course leans on. A quick self-check — if these
all feel familiar, you're ready for Lesson 1:
- Declaring variables with `:=` and `var`, and Go's zero values
- Writing functions with multiple return values, and the `if err != nil`
pattern
- Structs, exported vs. unexported fields, struct tags
- Pointers: `&` to get an address, `*` to dereference, and why functions
take `*User` instead of `User` when they need to modify it
- Methods with value vs. pointer receivers
- Interfaces being satisfied implicitly (no `implements` keyword)
- Slices (`append`, indexing, `range`) and maps (`map[string]any`, the
comma-ok idiom)
- How packages/imports/modules fit together, and what `internal/` means
- `go someFunc()` starting a goroutine, and why that matters for a
blocking call like `ListenAndServe`
- `json.NewEncoder(w).Encode(...)` / `json.NewDecoder(r.Body).Decode(&x)`
Head to `lesson-01-project-skeleton-chi-routing.md` next.
@@ -0,0 +1,318 @@
# Lesson 1 — Project Skeleton & chi Routing
> **New Go concepts in this lesson:** packages/imports/modules, structs,
> pointers, interfaces (implicitly satisfied), goroutines. If any of those
> feel shaky, review `00-go-basics-3-interfaces-errors-concurrency-packages.md`
> first.
## What we're building
By the end of this lesson you'll have a real, runnable HTTP server with:
- A standard Go project layout you'll keep extending for the rest of the course
- A router (using the `chi` library) that maps URLs to handler functions
- A `/health` endpoint that returns JSON
- A **graceful shutdown** sequence — the server finishes in-flight
requests before exiting on Ctrl+C, instead of just dying mid-request
## Project structure (final shape, built up over the whole course)
```
go-simple-api/
├── cmd/api/main.go
├── internal/
│ ├── config/config.go
│ ├── handlers/health.go
│ └── router/router.go
├── go.mod
```
(More folders get added lesson by lesson — this is just what exists after
Lesson 1.)
## Setup
```bash
mkdir go-simple-api && cd go-simple-api
go mod init git.hamidsoltani.com/hamid/go-simple-api
go get github.com/go-chi/chi/v5@latest
```
A quick word on that module path: `git.hamidsoltani.com/hamid/go-simple-api`
isn't a real, fetchable URL — it's just a naming convention (commonly your
Git host + username + project name). It becomes the prefix for every
internal import in this project, e.g.
`git.hamidsoltani.com/hamid/go-simple-api/internal/config`. If you're
following along with your own project, use your own path here — just stay
consistent with it everywhere.
`go get github.com/go-chi/chi/v5@latest` downloads
[chi](https://github.com/go-chi/chi), a small, popular HTTP router for Go.
Why use a router library instead of the standard library's own
`http.ServeMux`? chi gives us URL parameters (`/users/{id}`), route
groups, and a large ecosystem of compatible middleware (rate limiting,
CORS, request logging) that we'll use throughout this course — the
standard library's router is fine for very simple cases but doesn't have
these built in.
## `internal/config/config.go`
```go
package config
import "os"
type Config struct {
Port string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
```
Line by line:
- `package config` — its own package, so both `main.go` and any future
file can import it and call `config.Load()`.
- `type Config struct { Port string }` — a plain struct holding settings.
We'll add many more fields to this over the course (database settings,
Redis settings, OAuth settings...) — this is the ONE place all of the
app's configuration lives.
- `func Load() Config` — returns a `Config` **by value** (not a pointer)
since it's small and, once built, nothing needs to mutate it in place.
- `getEnv` is unexported (lowercase — see Go Basics Part 2 on
capitalization) — nothing outside this file needs to call it directly.
`os.Getenv(key)` reads an environment variable; if it's empty (unset),
we return `fallback` instead. This is how you avoid hardcoding things
like ports directly in your code.
## `internal/handlers/health.go`
```go
package handlers
import (
"encoding/json"
"net/http"
)
func Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
```
- Every HTTP handler in Go (with or without chi) has this exact function
signature: `func(w http.ResponseWriter, r *http.Request)`.
- `w http.ResponseWriter` is how you write the response back to the
client — it's an interface (see Go Basics Part 3) with methods like
`Write`, `WriteHeader`, and `Header()`.
- `r *http.Request` is a pointer to a struct describing the incoming
request — method, URL, headers, body, etc.
- `w.Header().Set("Content-Type", "application/json")` — sets a response
header. This **must** happen before `w.WriteHeader(...)` is called —
once you write the status code, the headers are locked in and can't be
changed afterward.
- `w.WriteHeader(http.StatusOK)` — writes the HTTP status code (`200`).
`http.StatusOK` is just a predefined constant equal to `200` — using the
named constant instead of the raw number is more readable and less
error-prone.
- `json.NewEncoder(w).Encode(map[string]string{"status": "ok"})` — from Go
Basics Part 3: creates a JSON encoder that writes directly to `w`
(which is a stream, an `io.Writer`), then encodes our map as JSON and
writes it out. `map[string]string` is a map with `string` keys and
`string` values — see Go Basics Part 3 on maps.
## `internal/router/router.go`
```go
package router
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
)
func New() *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/health", handlers.Health)
return r
}
```
- `chi.NewRouter()` returns a `*chi.Mux` — a pointer to chi's router type.
`*chi.Mux` happens to have a `ServeHTTP(w, r)` method, which means it
automatically satisfies the standard library's `http.Handler` interface
(see Go Basics Part 3 on interfaces) — no explicit declaration needed,
it "just works" because the method exists.
- `r.Use(...)` registers **middleware**: a function that wraps every
request passing through the router. Each of these has the shape
`func(http.Handler) http.Handler` — takes the "next" handler in the
chain, returns a new handler that does something extra before/after
calling it.
- `middleware.RequestID` — tags each request with a unique ID (useful
later once we add structured logging, in Lesson 2).
- `middleware.Logger` — chi's built-in logger; prints a line per
request to your terminal (we'll replace this with our own structured
JSON version in Lesson 2).
- `middleware.Recoverer` — catches panics inside any handler and turns
them into a `500` response, instead of crashing the entire server
process over one bad request.
- `r.Get("/health", handlers.Health)` — registers `handlers.Health` to run
for `GET` requests to `/health`. Note we pass the function itself
(`handlers.Health`), not a call to it (`handlers.Health()`) — chi will
call it later, once per matching request.
## `cmd/api/main.go`
```go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
)
func main() {
cfg := config.Load()
r := router.New()
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
log.Printf("server starting on port %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
log.Println("server stopped")
}
```
This is the most concept-dense file in the lesson. Take it slowly:
- `cfg := config.Load()` then `r := router.New()` — build our two pieces
using the constructors we just wrote.
- `srv := &http.Server{Addr: ":" + cfg.Port, Handler: r}` — instead of
calling the simpler `http.ListenAndServe(addr, handler)` directly, we
build an `*http.Server` struct ourselves (note the `&` — we want a
pointer, since we're going to call methods on it later that need to
operate on this exact instance). We do this specifically so we can call
`.Shutdown()` on it further down — `http.ListenAndServe` alone gives you
no way to stop it gracefully.
- `go func() { ... }()`**this is a goroutine** (Go Basics Part 3,
section 5). `srv.ListenAndServe()` blocks forever, serving requests
until the server stops. If we called it directly here (without `go`),
the code below it — the part that waits for Ctrl+C — would never run;
the program would just sit inside `ListenAndServe` permanently. Running
it as a goroutine lets it serve requests in the background while
`main()`'s primary execution moves on to the next lines.
- `if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed`
`ListenAndServe` always returns a non-nil error when it stops (even on
a normal, intentional shutdown) — `http.ErrServerClosed` specifically
means "this was a deliberate `Shutdown()` call, not a real problem," so
we only treat OTHER errors as fatal.
- `quit := make(chan os.Signal, 1)` — a **channel**, Go's built-in
mechanism for goroutines to communicate. We're using it here in its
simplest form: as a way to "wait for a signal to arrive." (We don't go
deeper into channels in this course — this is the only one you'll need.)
- `signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)` — tells the Go
runtime "when the OS sends this process a SIGINT (Ctrl+C) or SIGTERM
(e.g. `docker stop`), send a value into the `quit` channel instead of
just killing the process outright."
- `<-quit` — this is the **receive** operation on a channel: it blocks
(pauses) the current goroutine — here, `main()`'s own execution — until
something arrives on `quit`. This is what actually keeps the program
alive and waiting, instead of exiting immediately after starting the
server goroutine.
- `context.WithTimeout(context.Background(), 5*time.Second)` — builds a
`context.Context` (we'll use these a lot more starting in Lesson 8) that
automatically expires after 5 seconds. `context.Background()` is the
standard "empty starting point" for building a new context.
- `defer cancel()``defer` schedules a function call to run right before
the surrounding function (`main`, here) returns, regardless of how it
returns. `cancel` releases resources associated with the timeout context
once we're done with it — always pair `WithTimeout`/`WithCancel` with a
`defer cancel()` immediately after creating them.
- `srv.Shutdown(ctx)` — tells the server to stop accepting new
connections, and wait for existing in-flight requests to finish, up to
the 5-second deadline in `ctx`. This is what "graceful" shutdown means:
requests that were already in progress get to complete normally instead
of being cut off mid-response.
## Try it
```bash
go run ./cmd/api
```
In another terminal:
```bash
curl http://localhost:8080/health
```
You should get back `{"status":"ok"}`.
Now go back to the terminal running the server and press **Ctrl+C**. You
should see:
```
shutting down gracefully...
server stopped
```
instead of the process just vanishing instantly — that's the graceful
shutdown sequence working.
## Common mistakes at this stage
- **Forgetting the parentheses when calling a function**: writing
`r := router.New` (assigns the function itself) instead of
`r := router.New()` (calls it and gets the `*chi.Mux` back). The
compiler error looks like: `cannot use r (variable of type func() *chi.Mux)
as http.Handler value` — if you see that shape of error, check for a
missing `()`.
- **Forgetting `defer db.Close()` / `defer cancel()`** on things that need
cleanup — not an issue yet in this lesson, but a habit to build now,
since it appears constantly starting in Lesson 3.
Once `/health` works and Ctrl+C shuts down cleanly, move on to Lesson 2 —
structured JSON logging.
@@ -0,0 +1,386 @@
# Lesson 2 — Structured JSON Logging with `slog`
> **New Go concepts in this lesson:** closures (the three-layer middleware
> pattern), variadic-style function calls, type aliases for interfaces.
> Review the "closures" section of `00-go-basics-2-functions-structs-pointers.md`
> before this one if middleware still feels confusing after Lesson 1.
## Why this matters
Right now (end of Lesson 1), `middleware.Logger` from chi prints
human-readable text to your terminal. That's fine to read by eye, but if
you ever want to ship logs to something like Grafana Loki (via Grafana
Alloy), you want **structured JSON** — one JSON object per log line — so
you can filter and query by field (`status=500`, `path="/login"`, etc.)
instead of parsing free-form text with regexes.
Go's standard library has had a structured logging package, `log/slog`,
since Go 1.21 — no third-party dependency needed.
## Part A — standalone playground
Build understanding in isolation first, in a throwaway project:
```bash
mkdir ~/go-playground/slog-demo && cd ~/go-playground/slog-demo
go mod init slog-demo
```
**`main.go`**
```go
package main
import (
"log/slog"
"os"
"time"
)
func main() {
// 1. A plain text logger (human-readable, default style)
textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
textLogger.Info("this is text format", "user", "hamid", "attempt", 1)
// 2. A JSON logger (what we want for Loki)
jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
jsonLogger.Info("this is json format", "user", "hamid", "attempt", 1)
// 3. Log levels
jsonLogger.Debug("debug message - hidden by default")
jsonLogger.Info("info message - shown")
jsonLogger.Warn("warn message - shown")
jsonLogger.Error("error message - shown", "err", "something broke")
// 4. Structured fields with types
jsonLogger.Info("user logged in",
slog.String("username", "hamid"),
slog.Int("user_id", 42),
slog.Duration("took", 150*time.Millisecond),
slog.Bool("success", true),
)
// 5. A logger with permanent fields attached
requestLogger := jsonLogger.With(
slog.String("request_id", "abc-123"),
slog.String("service", "go-simple-api"),
)
requestLogger.Info("handling request")
requestLogger.Info("finished request", slog.Int("status", 200))
// 6. Controlling minimum level explicitly
debugLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
debugLogger.Debug("now debug shows up because we set the level")
}
```
Run it:
```bash
go run .
```
What to notice:
- `slog.New(handler)` — every logger is a `*slog.Logger` wrapping a
**Handler**, which decides output format and destination. Swap
`NewTextHandler``NewJSONHandler` and everything else in your code
stays identical — this is the interface/implementation split from Go
Basics Part 3 in action: your code depends on `*slog.Logger`'s methods
(`Info`, `Error`, ...), not on which Handler is behind it.
- By default, `Debug(...)` calls are **silently dropped** unless you
explicitly set `Level: slog.LevelDebug` in `HandlerOptions` — that's why
section 3's debug line doesn't print, but section 6's does.
- `slog.String`, `slog.Int`, `slog.Duration`, `slog.Bool` are typed field
constructors. You *can* skip them and just pass raw `"key", value` pairs
(as in sections 12) and `slog` infers the type, but explicit typing is
slightly faster and safer in hot paths.
- `.With(...)` (section 5) returns a **new logger** with those fields
baked in permanently — every call on `requestLogger` afterward
automatically includes `request_id` and `service`. This is exactly the
pattern we'll use per-request: attach a request ID once, log normally
after that.
### How to change a logger's level *after* it's created
You can't mutate the level on an existing logger directly — it lives
inside the Handler and is normally fixed at creation. The fix is
`slog.LevelVar`, a small mutable "box" for a level:
```go
var level slog.LevelVar // defaults to LevelInfo
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &level, // pointer to the LevelVar, not a fixed value
}))
logger.Debug("hidden") // nothing prints, level is Info
level.Set(slog.LevelDebug) // change it later, anytime
logger.Debug("now visible") // this prints
```
`HandlerOptions.Level` accepts anything implementing a `Leveler`
interface (one method: `Level() slog.Level`). A plain `slog.Level`
implements it by returning itself (fixed forever); `*slog.LevelVar` also
implements it, but its `Level()` reads a value you can change at runtime
via `.Set()`. The handler re-checks the level on every log call.
## Part B — apply it to the project
**No new dependencies**`log/slog` is part of the standard library.
**`internal/logging/logger.go`**
```go
package logging
import (
"log/slog"
"os"
)
func New() *slog.Logger {
level := slog.LevelInfo
if os.Getenv("LOG_LEVEL") == "debug" {
level = slog.LevelDebug
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
```
Matches Part A section 6 — JSON handler, level controlled by env var
instead of hardcoded.
### The middleware "three-layer function" pattern, explained from scratch
Before the request-logging middleware code, let's build up to it slowly,
since this shape (a function that takes some setup and returns a
`func(http.Handler) http.Handler`) will reappear for authentication in
Lesson 8.
**Step 1 — the simplest possible middleware, no arguments:**
```go
func SimpleLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("before request")
next.ServeHTTP(w, r)
log.Println("after request")
})
}
```
- Takes `next` (whatever handler comes after this one in the chain).
- Returns a NEW `http.Handler`. `http.HandlerFunc(...)` is a type
conversion — it turns a plain `func(w, r)` into something satisfying the
`http.Handler` interface (see Go Basics Part 3: interfaces are just
"has the right method," and `HandlerFunc` is a built-in adapter that
gives any matching function a `ServeHTTP` method for free).
- Code before `next.ServeHTTP(w, r)` runs **before** the real request
handling; code after runs **after**.
- Usage: `r.Use(SimpleLogger)` — no parentheses needed after
`SimpleLogger`, since we're passing the function itself, and it already
has the exact shape `r.Use` expects.
**Step 2 — now we want to pass in a logger.** `r.Use()` only accepts
`func(http.Handler) http.Handler` — no room for extra arguments. So we
wrap that shape inside ANOTHER function that takes the logger first:
```go
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
// ^ takes the logger ^ returns the middleware shape
return func(next http.Handler) http.Handler {
// ^ THIS is the actual func(http.Handler) http.Handler chi wants
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ^ THIS is the real per-request logic
...
})
}
}
```
Three layers, each running at a different time:
| Layer | Runs when | Purpose |
|---|---|---|
| `RequestLogger(logger)` | Once, when building the router | Captures `logger` in a closure |
| `func(next http.Handler) http.Handler` | Once, when chi wires up the chain | Captures `next` in a closure |
| `func(w, r) {...}` | On every single HTTP request | Does the actual logging |
This is exactly the **closure** concept from Go Basics Part 2's
`makeCounter` example — each inner function "remembers" variables from
the outer function that created it, even after that outer function has
returned.
Usage: `r.Use(RequestLogger(logger))` — note `RequestLogger(logger)` is a
**function call**, not a bare reference. It runs the outer layer
immediately and returns the middle layer, which is what actually gets
handed to `r.Use()`.
### `internal/middleware/request_logger.go`
```go
package middleware
import (
"log/slog"
"net/http"
"time"
chimw "github.com/go-chi/chi/v5/middleware"
)
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// record the time BEFORE the request is handled, so we can
// measure how long it took afterward
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
// a plain http.ResponseWriter only lets you WRITE a
// status/body, not read it back afterward. This wraps it so
// ww.Status() and ww.BytesWritten() become available once the
// response has been sent.
next.ServeHTTP(ww, r)
// run the rest of the chain / the final handler. We pass ww
// (the wrapped writer), not w, so the wrapping actually
// captures what gets written downstream. Everything BELOW
// this line runs AFTER the response is done.
logger.Info("http_request",
slog.String("request_id", chimw.GetReqID(r.Context())),
// the RequestID middleware (earlier in the chain) stored
// an ID inside the request's context; we read it back
// here
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int("bytes", ww.BytesWritten()),
slog.Duration("duration_ms", time.Since(start)),
slog.String("remote_addr", r.RemoteAddr),
)
})
}
}
```
We alias `chimw "github.com/go-chi/chi/v5/middleware"` in the import so it
doesn't collide with our own package's name (`middleware`).
### `internal/router/router.go` (updated)
```go
package router
import (
"log/slog"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
)
func New(logger *slog.Logger) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Get("/health", handlers.Health)
return r
}
```
`New` now takes a `*slog.Logger` **parameter** — this is dependency
injection (see the main README/ARCHITECTURE docs): instead of the router
building its own logger internally, it receives one from `main.go`, so
the whole app shares exactly one logger instance.
### `cmd/api/main.go` (updated)
```go
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/logging"
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
)
func main() {
cfg := config.Load()
logger := logging.New()
r := router.New(logger)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
logger.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down gracefully")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("forced shutdown", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
```
We swapped `log.Printf`/`log.Fatalf` for our structured `logger`. Note
`logger.Info("server starting", "port", cfg.Port)``slog`'s convenience
methods also accept plain alternating key/value pairs (no `slog.String`
wrapper needed) when calling `.Info`/`.Error` directly; both styles
produce the same structured JSON. We replaced `log.Fatalf` with
`logger.Error(...)` + `os.Exit(1)`, since `log.Fatal` writes plain text
and would break our "everything is JSON" goal.
## Try it
```bash
go run ./cmd/api
curl http://localhost:8080/health
```
You should see JSON lines like:
```json
{"time":"2026-07-15T10:00:00Z","level":"INFO","msg":"server starting","port":"8080"}
{"time":"2026-07-15T10:00:05Z","level":"INFO","msg":"http_request","request_id":"...","method":"GET","path":"/health","status":200,"bytes":16,"duration_ms":123000,"remote_addr":"127.0.0.1:54321"}
```
This is exactly the shape Grafana Alloy likes to scrape from container
stdout and ship to Loki — one JSON object per line, consistent keys, no
custom parsing needed.
Once both parts run cleanly, move to Lesson 3 — config & MySQL connection.
+372
View File
@@ -0,0 +1,372 @@
# Lesson 3 — Config & MySQL Connection
> **New Go concepts in this lesson:** `defer`, blank imports (`_`),
> `context.WithTimeout` deadlines, working with `*sql.DB`/`*sql.Rows`.
> These build on pointers and error handling from the Go Basics lessons —
> review those if anything below feels unfamiliar.
## Part A — standalone playground
First, run a throwaway MySQL with Docker so you have something to connect
to:
```bash
docker run --name mysql-demo -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=demo -p 3306:3306 -d mysql:9
```
Then the playground project:
```bash
mkdir ~/go-playground/mysql-demo && cd ~/go-playground/mysql-demo
go mod init mysql-demo
go get github.com/go-sql-driver/mysql@latest
```
**`main.go`**
```go
package main
import (
"context"
"database/sql"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// 1. Open a connection pool (this does NOT actually connect yet!)
db, err := sql.Open("mysql", "root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true")
if err != nil {
log.Fatalf("sql.Open failed: %v", err)
}
defer db.Close()
// 2. Configure the pool
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// 3. Actually verify we can connect
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Fatalf("ping failed: %v", err)
}
log.Println("connected to mysql")
// 4. Create a table and insert a row
_, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS notes (
id INT AUTO_INCREMENT PRIMARY KEY,
body VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL
)`)
if err != nil {
log.Fatalf("create table failed: %v", err)
}
res, err := db.ExecContext(ctx,
"INSERT INTO notes (body, created_at) VALUES (?, ?)",
"hello from go", time.Now(),
)
if err != nil {
log.Fatalf("insert failed: %v", err)
}
id, _ := res.LastInsertId()
log.Printf("inserted note with id %d", id)
// 5. Query rows back
rows, err := db.QueryContext(ctx, "SELECT id, body, created_at FROM notes")
if err != nil {
log.Fatalf("query failed: %v", err)
}
defer rows.Close()
for rows.Next() {
var (
noteID int
body string
createdAt time.Time
)
if err := rows.Scan(&noteID, &body, &createdAt); err != nil {
log.Fatalf("scan failed: %v", err)
}
log.Printf("note: id=%d body=%q created=%s", noteID, body, createdAt)
}
if err := rows.Err(); err != nil {
log.Fatalf("rows error: %v", err)
}
}
```
Run it:
```bash
go run .
```
### First, a quick primer on `defer`
You'll see `defer` constantly from this lesson onward. It schedules a
function call to run right when the **surrounding function** returns — no
matter how it returns (normal return, or via a `log.Fatal`-style exit,
etc., with some caveats). It's most commonly used for cleanup:
```go
func doSomething() {
f := open()
defer f.Close() // runs automatically when doSomething() returns, wherever that happens
// ... lots of code, maybe with early returns ...
}
```
Without `defer`, you'd have to remember to call `f.Close()` before *every*
`return` in the function — easy to forget on one path. `defer` guarantees
it happens exactly once, right before the function actually exits.
### Line by line, the rest of the file
- `_ "github.com/go-sql-driver/mysql"` — a **blank import**. The
underscore means "import this package purely for its side effects; I'm
not calling any of its functions directly by name." This package's
`init()` function (a special function that runs automatically on
import) registers `"mysql"` as a driver name with `database/sql`. This
is a common pattern for database drivers and plugins in Go.
- `sql.Open("mysql", dsn)` — despite the name, this does **not** connect
yet. It validates the DSN format and returns a `*sql.DB`, which is
really a **connection pool manager**, not one live connection.
Connections open lazily, on first actual use.
- The DSN (data source name) `"root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true"`
is `user:password@tcp(host:port)/dbname?options`. `parseTime=true` tells
the driver to convert MySQL `DATETIME`/`TIMESTAMP` columns into Go
`time.Time` values automatically instead of raw bytes.
- `defer db.Close()` — closes the pool when `main` exits. In a real
server, this only runs once, at shutdown — you build ONE `*sql.DB` for
the whole app's lifetime, never one per request.
- `SetMaxOpenConns` / `SetMaxIdleConns` / `SetConnMaxLifetime` — pool
tuning. Max open caps simultaneous connections (protects the database
from being overwhelmed). Max idle keeps some connections warm instead of
reopening constantly. Conn max lifetime forces periodic recycling
(useful behind load balancers, or if MySQL itself closes long-idle
connections).
- `context.WithTimeout(context.Background(), 5*time.Second)` — builds a
context that automatically expires after 5 seconds (same construct used
for graceful shutdown in Lesson 1). Passing this into `PingContext`
means the ping gives up after 5 seconds instead of hanging forever if
the database host is unreachable.
- `db.PingContext(ctx)` — this is what actually forces a real connection
attempt, so bad credentials/host are caught immediately at startup,
instead of failing later on the first real query.
- `db.ExecContext(ctx, query, args...)` — for statements that don't return
rows (CREATE, INSERT, UPDATE, DELETE). The `?` placeholders are
**parameterized queries** — never build SQL by concatenating strings
with user input; this is what prevents SQL injection.
- `res.LastInsertId()` — returns the auto-increment ID of the row you just
inserted.
- `db.QueryContext(ctx, query)` — for SELECT statements, returns
`*sql.Rows`.
- `defer rows.Close()`**always close rows**, or you leak the underlying
connection back to the pool. One of the most common Go/SQL bugs.
- `rows.Next()` — advances to the next row; returns `false` when there are
no more, or on error.
- `rows.Scan(&noteID, &body, &createdAt)` — copies the current row's
columns into your variables, **in order**, by pointer (note the `&`
same reason as always: `Scan` needs to write into your variables, so it
needs their addresses). Types must match or be convertible.
- `rows.Err()` — check this after the loop. `Next()` returning `false`
doesn't tell you *why* it stopped — could just mean "no more rows"
(fine), or a connection error mid-scan (not fine). Always check.
Run the program twice and notice two notes get inserted, since we never
cleared the table.
## Part B — apply it to the project
**Extend `internal/config/config.go`** with DB settings:
```go
package config
import "os"
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", "devpass"),
DBName: getEnv("DB_NAME", "go_simple_api"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
```
Same `getEnv` pattern from Lesson 1 — just more fields.
**`internal/database/mysql.go`**
```go
package database
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
func NewMySQL(ctx context.Context, cfg config.Config) (*sql.DB, error) {
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?parseTime=true",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName,
)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return nil, fmt.Errorf("ping mysql: %w", err)
}
return db, nil
}
```
This is Part A's connection logic, reshaped into a function that returns
`(*sql.DB, error)` instead of calling `log.Fatal` directly — a
library-style function shouldn't kill the whole program itself; it should
return the error and let the **caller** (`main.go`) decide what to do
about it.
New here: `fmt.Errorf("open mysql: %w", err)` **wraps** the original error
(see Go Basics Part 3) — adding context ("open mysql: ...") while
preserving the original error so it can still be inspected further up the
call chain with `errors.Is`/`errors.As`. This is the idiomatic Go way to
add context to errors as they bubble up through layers.
**Update `cmd/api/main.go`** to connect on startup and close on shutdown:
```go
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/database"
"git.hamidsoltani.com/hamid/go-simple-api/internal/logging"
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
)
func main() {
cfg := config.Load()
logger := logging.New()
ctx := context.Background()
db, err := database.NewMySQL(ctx, cfg)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
logger.Info("connected to database", "host", cfg.DBHost, "db", cfg.DBName)
r := router.New(logger)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
logger.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down gracefully")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("forced shutdown", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
```
- `db, err := database.NewMySQL(ctx, cfg)` — if this fails, we log and
`os.Exit(1)` immediately; there's no point starting an HTTP server that
can't reach its own database.
- `defer db.Close()` — closes the connection pool when `main` returns,
i.e. after graceful shutdown finishes below.
- `db` isn't used by the router yet — that's Lesson 4, once we build a
user repository that needs it to run queries. For now we're just
proving the connection works at startup.
**Add a `.env` file** at the project root (we're not auto-loading it yet —
export these manually for now, or run
`export $(grep -v '^#' .env | xargs)`):
```
PORT=8080
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=devpass
DB_NAME=go_simple_api
```
## Try it
```bash
go get github.com/go-sql-driver/mysql@latest
docker run --name mysql-api -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=go_simple_api -p 3306:3306 -d mysql:9
go run ./cmd/api
```
You should see `"connected to database"` in the JSON logs, then `"server
starting"`. Ctrl+C should still shut down gracefully, closing the DB pool
cleanly along the way.
Once this works, move to Lesson 4 — the user model & repository pattern
(this is also where the pointer concepts from Go Basics Part 2 pay off in
full).
@@ -0,0 +1,340 @@
# Lesson 4 — User Model & Repository Pattern
> **New Go concepts in this lesson:** applying pointers and pointer
> receivers for real (not just toy examples), sentinel errors in
> practice, `QueryRowContext` vs `QueryContext`. Make sure you've done
> the pointers section of `00-go-basics-2-functions-structs-pointers.md`
> and the errors section of `00-go-basics-3-...md` before this lesson —
> everything here depends on both.
## Quick pointer refresher, applied
Two rules from Go Basics you'll use constantly in this lesson:
1. If a function needs to **write a result back** into the caller's
variable, it must take a pointer (`*Book`), and write through it
(`b.ID = ...`).
2. If a struct wraps something stateful/shared (like a database
connection pool), methods on it should use a **pointer receiver**
(`func (r *BookRepository) ...`), so every call operates on the same
underlying resource instead of an accidental copy.
Keep those two rules in mind as you read the code below — they explain
almost every `*` you'll see in this lesson.
## Part A — standalone playground
We'll practice the **repository pattern**: separating "how do I talk to
the database" from "what does my business logic do."
Reuse the MySQL container from Lesson 3, or start fresh:
```bash
docker run --name mysql-demo2 -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=demo -p 3306:3306 -d mysql:9
mkdir ~/go-playground/repo-demo && cd ~/go-playground/repo-demo
go mod init repo-demo
go get github.com/go-sql-driver/mysql@latest
```
**`main.go`**
```go
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
// 1. The domain model - a plain struct representing one "thing" in your
// app. No database code here at all - this is just data.
type Book struct {
ID int
Title string
Author string
CreatedAt time.Time
}
// 2. The repository - a struct that wraps *sql.DB and knows how to turn
// SQL rows into Book structs, and Book structs into SQL writes.
type BookRepository struct {
db *sql.DB
}
// Constructor function - Go convention: NewXxx returns a *Xxx
func NewBookRepository(db *sql.DB) *BookRepository {
return &BookRepository{db: db}
}
var ErrNotFound = errors.New("book not found")
// 3. Pointer receiver: (r *BookRepository) - because we don't want to
// copy the struct (it holds a *sql.DB) on every single method call.
func (r *BookRepository) Create(ctx context.Context, b *Book) error {
res, err := r.db.ExecContext(ctx,
"INSERT INTO books (title, author, created_at) VALUES (?, ?, ?)",
b.Title, b.Author, time.Now(),
)
if err != nil {
return fmt.Errorf("insert book: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("get last insert id: %w", err)
}
b.ID = int(id) // write the new ID back into the caller's Book
return nil
}
func (r *BookRepository) FindByID(ctx context.Context, id int) (*Book, error) {
var b Book
err := r.db.QueryRowContext(ctx,
"SELECT id, title, author, created_at FROM books WHERE id = ?", id,
).Scan(&b.ID, &b.Title, &b.Author, &b.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("find book: %w", err)
}
return &b, nil
}
func main() {
db, err := sql.Open("mysql", "root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true")
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS books (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL
)`); err != nil {
log.Fatal(err)
}
repo := NewBookRepository(db)
b := &Book{Title: "The Go Programming Language", Author: "Donovan & Kernighan"}
if err := repo.Create(ctx, b); err != nil {
log.Fatal(err)
}
log.Printf("created book with id %d", b.ID)
found, err := repo.FindByID(ctx, b.ID)
if err != nil {
log.Fatal(err)
}
log.Printf("found: %+v", found)
_, err = repo.FindByID(ctx, 999999)
if errors.Is(err, ErrNotFound) {
log.Println("correctly got ErrNotFound for missing book")
}
}
```
Run it:
```bash
go run .
```
What's new here:
- **`Book` struct has zero database knowledge** — it's pure data. Your
handlers/business logic will work with `Book`, never with raw SQL rows
directly.
- **`BookRepository` wraps `*sql.DB`** and is the *only* place SQL queries
live. Swap MySQL for Postgres later, and you change this one file, not
every handler.
- **`NewBookRepository(db)` constructor** — Go has no
classes/constructors as a language feature; `NewXxx` returning `*Xxx` is
purely a naming convention, but the entire ecosystem follows it, so you
should too.
- **`func (r *BookRepository) Create(...)`** — pointer receiver, per the
refresher above. `r` is the repository itself; inside, `r.db` accesses
the wrapped connection pool.
- **`b.ID = int(id)`** — since `Create` takes `b *Book` (a pointer), it
can write the newly generated ID directly back into the caller's
struct. This is rule #1 from the refresher, applied for real.
- **`QueryRowContext(...).Scan(...)`** — new: `QueryRowContext` (singular
`Row`) is for when you expect exactly one result, like a lookup by ID.
It skips the `rows.Next()`/`rows.Close()` dance from Lesson 3 since
there's at most one row.
- **`errors.Is(err, sql.ErrNoRows)`** — `sql.ErrNoRows` is the driver's
own sentinel error for "query matched zero rows." We translate it into
our own `ErrNotFound` so callers of `FindByID` don't need to know or
care that the underlying storage is SQL at all — this is the sentinel
error pattern from Go Basics Part 3, put to real use.
- **`var ErrNotFound = errors.New(...)`** — a package-level sentinel error,
so callers can check `errors.Is(err, ErrNotFound)` without caring what's
underneath.
Try inserting a second book, querying it, then calling `FindByID` with an
ID you know doesn't exist and confirm you get `ErrNotFound`, not a crash.
## Part B — apply it to the project
**`internal/models/user.go`** — the domain struct:
```go
package models
import "time"
type User struct {
ID int
Email string
PasswordHash string
GoogleID string // empty if the user registered with a password
CreatedAt time.Time
}
```
`PasswordHash`, not `Password` — we will **never** store or handle
plaintext passwords beyond the brief moment they're hashed (Lesson 5).
`GoogleID` is here now so Lesson 7 (Google OAuth) doesn't require
restructuring this struct later.
**`internal/database/migrate.go`** — creates the table on startup (fine
for a learning project; a real project would use a dedicated migration
tool):
```go
package database
import (
"context"
"database/sql"
"fmt"
)
func Migrate(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
google_id VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
)`)
if err != nil {
return fmt.Errorf("migrate users table: %w", err)
}
return nil
}
```
**`internal/models/user_repository.go`** — same pattern as
`BookRepository`:
```go
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
var ErrUserNotFound = errors.New("user not found")
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, u *User) error {
res, err := r.db.ExecContext(ctx,
"INSERT INTO users (email, password_hash, google_id, created_at) VALUES (?, ?, ?, ?)",
u.Email, u.PasswordHash, u.GoogleID, time.Now(),
)
if err != nil {
return fmt.Errorf("create user: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("get last insert id: %w", err)
}
u.ID = int(id)
return nil
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE email = ?", email,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("find user by email: %w", err)
}
return &u, nil
}
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE id = ?", id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("find user by id: %w", err)
}
return &u, nil
}
```
`FindByEmail` is used during login (users log in with an email, not an
ID). `FindByID` is used later once sessions store just the user's ID
(Lesson 6+).
**Update `cmd/api/main.go`** — run the migration and construct the
repository on startup:
```go
if err := database.Migrate(ctx, db); err != nil {
logger.Error("failed to migrate database", "error", err)
os.Exit(1)
}
logger.Info("database migrated")
userRepo := models.NewUserRepository(db)
_ = userRepo // used starting Lesson 5 - silences "declared but not used" for now
```
(Add `"git.hamidsoltani.com/hamid/go-simple-api/internal/models"` to the
import block.)
`_ = userRepo` — remember from Go Basics Part 1, the blank identifier
`_` discards a value so the compiler doesn't complain about an unused
variable. We're not wiring `userRepo` into any handler yet (that's
Lesson 5), so this line is a temporary placeholder — delete it once
`userRepo` is actually passed into `router.New(...)`.
## Try it
```bash
go run ./cmd/api
```
Check your logs for `"database migrated"`, then confirm the table exists:
```bash
docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "DESCRIBE users;"
```
Once both parts run, move to Lesson 5 — password-based register/login
with bcrypt, which is where `userRepo` finally gets used for real.
+410
View File
@@ -0,0 +1,410 @@
# Lesson 5 — Password Login with bcrypt
> **New Go concepts in this lesson:** working with `[]byte` vs `string`,
> `httptest` for testing handlers without a real server, struct tags for
> JSON (a deeper look). Review the "slices" and "JSON basics" sections of
> `00-go-basics-3-...md` if `[]byte` conversions look unfamiliar.
## Part A — standalone playground
Two things to practice before touching the real project: **hashing
passwords with bcrypt**, and **decoding + validating JSON request
bodies**.
```bash
mkdir ~/go-playground/bcrypt-demo && cd ~/go-playground/bcrypt-demo
go mod init bcrypt-demo
go get golang.org/x/crypto/bcrypt@latest
```
**`main.go`**
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"golang.org/x/crypto/bcrypt"
)
func main() {
// ---- Part 1: bcrypt hashing ----
password := "my-secret-password"
// 1. Hash the password. The second argument is the "cost" - higher =
// slower = more resistant to brute-force, but more CPU per login.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}
fmt.Println("hash:", string(hash))
// looks like: $2a$10$N9qo8uLOickgx2ZMRZoMy...
// 2. Hash the SAME password again - notice the output is DIFFERENT
// each time.
hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
fmt.Println("hash2:", string(hash2))
fmt.Println("hashes equal?", string(hash) == string(hash2)) // false!
// 3. But both still verify correctly against the original password.
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
fmt.Println("hash matches password:", err == nil)
err = bcrypt.CompareHashAndPassword(hash2, []byte(password))
fmt.Println("hash2 matches password:", err == nil)
// 4. Wrong password correctly fails.
err = bcrypt.CompareHashAndPassword(hash, []byte("wrong-password"))
fmt.Println("wrong password matches:", err == nil)
// ---- Part 2: decoding JSON request bodies ----
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
handler := func(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
// Decode reads the JSON body straight into our struct.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
// Basic manual validation - no library needed for something this
// simple.
if req.Email == "" || req.Password == "" {
http.Error(w, "email and password are required", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "got email=%s password=%s\n", req.Email, req.Password)
}
// httptest lets us fire fake HTTP requests without starting a real
// server - great for testing handlers directly.
body := bytes.NewBufferString(`{"email":"hamid@example.com","password":"secret123"}`)
req := httptest.NewRequest(http.MethodPost, "/login", body)
rec := httptest.NewRecorder()
handler(rec, req)
fmt.Println("status:", rec.Code)
fmt.Println("body:", rec.Body.String())
}
```
Run it:
```bash
go run .
```
Line by line, what matters:
- `[]byte(password)` — bcrypt works on `[]byte` (a slice of raw bytes),
not `string`. Go strings are already UTF-8 byte sequences under the
hood, so `[]byte(someString)` is a cheap, direct conversion — see Go
Basics Part 1's type table.
- `bcrypt.GenerateFromPassword(..., bcrypt.DefaultCost)``DefaultCost`
(currently 10) controls how many rounds of internal hashing happen —
intentionally slow, on purpose, to make brute-forcing expensive.
Returns `([]byte, error)` — the classic multi-return pattern from Go
Basics Part 2.
- **Why `hash` and `hash2` differ** — bcrypt generates a random **salt**
internally every time you call `GenerateFromPassword`, and bakes that
salt into the output string itself (visible as part of the
`$2a$10$...` format). This means identical passwords produce different
hashes, preventing an attacker from spotting "these two users have the
same password" just by comparing hashes in a leaked database.
- `bcrypt.CompareHashAndPassword(hash, []byte(password))` — the *only*
correct way to check a password. It re-derives the hash using the salt
embedded in `hash`, then compares. Returns `nil` on match, an error
otherwise. **You cannot "unhash" a bcrypt hash back to the original
password** — that's the whole point.
- `json.NewDecoder(r.Body).Decode(&req)` — same `Encoder`/`Decoder`
pattern from Lesson 1/Go Basics Part 3, reversed. `r.Body` is an
`io.ReadCloser` (a stream) containing the raw request bytes; `Decode`
parses JSON straight from it into `req`. The `&req` matters — `Decode`
needs to *write into* `req`, so it needs `req`'s address.
- `` `json:"email"` `` — a struct tag (Go Basics Part 2). Maps the JSON key
`email` to this Go field regardless of capitalization. Explicit tags are
best practice: they document the wire format, and let you rename Go
fields freely without breaking the API's JSON shape.
- `httptest.NewRequest` / `httptest.NewRecorder` — lets you call a
handler function directly, without binding a real port. `NewRecorder()`
gives you a fake `http.ResponseWriter` you can inspect afterward
(`rec.Code`, `rec.Body`). Very useful for automated tests later.
Try breaking the JSON body (remove a quote) and watch the "invalid JSON
body" error trigger. Try sending an empty password and see the validation
error path.
## Part B — apply it to the project
**Add the dependency:**
```bash
go get golang.org/x/crypto/bcrypt@latest
```
**`internal/handlers/auth.go`** — the register and login handlers:
```go
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"golang.org/x/crypto/bcrypt"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
)
// AuthHandler groups auth-related handlers together and holds their
// shared dependencies (repository, logger) as struct fields.
type AuthHandler struct {
userRepo *models.UserRepository
logger *slog.Logger
}
func NewAuthHandler(userRepo *models.UserRepository, logger *slog.Logger) *AuthHandler {
return &AuthHandler{userRepo: userRepo, logger: logger}
}
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
// Check if the email is already taken.
_, err := h.userRepo.FindByEmail(r.Context(), req.Email)
if err == nil {
writeError(w, http.StatusConflict, "email already registered")
return
}
if !errors.Is(err, models.ErrUserNotFound) {
h.logger.Error("find user by email failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
h.logger.Error("hash password failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
user := &models.User{
Email: req.Email,
PasswordHash: string(hash),
}
if err := h.userRepo.Create(r.Context(), user); err != nil {
h.logger.Error("create user failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
user, err := h.userRepo.FindByEmail(r.Context(), req.Email)
if errors.Is(err, models.ErrUserNotFound) {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err != nil {
h.logger.Error("find user by email failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
// Session creation happens here starting Lesson 6.
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
```
New patterns worth calling out:
- `type AuthHandler struct { userRepo *models.UserRepository; logger *slog.Logger }`
— instead of standalone functions like `handlers.Health`, these
handlers need dependencies. The idiomatic Go way: put dependencies as
**fields on a struct**, and make the handlers **methods** on that
struct (`func (h *AuthHandler) Register(...)`) — the same
pointer-receiver pattern as `BookRepository`/`UserRepository` in
Lesson 4. `h` gives every method access to `h.userRepo` and
`h.logger`.
- `registerRequest` / `loginRequest` — small unexported structs (Go
Basics Part 2: lowercase = private to this file/package), scoped just
to what each endpoint expects. Kept separate from `models.User`
deliberately — the wire format shouldn't be coupled to the database
model; a register request should never be able to set `PasswordHash` or
`ID` directly.
- `if !errors.Is(err, models.ErrUserNotFound)` — "if the error is
something *other than* not-found, that's a real, unexpected problem."
We separate the *expected* case (email doesn't exist yet — good,
proceed) from *unexpected* failures (database down, etc.), logging only
the latter.
- **In `Login`**: the *same* generic error message
(`"invalid email or password"`) covers both "no such email" and "wrong
password." This is deliberate — separate messages would let an attacker
enumerate which emails are registered. Always give identical, generic
feedback for both failure cases in a login flow.
**`internal/handlers/respond.go`** — small shared helpers, used by every
handler from now on:
```go
package handlers
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
```
`data any``any` (Go Basics Part 3) accepts a value of any type, which
lets `writeJSON` handle both `map[string]any{...}` and, later, any struct
we want to serialize.
**Update `internal/router/router.go`** to wire the new routes:
```go
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
)
func New(logger *slog.Logger, db *sql.DB) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, logger)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
return r
}
```
`New` now also takes `db *sql.DB` — it needs it to build `userRepo`. Note
`r.Post("/register", authHandler.Register)` passes a **method value**: Go
bundles `authHandler.Register` together with the specific `authHandler`
instance it belongs to, producing something with exactly the
`func(http.ResponseWriter, *http.Request)` shape chi expects — even
though `Register` is defined with a receiver (`func (h *AuthHandler)
Register(...)`). You don't manually pass `authHandler` as an argument;
Go's method-value syntax handles that binding for you.
**Update `cmd/api/main.go`** — replace:
```go
userRepo := models.NewUserRepository(db)
_ = userRepo
r := router.New(logger)
```
with:
```go
r := router.New(logger, db)
```
(Delete the `userRepo` lines from `main.go` entirely — that construction
now happens inside `router.New`.)
## Try it
```bash
go run ./cmd/api
```
Register:
```bash
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
```
Login:
```bash
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
```
Try a wrong password (expect `401` with the generic message) and
registering the same email twice (expect `409`).
Once both parts work, move to Lesson 6 — server-side sessions with scs +
Redis, where a successful login finally starts a real session instead of
just returning `200`.
+471
View File
@@ -0,0 +1,471 @@
# Lesson 6 — Server-Side Sessions with scs + Redis
> **New Go concepts in this lesson:** working with a connection pool for a
> second kind of backing store (Redis, via redigo), middleware composing
> with something other than chi's own middleware. Nothing brand new at
> the language level here — mostly applying everything from the Go Basics
> lessons to a new library.
## What "server-side session" means, concretely
The browser only ever holds a random, meaningless token in a cookie. All
the actual session **data** (which user is logged in, etc.) lives in
Redis, keyed by that token. This is different from storing data directly
inside a signed/encrypted cookie: server-side sessions can be instantly
revoked (delete the Redis key), don't grow the cookie as you store more
data, and never expose their contents to the browser at all.
## Part A — standalone playground
First, run Redis:
```bash
docker run --name redis-demo -p 6379:6379 -d redis:8
```
```bash
mkdir ~/go-playground/session-demo && cd ~/go-playground/session-demo
go mod init session-demo
go get github.com/alexedwards/scs/v2@latest
go get github.com/alexedwards/scs/redisstore@latest
go get github.com/gomodule/redigo@latest
```
**`main.go`**
```go
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gomodule/redigo/redis"
)
// A package-level session manager - scs is designed to be created once
// and reused everywhere, similar to how we handle *sql.DB.
var sessionManager *scs.SessionManager
func main() {
// 1. Build a Redis connection pool (redigo, not redis/go-redis - this
// is the client library scs's redisstore is built on).
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", "127.0.0.1:6379")
},
}
// 2. Create the session manager and point its Store at Redis.
sessionManager = scs.New()
sessionManager.Store = redisstore.New(pool)
sessionManager.Lifetime = 24 * time.Hour
sessionManager.Cookie.Name = "session_id"
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
mux := http.NewServeMux()
mux.HandleFunc("/set", setHandler)
mux.HandleFunc("/get", getHandler)
mux.HandleFunc("/clear", clearHandler)
// 3. Wrap the whole mux with LoadAndSave - this is scs's own
// middleware, same shape as chi's: func(http.Handler) http.Handler.
log.Println("listening on :4000")
log.Fatal(http.ListenAndServe(":4000", sessionManager.LoadAndSave(mux)))
}
func setHandler(w http.ResponseWriter, r *http.Request) {
// Put stores a value in the session, keyed by string.
sessionManager.Put(r.Context(), "username", "hamid")
sessionManager.Put(r.Context(), "visits", 1)
fmt.Fprintln(w, "session data set")
}
func getHandler(w http.ResponseWriter, r *http.Request) {
// GetString / GetInt read back typed values. If the key doesn't
// exist, they return the zero value ("" or 0), not an error.
username := sessionManager.GetString(r.Context(), "username")
visits := sessionManager.GetInt(r.Context(), "visits")
// Exists checks presence explicitly, useful to distinguish "never
// set" from "set to zero value".
if !sessionManager.Exists(r.Context(), "username") {
fmt.Fprintln(w, "no session data yet - try /set first")
return
}
fmt.Fprintf(w, "username=%s visits=%d\n", username, visits)
}
func clearHandler(w http.ResponseWriter, r *http.Request) {
// Destroy wipes the session entirely and tells the browser to delete
// the cookie.
if err := sessionManager.Destroy(r.Context()); err != nil {
http.Error(w, "failed to destroy session", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "session destroyed")
}
```
Run it:
```bash
go run .
```
In another terminal, **use `-c cookies.txt -b cookies.txt`** so curl
remembers the session cookie between requests, just like a browser would:
```bash
curl -c cookies.txt -b cookies.txt http://localhost:4000/set
curl -c cookies.txt -b cookies.txt http://localhost:4000/get
curl -c cookies.txt -b cookies.txt http://localhost:4000/clear
curl -c cookies.txt -b cookies.txt http://localhost:4000/get # back to "no session data yet"
```
While it's running, peek into Redis directly to *see* the session data
living server-side:
```bash
docker exec -it redis-demo redis-cli KEYS '*'
docker exec -it redis-demo redis-cli GET "scs:session:<token-from-above>"
```
Line by line:
- `redis.Pool{...}` — the same "connection pool" concept as `*sql.DB`
from Lesson 3, just for Redis instead of MySQL. `Dial` is a function the
pool calls whenever it needs a fresh connection.
- `scs.New()` — creates a `*scs.SessionManager` with sensible defaults
(in-memory store, no cookie config yet).
- `sessionManager.Store = redisstore.New(pool)` — by default scs stores
sessions **in memory** (lost on restart, useless across multiple server
instances). Setting `.Store` swaps the backend to Redis — same manager,
same API, completely different storage underneath. This is the same
"swap the implementation, keep the interface" idea from Lesson 2's
Text/JSON handler swap.
- `sessionManager.Lifetime = 24 * time.Hour` — how long a session stays
valid since it was created.
- `sessionManager.Cookie.HttpOnly = true` — the browser's JavaScript can't
read this cookie (`document.cookie` won't show it), blocking a large
class of XSS-based session theft.
- `sessionManager.Cookie.SameSite = http.SameSiteLaxMode` — restricts when
the browser sends this cookie on cross-site requests, mitigating CSRF
(more on this in Lesson 9).
- **How it all connects**: `sessionManager.LoadAndSave(mux)` wraps your
entire mux, same middleware pattern from Lesson 2. On every request: it
reads the session cookie, loads that session's data from Redis into the
request's context, lets your handler run (which reads/writes session
data via `sessionManager.Put`/`Get`, using `r.Context()` to know *which*
session it's operating on), then after your handler finishes, saves any
changes back to Redis and writes/refreshes the cookie on the response.
You never touch cookies or Redis directly.
- `sessionManager.Put(r.Context(), "username", "hamid")` — stores a value
under a string key, scoped to the session identified by this request's
cookie.
- `sessionManager.GetString(...)` / `GetInt(...)` — typed getters. There's
also `GetBool`, `GetFloat`, `GetTime`, and a generic `Get` returning
`any` for custom types.
- `sessionManager.Destroy(r.Context())` — deletes the session from Redis
and instructs the browser (via response headers) to expire the cookie.
Try stopping and restarting your Go program (Ctrl+C, `go run .` again)
*without* restarting Redis — set a session, restart the app, `GET` again.
The session survives, because it never lived in your Go process's memory
in the first place.
## Part B — apply it to the project
**Add the dependencies:**
```bash
go get github.com/alexedwards/scs/v2@latest
go get github.com/alexedwards/scs/redisstore@latest
go get github.com/gomodule/redigo@latest
```
**Extend `internal/config/config.go`** with Redis settings:
```go
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
RedisAddr string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", "devpass"),
DBName: getEnv("DB_NAME", "go_simple_api"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
}
}
```
**`internal/session/session.go`** — builds the shared session manager:
```go
package session
import (
"net/http"
"time"
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gomodule/redigo/redis"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
func New(cfg config.Config) *scs.SessionManager {
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", cfg.RedisAddr)
},
}
manager := scs.New()
manager.Store = redisstore.New(pool)
manager.Lifetime = 24 * time.Hour
manager.Cookie.Name = "session_id"
manager.Cookie.HttpOnly = true
manager.Cookie.SameSite = http.SameSiteLaxMode
return manager
}
```
Identical to Part A's setup, wrapped in `New(cfg)` so `main.go` builds it
the same way it builds `database.NewMySQL(...)` and `logging.New()`.
**`internal/session/keys.go`** — a central place for session data keys:
```go
package session
const UserIDKey = "user_id"
```
Defining this constant once avoids typos across files that would silently
break authentication (e.g. one file writes `"user_id"`, another reads
`"userId"` — the compiler can't catch that for you if they're raw
strings; a shared constant makes that class of bug impossible).
**Update `internal/handlers/auth.go`** — inject the session manager, and
actually start a session on login. Update the struct and constructor:
```go
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"golang.org/x/crypto/bcrypt"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
type AuthHandler struct {
userRepo *models.UserRepository
sessions *scs.SessionManager
logger *slog.Logger
}
func NewAuthHandler(userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *AuthHandler {
return &AuthHandler{userRepo: userRepo, sessions: sessions, logger: logger}
}
```
(`Register` is unchanged from Lesson 5 — leave it as-is.)
Update `Login` to actually create a session, and add `Logout` + `Me`:
```go
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
user, err := h.userRepo.FindByEmail(r.Context(), req.Email)
if errors.Is(err, models.ErrUserNotFound) {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err != nil {
h.logger.Error("find user by email failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
// Prevent session fixation: issue a fresh session token now that the
// user's privilege level is about to change (anonymous -> authenticated).
if err := h.sessions.RenewToken(r.Context()); err != nil {
h.logger.Error("renew token failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if err := h.sessions.Destroy(r.Context()); err != nil {
h.logger.Error("destroy session failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
userID := h.sessions.GetInt(r.Context(), session.UserIDKey)
if userID == 0 {
writeError(w, http.StatusUnauthorized, "not logged in")
return
}
user, err := h.userRepo.FindByID(r.Context(), userID)
if errors.Is(err, models.ErrUserNotFound) {
writeError(w, http.StatusUnauthorized, "not logged in")
return
}
if err != nil {
h.logger.Error("find user by id failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
```
What's new:
- `h.sessions.RenewToken(r.Context())` — generates a brand-new session
token while keeping the session's existing data intact, invalidating
the old token. This is **preventing session fixation**: if an attacker
somehow got a victim to use a *known* session token before login,
renewing it at the moment of authentication makes the pre-login token
useless. Always call this right before a privilege change (login here).
- `h.sessions.Put(r.Context(), session.UserIDKey, user.ID)` — this is the
entire "session" from the server's perspective: we store the user's ID,
not the whole `User` struct. Anything else about the user (email, etc.)
is looked up fresh from the database when needed (as in `Me`) — this
keeps the session small and avoids serving *stale* cached user data.
- `Me` reads back `session.UserIDKey` via `GetInt`, then does a real
`FindByID` lookup. This route is your template for **any** future route
that needs "the current logged-in user" — in Lesson 8 we'll extract the
"check the session, else 401" part into reusable middleware instead of
repeating it in every handler.
**Update `internal/router/router.go`**:
```go
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
)
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
// scs's own middleware must wrap every route that touches sessions.
// Simplest for now: wrap the whole router.
r.Use(sessions.LoadAndSave)
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Post("/logout", authHandler.Logout)
r.Get("/me", authHandler.Me)
return r
}
```
`r.Use(sessions.LoadAndSave)` — exactly like Part A's manual wrapping, but
as chi middleware. `sessions.LoadAndSave` already has the
`func(http.Handler) http.Handler` shape chi's `Use` expects, so it's
passed directly (same as `chimw.Recoverer`).
**Update `cmd/api/main.go`**:
```go
sessions := session.New(cfg)
logger.Info("session manager configured", "redis_addr", cfg.RedisAddr)
r := router.New(logger, db, sessions)
```
(Add `"git.hamidsoltani.com/hamid/go-simple-api/internal/session"` to
imports.)
## Try it
```bash
docker run --name redis-api -p 6379:6379 -d redis:8
go run ./cmd/api
```
```bash
curl -c cookies.txt -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
curl -b cookies.txt http://localhost:8080/me
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/logout
curl -b cookies.txt http://localhost:8080/me # should now be unauthorized
```
Check Redis directly to see your real session sitting there server-side:
```bash
docker exec -it redis-api redis-cli KEYS '*'
```
Once `/me` correctly returns your user after login and fails after
logout, move to Lesson 7 — Google OAuth login.
+542
View File
@@ -0,0 +1,542 @@
# Lesson 7 — Login with Google (OAuth2)
> **New Go concepts in this lesson:** anonymous structs for one-off JSON
> shapes, `crypto/rand` vs `math/rand`, working with an `*http.Client`
> returned by a library. Builds on JSON basics and error handling from
> the Go Basics lessons — nothing fundamentally new at the language
> level, but a new external flow (OAuth2) to understand conceptually.
## What OAuth2 actually does here
Your app never sees the user's Google password. Instead, your app
redirects the user to Google, the user logs into *Google* and approves
"let this app see your email," and Google redirects back to your app with
a temporary code. Your app exchanges that code (server-to-server, never
visible to the browser) for an access token, then uses that token to ask
Google "who is this user?" This is called the **Authorization Code
flow** — the standard, secure OAuth2 pattern.
## Setting up Google credentials (one-time, outside the code)
1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create a project if needed.
2. Create an **OAuth 2.0 Client ID** (Application type: Web application).
3. Add an **Authorized redirect URI**: `http://localhost:8080/auth/google/callback`.
4. You'll get a **Client ID** and **Client Secret** — treat the secret
like a password, never commit it to git.
## Part A — standalone playground
```bash
mkdir ~/go-playground/oauth-demo && cd ~/go-playground/oauth-demo
go mod init oauth-demo
go get golang.org/x/oauth2@latest
```
**`main.go`**
```go
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// 1. oauth2.Config describes everything needed to talk to Google's OAuth
// system. Fill in your own Client ID/Secret from the Cloud Console.
var googleOAuthConfig = &oauth2.Config{
ClientID: "YOUR_CLIENT_ID.apps.googleusercontent.com",
ClientSecret: "YOUR_CLIENT_SECRET",
RedirectURL: "http://localhost:4000/auth/google/callback",
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
Endpoint: google.Endpoint,
}
// In real code this state should be stored per-session, not a global -
// we use a global here ONLY to keep this playground minimal.
var expectedState string
func main() {
http.HandleFunc("/login", loginHandler)
http.HandleFunc("/auth/google/callback", callbackHandler)
log.Println("visit http://localhost:4000/login")
log.Fatal(http.ListenAndServe(":4000", nil))
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
// 2. Generate a random "state" value - CSRF protection: we'll check
// that the state Google sends back matches what we generated, proving
// the callback really came from a login WE initiated.
state, err := generateState()
if err != nil {
http.Error(w, "failed to generate state", http.StatusInternalServerError)
return
}
expectedState = state
// 3. AuthCodeURL builds the full URL to Google's consent screen,
// embedding our client ID, redirect URL, scopes, and state.
url := googleOAuthConfig.AuthCodeURL(state)
// 4. Send the browser there.
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
// 5. Google redirects back here with ?state=...&code=... in the URL.
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
return
}
// 6. Exchange the temporary code for an actual access token. This is
// a direct server-to-server HTTPS call to Google - the code is
// single-use and expires quickly.
token, err := googleOAuthConfig.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "code exchange failed", http.StatusInternalServerError)
return
}
// 7. Use the access token to call Google's userinfo endpoint and find
// out who actually logged in.
client := googleOAuthConfig.Client(r.Context(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
http.Error(w, "failed to fetch user info", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "failed to read user info", http.StatusInternalServerError)
return
}
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
}
if err := json.Unmarshal(body, &googleUser); err != nil {
http.Error(w, "failed to parse user info", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "logged in as: id=%s email=%s\n", googleUser.ID, googleUser.Email)
}
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
```
Run it, plug in your real Client ID/Secret, then visit
`http://localhost:4000/login` **in a real browser** (curl can't drive
Google's login page).
Line by line:
- `oauth2.Config` — a struct holding everything needed to run the flow:
your app's identity (`ClientID`/`ClientSecret`), where Google sends the
user back (`RedirectURL`**must exactly match** the Cloud Console
registration), what data you're requesting (`Scopes`), and which
provider's endpoints to use (`Endpoint: google.Endpoint`, a predefined
value pointing at Google's real auth/token URLs).
- `var googleUser struct { ID string \`json:"id"\`; Email string \`json:"email"\` }`
— this is an **anonymous struct**: a struct type defined inline,
without a `type Name struct` declaration, used because we only need
this shape once, right here, to decode one specific JSON response.
- `generateState()``crypto/rand` (NOT `math/rand`!) generates
cryptographically secure random bytes, unpredictable even if an
attacker knows previous outputs. This matters because `state` is a
security mechanism, not just a random label. `math/rand` is fine for
games/simulations, never for anything security-sensitive.
- **Why `state` matters**: without it, an attacker could craft their own
malicious callback link using *their own* Google account's code, trick
a victim into clicking it while logged into your app, and potentially
link the attacker's Google account to the victim's session. Checking
that `state` matches what *we* generated closes that hole.
- `googleOAuthConfig.AuthCodeURL(state)` — builds the actual Google
consent-screen URL; you never hand-construct this.
- `http.Redirect(w, r, url, http.StatusTemporaryRedirect)` — sends an
HTTP 302-style response telling the browser "go here instead." The
browser follows it to Google.
- `googleOAuthConfig.Exchange(r.Context(), code)` — server-to-server: your
Go program makes an HTTPS POST to Google, presenting `code` plus your
`ClientSecret` (proving it's really your registered app), and gets back
an `oauth2.Token`.
- `googleOAuthConfig.Client(r.Context(), token)` — returns a regular
`*http.Client`, pre-configured to automatically attach the access token
as an `Authorization: Bearer ...` header on every request — no manual
header handling needed.
- `io.ReadAll(resp.Body)` then `json.Unmarshal(body, &googleUser)`
slightly different from `json.NewDecoder(r.Body).Decode(&x)` used
elsewhere. Both work; `Unmarshal` needs the full byte slice up front
(hence `ReadAll` first), `Decode` streams directly. Either is fine for
small responses — you'll see both styles in real Go code.
- `defer resp.Body.Close()` — same rule as `defer rows.Close()` from
Lesson 3: any "reader" resource (HTTP body, SQL rows, open file) should
always be closed when you're done with it.
Try it end to end, confirm you see your real Google email printed back.
Try mangling the `state` value in the URL manually and confirm "invalid
state."
## Part B — apply it to the project
**Add the dependency:**
```bash
go get golang.org/x/oauth2@latest
```
**Extend `internal/config/config.go`:**
```go
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
RedisAddr string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", "devpass"),
DBName: getEnv("DB_NAME", "go_simple_api"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
}
}
```
Add to `.env`:
```
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback
```
**`internal/oauth/google.go`** — builds the `*oauth2.Config` from our
app's `Config`:
```go
package oauth
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
func NewGoogleConfig(cfg config.Config) *oauth2.Config {
return &oauth2.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
RedirectURL: cfg.GoogleRedirectURL,
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
Endpoint: google.Endpoint,
}
}
```
**`internal/handlers/oauth_google.go`** — the real handler, reusing the
flow from Part A but wired into our session/repository system:
```go
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"golang.org/x/oauth2"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
type GoogleOAuthHandler struct {
config *oauth2.Config
userRepo *models.UserRepository
sessions *scs.SessionManager
logger *slog.Logger
}
func NewGoogleOAuthHandler(config *oauth2.Config, userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *GoogleOAuthHandler {
return &GoogleOAuthHandler{config: config, userRepo: userRepo, sessions: sessions, logger: logger}
}
const oauthStateSessionKey = "oauth_state"
func (h *GoogleOAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
state, err := generateState()
if err != nil {
h.logger.Error("generate oauth state failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
// Store state in the SESSION instead of a global variable (Part A cut
// this corner deliberately, having no session system yet). This
// means state survives correctly even with multiple users hitting
// /auth/google/login concurrently.
h.sessions.Put(r.Context(), oauthStateSessionKey, state)
url := h.config.AuthCodeURL(state)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
if expectedState == "" || r.URL.Query().Get("state") != expectedState {
writeError(w, http.StatusBadRequest, "invalid oauth state")
return
}
h.sessions.Remove(r.Context(), oauthStateSessionKey) // one-time use
code := r.URL.Query().Get("code")
if code == "" {
writeError(w, http.StatusBadRequest, "missing code")
return
}
token, err := h.config.Exchange(r.Context(), code)
if err != nil {
h.logger.Error("oauth exchange failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
client := h.config.Client(r.Context(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
h.logger.Error("fetch google userinfo failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
h.logger.Error("read google userinfo failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
}
if err := json.Unmarshal(body, &googleUser); err != nil {
h.logger.Error("parse google userinfo failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
user, err := h.findOrCreateGoogleUser(r, googleUser.ID, googleUser.Email)
if err != nil {
h.logger.Error("find or create google user failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if err := h.sessions.RenewToken(r.Context()); err != nil {
h.logger.Error("renew token failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
// findOrCreateGoogleUser links a Google identity to a local user. Three
// cases: an existing user with this email but no google_id yet (link
// it), a user already linked to this google_id (just fetch it), or
// nobody with this email exists yet (create a new user).
func (h *GoogleOAuthHandler) findOrCreateGoogleUser(r *http.Request, googleID, email string) (*models.User, error) {
existing, err := h.userRepo.FindByEmail(r.Context(), email)
if errors.Is(err, models.ErrUserNotFound) {
newUser := &models.User{
Email: email,
GoogleID: googleID,
// PasswordHash stays empty - this user can only log in via Google.
}
if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
return nil, createErr
}
return newUser, nil
}
if err != nil {
return nil, err
}
// User exists by email. If they haven't linked Google yet, link it now.
if existing.GoogleID == "" {
if linkErr := h.userRepo.SetGoogleID(r.Context(), existing.ID, googleID); linkErr != nil {
return nil, linkErr
}
existing.GoogleID = googleID
}
return existing, nil
}
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
```
Notable differences from Part A, and why:
- **State stored in the session, not a global variable.** Part A's
`var expectedState string` only works for one user at a time — a real
server handles many concurrent users, and a shared global would let one
user's login attempt clobber another's state. Storing it via
`h.sessions.Put(...)` scopes it correctly per-visitor.
- `h.sessions.Remove(...)` — a new scs method: removes a single key from
the session (as opposed to `Destroy`, which wipes the whole session).
`state` is only needed for this one round trip, so we clean it up
immediately after checking it.
- `h.findOrCreateGoogleUser(...)` — the **account linking** logic. Three
distinct paths, each built entirely from repository methods you already
know from Lesson 4/5.
**Add one more repository method — `internal/models/user_repository.go`:**
```go
func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
)
if err != nil {
return fmt.Errorf("set google id: %w", err)
}
return nil
}
```
**Update `internal/router/router.go`:**
```go
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Use(sessions.LoadAndSave)
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Post("/logout", authHandler.Logout)
r.Get("/me", authHandler.Me)
googleConfig := oauth.NewGoogleConfig(cfg)
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
r.Get("/auth/google/login", googleHandler.Login)
r.Get("/auth/google/callback", googleHandler.Callback)
return r
}
```
`New` now also takes `cfg config.Config` (needed to build the Google
OAuth config).
**Update `cmd/api/main.go`** — change the `router.New(...)` call:
```go
r := router.New(logger, db, sessions, cfg)
```
## Try it
```bash
go run ./cmd/api
```
Visit `http://localhost:8080/auth/google/login` **in a browser**. After
approving, you should land on `/auth/google/callback` and see JSON back
with your id/email, plus a session cookie:
```bash
# copy the session_id cookie value from your browser's devtools
curl -b "session_id=<paste-cookie-value-here>" http://localhost:8080/me
```
Confirm in MySQL:
```bash
docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "SELECT id, email, google_id FROM users;"
```
Once a full Google login round-trip works, move to Lesson 8 — auth
middleware & route protection.
+359
View File
@@ -0,0 +1,359 @@
# Lesson 8 — Auth Middleware & Route Protection
> **New Go concepts in this lesson:** `context.Context` in depth
> (`context.WithValue`, `r.WithContext`), private types used purely as
> unique context keys, type assertions applied for real. This is the
> concept-heaviest lesson in the course — take it slowly, and don't skip
> Part A.
## Why we need this
Right now, `Me` manually checks the session and returns 401 if there's no
user. As we add more protected routes later, copy-pasting that check into
every handler is error-prone: forget it once, and you've got an
unprotected route. The fix is **middleware that guards routes**, plus
using the request's **context** to hand the logged-in user down to
whichever handler runs next.
## Part A — standalone playground
This lesson is really about one core Go mechanism: `context.Context` as a
way to pass request-scoped values through a middleware chain. Let's build
it from scratch, no chi, no scs — just `net/http` and `context`.
```bash
mkdir ~/go-playground/context-demo && cd ~/go-playground/context-demo
go mod init context-demo
```
**`main.go`**
```go
package main
import (
"context"
"fmt"
"log"
"net/http"
)
// 1. A custom type for our context key. Using a plain string like "user"
// as a key is risky - other packages might use the same string and
// silently collide. A private, unexported type guarantees uniqueness.
type contextKey string
const userContextKey contextKey = "user"
type User struct {
ID int
Email string
}
// 2. Middleware that pretends to authenticate a request (checks a fake
// header instead of a real session, just to isolate the context concept).
func fakeAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "secret-token" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // IMPORTANT: we do NOT call next.ServeHTTP - chain stops here
}
user := &User{ID: 1, Email: "hamid@example.com"}
// 3. Store the user in a NEW context, derived from the request's
// existing context, then build a NEW request carrying that context.
ctx := context.WithValue(r.Context(), userContextKey, user)
r = r.WithContext(ctx)
// 4. Pass the request onward - now carrying the user.
next.ServeHTTP(w, r)
})
}
func protectedHandler(w http.ResponseWriter, r *http.Request) {
// 5. Read the value back out of the context, in the final handler.
user, ok := r.Context().Value(userContextKey).(*User)
if !ok {
// Should never happen if the middleware ran correctly, but
// defensive code is cheap insurance.
http.Error(w, "no user in context", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "hello, %s (id=%d)\n", user.Email, user.ID)
}
func main() {
mux := http.NewServeMux()
mux.Handle("/protected", fakeAuthMiddleware(http.HandlerFunc(protectedHandler)))
log.Println("listening on :4000")
log.Fatal(http.ListenAndServe(":4000", mux))
}
```
Run it:
```bash
go run .
```
Test it:
```bash
curl http://localhost:4000/protected
# 401 unauthorized
curl -H "Authorization: secret-token" http://localhost:4000/protected
# hello, hamid@example.com (id=1)
```
Line by line — this is the trickiest idiom in the whole course, worth
sitting with:
- `type contextKey string` then `const userContextKey contextKey = "user"`
— why not just use the plain string `"user"` directly? Because
`context.WithValue` keys are compared by **both type and value**. If
two unrelated packages both used the plain string `"user"` as a key,
they'd accidentally read/overwrite each other's data. By defining our
own named type `contextKey`, our key `userContextKey` can never collide
with a plain `string` key or another package's own custom-typed key —
even if the underlying text is identical. This is a well-known,
idiomatic Go pattern specifically to avoid that collision class.
- `if token != "secret-token" { http.Error(...); return }` — note there's
**no call to `next.ServeHTTP`** in this branch. This is the entire
mechanism of "blocking" a request in middleware: simply *don't* call
the next handler. The chain just stops, and whatever you already wrote
to `w` (here, the 401) is the final response.
- `context.WithValue(r.Context(), userContextKey, user)` — contexts are
**immutable**. You can't add a value to an existing context; `WithValue`
returns a **brand-new** context wrapping the old one plus the new
key/value pair. The original `r.Context()` is untouched.
- `r = r.WithContext(ctx)` — similarly, `*http.Request` is designed so
you don't mutate its context in place; `WithContext` returns a **new**
`*http.Request` (a shallow copy) with the new context attached.
Reassigning `r` to this new value is how we "carry" the updated context
forward.
- `next.ServeHTTP(w, r)` — passing the **new** `r` (with the user
embedded) onward. Anything called after this point — more middleware,
or the final handler — can pull the user back out.
- `r.Context().Value(userContextKey).(*User)``Value` returns `any`
(could be anything, or `nil` if the key isn't present), so we need a
**type assertion** (`.(*User)`) to convert it back to our concrete
type. The two-value form `user, ok := ...` is the safe version: `ok`
is `false` if the assertion fails (wrong type, or key missing) instead
of panicking. **Always use the two-value form** when the value's
presence isn't 100% guaranteed — a single-value assertion panics on
failure, crashing your whole request.
Try removing the `Authorization` header check entirely and calling
`protectedHandler` directly, without going through the middleware — you'll
see the `ok` false-path trigger, since nothing populated the context.
## Part B — apply it to the project
We'll build real middleware that checks the actual session (Lesson 6),
loads the actual user from MySQL (Lesson 4), and stores it in context
using the exact pattern from Part A.
**`internal/middleware/require_auth.go`**
```go
package middleware
import (
"context"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
type contextKey string
const userContextKey contextKey = "current_user"
// RequireAuth is a middleware FACTORY - same three-layer shape as
// RequestLogger from Lesson 2. It takes the dependencies it needs
// (sessions, userRepo, logger), and returns the actual chi middleware.
func RequireAuth(sessions *scs.SessionManager, userRepo *models.UserRepository, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := sessions.GetInt(r.Context(), session.UserIDKey)
if userID == 0 {
writeUnauthorized(w)
return
}
user, err := userRepo.FindByID(r.Context(), userID)
if err != nil {
// Covers both "not found" (e.g. account deleted after
// login) and real DB errors - either way, this request
// cannot proceed as authenticated.
logger.Error("require auth: find user failed", "error", err, "user_id", userID)
writeUnauthorized(w)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CurrentUser is how handlers pull the authenticated user back out.
// Handlers never touch userContextKey directly - they just call this.
func CurrentUser(r *http.Request) *models.User {
user, ok := r.Context().Value(userContextKey).(*models.User)
if !ok {
return nil
}
return user
}
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
}
```
This should now read very familiarly — it's Part A's pattern with the
fake pieces swapped for real ones:
- `sessions.GetInt(...)` — same check `Me` did manually in Lesson 6.
- `userRepo.FindByID(...)` — same repository lookup `Me` did.
- `context.WithValue` / `r.WithContext` / `next.ServeHTTP(w, r.WithContext(ctx))`
— identical mechanism from Part A.
- `CurrentUser(r *http.Request) *models.User` — a small **exported
helper function**, not a method, wrapping the type assertion so
handlers never need to know about `userContextKey` at all (it's
unexported — package-private — precisely so only this file can create
or read that specific key). This pairs a private context key with a
public accessor function, a common Go idiom.
- `writeUnauthorized` — a tiny local helper, written by hand instead of
reusing `handlers.writeError`, because `internal/middleware` and
`internal/handlers` are separate packages, and `writeError` is
unexported in `handlers`. This is an intentional package boundary, not
an oversight — if we wanted to share it, we'd need to export it
(`WriteError`) from a package both can import.
**Simplify `Me` in `internal/handlers/auth.go`** now that middleware does
the lookup:
```go
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
user := middleware.CurrentUser(r)
if user == nil {
writeError(w, http.StatusUnauthorized, "not logged in")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
```
Add the import: `"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"`.
`Me` no longer touches `h.sessions` or does a `FindByID` itself at all —
the middleware already did that work before `Me` ever runs, and just
hands us the result via `CurrentUser(r)`. The `nil` check stays as a
defensive safety net (in case someone wires this handler up without the
middleware by mistake), but in normal operation it should never trigger.
**Update `internal/router/router.go`** to apply `RequireAuth` to `/me`,
using chi's route grouping:
```go
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Use(sessions.LoadAndSave)
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Post("/logout", authHandler.Logout)
// Group: every route inside here goes through requireAuth first.
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Get("/me", authHandler.Me)
})
googleConfig := oauth.NewGoogleConfig(cfg)
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
r.Get("/auth/google/login", googleHandler.Login)
r.Get("/auth/google/callback", googleHandler.Callback)
return r
}
```
- `requireAuth := middleware.RequireAuth(sessions, userRepo, logger)`
calling the middleware factory *once*, producing the actual
`func(http.Handler) http.Handler` (same "call it once to get the real
middleware" pattern as `RequestLogger(logger)` in Lesson 2).
- `r.Group(func(r chi.Router) { ... })` — chi's way of scoping middleware
to a *subset* of routes instead of the whole router. Inside the group,
`r.Use(requireAuth)` only applies to routes registered *within that same
closure* — `/me` is protected, but `/register`/`/login`/`/logout`
(registered outside the group) are not. Add future authenticated-only
routes inside this same `r.Group(...)` block.
## Try it
```bash
go run ./cmd/api
```
```bash
curl http://localhost:8080/me
# {"error":"unauthorized"}
curl -c cookies.txt -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
curl -b cookies.txt http://localhost:8080/me
# now works
```
Try logging out and hitting `/me` again — should go back to
`unauthorized`, this time via the middleware instead of manual logic
inside the handler.
**A sanity check on your understanding:** if you comment out
`r.Use(requireAuth)` inside the `Group`, `/me` will still correctly
return `401` (via `Me`'s defensive `nil` check on `CurrentUser(r)`), not a
crash — because `middleware.CurrentUser(r)` finds nothing in the context
(the middleware never ran to put it there), and `Me`'s check catches
that. Try it and read the log line that gets printed.
Once the protected/unprotected split works, move to Lesson 9 — rate
limiting & security hardening.
+383
View File
@@ -0,0 +1,383 @@
# Lesson 9 — Rate Limiting & Security Hardening
> **New Go concepts in this lesson:** almost none new at the language
> level — this lesson is mostly about correctly configuring existing
> tools (`httprate`, `cors`, cookie flags) rather than new syntax. A good
> lesson to consolidate everything from Go Basics so far.
Four separate concerns, each small on its own: **rate limiting** (stop
abuse/brute-force), **secure cookie flags** (protect the session cookie
itself), **CORS** (control which websites can call your API from a
browser), and a basic **CSRF** mitigation for our cookie-based sessions.
## Part A — standalone playgrounds
### 1. Rate limiting with `httprate`
```bash
mkdir ~/go-playground/security-demo && cd ~/go-playground/security-demo
go mod init security-demo
go get github.com/go-chi/httprate@latest
go get github.com/go-chi/chi/v5@latest
```
**`main.go`**
```go
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
)
func main() {
r := chi.NewRouter()
// 1. Limit EVERY client to 5 requests per 10 seconds, keyed by IP.
r.Use(httprate.LimitByIP(5, 10*time.Second))
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "pong")
})
log.Println("listening on :4000")
log.Fatal(http.ListenAndServe(":4000", r))
}
```
Run it and hammer it:
```bash
go run .
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/ping; done
```
You should see `200` five times, then `429` (Too Many Requests) for the
rest, until 10 seconds pass.
- `httprate.LimitByIP(5, 10*time.Second)` — a ready-made middleware (same
`func(http.Handler) http.Handler` shape you already know) tracking
request counts **per client IP**, in a sliding window. Exceeding the
limit auto-responds with `429 Too Many Requests` — you don't write that
logic yourself.
- Why keyed by IP: without a key, one abusive client could exhaust the
"budget" for every other user too. `LimitByIP` isolates each caller's
own quota. (Other keying strategies exist too — `LimitByRealIP`, or
custom keys like "by user ID" once authenticated.) This matters most on
`/login` and `/register` — without it, someone could script thousands
of password guesses per second against `/login`.
### 2. Cookie security flags
No need to run this one — just understand each flag, since we set these
on `scs`'s cookie config (already partly done in Lesson 6), not by hand:
```go
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "abc123",
Path: "/",
HttpOnly: true, // JS cannot read this cookie
Secure: true, // browser only sends it over HTTPS
SameSite: http.SameSiteLaxMode, // restricts cross-site sending
})
```
- `HttpOnly: true` — blocks `document.cookie` access from JavaScript.
Defeats a whole class of XSS attacks that try to steal the session
cookie via injected script.
- `Secure: true` — the browser will refuse to send this cookie over plain
HTTP, only HTTPS. **Important gotcha**: if you set this while
developing locally over `http://localhost`, the cookie won't be sent at
all — you'll be confused why sessions "don't work." We'll make this
environment-dependent in Part B.
- `SameSite: http.SameSiteLaxMode` — controls whether the cookie is sent
on cross-site requests. `Lax` (a good default) sends the cookie on
top-level navigations (clicking a link to your site) but not on
cross-site `POST`s triggered by another page (like a malicious
`<form>` auto-submitting to your `/logout`) — this is your main defense
against CSRF for cookie-based auth. `Strict` is even tighter but can
break legitimate cross-site navigation flows (like our own OAuth
callback from Google!). `None` disables the protection entirely and
requires `Secure: true`.
### 3. CORS
CORS only matters for requests made **from browser JavaScript running on
a different origin** than your API (e.g., a React app on
`http://localhost:3000` calling your API on `http://localhost:8080`). It
does **not** protect your API from curl, mobile apps, or server-to-server
calls — CORS is a browser-enforced rule, not a server-side security
boundary. It controls *which websites* a browser will let call your API
with the user's cookies/credentials attached.
```bash
go get github.com/go-chi/cors@latest
```
```go
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
)
func main() {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"}, // your frontend's origin
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true, // required for cookies to be sent cross-origin
}))
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
http.ListenAndServe(":4000", r)
}
```
- `AllowedOrigins` — an explicit allowlist. **Never** use `"*"` (wildcard)
together with `AllowCredentials: true` — browsers actually forbid that
combination outright, and even without credentials it's a bad default
for anything handling auth.
- `AllowCredentials: true` — without this, the browser won't include
cookies on cross-origin requests to your API at all, so session-based
auth from a separate frontend wouldn't work.
## Part B — apply it all to the project
**Get the dependencies:**
```bash
go get github.com/go-chi/httprate@latest
go get github.com/go-chi/cors@latest
```
**Update `internal/router/router.go`** — apply a general limit to
everything, and a stricter one specifically to auth endpoints:
```go
package router
import (
"database/sql"
"log/slog"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true,
}))
// A generous global limit - mostly to stop runaway scripts/bots.
r.Use(httprate.LimitByIP(100, time.Minute))
r.Use(sessions.LoadAndSave)
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
// A much stricter limit specifically on login/register, since these
// are exactly what a credential-stuffing / brute-force script targets.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(5, time.Minute))
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
})
r.Post("/logout", authHandler.Logout)
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Get("/me", authHandler.Me)
})
googleConfig := oauth.NewGoogleConfig(cfg)
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
r.Get("/auth/google/login", googleHandler.Login)
r.Get("/auth/google/callback", googleHandler.Callback)
return r
}
```
- Two separate `httprate.LimitByIP` calls at different scopes — the
global `100/minute` is a loose safety net for the whole API, while the
`r.Group` around `/register` and `/login` layers a *much* tighter
`5/minute` on top. Both limits apply simultaneously to requests inside
the group (they stack).
- `/logout` deliberately sits *outside* that strict group — you don't
want to rate-limit a legitimate logged-in user trying to log out.
- `cors.Handler(...)` now reads `cfg.AllowedOrigins` instead of a
hardcoded value.
**Extend `internal/config/config.go`** for CORS origins and cookie
security:
```go
import "strings"
type Config struct {
Port string
Env string // "development" or "production"
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
RedisAddr string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
AllowedOrigins []string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
Env: getEnv("ENV", "development"),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", "devpass"),
DBName: getEnv("DB_NAME", "go_simple_api"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
}
}
```
- `Env` — distinguishes development from production, used next for the
cookie's `Secure` flag.
- `strings.Split(getEnv(...), ",")` — lets you configure multiple allowed
origins via one comma-separated env var (see Go Basics Part 3 on
slices), e.g. `ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com`.
**Update `internal/session/session.go`** — make `Secure` environment-aware,
fixing the localhost gotcha from Part A:
```go
package session
import (
"net/http"
"time"
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gomodule/redigo/redis"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)
func New(cfg config.Config) *scs.SessionManager {
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", cfg.RedisAddr)
},
}
manager := scs.New()
manager.Store = redisstore.New(pool)
manager.Lifetime = 24 * time.Hour
manager.Cookie.Name = "session_id"
manager.Cookie.HttpOnly = true
manager.Cookie.SameSite = http.SameSiteLaxMode
manager.Cookie.Secure = cfg.Env == "production" // only require HTTPS in prod
return manager
}
```
`manager.Cookie.Secure = cfg.Env == "production"` — in development
(`ENV` unset or `"development"`), the cookie works over plain
`http://localhost`. In production, set `ENV=production` and the cookie
will refuse to be sent over anything but HTTPS.
**Update `cmd/api/main.go`** — no change needed; `router.New(logger, db,
sessions, cfg)` already passes `cfg`, which now carries `AllowedOrigins`
and `Env`.
**Add to your `.env`:**
```
ENV=development
ALLOWED_ORIGINS=http://localhost:3000
```
## Try it
```bash
go run ./cmd/api
```
**Rate limiting:**
```bash
for i in $(seq 1 7); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"nope@example.com","password":"wrong"}'
done
```
You should see `401` (wrong credentials) for the first 5, then `429`
(rate limited) for the rest.
**CORS:**
```bash
curl -i -X OPTIONS http://localhost:8080/login \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST"
```
Look for `Access-Control-Allow-Origin: http://localhost:3000` in the
response headers.
A note on what we're *not* doing yet: full CSRF-token-based protection (a
token embedded in forms and checked server-side) is a deeper topic on its
own, and `SameSite=Lax` already covers the most common cookie-based CSRF
vector for a JSON API like this. If you later build a traditional
HTML-form frontend served from the same origin, that's when a dedicated
CSRF token library becomes worth adding — treat current protections as
sufficient for this course's scope.
Once rate limiting and CORS both check out, move to Lesson 10 — Docker,
docker-compose, and the full course wrap-up.
+330
View File
@@ -0,0 +1,330 @@
# Lesson 10 — Docker, docker-compose & Course Wrap-up
> **New Go concepts in this lesson:** none — this lesson is entirely about
> Docker/containerization, which is language-agnostic. If you've followed
> the Go Basics lessons and Lessons 19, you already know everything Go
> needs for this course.
This is the last lesson — we'll containerize the whole app (API + MySQL +
Redis) so it runs with one command, then do a full review of everything
you've built.
## Part A — Docker basics playground
A minimal example first, so the concepts aren't tangled up with our full
project.
```bash
mkdir ~/go-playground/docker-demo && cd ~/go-playground/docker-demo
go mod init docker-demo
```
**`main.go`**
```go
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello from inside docker")
})
http.ListenAndServe(":8080", nil)
}
```
**`Dockerfile`**
```dockerfile
# ---- Stage 1: build ----
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 produces a statically-linked binary - no C libraries
# needed, which lets us run it on a tiny base image in stage 2.
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .
# ---- Stage 2: run ----
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/bin/server .
EXPOSE 8080
CMD ["./server"]
```
Build and run it:
```bash
docker build -t docker-demo .
docker run -p 8080:8080 docker-demo
curl http://localhost:8080
```
Line by line:
- **Multi-stage build** — two `FROM` lines means two separate images are
involved. The first (`builder`) has the full Go toolchain (~800MB+) and
compiles your binary. The second (`alpine`) is a tiny (~7MB) Linux
image that only receives the *finished binary*, not the compiler,
source code, or build tools. Your final shipped image ends up small
with a much smaller attack surface — no compiler sitting around in
production.
- `FROM golang:1.26 AS builder``AS builder` names this stage so we can
reference it later with `--from=builder`.
- `WORKDIR /app` — sets the working directory inside the image for all
subsequent commands, same idea as `cd`.
- `COPY go.mod ./` then `RUN go mod download` **before** `COPY . .` — this
ordering is deliberate and matters for build speed. Docker caches each
layer; if `go.mod` hasn't changed, Docker reuses the cached
`go mod download` layer instead of re-downloading every dependency on
every code change. If we copied all the source first, any code edit
would invalidate the cache and force a full re-download every build.
- `CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .`
`CGO_ENABLED=0` disables cgo (Go code calling C code), forcing a fully
static binary with no dynamic library dependencies — this is what lets
it run on the minimal `alpine` image without missing shared libraries.
`GOOS=linux` ensures we cross-compile for Linux even if you're building
this on macOS/Windows.
- `COPY --from=builder /app/bin/server .` — the actual multi-stage magic:
pull just one file out of the *first* image into the *second*,
discarding everything else from the builder stage.
- `EXPOSE 8080` — documentation for humans/tools about which port the
container listens on; doesn't actually publish the port by itself
(that's `-p` on `docker run`).
- `CMD ["./server"]` — the command that runs when the container starts.
Now let's connect it to something else via **docker-compose**, so you see
multi-container orchestration before we do it for real:
**`docker-compose.yml`**
```yaml
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- redis
environment:
REDIS_ADDR: redis:6379
redis:
image: redis:8
ports:
- "6379:6379"
```
```bash
docker compose up --build
```
- `build: .` — build the image from the `Dockerfile` in the current
directory, instead of pulling a pre-built image.
- `depends_on: [redis]` — tells compose to start `redis` before `app`.
Note: this only controls *startup order*, not "wait until Redis is
actually ready to accept connections" — a fast-starting app can still
race ahead of a slow-starting dependency.
- `environment: REDIS_ADDR: redis:6379` — the key insight for compose
networking: **service names become hostnames**. Inside the compose
network, the `app` container can reach Redis at the hostname `redis`
(not `127.0.0.1`!), because compose sets up internal DNS that resolves
service names to the right container's IP. This is exactly why our app
reads `REDIS_ADDR` from config instead of hardcoding
`127.0.0.1:6379` — it needs to be different in Docker vs. local dev.
## Part B — containerize the full project
**`Dockerfile`** at the project root (same multi-stage pattern, adjusted
for our module path):
```dockerfile
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server ./cmd/api
FROM alpine:3.20
# ca-certificates is needed for outbound HTTPS calls - our Google OAuth
# token exchange and userinfo requests both need this to verify certs.
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/bin/server .
EXPOSE 8080
CMD ["./server"]
```
- `COPY go.mod go.sum* ./` — the `*` after `go.sum` means "copy it if it
exists, don't fail if it doesn't" (useful before you've run
`go mod tidy` the very first time).
- `./cmd/api` in the build command — points at our actual entrypoint
package from Lesson 1's project layout, not the project root.
- `RUN apk add --no-cache ca-certificates` — Alpine's minimal base
doesn't include root CA certificates by default. Without this, any
outbound HTTPS call our app makes (Google's token/userinfo endpoints)
would fail with a certificate verification error.
**`docker-compose.yml`** — the full stack: our app, MySQL, and Redis:
```yaml
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
PORT: 8080
ENV: development
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: devpass
DB_NAME: go_simple_api
REDIS_ADDR: redis:6379
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URL: http://localhost:8080/auth/google/callback
ALLOWED_ORIGINS: http://localhost:3000
mysql:
image: mysql:9
environment:
MYSQL_ROOT_PASSWORD: devpass
MYSQL_DATABASE: go_simple_api
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:8
ports:
- "6379:6379"
volumes:
mysql_data:
```
- `DB_HOST: mysql` / `REDIS_ADDR: redis:6379` — using compose service
names as hostnames, exactly as explained in Part A. This is *why* we
built `config.go` to read these from env vars back in Lesson 3/6
instead of hardcoding `127.0.0.1` — the same code now works both
locally and inside compose, just by changing environment variables.
- `${GOOGLE_CLIENT_ID}` / `${GOOGLE_CLIENT_SECRET}` — compose substitutes
these from your shell environment or a `.env` file sitting next to
`docker-compose.yml` (compose auto-loads a file literally named `.env`
in the same directory).
- `volumes: mysql_data:/var/lib/mysql` — without this, MySQL's data
directory lives *inside* the container's writable layer, destroyed when
the container is removed (`docker compose down`). A **named volume**
persists that data on the host, independent of the container's
lifecycle.
- About the `depends_on` startup-order caveat: MySQL can take a few
seconds to become ready even after its container "starts." Our
`database.NewMySQL` already calls `db.PingContext` with a timeout and
returns an error if it fails — so if you hit a race on
`docker compose up`, the cleanest fix is either restarting just the
`app` service, or adding a small retry loop around the ping in
`NewMySQL`. Treat that as an optional improvement rather than something
required for this course.
**Try the whole stack:**
```bash
docker compose up --build
```
```bash
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
curl -c cookies.txt -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"email":"hamid@example.com","password":"secret123"}'
curl -b cookies.txt http://localhost:8080/me
```
Stop everything cleanly:
```bash
docker compose down # stops and removes containers, keeps the volume
docker compose down -v # also wipes the mysql_data volume
```
---
## Course review — what you actually built
| Concept | Where you learned it | Where it lives now |
|---|---|---|
| chi routing, graceful shutdown | Lesson 1 | `router/`, `cmd/api/main.go` |
| Structured JSON logging (`slog`) | Lesson 2 | `logging/`, `middleware/request_logger.go` |
| MySQL connection pooling | Lesson 3 | `database/mysql.go` |
| Repository pattern, pointers | Lesson 4 | `models/user_repository.go` |
| bcrypt, JSON request handling | Lesson 5 | `handlers/auth.go` |
| Server-side sessions (scs + Redis) | Lesson 6 | `session/`, login/logout/me |
| OAuth2 (Google login) | Lesson 7 | `oauth/`, `handlers/oauth_google.go` |
| Context values, auth middleware | Lesson 8 | `middleware/require_auth.go` |
| Rate limiting, CORS, cookie security | Lesson 9 | `router.go`, `session.go`, `config.go` |
| Docker & docker-compose | Lesson 10 | `Dockerfile`, `docker-compose.yml` |
## Core Go ideas that came up repeatedly — make sure these are solid
- **Pointers (`*`/`&`)** — sharing state (`*sql.DB`, `*scs.SessionManager`)
vs. copying values; writing into caller variables (`rows.Scan`,
`res.LastInsertId``b.ID`).
- **Interfaces implicitly satisfied** — `*chi.Mux` and our custom handlers
all satisfy `http.Handler` just by having the right method, no explicit
"implements" keyword.
- **Closures and the three-layer middleware pattern** —
`func(deps) func(http.Handler) http.Handler`, seen in `RequestLogger`
and `RequireAuth`.
- **`context.Context`** — carrying request-scoped values (request ID,
current user) and deadlines (timeouts) through a call chain without
threading extra parameters everywhere.
- **Error wrapping (`%w`) and sentinel errors** — `ErrUserNotFound`,
`errors.Is`, giving callers a stable way to distinguish error *kinds*
without string-matching messages.
- **Dependency injection via structs** —
`AuthHandler{userRepo, sessions, logger}` instead of global variables,
making every handler's dependencies explicit and testable.
## Reasonable next steps, if you want to keep going
- **Testing** — table-driven tests, `httptest` (you touched this in
Lesson 5's Part A) for handlers, and mocking the repository via an
interface instead of a concrete `*sql.DB`-backed struct.
- **A real migration tool** (e.g. `golang-migrate`) instead of
`CREATE TABLE IF NOT EXISTS` on every boot — versioned, reversible
schema changes.
- **CSRF tokens**, if you ever add a same-origin HTML form frontend, as
flagged in Lesson 9.
- **Refresh tokens / remember-me**, since right now a session simply
expires after 24 hours with no renewal path.
- **Structured error responses with error codes**, so a frontend can
branch on `"error_code": "invalid_credentials"` instead of parsing
message strings.
- **Observability**: running Grafana Alloy to tail this container's JSON
stdout logs and ship them to Loki is a natural next step, since Lesson
2 already gives you the right log shape for it.
That's the course. You went from an empty folder to a real, containerized
Go API with password auth, Google OAuth, Redis-backed sessions, rate
limiting, and structured logging — and along the way, picked up the core
Go idioms (pointers, interfaces, closures, contexts, error handling) that
show up in essentially every real-world Go codebase.