commit 423c528b2f61c5b486a06c0e7d9cb0e61b1875e4 Author: hamid Date: Thu Jul 16 10:13:46 2026 +0330 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f7b0c8b --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18a0595 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..100d435 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81cb85d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..15c40f6 --- /dev/null +++ b/README.md @@ -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`) diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..dd17b0c --- /dev/null +++ b/cmd/api/main.go @@ -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") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4234e1e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b9f0090 --- /dev/null +++ b/docs/API.md @@ -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": ""}`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b8f5d84 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/LESSONS.md b/docs/LESSONS.md new file mode 100644 index 0000000..4483ba7 --- /dev/null +++ b/docs/LESSONS.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d67610 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b3aee93 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..75a5244 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..93f5a51 --- /dev/null +++ b/internal/database/migrate.go @@ -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 +} diff --git a/internal/database/mysql.go b/internal/database/mysql.go new file mode 100644 index 0000000..1545298 --- /dev/null +++ b/internal/database/mysql.go @@ -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 +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..474acbc --- /dev/null +++ b/internal/handlers/auth.go @@ -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, + }) +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..f13597c --- /dev/null +++ b/internal/handlers/health.go @@ -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"}) +} diff --git a/internal/handlers/oauth_google.go b/internal/handlers/oauth_google.go new file mode 100644 index 0000000..6627aa8 --- /dev/null +++ b/internal/handlers/oauth_google.go @@ -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 +} diff --git a/internal/handlers/respond.go b/internal/handlers/respond.go new file mode 100644 index 0000000..133b5fd --- /dev/null +++ b/internal/handlers/respond.go @@ -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}) +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..98c2ca9 --- /dev/null +++ b/internal/logging/logger.go @@ -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) +} diff --git a/internal/middleware/request_logger.go b/internal/middleware/request_logger.go new file mode 100644 index 0000000..c9487a9 --- /dev/null +++ b/internal/middleware/request_logger.go @@ -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), + ) + }) + } +} diff --git a/internal/middleware/require_auth.go b/internal/middleware/require_auth.go new file mode 100644 index 0000000..8c3a72a --- /dev/null +++ b/internal/middleware/require_auth.go @@ -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"}`)) +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..f2f19fd --- /dev/null +++ b/internal/models/user.go @@ -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 +} diff --git a/internal/models/user_repository.go b/internal/models/user_repository.go new file mode 100644 index 0000000..f34628b --- /dev/null +++ b/internal/models/user_repository.go @@ -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 +} diff --git a/internal/oauth/google.go b/internal/oauth/google.go new file mode 100644 index 0000000..6142d9c --- /dev/null +++ b/internal/oauth/google.go @@ -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, + } +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..469ea4b --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/internal/session/keys.go b/internal/session/keys.go new file mode 100644 index 0000000..84bb7a8 --- /dev/null +++ b/internal/session/keys.go @@ -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" diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..83c4126 --- /dev/null +++ b/internal/session/session.go @@ -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
+ // 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 +} diff --git a/lessons/00-INDEX.md b/lessons/00-INDEX.md new file mode 100644 index 0000000..355c513 --- /dev/null +++ b/lessons/00-INDEX.md @@ -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 (1–10) 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. diff --git a/lessons/00-go-basics-1-syntax-and-types.md b/lessons/00-go-basics-1-syntax-and-types.md new file mode 100644 index 0000000..38efc48 --- /dev/null +++ b/lessons/00-go-basics-1-syntax-and-types.md @@ -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. diff --git a/lessons/00-go-basics-2-functions-structs-pointers.md b/lessons/00-go-basics-2-functions-structs-pointers.md new file mode 100644 index 0000000..90b38b9 --- /dev/null +++ b/lessons/00-go-basics-2-functions-structs-pointers.md @@ -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. diff --git a/lessons/00-go-basics-3-interfaces-errors-concurrency-packages.md b/lessons/00-go-basics-3-interfaces-errors-concurrency-packages.md new file mode 100644 index 0000000..faed012 --- /dev/null +++ b/lessons/00-go-basics-3-interfaces-errors-concurrency-packages.md @@ -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. diff --git a/lessons/lesson-01-project-skeleton-chi-routing.md b/lessons/lesson-01-project-skeleton-chi-routing.md new file mode 100644 index 0000000..ffd1ac9 --- /dev/null +++ b/lessons/lesson-01-project-skeleton-chi-routing.md @@ -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. diff --git a/lessons/lesson-02-structured-json-logging.md b/lessons/lesson-02-structured-json-logging.md new file mode 100644 index 0000000..47e3a12 --- /dev/null +++ b/lessons/lesson-02-structured-json-logging.md @@ -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 1–2) 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. diff --git a/lessons/lesson-03-config-and-mysql.md b/lessons/lesson-03-config-and-mysql.md new file mode 100644 index 0000000..5762b30 --- /dev/null +++ b/lessons/lesson-03-config-and-mysql.md @@ -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(¬eID, &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(¬eID, &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). diff --git a/lessons/lesson-04-user-model-repository-pattern.md b/lessons/lesson-04-user-model-repository-pattern.md new file mode 100644 index 0000000..5d8b317 --- /dev/null +++ b/lessons/lesson-04-user-model-repository-pattern.md @@ -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. diff --git a/lessons/lesson-05-password-login-bcrypt.md b/lessons/lesson-05-password-login-bcrypt.md new file mode 100644 index 0000000..6e80a29 --- /dev/null +++ b/lessons/lesson-05-password-login-bcrypt.md @@ -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`. diff --git a/lessons/lesson-06-sessions-scs-redis.md b/lessons/lesson-06-sessions-scs-redis.md new file mode 100644 index 0000000..b6a2cad --- /dev/null +++ b/lessons/lesson-06-sessions-scs-redis.md @@ -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:" +``` + +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. diff --git a/lessons/lesson-07-google-oauth.md b/lessons/lesson-07-google-oauth.md new file mode 100644 index 0000000..f8190d0 --- /dev/null +++ b/lessons/lesson-07-google-oauth.md @@ -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=" 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. diff --git a/lessons/lesson-08-auth-middleware.md b/lessons/lesson-08-auth-middleware.md new file mode 100644 index 0000000..8dfc6c0 --- /dev/null +++ b/lessons/lesson-08-auth-middleware.md @@ -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. diff --git a/lessons/lesson-09-rate-limiting-security.md b/lessons/lesson-09-rate-limiting-security.md new file mode 100644 index 0000000..d1064db --- /dev/null +++ b/lessons/lesson-09-rate-limiting-security.md @@ -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 + `` 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. diff --git a/lessons/lesson-10-docker-wrapup.md b/lessons/lesson-10-docker-wrapup.md new file mode 100644 index 0000000..f40b730 --- /dev/null +++ b/lessons/lesson-10-docker-wrapup.md @@ -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 1–9, 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.