first commit
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Copy this file to .env and fill in real values.
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# .env is read automatically by `docker compose` when it sits next to
|
||||||
|
# docker-compose.yml. For running the app directly with `go run ./cmd/api`
|
||||||
|
# (outside Docker), export these variables in your shell instead, or use a
|
||||||
|
# tool like `direnv` / `godotenv` to load them (not included in this
|
||||||
|
# project, but a natural addition later).
|
||||||
|
|
||||||
|
# --- Server ---
|
||||||
|
PORT=8080
|
||||||
|
# "development" or "production" - controls whether the session cookie
|
||||||
|
# requires HTTPS (Secure flag). Keep this as "development" for local work.
|
||||||
|
ENV=development
|
||||||
|
# "debug" enables verbose debug-level log lines; anything else hides them.
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- MySQL ---
|
||||||
|
# When running via docker-compose, DB_HOST must be "mysql" (the service
|
||||||
|
# name) instead of 127.0.0.1 - see docker-compose.yml for why.
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=devpass
|
||||||
|
DB_NAME=go_simple_api
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
|
# Same rule as DB_HOST: "redis" when running via docker-compose.
|
||||||
|
REDIS_ADDR=127.0.0.1:6379
|
||||||
|
|
||||||
|
# --- Google OAuth2 ---
|
||||||
|
# Get these from https://console.cloud.google.com/apis/credentials
|
||||||
|
# Register the redirect URI below EXACTLY as your app's "Authorized
|
||||||
|
# redirect URI" in the Google Cloud Console, or the OAuth flow will fail.
|
||||||
|
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||||
|
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
# Comma-separated list of frontend origins allowed to call this API from
|
||||||
|
# browser JavaScript with credentials (cookies) attached.
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Environment files - never commit real secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Go workspace / tooling files
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# curl cookie jars used while testing the API by hand
|
||||||
|
cookies.txt
|
||||||
|
|
||||||
|
# OS / editor cruft
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
+50
@@ -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"]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# go-simple-api
|
||||||
|
|
||||||
|
A small, heavily-commented Go REST API built as a learning project. It
|
||||||
|
implements user authentication two ways - email/password and "Sign in with
|
||||||
|
Google" - using server-side sessions stored in Redis, backed by MySQL for
|
||||||
|
user data.
|
||||||
|
|
||||||
|
This project was built incrementally, lesson by lesson, specifically to
|
||||||
|
teach Go web-service fundamentals. Every file has generous inline comments
|
||||||
|
explaining *why* the code is written the way it is, not just what it does.
|
||||||
|
See [`docs/LESSONS.md`](docs/LESSONS.md) for the full course this project
|
||||||
|
was built from, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a
|
||||||
|
deeper explanation of how the pieces fit together.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Concern | Technology |
|
||||||
|
|---|---|
|
||||||
|
| HTTP routing | [chi](https://github.com/go-chi/chi) v5 |
|
||||||
|
| Structured logging | `log/slog` (Go standard library), JSON output |
|
||||||
|
| Database | MySQL, via `database/sql` + `go-sql-driver/mysql` |
|
||||||
|
| Sessions | [scs](https://github.com/alexedwards/scs) v2, backed by Redis |
|
||||||
|
| Password hashing | `golang.org/x/crypto/bcrypt` |
|
||||||
|
| Google login | `golang.org/x/oauth2` (Authorization Code flow) |
|
||||||
|
| Rate limiting | [httprate](https://github.com/go-chi/httprate) |
|
||||||
|
| CORS | [go-chi/cors](https://github.com/go-chi/cors) |
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
go-simple-api/
|
||||||
|
├── cmd/api/main.go # entrypoint: wires everything, runs the server
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # env var loading
|
||||||
|
│ ├── logging/ # structured JSON logger (slog)
|
||||||
|
│ ├── database/ # MySQL connection + migrations
|
||||||
|
│ ├── models/ # User struct + UserRepository (all SQL lives here)
|
||||||
|
│ ├── session/ # scs session manager, backed by Redis
|
||||||
|
│ ├── oauth/ # Google oauth2.Config builder
|
||||||
|
│ ├── handlers/ # HTTP handlers (health, auth, google oauth)
|
||||||
|
│ ├── middleware/ # request logging + auth-guard middleware
|
||||||
|
│ └── router/ # wires routes + middleware together
|
||||||
|
├── docs/ # architecture, API reference, course notes
|
||||||
|
├── Dockerfile # multi-stage build
|
||||||
|
├── docker-compose.yml # app + MySQL + Redis
|
||||||
|
├── .env.example # every config variable, documented
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running locally (without Docker)
|
||||||
|
|
||||||
|
Requires Go 1.26+, and MySQL + Redis reachable somewhere.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start MySQL and Redis (or point at existing instances)
|
||||||
|
docker run --name mysql-api -e MYSQL_ROOT_PASSWORD=devpass \
|
||||||
|
-e MYSQL_DATABASE=go_simple_api -p 3306:3306 -d mysql:9
|
||||||
|
docker run --name redis-api -p 6379:6379 -d redis:8
|
||||||
|
|
||||||
|
# 2. Set up environment
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env - at minimum, fill in GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
|
||||||
|
# if you want to test Google login. Password login works without them.
|
||||||
|
export $(grep -v '^#' .env | xargs) # or use a tool like direnv
|
||||||
|
|
||||||
|
# 3. Fetch dependencies (go.sum is not included - see go.mod for why)
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# 4. Run
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens on `:8080` by default. Try:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running with Docker Compose (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# fill in GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET if you want Google login
|
||||||
|
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the API, MySQL, and Redis together, with the API waiting on
|
||||||
|
the other two. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how
|
||||||
|
service networking works inside Compose.
|
||||||
|
|
||||||
|
Stop everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down # stops containers, keeps the MySQL volume
|
||||||
|
docker compose down -v # also wipes the MySQL volume (fresh start)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
See [`docs/API.md`](docs/API.md) for every endpoint, request/response
|
||||||
|
shapes, and example `curl` commands.
|
||||||
|
|
||||||
|
Quick overview:
|
||||||
|
|
||||||
|
| Method | Path | Auth required? | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/health` | no | Liveness check |
|
||||||
|
| POST | `/register` | no | Create a password-based account |
|
||||||
|
| POST | `/login` | no | Log in with email + password, starts a session |
|
||||||
|
| POST | `/logout` | no (needs a session to destroy) | Ends the current session |
|
||||||
|
| GET | `/me` | **yes** | Returns the currently logged-in user |
|
||||||
|
| GET | `/auth/google/login` | no | Redirects the browser to Google |
|
||||||
|
| GET | `/auth/google/callback` | no | Google redirects here after login |
|
||||||
|
|
||||||
|
## Google OAuth setup
|
||||||
|
|
||||||
|
1. Go to the [Google Cloud Console credentials page](https://console.cloud.google.com/apis/credentials).
|
||||||
|
2. Create an **OAuth 2.0 Client ID** (Application type: Web application).
|
||||||
|
3. Add an **Authorized redirect URI**: `http://localhost:8080/auth/google/callback`
|
||||||
|
(must match `GOOGLE_REDIRECT_URL` in your `.env` exactly).
|
||||||
|
4. Copy the Client ID and Client Secret into `.env`.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
This project deliberately implements several production-appropriate
|
||||||
|
security practices, explained in detail in the code comments where they
|
||||||
|
appear:
|
||||||
|
|
||||||
|
- Passwords are hashed with bcrypt, never stored or logged in plaintext.
|
||||||
|
- Login returns an identical, generic error for both "no such email" and
|
||||||
|
"wrong password", to avoid leaking which emails are registered.
|
||||||
|
- Sessions are server-side (Redis-backed) - the browser only ever holds a
|
||||||
|
random token, never the actual session data.
|
||||||
|
- `sessions.RenewToken()` is called on every successful login (password or
|
||||||
|
Google) to prevent session fixation.
|
||||||
|
- The session cookie is `HttpOnly` (JS can't read it) and `SameSite=Lax`
|
||||||
|
(mitigates CSRF); `Secure` is enabled automatically when `ENV=production`.
|
||||||
|
- The OAuth2 flow uses a random `state` value, checked on callback, to
|
||||||
|
prevent CSRF against the login flow itself.
|
||||||
|
- `/login` and `/register` have a much stricter rate limit than the rest
|
||||||
|
of the API, to slow down credential-stuffing / brute-force attempts.
|
||||||
|
- CORS is an explicit origin allowlist (`ALLOWED_ORIGINS`), never a
|
||||||
|
wildcard, since the API uses credentialed (cookie-based) requests.
|
||||||
|
|
||||||
|
## What's not included (possible next steps)
|
||||||
|
|
||||||
|
- Automated tests (`httptest`, table-driven tests, a mockable repository interface)
|
||||||
|
- A real migration tool (e.g. `golang-migrate`) instead of `CREATE TABLE IF NOT EXISTS`
|
||||||
|
- CSRF tokens for a same-origin HTML form frontend (SameSite=Lax already
|
||||||
|
covers the cookie-based JSON API case)
|
||||||
|
- Refresh/renewal flow for sessions beyond their fixed 24h lifetime
|
||||||
|
- Machine-readable error codes in API responses (currently just a message string)
|
||||||
|
- Shipping logs to Grafana Loki via Grafana Alloy (the JSON log shape
|
||||||
|
produced by this app is already Alloy/Loki-friendly - see
|
||||||
|
`internal/logging` and `internal/middleware/request_logger.go`)
|
||||||
+102
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Brings up the full stack with one command: the Go API, MySQL, and Redis.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cp .env.example .env # fill in real values, especially Google OAuth
|
||||||
|
# docker compose up --build
|
||||||
|
#
|
||||||
|
# docker compose automatically loads a file literally named ".env" sitting
|
||||||
|
# next to this file, and substitutes ${VAR} references below from it.
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8070:8080"
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
PORT: 8080
|
||||||
|
ENV: development
|
||||||
|
|
||||||
|
# NOTE: these hostnames are NOT "127.0.0.1" - inside the compose
|
||||||
|
# network, each service's NAME becomes its hostname. Compose runs an
|
||||||
|
# internal DNS that resolves "mysql" and "redis" to the correct
|
||||||
|
# container's IP address automatically. This is exactly why
|
||||||
|
# internal/config reads these from environment variables instead of
|
||||||
|
# hardcoding 127.0.0.1 - the same compiled binary works unchanged
|
||||||
|
# both locally and inside Docker, just by changing env vars.
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: devpass
|
||||||
|
DB_NAME: go_simple_api
|
||||||
|
|
||||||
|
REDIS_ADDR: redis:6379
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
GOOGLE_REDIRECT_URL: http://localhost:8080/auth/google/callback
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS: http://localhost:3000
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:9
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: devpass
|
||||||
|
MYSQL_DATABASE: go_simple_api
|
||||||
|
ports:
|
||||||
|
- "13306:3306"
|
||||||
|
volumes:
|
||||||
|
# Named volume: MySQL's data directory is persisted on the host,
|
||||||
|
# independent of the container's lifecycle. Without this, all data
|
||||||
|
# would be lost every time you run `docker compose down`.
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
ports:
|
||||||
|
- "16379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Base URL (local dev): `http://localhost:8080`
|
||||||
|
|
||||||
|
All request/response bodies are JSON. Authenticated endpoints rely on the
|
||||||
|
`session_id` cookie set by `/login` or the Google OAuth callback - include
|
||||||
|
it automatically by using a cookie-aware HTTP client (browsers do this
|
||||||
|
natively; with `curl`, use `-c cookies.txt -b cookies.txt`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /health`
|
||||||
|
|
||||||
|
Liveness check. No authentication, no rate limiting beyond the global
|
||||||
|
limit.
|
||||||
|
|
||||||
|
**Response `200`**
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /register`
|
||||||
|
|
||||||
|
Creates a new password-based account.
|
||||||
|
|
||||||
|
Rate limited to **5 requests/minute per IP** (shared with `/login`).
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
```json
|
||||||
|
{ "email": "hamid@example.com", "password": "secret123" }
|
||||||
|
```
|
||||||
|
- `email` - required, must be unique across all accounts.
|
||||||
|
- `password` - required, minimum 8 characters.
|
||||||
|
|
||||||
|
**Response `201`**
|
||||||
|
```json
|
||||||
|
{ "id": 1, "email": "hamid@example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
| Status | Body | Cause |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | `{"error":"invalid request body"}` | Malformed JSON |
|
||||||
|
| 400 | `{"error":"email and password are required"}` | Missing field |
|
||||||
|
| 400 | `{"error":"password must be at least 8 characters"}` | Password too short |
|
||||||
|
| 409 | `{"error":"email already registered"}` | Email already taken |
|
||||||
|
| 429 | (rate limit response) | Too many requests from this IP |
|
||||||
|
| 500 | `{"error":"internal error"}` | Unexpected server/database failure |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"hamid@example.com","password":"secret123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /login`
|
||||||
|
|
||||||
|
Authenticates with email + password and starts a server-side session.
|
||||||
|
|
||||||
|
Rate limited to **5 requests/minute per IP** (shared with `/register`).
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
```json
|
||||||
|
{ "email": "hamid@example.com", "password": "secret123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `200`**
|
||||||
|
```json
|
||||||
|
{ "id": 1, "email": "hamid@example.com" }
|
||||||
|
```
|
||||||
|
Also sets a `session_id` cookie (`HttpOnly`, `SameSite=Lax`, `Secure` in
|
||||||
|
production).
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
| Status | Body | Cause |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | `{"error":"invalid request body"}` | Malformed JSON |
|
||||||
|
| 401 | `{"error":"invalid email or password"}` | No such email, OR wrong password (identical message for both, deliberately - see Security notes in the README) |
|
||||||
|
| 429 | (rate limit response) | Too many requests from this IP |
|
||||||
|
| 500 | `{"error":"internal error"}` | Unexpected server/database failure |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -c cookies.txt -X POST http://localhost:8080/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"hamid@example.com","password":"secret123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /logout`
|
||||||
|
|
||||||
|
Destroys the current session (deletes it from Redis, expires the cookie).
|
||||||
|
Not rate-limited beyond the global limit - deliberately excluded from the
|
||||||
|
strict `/login`/`/register` limit so a legitimate user can always log out.
|
||||||
|
|
||||||
|
**Response `200`**
|
||||||
|
```json
|
||||||
|
{ "message": "logged out" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /me`
|
||||||
|
|
||||||
|
**Requires authentication** (a valid `session_id` cookie from a prior
|
||||||
|
login). Returns the currently logged-in user.
|
||||||
|
|
||||||
|
**Response `200`**
|
||||||
|
```json
|
||||||
|
{ "id": 1, "email": "hamid@example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
| Status | Body | Cause |
|
||||||
|
|---|---|---|
|
||||||
|
| 401 | `{"error":"unauthorized"}` | No session, expired session, or the session's user no longer exists |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt http://localhost:8080/me
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /auth/google/login`
|
||||||
|
|
||||||
|
Redirects the browser to Google's OAuth2 consent screen. **Must be opened
|
||||||
|
in an actual browser** - this endpoint returns an HTTP redirect, and the
|
||||||
|
subsequent Google login page cannot be driven via `curl`.
|
||||||
|
|
||||||
|
**Response**: `307 Temporary Redirect` to `accounts.google.com`.
|
||||||
|
|
||||||
|
```
|
||||||
|
open http://localhost:8080/auth/google/login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /auth/google/callback`
|
||||||
|
|
||||||
|
Google redirects here automatically after the user approves access. Not
|
||||||
|
meant to be called directly - `state` and `code` query parameters are
|
||||||
|
supplied by Google.
|
||||||
|
|
||||||
|
On success: creates a new user (or links Google to an existing
|
||||||
|
email-matched account), starts a session exactly like `/login` does, and
|
||||||
|
returns:
|
||||||
|
|
||||||
|
**Response `200`**
|
||||||
|
```json
|
||||||
|
{ "id": 2, "email": "hamid@gmail.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
| Status | Body | Cause |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | `{"error":"invalid oauth state"}` | Missing/mismatched CSRF state - usually means the flow wasn't started via `/auth/google/login`, or the session expired mid-flow |
|
||||||
|
| 400 | `{"error":"missing code"}` | Google didn't include an authorization code |
|
||||||
|
| 500 | `{"error":"internal error"}` | Token exchange, Google API call, or database failure |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General notes
|
||||||
|
|
||||||
|
- **CORS**: browser-based requests from an origin not listed in
|
||||||
|
`ALLOWED_ORIGINS` will be blocked by the browser itself, before this
|
||||||
|
API's own logic ever runs. Non-browser clients (curl, mobile apps,
|
||||||
|
server-to-server) are unaffected by CORS entirely.
|
||||||
|
- **Rate limiting**: exceeding a limit returns HTTP `429 Too Many
|
||||||
|
Requests`. The global limit (100/min/IP) applies to every route; the
|
||||||
|
strict limit (5/min/IP) applies only to `/register` and `/login`, and
|
||||||
|
stacks with the global limit.
|
||||||
|
- All error responses share the same shape: `{"error": "<message>"}`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}`))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Package session builds and configures the application's server-side
|
||||||
|
// session manager. We use github.com/alexedwards/scs (SCS) for the session
|
||||||
|
// framework itself, backed by Redis for storage.
|
||||||
|
//
|
||||||
|
// "Server-side session" means: the browser only ever holds a random,
|
||||||
|
// meaningless token in a cookie. All the actual session DATA (which user
|
||||||
|
// is logged in, etc.) lives in Redis, keyed by that token. This is in
|
||||||
|
// contrast to storing data directly inside a signed/encrypted cookie -
|
||||||
|
// server-side sessions can be instantly revoked (just delete the Redis
|
||||||
|
// key), don't grow the cookie size as you store more data, and never
|
||||||
|
// expose their contents to the browser at all.
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/redisstore"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/gomodule/redigo/redis"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New builds a *scs.SessionManager wired up to Redis, with cookie settings
|
||||||
|
// appropriate for an authentication system.
|
||||||
|
func New(cfg config.Config) *scs.SessionManager {
|
||||||
|
// redigo's connection pool - conceptually identical to *sql.DB's pool
|
||||||
|
// from the database package, just for Redis instead of MySQL. Dial is
|
||||||
|
// a function the pool calls whenever it needs to open a new
|
||||||
|
// connection.
|
||||||
|
pool := &redis.Pool{
|
||||||
|
MaxIdle: 10,
|
||||||
|
Dial: func() (redis.Conn, error) {
|
||||||
|
return redis.Dial("tcp", cfg.RedisAddr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := scs.New()
|
||||||
|
|
||||||
|
// By default scs stores session data IN MEMORY, which would be lost on
|
||||||
|
// every restart and wouldn't work at all if you ever ran more than one
|
||||||
|
// instance of this app behind a load balancer. Setting .Store swaps
|
||||||
|
// the storage backend to Redis while keeping the exact same
|
||||||
|
// Put/Get/Destroy API - the rest of the app never needs to know Redis
|
||||||
|
// is involved at all.
|
||||||
|
manager.Store = redisstore.New(pool)
|
||||||
|
|
||||||
|
// How long a session stays valid after creation.
|
||||||
|
manager.Lifetime = 24 * time.Hour
|
||||||
|
|
||||||
|
// --- Cookie security settings ---
|
||||||
|
manager.Cookie.Name = "session_id"
|
||||||
|
|
||||||
|
// HttpOnly: JavaScript in the browser cannot read this cookie via
|
||||||
|
// document.cookie. Blocks a large class of XSS-based session theft.
|
||||||
|
manager.Cookie.HttpOnly = true
|
||||||
|
|
||||||
|
// SameSite=Lax: the cookie IS sent on normal top-level navigation
|
||||||
|
// (e.g. clicking a link to this site, or Google redirecting back to
|
||||||
|
// our OAuth callback), but is NOT sent on cross-site requests
|
||||||
|
// triggered by another page (e.g. a malicious auto-submitting <form>
|
||||||
|
// pointed at our /logout). This is our primary CSRF defense for the
|
||||||
|
// cookie-based session.
|
||||||
|
manager.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
|
||||||
|
// Secure: when true, the browser will refuse to send this cookie over
|
||||||
|
// plain HTTP, only HTTPS. We only turn this on in production, because
|
||||||
|
// enabling it during local development (over http://localhost) would
|
||||||
|
// silently break the cookie entirely.
|
||||||
|
manager.Cookie.Secure = cfg.Env == "production"
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# Go Basics, Part 1 — Syntax, Types, and Control Flow
|
||||||
|
|
||||||
|
This is the first of three "Go Basics" lessons. If you've never written Go
|
||||||
|
before, do all three before starting Lesson 1 of the main course. If you
|
||||||
|
already know another programming language (Python, JavaScript, PHP, Java,
|
||||||
|
C, etc.), you'll move through this fast — Go is a small, simple language
|
||||||
|
on purpose.
|
||||||
|
|
||||||
|
## 1. Installing Go and running your first program
|
||||||
|
|
||||||
|
Download and install Go from [go.dev/dl](https://go.dev/dl/). Confirm it worked:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go version
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see something like `go version go1.26.x`.
|
||||||
|
|
||||||
|
Make a folder and your first program:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir hello && cd hello
|
||||||
|
go mod init hello
|
||||||
|
```
|
||||||
|
|
||||||
|
`go mod init hello` creates a `go.mod` file — this marks the folder as a
|
||||||
|
**Go module** (a self-contained project with its own dependencies). We'll
|
||||||
|
explain modules properly in Part 3; for now, just know every Go project
|
||||||
|
needs one.
|
||||||
|
|
||||||
|
**`main.go`**
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("hello, world")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `hello, world` printed. Let's break down every single piece
|
||||||
|
of that file, since you'll type this pattern constantly:
|
||||||
|
|
||||||
|
- `package main` — every Go file starts by declaring which **package** it
|
||||||
|
belongs to. A package is just a folder of `.go` files that are compiled
|
||||||
|
together and can freely call each other's code. `package main` is
|
||||||
|
special: it means "this produces a runnable program," not a reusable
|
||||||
|
library.
|
||||||
|
- `import "fmt"` — pulls in the standard library's `fmt` package (short
|
||||||
|
for "format"), which has functions for printing text and formatting
|
||||||
|
strings.
|
||||||
|
- `func main()` — the function named `main`, inside `package main`, is the
|
||||||
|
**entry point**. When you run the compiled program, execution starts
|
||||||
|
here. There must be exactly one `main` function in `package main`.
|
||||||
|
- `fmt.Println("hello, world")` — calls the `Println` function from the
|
||||||
|
`fmt` package (note the dot: `package.Function`), passing it the string
|
||||||
|
`"hello, world"`. `Println` prints its arguments followed by a newline.
|
||||||
|
|
||||||
|
Two ways to run Go code:
|
||||||
|
- `go run .` (or `go run main.go`) — compiles and runs immediately,
|
||||||
|
doesn't leave a binary behind. Good for development.
|
||||||
|
- `go build .` — compiles into an actual executable file (e.g. `hello` or
|
||||||
|
`hello.exe`) that you can run directly (`./hello`) without Go installed
|
||||||
|
on the machine that runs it. This is what you'd do to ship the program.
|
||||||
|
|
||||||
|
## 2. Variables and basic types
|
||||||
|
|
||||||
|
Go is **statically typed**: every variable has a fixed type, decided
|
||||||
|
either explicitly or by inference, and that type never changes.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Explicit type
|
||||||
|
var age int = 30
|
||||||
|
|
||||||
|
// Type inferred from the value (int, since 30 has no decimal point)
|
||||||
|
var name = "Hamid"
|
||||||
|
|
||||||
|
// The short declaration operator ":=" — declares AND assigns in one
|
||||||
|
// step, inferring the type. This is by far the most common way to
|
||||||
|
// declare variables inside a function body.
|
||||||
|
city := "Tehran"
|
||||||
|
height := 1.78 // inferred as float64
|
||||||
|
|
||||||
|
fmt.Println(age, name, city, height)
|
||||||
|
|
||||||
|
// Reassignment - no "var" or ":=" needed, the variable already exists
|
||||||
|
age = 31
|
||||||
|
fmt.Println(age)
|
||||||
|
|
||||||
|
// Multiple variables at once
|
||||||
|
var x, y int = 1, 2
|
||||||
|
a, b := 3, 4
|
||||||
|
fmt.Println(x, y, a, b)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- `:=` can ONLY be used to declare a **new** variable (usually inside a
|
||||||
|
function). It's shorthand for `var x = value` with the type inferred.
|
||||||
|
- `var` can be used with or without an initial value: `var count int`
|
||||||
|
declares `count` as an `int` with the **zero value** `0` (Go always
|
||||||
|
initializes variables — there's no "undefined" or garbage value).
|
||||||
|
- You cannot declare a variable and never use it — Go's compiler will
|
||||||
|
refuse to build code with an unused local variable. This trips up
|
||||||
|
everyone coming from other languages at first.
|
||||||
|
|
||||||
|
### The built-in types you'll use constantly
|
||||||
|
|
||||||
|
| Type | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `int` | `42` | Platform-dependent size (64-bit on modern machines); use this by default for whole numbers |
|
||||||
|
| `string` | `"hello"` | UTF-8 text, immutable |
|
||||||
|
| `bool` | `true`, `false` | |
|
||||||
|
| `float64` | `3.14` | Default type for decimal numbers |
|
||||||
|
| `byte` | | Alias for `uint8`, used for raw binary data |
|
||||||
|
| `[]byte` | | A "slice of bytes" — how Go represents raw binary data (we cast strings to this constantly, e.g. for password hashing) |
|
||||||
|
|
||||||
|
Zero values (what a variable holds if declared without an initial value):
|
||||||
|
`int` → `0`, `string` → `""` (empty string), `bool` → `false`, pointers →
|
||||||
|
`nil` (explained in Part 2).
|
||||||
|
|
||||||
|
### Type conversion
|
||||||
|
|
||||||
|
Go **never** silently converts between types (unlike JavaScript or PHP).
|
||||||
|
You must convert explicitly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var i int = 42
|
||||||
|
var f float64 = float64(i) // must explicitly convert int -> float64
|
||||||
|
var s string = fmt.Sprintf("%d", i) // int -> string via formatting
|
||||||
|
|
||||||
|
id := "123"
|
||||||
|
// s, err := strconv.Atoi(id) // string -> int, using the strconv package
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see this constantly in the main course, e.g. `int(id)` when
|
||||||
|
converting a database's `int64` auto-increment ID into our own `int`
|
||||||
|
field.
|
||||||
|
|
||||||
|
## 3. `if`, `for`, and `switch`
|
||||||
|
|
||||||
|
Go has exactly one looping construct — `for` — no `while`, no `do-while`.
|
||||||
|
It also does **not** use parentheses around conditions, but curly braces
|
||||||
|
`{ }` are always required (even for a single-line body).
|
||||||
|
|
||||||
|
### `if`
|
||||||
|
|
||||||
|
```go
|
||||||
|
age := 20
|
||||||
|
|
||||||
|
if age >= 18 {
|
||||||
|
fmt.Println("adult")
|
||||||
|
} else if age >= 13 {
|
||||||
|
fmt.Println("teenager")
|
||||||
|
} else {
|
||||||
|
fmt.Println("child")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A very common Go idiom: declaring a variable that's scoped ONLY to the
|
||||||
|
`if`/`else` block, often used with error handling (you'll see this
|
||||||
|
constantly starting in Part 2):
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := doSomething(); err != nil {
|
||||||
|
fmt.Println("failed:", err)
|
||||||
|
}
|
||||||
|
// err doesn't exist out here — it was scoped to the if statement
|
||||||
|
```
|
||||||
|
|
||||||
|
### `for` — Go's only loop
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Classic three-part loop (like C's for)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fmt.Println(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "while" style - just the condition
|
||||||
|
count := 0
|
||||||
|
for count < 3 {
|
||||||
|
fmt.Println("counting:", count)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infinite loop - break out manually
|
||||||
|
for {
|
||||||
|
fmt.Println("runs forever until break")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looping over a collection (slices, maps - covered in Part 3)
|
||||||
|
names := []string{"alice", "bob", "carol"}
|
||||||
|
for index, name := range names {
|
||||||
|
fmt.Println(index, name)
|
||||||
|
}
|
||||||
|
// If you don't need the index, use _ (the "blank identifier") to
|
||||||
|
// explicitly discard it - Go's compiler complains about unused
|
||||||
|
// variables, and _ is the escape hatch:
|
||||||
|
for _, name := range names {
|
||||||
|
fmt.Println(name)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see `_` constantly throughout the main course — any time a
|
||||||
|
function returns something you genuinely don't need, `_` discards it
|
||||||
|
without triggering an "unused variable" error.
|
||||||
|
|
||||||
|
### `switch`
|
||||||
|
|
||||||
|
```go
|
||||||
|
day := "Monday"
|
||||||
|
|
||||||
|
switch day {
|
||||||
|
case "Saturday", "Sunday":
|
||||||
|
fmt.Println("weekend")
|
||||||
|
default:
|
||||||
|
fmt.Println("weekday")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike C/Java, Go's `switch` cases do **not** fall through by default —
|
||||||
|
each case automatically breaks after its block, no `break` statement
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## 4. Strings, formatting, and comments
|
||||||
|
|
||||||
|
```go
|
||||||
|
name := "Hamid"
|
||||||
|
age := 31
|
||||||
|
|
||||||
|
// Println - space-separated, newline at the end
|
||||||
|
fmt.Println("name:", name, "age:", age)
|
||||||
|
|
||||||
|
// Printf - C-style format string, YOU add the newline with \n
|
||||||
|
fmt.Printf("name: %s, age: %d\n", name, age)
|
||||||
|
|
||||||
|
// Sprintf - same as Printf but returns a string instead of printing it
|
||||||
|
message := fmt.Sprintf("hello %s, you are %d years old", name, age)
|
||||||
|
fmt.Println(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
Common format verbs you'll use throughout the course:
|
||||||
|
| Verb | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `%s` | string |
|
||||||
|
| `%d` | integer |
|
||||||
|
| `%f` | float |
|
||||||
|
| `%v` | "default" representation of any value — great for debugging |
|
||||||
|
| `%+v` | like `%v` but includes struct field names |
|
||||||
|
| `%w` | wraps an error (covered in Part 3) — only valid with `fmt.Errorf` |
|
||||||
|
|
||||||
|
Comments:
|
||||||
|
```go
|
||||||
|
// A single-line comment
|
||||||
|
|
||||||
|
/*
|
||||||
|
A multi-line comment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A comment directly above a function/type, with no blank line between,
|
||||||
|
// is a DOC comment - tools display it as that function's documentation.
|
||||||
|
// This project's code uses these constantly.
|
||||||
|
func DoSomething() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Try it yourself
|
||||||
|
|
||||||
|
Before moving to Part 2, write a small standalone program (new folder,
|
||||||
|
`go mod init practice`, `main.go`) that:
|
||||||
|
|
||||||
|
1. Declares a `string` name and an `int` age using `:=`.
|
||||||
|
2. Uses `if`/`else if`/`else` to print a different message depending on
|
||||||
|
the age (e.g. "minor", "adult", "senior" for under 18 / under 65 / 65+).
|
||||||
|
3. Uses a `for` loop to print the numbers 1 through 10.
|
||||||
|
4. Uses `fmt.Printf` to print the name and age formatted into one sentence.
|
||||||
|
|
||||||
|
Run it with `go run .`. Once this feels natural, move to Part 2 —
|
||||||
|
functions, structs, and pointers, which is where Go starts looking like
|
||||||
|
the code you'll write in the actual course.
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Go Basics, Part 2 — Functions, Structs, Methods, and Pointers
|
||||||
|
|
||||||
|
This continues directly from Part 1. By the end of this lesson you'll
|
||||||
|
understand every syntactic shape used in the main course's handler and
|
||||||
|
repository code.
|
||||||
|
|
||||||
|
## 1. Functions
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// A function with two parameters (both int) and one return value (int).
|
||||||
|
// Parameters sharing a type can share the type annotation: "a, b int"
|
||||||
|
// means both a and b are int.
|
||||||
|
func add(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
sum := add(3, 4)
|
||||||
|
fmt.Println(sum) // 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple return values — used constantly in Go
|
||||||
|
|
||||||
|
This is one of Go's most distinctive features, and you'll see it on
|
||||||
|
almost every line of real Go code, especially for error handling:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func divide(a, b int) (int, error) {
|
||||||
|
if b == 0 {
|
||||||
|
return 0, fmt.Errorf("cannot divide by zero")
|
||||||
|
}
|
||||||
|
return a / b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
result, err := divide(10, 2)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("result:", result)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The pattern `value, err := someFunc()` followed immediately by
|
||||||
|
`if err != nil { ... }` is THE dominant idiom in Go. You will type this
|
||||||
|
exact shape hundreds of times in the main course. There are no exceptions
|
||||||
|
/ try-catch in Go (with one narrow exception, `panic`/`recover`, which
|
||||||
|
we'll touch on briefly later) — errors are just regular return values that
|
||||||
|
you're expected to check every time.
|
||||||
|
|
||||||
|
`nil` is Go's "no value" — similar to `null` in other languages. It's the
|
||||||
|
zero value for pointers, interfaces, slices, maps, channels, and function
|
||||||
|
types. `error` is an interface (explained in Part 3), so `nil` is its zero
|
||||||
|
value too — "no error occurred."
|
||||||
|
|
||||||
|
### Named return values (used occasionally, good to recognize)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func divide(a, b int) (result int, err error) {
|
||||||
|
if b == 0 {
|
||||||
|
err = fmt.Errorf("cannot divide by zero")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result = a / b
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`result` and `err` are declared as part of the function signature; a bare
|
||||||
|
`return` sends back their current values. You won't write much code this
|
||||||
|
way in this course, but you'll see it in standard-library source if you
|
||||||
|
ever go looking.
|
||||||
|
|
||||||
|
### Anonymous functions and closures
|
||||||
|
|
||||||
|
A function can be defined without a name and assigned to a variable, or
|
||||||
|
passed directly as an argument:
|
||||||
|
|
||||||
|
```go
|
||||||
|
square := func(n int) int {
|
||||||
|
return n * n
|
||||||
|
}
|
||||||
|
fmt.Println(square(5)) // 25
|
||||||
|
```
|
||||||
|
|
||||||
|
A **closure** is an anonymous function that "remembers" variables from the
|
||||||
|
scope it was created in, even after that outer function has returned:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func makeCounter() func() int {
|
||||||
|
count := 0
|
||||||
|
return func() int {
|
||||||
|
count++
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
counter := makeCounter()
|
||||||
|
fmt.Println(counter()) // 1
|
||||||
|
fmt.Println(counter()) // 2
|
||||||
|
fmt.Println(counter()) // 3 - count persists between calls!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is important: `makeCounter` returns a function, and that returned
|
||||||
|
function still has access to `count`, which technically belongs to
|
||||||
|
`makeCounter`'s own (finished) execution. This exact mechanism is what
|
||||||
|
makes Go's HTTP middleware pattern work — you'll see functions that take
|
||||||
|
some setup arguments and return another function, three layers deep, all
|
||||||
|
throughout the main course (starting in Lesson 2). Understanding this
|
||||||
|
closure example is the key to understanding that pattern.
|
||||||
|
|
||||||
|
## 2. Structs — Go's way of grouping data
|
||||||
|
|
||||||
|
Go doesn't have classes. Instead, it has **structs**: named groups of
|
||||||
|
fields.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string
|
||||||
|
Age int
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Construct a struct with a "struct literal"
|
||||||
|
u := User{
|
||||||
|
Name: "Hamid",
|
||||||
|
Age: 31,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(u.Name, u.Age) // access fields with dot notation
|
||||||
|
u.Age = 32 // fields are mutable
|
||||||
|
fmt.Println(u.Age)
|
||||||
|
|
||||||
|
// You can also build one without field names, in declared order
|
||||||
|
// (works, but fragile - prefer named fields)
|
||||||
|
u2 := User{"Sara", 28}
|
||||||
|
fmt.Println(u2)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capitalization matters: exported vs. unexported
|
||||||
|
|
||||||
|
This is one of Go's most important and most beginner-surprising rules:
|
||||||
|
|
||||||
|
> **Any identifier (variable, function, type, struct field...) that
|
||||||
|
> starts with an UPPERCASE letter is "exported" — visible outside its
|
||||||
|
> package. Anything starting lowercase is "unexported" — private to its
|
||||||
|
> own package.**
|
||||||
|
|
||||||
|
There's no `public`/`private` keyword. Capitalization IS the access
|
||||||
|
control.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
Name string // exported - visible to other packages
|
||||||
|
age int // unexported - only visible inside THIS package
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see this constantly in the main course: struct fields like
|
||||||
|
`models.User.Email` are capitalized (need to be readable/settable from
|
||||||
|
`handlers`), while helper functions like `getEnv` in the config package
|
||||||
|
are lowercase (only used internally, no other package needs them).
|
||||||
|
|
||||||
|
### Struct tags — metadata attached to fields
|
||||||
|
|
||||||
|
```go
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The text in backticks after a field is a **struct tag** — a string of
|
||||||
|
metadata that other packages can read via reflection. `encoding/json`
|
||||||
|
(the standard library's JSON package) reads the `json:"..."` tag to know
|
||||||
|
"the JSON key `email` maps to this Go field," regardless of the Go field
|
||||||
|
name's capitalization. We use this on nearly every request/response struct
|
||||||
|
in the main course.
|
||||||
|
|
||||||
|
## 3. Pointers (`*` and `&`) — the single most important concept to nail down
|
||||||
|
|
||||||
|
Every variable lives somewhere in memory, at an address. A **pointer** is
|
||||||
|
a variable whose value IS a memory address — it "points to" where another
|
||||||
|
variable lives.
|
||||||
|
|
||||||
|
- `&x` — "give me the address of `x`" (turns a value into a pointer to it)
|
||||||
|
- `*T` (in a type position) — means "a pointer to a `T`", e.g. `*int`,
|
||||||
|
`*User`
|
||||||
|
- `*p` (in an expression) — "dereference `p`": go to the address it holds
|
||||||
|
and read/write the value stored there
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
x := 10
|
||||||
|
p := &x // p is a pointer to x; p holds x's memory address
|
||||||
|
|
||||||
|
fmt.Println(x) // 10
|
||||||
|
fmt.Println(p) // something like 0xc0000140a0 - an address
|
||||||
|
fmt.Println(*p) // 10 - dereferencing p gives x's value back
|
||||||
|
|
||||||
|
*p = 20 // dereference p, then assign through it - changes x itself!
|
||||||
|
fmt.Println(x) // 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why pointers matter: Go passes everything by value
|
||||||
|
|
||||||
|
When you pass a variable to a function, the function receives a **copy**.
|
||||||
|
If you want a function to actually modify the caller's variable, you must
|
||||||
|
pass a pointer, and the function must dereference it to write through.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func double(n int) {
|
||||||
|
n = n * 2 // only changes the LOCAL COPY
|
||||||
|
}
|
||||||
|
|
||||||
|
func doublePtr(n *int) {
|
||||||
|
*n = *n * 2 // dereferences and changes the ORIGINAL
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
x := 5
|
||||||
|
double(x)
|
||||||
|
fmt.Println(x) // 5 - unchanged!
|
||||||
|
|
||||||
|
doublePtr(&x)
|
||||||
|
fmt.Println(x) // 10 - changed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pointers to structs, and why the main course uses them everywhere
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Book struct {
|
||||||
|
Title string
|
||||||
|
Pages int
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPages(b *Book, extra int) {
|
||||||
|
b.Pages += extra // note: no need to write (*b).Pages, Go allows b.Pages directly
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
book := Book{Title: "Go 101", Pages: 100}
|
||||||
|
addPages(&book, 50)
|
||||||
|
fmt.Println(book.Pages) // 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `b.Pages` instead of `(*b).Pages` — Go automatically dereferences
|
||||||
|
struct pointers for field access, as a convenience. Both work; everyone
|
||||||
|
writes `b.Pages`.
|
||||||
|
|
||||||
|
Two big reasons the main course uses pointers to structs constantly:
|
||||||
|
|
||||||
|
1. **Writing a result back into the caller's variable.** E.g. after
|
||||||
|
inserting a new row into the database, we want to write the newly
|
||||||
|
generated ID back into the struct the caller already has — that only
|
||||||
|
works if the function received a pointer.
|
||||||
|
2. **Sharing one instance instead of copying it.** Things like a database
|
||||||
|
connection pool or a logger should be ONE shared instance used
|
||||||
|
everywhere, not copied every time they're passed around. That's why
|
||||||
|
`sql.Open` returns `*sql.DB`, not `sql.DB` — every part of the app
|
||||||
|
needs to share the exact same pool.
|
||||||
|
|
||||||
|
### Methods and receivers
|
||||||
|
|
||||||
|
A **method** is a function attached to a specific type, via a **receiver**
|
||||||
|
declared between `func` and the method name:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Counter struct {
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value receiver - c is a COPY of the Counter this method was called on.
|
||||||
|
func (c Counter) Value() int {
|
||||||
|
return c.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer receiver - c is the ADDRESS of the real Counter.
|
||||||
|
func (c *Counter) Increment() {
|
||||||
|
c.count++ // modifies the REAL struct, not a copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
c := Counter{}
|
||||||
|
c.Increment()
|
||||||
|
c.Increment()
|
||||||
|
fmt.Println(c.Value()) // 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule of thumb, used throughout the main course:** if a method needs to
|
||||||
|
modify the struct, or the struct holds a resource like a database
|
||||||
|
connection, use a pointer receiver (`*Counter`). If the struct is small
|
||||||
|
and the method is purely read-only, a value receiver is fine — but
|
||||||
|
pointer receivers are the overwhelming default for anything nontrivial,
|
||||||
|
and that's what you'll see almost everywhere in this project (e.g. every
|
||||||
|
method on `*UserRepository`, `*AuthHandler`).
|
||||||
|
|
||||||
|
Go automatically inserts the `&` for you when calling a pointer-receiver
|
||||||
|
method on an addressable value — `c.Increment()` is really
|
||||||
|
`(&c).Increment()` behind the scenes. You don't need to write that `&`
|
||||||
|
yourself; just know it's happening.
|
||||||
|
|
||||||
|
## 4. Try it yourself
|
||||||
|
|
||||||
|
New scratch folder, `go mod init practice2`:
|
||||||
|
|
||||||
|
1. Define a `Book` struct with `Title string`, `Author string`, and
|
||||||
|
`Read bool`.
|
||||||
|
2. Write a function `NewBook(title, author string) *Book` that constructs
|
||||||
|
and returns a pointer to a `Book` (this is the exact "constructor"
|
||||||
|
pattern used throughout the main course — `NewXxx` returning `*Xxx`).
|
||||||
|
3. Write a method `func (b *Book) MarkAsRead()` that sets `Read = true`.
|
||||||
|
4. In `main`, create a book with `NewBook`, call `MarkAsRead()` on it, and
|
||||||
|
print the struct with `fmt.Printf("%+v\n", book)` to confirm `Read` is
|
||||||
|
now `true`.
|
||||||
|
|
||||||
|
Once this feels solid, move to Part 3 — interfaces, error handling,
|
||||||
|
slices/maps, packages, and a first look at goroutines and JSON, which
|
||||||
|
rounds out everything you need for Lesson 1.
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
# Go Basics, Part 3 — Interfaces, Errors, Collections, Packages, and Concurrency
|
||||||
|
|
||||||
|
This is the last basics lesson. It covers everything else the main course
|
||||||
|
leans on: interfaces (how `http.Handler` and similar types work),
|
||||||
|
proper error handling patterns, slices and maps, how packages/modules/imports
|
||||||
|
actually work, a first look at goroutines (needed for graceful shutdown),
|
||||||
|
and JSON encoding/decoding.
|
||||||
|
|
||||||
|
## 1. Interfaces — Go's version of "any type that can do X"
|
||||||
|
|
||||||
|
An **interface** is a type defined purely by a set of method signatures.
|
||||||
|
Any type that has those methods automatically satisfies the interface —
|
||||||
|
there's no `implements` keyword, no explicit declaration. This is called
|
||||||
|
**structural typing** or "duck typing, but checked at compile time."
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Any type with a Speak() string method satisfies Speaker - automatically.
|
||||||
|
type Speaker interface {
|
||||||
|
Speak() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dog struct{}
|
||||||
|
func (d Dog) Speak() string { return "Woof!" }
|
||||||
|
|
||||||
|
type Cat struct{}
|
||||||
|
func (c Cat) Speak() string { return "Meow!" }
|
||||||
|
|
||||||
|
func announce(s Speaker) {
|
||||||
|
fmt.Println(s.Speak())
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
announce(Dog{}) // Woof!
|
||||||
|
announce(Cat{}) // Meow!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dog` and `Cat` never mention `Speaker` anywhere in their code. They just
|
||||||
|
happen to have a method with the right name and signature, which is
|
||||||
|
enough. This is why, in the main course, `*chi.Mux` can be passed directly
|
||||||
|
to `http.Server{Handler: r}` — `http.Handler` is defined (in the standard
|
||||||
|
library) as:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Handler interface {
|
||||||
|
ServeHTTP(ResponseWriter, *Request)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`*chi.Mux` happens to have a `ServeHTTP` method, so it automatically
|
||||||
|
satisfies `http.Handler`, with zero extra code. Same story for our own
|
||||||
|
handlers wrapped via `http.HandlerFunc(...)` — a small built-in adapter
|
||||||
|
type that turns any function shaped `func(w, r)` into something with a
|
||||||
|
`ServeHTTP` method, satisfying the interface.
|
||||||
|
|
||||||
|
### `any` (a.k.a. `interface{}`)
|
||||||
|
|
||||||
|
The empty interface — one with zero required methods — is satisfied by
|
||||||
|
**every** type, since every type trivially has "at least zero" methods.
|
||||||
|
Go has a built-in alias for this: `any` (added in Go 1.18; older code
|
||||||
|
uses the equivalent `interface{}`).
|
||||||
|
|
||||||
|
```go
|
||||||
|
func describe(v any) {
|
||||||
|
fmt.Printf("value: %v, type: %T\n", v, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(42) // value: 42, type: int
|
||||||
|
describe("hello") // value: hello, type: string
|
||||||
|
describe(User{}) // value: {}, type: main.User
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see `any` used for things like generic JSON response helpers
|
||||||
|
(`map[string]any`) where the value could be a string, a number, a nested
|
||||||
|
object — anything.
|
||||||
|
|
||||||
|
### Type assertions
|
||||||
|
|
||||||
|
If you have a value typed as an interface (or `any`) and need the
|
||||||
|
concrete type back out, use a **type assertion**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var v any = "hello"
|
||||||
|
|
||||||
|
s := v.(string) // single-value form - PANICS if v isn't actually a string
|
||||||
|
|
||||||
|
s, ok := v.(string) // two-value form - SAFE: ok is false on mismatch, no panic
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("v was not a string")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always prefer the two-value form** unless you're absolutely certain of
|
||||||
|
the type — a failed single-value assertion crashes your program. This
|
||||||
|
shows up in the main course when reading a value back out of a
|
||||||
|
`context.Context` (Lesson 8) — the value is stored as `any`, so you need a
|
||||||
|
type assertion to get a concrete struct back.
|
||||||
|
|
||||||
|
## 2. Error handling, properly
|
||||||
|
|
||||||
|
Go's `error` is just an interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type error interface {
|
||||||
|
Error() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Any type with an `Error() string` method IS an error. The standard library
|
||||||
|
gives you two easy ways to create one:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
err1 := errors.New("something went wrong")
|
||||||
|
err2 := fmt.Errorf("failed to process user %d", 42)
|
||||||
|
```
|
||||||
|
|
||||||
|
### The `if err != nil` pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
func readConfig() (string, error) {
|
||||||
|
// pretend this can fail
|
||||||
|
return "", errors.New("config file not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
return // stop here - don't continue using `config`, it's meaningless
|
||||||
|
}
|
||||||
|
fmt.Println("config:", config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Checking `err != nil` after every call that can fail, and handling it
|
||||||
|
immediately, is the single most repeated pattern in idiomatic Go — and in
|
||||||
|
the entire main course.
|
||||||
|
|
||||||
|
### Wrapping errors with `%w`
|
||||||
|
|
||||||
|
When an error crosses through several layers of your program, it's useful
|
||||||
|
to add context at each layer without losing the original error:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func openFile() error {
|
||||||
|
return errors.New("file not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() error {
|
||||||
|
if err := openFile(); err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err) // %w WRAPS, preserving err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`%w` (as opposed to `%v` or `%s`) specifically **wraps** the original
|
||||||
|
error, meaning code further up the chain can still inspect what the
|
||||||
|
original error actually was, using `errors.Is` or `errors.As`.
|
||||||
|
|
||||||
|
### Sentinel errors and `errors.Is`
|
||||||
|
|
||||||
|
A **sentinel error** is a specific, predefined error value that callers
|
||||||
|
can check for by identity, not by comparing message strings (which is
|
||||||
|
fragile — messages change, causes bugs).
|
||||||
|
|
||||||
|
```go
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
func findUser(id int) (string, error) {
|
||||||
|
if id != 1 {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return "Hamid", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_, err := findUser(99)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
fmt.Println("no such user!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`errors.Is` works correctly even if the error was wrapped with `%w`
|
||||||
|
several layers deep — it "unwraps" automatically to check. This exact
|
||||||
|
pattern (`var ErrUserNotFound = errors.New(...)`, then
|
||||||
|
`errors.Is(err, ErrUserNotFound)`) is used throughout the main course's
|
||||||
|
repository layer.
|
||||||
|
|
||||||
|
## 3. Slices and maps — Go's core collection types
|
||||||
|
|
||||||
|
### Slices — dynamically-sized lists
|
||||||
|
|
||||||
|
```go
|
||||||
|
// A slice literal
|
||||||
|
names := []string{"alice", "bob", "carol"}
|
||||||
|
|
||||||
|
fmt.Println(names[0]) // "alice" - zero-indexed
|
||||||
|
fmt.Println(len(names)) // 3
|
||||||
|
|
||||||
|
names = append(names, "dave") // append returns a NEW slice - reassign it!
|
||||||
|
fmt.Println(names) // [alice bob carol dave]
|
||||||
|
|
||||||
|
// An empty slice, grown later
|
||||||
|
var scores []int
|
||||||
|
scores = append(scores, 10)
|
||||||
|
scores = append(scores, 20)
|
||||||
|
|
||||||
|
// Looping (seen in Part 1, repeated here for completeness)
|
||||||
|
for i, name := range names {
|
||||||
|
fmt.Println(i, name)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important: `append` may or may not modify the original underlying array —
|
||||||
|
you should always use the return value (`names = append(names, ...)`),
|
||||||
|
never assume the original variable was updated in place.
|
||||||
|
|
||||||
|
### Maps — key/value lookups
|
||||||
|
|
||||||
|
```go
|
||||||
|
ages := map[string]int{
|
||||||
|
"alice": 30,
|
||||||
|
"bob": 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(ages["alice"]) // 30
|
||||||
|
ages["carol"] = 28 // add/update a key
|
||||||
|
delete(ages, "bob") // remove a key
|
||||||
|
|
||||||
|
// Reading a key that doesn't exist returns the TYPE'S ZERO VALUE, not an
|
||||||
|
// error or nil-equivalent crash:
|
||||||
|
fmt.Println(ages["nobody"]) // 0 (the zero value for int)
|
||||||
|
|
||||||
|
// The "comma ok" idiom - check whether a key actually exists:
|
||||||
|
age, ok := ages["nobody"]
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("no such key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looping over a map (order is NOT guaranteed - it's randomized each run)
|
||||||
|
for name, age := range ages {
|
||||||
|
fmt.Println(name, age)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see `map[string]any` used constantly in the main course for
|
||||||
|
building ad-hoc JSON responses, e.g. `map[string]any{"id": user.ID,
|
||||||
|
"email": user.Email}`.
|
||||||
|
|
||||||
|
## 4. Packages, imports, and modules — how a real project is organized
|
||||||
|
|
||||||
|
You already saw `package main` in Part 1. Any other folder full of `.go`
|
||||||
|
files declares its own package name (usually matching the folder name),
|
||||||
|
and can be imported by other code.
|
||||||
|
|
||||||
|
```
|
||||||
|
myproject/
|
||||||
|
├── go.mod
|
||||||
|
├── main.go -- package main
|
||||||
|
└── greeter/
|
||||||
|
└── greeter.go -- package greeter
|
||||||
|
```
|
||||||
|
|
||||||
|
**`greeter/greeter.go`**
|
||||||
|
```go
|
||||||
|
package greeter
|
||||||
|
|
||||||
|
func Hello(name string) string {
|
||||||
|
return "Hello, " + name + "!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`main.go`**
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"myproject/greeter" // import path = module path + folder path
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println(greeter.Hello("Hamid"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The import path `"myproject/greeter"` is built from the module's name
|
||||||
|
(declared in `go.mod` via `module myproject`) plus the folder path. This
|
||||||
|
is exactly the pattern behind every internal import you'll see in the main
|
||||||
|
course, e.g.:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
```
|
||||||
|
|
||||||
|
— the module is `git.hamidsoltani.com/hamid/go-simple-api` (declared once,
|
||||||
|
at the top of the project's `go.mod`), and `internal/config` is the folder
|
||||||
|
path to that specific package.
|
||||||
|
|
||||||
|
### The special `internal/` folder
|
||||||
|
|
||||||
|
Any package inside a folder literally named `internal/` can ONLY be
|
||||||
|
imported by code within the same module (specifically, code rooted at the
|
||||||
|
parent of `internal/`). This is a compiler-enforced way to say "this code
|
||||||
|
is a private implementation detail of this project, not a public library
|
||||||
|
for others to import." The main course's entire codebase lives under
|
||||||
|
`internal/` for exactly this reason.
|
||||||
|
|
||||||
|
### External packages and `go.mod`
|
||||||
|
|
||||||
|
To use code someone else published (like the chi router), you add it as a
|
||||||
|
dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/go-chi/chi/v5@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads the package, records it in `go.mod` (a "require" line with
|
||||||
|
a specific version), and records exact checksums in `go.sum` (so builds
|
||||||
|
are reproducible and verifiably untampered). After that, you import it
|
||||||
|
just like any other package:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/go-chi/chi/v5"
|
||||||
|
```
|
||||||
|
|
||||||
|
`go mod tidy` is a command you'll run often — it scans your code for
|
||||||
|
imports it doesn't yet know about, fetches them, and also removes
|
||||||
|
`go.mod` entries for anything you've stopped importing.
|
||||||
|
|
||||||
|
## 5. A first look at goroutines (needed for Lesson 1's graceful shutdown)
|
||||||
|
|
||||||
|
A **goroutine** is a lightweight, independently-running function — Go's
|
||||||
|
built-in concurrency primitive. You start one with the `go` keyword:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sayHello() {
|
||||||
|
fmt.Println("hello from a goroutine")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go sayHello() // starts sayHello running CONCURRENTLY, doesn't block
|
||||||
|
|
||||||
|
fmt.Println("this may print before OR after 'hello from a goroutine'")
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond) // give the goroutine time to run
|
||||||
|
// without this Sleep, main() might exit before sayHello ever runs -
|
||||||
|
// when main() returns, the WHOLE PROGRAM exits immediately, goroutines
|
||||||
|
// and all.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key thing to understand: `go someFunction()` starts `someFunction`
|
||||||
|
running in the background and immediately continues to the next line —
|
||||||
|
it does **not** wait for `someFunction` to finish. This is exactly why the
|
||||||
|
main course wraps `srv.ListenAndServe()` in a goroutine in Lesson 1: that
|
||||||
|
call blocks forever (serving requests) — running it as a goroutine frees
|
||||||
|
up `main()`'s main line of execution to move on and listen for shutdown
|
||||||
|
signals (Ctrl+C) instead of getting stuck forever inside `ListenAndServe`.
|
||||||
|
|
||||||
|
We won't go deeper into concurrency (channels, `sync.WaitGroup`, etc.) in
|
||||||
|
this course — the main project only needs this one goroutine pattern.
|
||||||
|
|
||||||
|
## 6. JSON basics with `encoding/json`
|
||||||
|
|
||||||
|
Go's standard library can convert between Go values and JSON text
|
||||||
|
automatically, using struct tags (from Part 2) to control field naming.
|
||||||
|
|
||||||
|
### Encoding (Go value → JSON)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
u := User{Name: "Hamid", Age: 31}
|
||||||
|
|
||||||
|
// Marshal converts a Go value into a []byte of JSON text
|
||||||
|
data, err := json.Marshal(u)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(string(data)) // {"name":"Hamid","age":31}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoding (JSON → Go value)
|
||||||
|
|
||||||
|
```go
|
||||||
|
jsonText := `{"name":"Sara","age":28}`
|
||||||
|
|
||||||
|
var u User
|
||||||
|
err := json.Unmarshal([]byte(jsonText), &u) // note the & - Unmarshal WRITES into u
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(u.Name, u.Age) // Sara 28
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `&u` — just like `rows.Scan(&x)` from database code, `Unmarshal`
|
||||||
|
needs to *write into* your variable, so it needs its address.
|
||||||
|
|
||||||
|
### Streaming versions: `Encoder`/`Decoder`
|
||||||
|
|
||||||
|
When working with HTTP requests/responses (which are streams, not
|
||||||
|
in-memory byte slices), you'll more often see the streaming forms:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Writing JSON directly to an io.Writer (e.g. http.ResponseWriter)
|
||||||
|
json.NewEncoder(w).Encode(u)
|
||||||
|
|
||||||
|
// Reading JSON directly from an io.Reader (e.g. an HTTP request body)
|
||||||
|
var u User
|
||||||
|
json.NewDecoder(r.Body).Decode(&u)
|
||||||
|
```
|
||||||
|
|
||||||
|
These do the same job as `Marshal`/`Unmarshal` but write/read directly to
|
||||||
|
a stream instead of requiring a full `[]byte` up front. You'll use
|
||||||
|
`NewDecoder(r.Body).Decode(...)` and `NewEncoder(w).Encode(...)` on nearly
|
||||||
|
every handler in the main course, starting in Lesson 1.
|
||||||
|
|
||||||
|
## 7. You're ready
|
||||||
|
|
||||||
|
That's everything the main course leans on. A quick self-check — if these
|
||||||
|
all feel familiar, you're ready for Lesson 1:
|
||||||
|
|
||||||
|
- Declaring variables with `:=` and `var`, and Go's zero values
|
||||||
|
- Writing functions with multiple return values, and the `if err != nil`
|
||||||
|
pattern
|
||||||
|
- Structs, exported vs. unexported fields, struct tags
|
||||||
|
- Pointers: `&` to get an address, `*` to dereference, and why functions
|
||||||
|
take `*User` instead of `User` when they need to modify it
|
||||||
|
- Methods with value vs. pointer receivers
|
||||||
|
- Interfaces being satisfied implicitly (no `implements` keyword)
|
||||||
|
- Slices (`append`, indexing, `range`) and maps (`map[string]any`, the
|
||||||
|
comma-ok idiom)
|
||||||
|
- How packages/imports/modules fit together, and what `internal/` means
|
||||||
|
- `go someFunc()` starting a goroutine, and why that matters for a
|
||||||
|
blocking call like `ListenAndServe`
|
||||||
|
- `json.NewEncoder(w).Encode(...)` / `json.NewDecoder(r.Body).Decode(&x)`
|
||||||
|
|
||||||
|
Head to `lesson-01-project-skeleton-chi-routing.md` next.
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Lesson 1 — Project Skeleton & chi Routing
|
||||||
|
|
||||||
|
> **New Go concepts in this lesson:** packages/imports/modules, structs,
|
||||||
|
> pointers, interfaces (implicitly satisfied), goroutines. If any of those
|
||||||
|
> feel shaky, review `00-go-basics-3-interfaces-errors-concurrency-packages.md`
|
||||||
|
> first.
|
||||||
|
|
||||||
|
## What we're building
|
||||||
|
|
||||||
|
By the end of this lesson you'll have a real, runnable HTTP server with:
|
||||||
|
- A standard Go project layout you'll keep extending for the rest of the course
|
||||||
|
- A router (using the `chi` library) that maps URLs to handler functions
|
||||||
|
- A `/health` endpoint that returns JSON
|
||||||
|
- A **graceful shutdown** sequence — the server finishes in-flight
|
||||||
|
requests before exiting on Ctrl+C, instead of just dying mid-request
|
||||||
|
|
||||||
|
## Project structure (final shape, built up over the whole course)
|
||||||
|
|
||||||
|
```
|
||||||
|
go-simple-api/
|
||||||
|
├── cmd/api/main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/config.go
|
||||||
|
│ ├── handlers/health.go
|
||||||
|
│ └── router/router.go
|
||||||
|
├── go.mod
|
||||||
|
```
|
||||||
|
(More folders get added lesson by lesson — this is just what exists after
|
||||||
|
Lesson 1.)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir go-simple-api && cd go-simple-api
|
||||||
|
go mod init git.hamidsoltani.com/hamid/go-simple-api
|
||||||
|
go get github.com/go-chi/chi/v5@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
A quick word on that module path: `git.hamidsoltani.com/hamid/go-simple-api`
|
||||||
|
isn't a real, fetchable URL — it's just a naming convention (commonly your
|
||||||
|
Git host + username + project name). It becomes the prefix for every
|
||||||
|
internal import in this project, e.g.
|
||||||
|
`git.hamidsoltani.com/hamid/go-simple-api/internal/config`. If you're
|
||||||
|
following along with your own project, use your own path here — just stay
|
||||||
|
consistent with it everywhere.
|
||||||
|
|
||||||
|
`go get github.com/go-chi/chi/v5@latest` downloads
|
||||||
|
[chi](https://github.com/go-chi/chi), a small, popular HTTP router for Go.
|
||||||
|
Why use a router library instead of the standard library's own
|
||||||
|
`http.ServeMux`? chi gives us URL parameters (`/users/{id}`), route
|
||||||
|
groups, and a large ecosystem of compatible middleware (rate limiting,
|
||||||
|
CORS, request logging) that we'll use throughout this course — the
|
||||||
|
standard library's router is fine for very simple cases but doesn't have
|
||||||
|
these built in.
|
||||||
|
|
||||||
|
## `internal/config/config.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Line by line:
|
||||||
|
- `package config` — its own package, so both `main.go` and any future
|
||||||
|
file can import it and call `config.Load()`.
|
||||||
|
- `type Config struct { Port string }` — a plain struct holding settings.
|
||||||
|
We'll add many more fields to this over the course (database settings,
|
||||||
|
Redis settings, OAuth settings...) — this is the ONE place all of the
|
||||||
|
app's configuration lives.
|
||||||
|
- `func Load() Config` — returns a `Config` **by value** (not a pointer)
|
||||||
|
since it's small and, once built, nothing needs to mutate it in place.
|
||||||
|
- `getEnv` is unexported (lowercase — see Go Basics Part 2 on
|
||||||
|
capitalization) — nothing outside this file needs to call it directly.
|
||||||
|
`os.Getenv(key)` reads an environment variable; if it's empty (unset),
|
||||||
|
we return `fallback` instead. This is how you avoid hardcoding things
|
||||||
|
like ports directly in your code.
|
||||||
|
|
||||||
|
## `internal/handlers/health.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Every HTTP handler in Go (with or without chi) has this exact function
|
||||||
|
signature: `func(w http.ResponseWriter, r *http.Request)`.
|
||||||
|
- `w http.ResponseWriter` is how you write the response back to the
|
||||||
|
client — it's an interface (see Go Basics Part 3) with methods like
|
||||||
|
`Write`, `WriteHeader`, and `Header()`.
|
||||||
|
- `r *http.Request` is a pointer to a struct describing the incoming
|
||||||
|
request — method, URL, headers, body, etc.
|
||||||
|
- `w.Header().Set("Content-Type", "application/json")` — sets a response
|
||||||
|
header. This **must** happen before `w.WriteHeader(...)` is called —
|
||||||
|
once you write the status code, the headers are locked in and can't be
|
||||||
|
changed afterward.
|
||||||
|
- `w.WriteHeader(http.StatusOK)` — writes the HTTP status code (`200`).
|
||||||
|
`http.StatusOK` is just a predefined constant equal to `200` — using the
|
||||||
|
named constant instead of the raw number is more readable and less
|
||||||
|
error-prone.
|
||||||
|
- `json.NewEncoder(w).Encode(map[string]string{"status": "ok"})` — from Go
|
||||||
|
Basics Part 3: creates a JSON encoder that writes directly to `w`
|
||||||
|
(which is a stream, an `io.Writer`), then encodes our map as JSON and
|
||||||
|
writes it out. `map[string]string` is a map with `string` keys and
|
||||||
|
`string` values — see Go Basics Part 3 on maps.
|
||||||
|
|
||||||
|
## `internal/router/router.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/health", handlers.Health)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `chi.NewRouter()` returns a `*chi.Mux` — a pointer to chi's router type.
|
||||||
|
`*chi.Mux` happens to have a `ServeHTTP(w, r)` method, which means it
|
||||||
|
automatically satisfies the standard library's `http.Handler` interface
|
||||||
|
(see Go Basics Part 3 on interfaces) — no explicit declaration needed,
|
||||||
|
it "just works" because the method exists.
|
||||||
|
- `r.Use(...)` registers **middleware**: a function that wraps every
|
||||||
|
request passing through the router. Each of these has the shape
|
||||||
|
`func(http.Handler) http.Handler` — takes the "next" handler in the
|
||||||
|
chain, returns a new handler that does something extra before/after
|
||||||
|
calling it.
|
||||||
|
- `middleware.RequestID` — tags each request with a unique ID (useful
|
||||||
|
later once we add structured logging, in Lesson 2).
|
||||||
|
- `middleware.Logger` — chi's built-in logger; prints a line per
|
||||||
|
request to your terminal (we'll replace this with our own structured
|
||||||
|
JSON version in Lesson 2).
|
||||||
|
- `middleware.Recoverer` — catches panics inside any handler and turns
|
||||||
|
them into a `500` response, instead of crashing the entire server
|
||||||
|
process over one bad request.
|
||||||
|
- `r.Get("/health", handlers.Health)` — registers `handlers.Health` to run
|
||||||
|
for `GET` requests to `/health`. Note we pass the function itself
|
||||||
|
(`handlers.Health`), not a call to it (`handlers.Health()`) — chi will
|
||||||
|
call it later, once per matching request.
|
||||||
|
|
||||||
|
## `cmd/api/main.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
r := router.New()
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.Port,
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("server starting on port %s", cfg.Port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Println("shutting down gracefully...")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatalf("forced shutdown: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("server stopped")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the most concept-dense file in the lesson. Take it slowly:
|
||||||
|
|
||||||
|
- `cfg := config.Load()` then `r := router.New()` — build our two pieces
|
||||||
|
using the constructors we just wrote.
|
||||||
|
- `srv := &http.Server{Addr: ":" + cfg.Port, Handler: r}` — instead of
|
||||||
|
calling the simpler `http.ListenAndServe(addr, handler)` directly, we
|
||||||
|
build an `*http.Server` struct ourselves (note the `&` — we want a
|
||||||
|
pointer, since we're going to call methods on it later that need to
|
||||||
|
operate on this exact instance). We do this specifically so we can call
|
||||||
|
`.Shutdown()` on it further down — `http.ListenAndServe` alone gives you
|
||||||
|
no way to stop it gracefully.
|
||||||
|
- `go func() { ... }()` — **this is a goroutine** (Go Basics Part 3,
|
||||||
|
section 5). `srv.ListenAndServe()` blocks forever, serving requests
|
||||||
|
until the server stops. If we called it directly here (without `go`),
|
||||||
|
the code below it — the part that waits for Ctrl+C — would never run;
|
||||||
|
the program would just sit inside `ListenAndServe` permanently. Running
|
||||||
|
it as a goroutine lets it serve requests in the background while
|
||||||
|
`main()`'s primary execution moves on to the next lines.
|
||||||
|
- `if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed`
|
||||||
|
— `ListenAndServe` always returns a non-nil error when it stops (even on
|
||||||
|
a normal, intentional shutdown) — `http.ErrServerClosed` specifically
|
||||||
|
means "this was a deliberate `Shutdown()` call, not a real problem," so
|
||||||
|
we only treat OTHER errors as fatal.
|
||||||
|
- `quit := make(chan os.Signal, 1)` — a **channel**, Go's built-in
|
||||||
|
mechanism for goroutines to communicate. We're using it here in its
|
||||||
|
simplest form: as a way to "wait for a signal to arrive." (We don't go
|
||||||
|
deeper into channels in this course — this is the only one you'll need.)
|
||||||
|
- `signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)` — tells the Go
|
||||||
|
runtime "when the OS sends this process a SIGINT (Ctrl+C) or SIGTERM
|
||||||
|
(e.g. `docker stop`), send a value into the `quit` channel instead of
|
||||||
|
just killing the process outright."
|
||||||
|
- `<-quit` — this is the **receive** operation on a channel: it blocks
|
||||||
|
(pauses) the current goroutine — here, `main()`'s own execution — until
|
||||||
|
something arrives on `quit`. This is what actually keeps the program
|
||||||
|
alive and waiting, instead of exiting immediately after starting the
|
||||||
|
server goroutine.
|
||||||
|
- `context.WithTimeout(context.Background(), 5*time.Second)` — builds a
|
||||||
|
`context.Context` (we'll use these a lot more starting in Lesson 8) that
|
||||||
|
automatically expires after 5 seconds. `context.Background()` is the
|
||||||
|
standard "empty starting point" for building a new context.
|
||||||
|
- `defer cancel()` — `defer` schedules a function call to run right before
|
||||||
|
the surrounding function (`main`, here) returns, regardless of how it
|
||||||
|
returns. `cancel` releases resources associated with the timeout context
|
||||||
|
once we're done with it — always pair `WithTimeout`/`WithCancel` with a
|
||||||
|
`defer cancel()` immediately after creating them.
|
||||||
|
- `srv.Shutdown(ctx)` — tells the server to stop accepting new
|
||||||
|
connections, and wait for existing in-flight requests to finish, up to
|
||||||
|
the 5-second deadline in `ctx`. This is what "graceful" shutdown means:
|
||||||
|
requests that were already in progress get to complete normally instead
|
||||||
|
of being cut off mid-response.
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
You should get back `{"status":"ok"}`.
|
||||||
|
|
||||||
|
Now go back to the terminal running the server and press **Ctrl+C**. You
|
||||||
|
should see:
|
||||||
|
```
|
||||||
|
shutting down gracefully...
|
||||||
|
server stopped
|
||||||
|
```
|
||||||
|
instead of the process just vanishing instantly — that's the graceful
|
||||||
|
shutdown sequence working.
|
||||||
|
|
||||||
|
## Common mistakes at this stage
|
||||||
|
|
||||||
|
- **Forgetting the parentheses when calling a function**: writing
|
||||||
|
`r := router.New` (assigns the function itself) instead of
|
||||||
|
`r := router.New()` (calls it and gets the `*chi.Mux` back). The
|
||||||
|
compiler error looks like: `cannot use r (variable of type func() *chi.Mux)
|
||||||
|
as http.Handler value` — if you see that shape of error, check for a
|
||||||
|
missing `()`.
|
||||||
|
- **Forgetting `defer db.Close()` / `defer cancel()`** on things that need
|
||||||
|
cleanup — not an issue yet in this lesson, but a habit to build now,
|
||||||
|
since it appears constantly starting in Lesson 3.
|
||||||
|
|
||||||
|
Once `/health` works and Ctrl+C shuts down cleanly, move on to Lesson 2 —
|
||||||
|
structured JSON logging.
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# Lesson 2 — Structured JSON Logging with `slog`
|
||||||
|
|
||||||
|
> **New Go concepts in this lesson:** closures (the three-layer middleware
|
||||||
|
> pattern), variadic-style function calls, type aliases for interfaces.
|
||||||
|
> Review the "closures" section of `00-go-basics-2-functions-structs-pointers.md`
|
||||||
|
> before this one if middleware still feels confusing after Lesson 1.
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
Right now (end of Lesson 1), `middleware.Logger` from chi prints
|
||||||
|
human-readable text to your terminal. That's fine to read by eye, but if
|
||||||
|
you ever want to ship logs to something like Grafana Loki (via Grafana
|
||||||
|
Alloy), you want **structured JSON** — one JSON object per log line — so
|
||||||
|
you can filter and query by field (`status=500`, `path="/login"`, etc.)
|
||||||
|
instead of parsing free-form text with regexes.
|
||||||
|
|
||||||
|
Go's standard library has had a structured logging package, `log/slog`,
|
||||||
|
since Go 1.21 — no third-party dependency needed.
|
||||||
|
|
||||||
|
## Part A — standalone playground
|
||||||
|
|
||||||
|
Build understanding in isolation first, in a throwaway project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/go-playground/slog-demo && cd ~/go-playground/slog-demo
|
||||||
|
go mod init slog-demo
|
||||||
|
```
|
||||||
|
|
||||||
|
**`main.go`**
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. A plain text logger (human-readable, default style)
|
||||||
|
textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||||
|
textLogger.Info("this is text format", "user", "hamid", "attempt", 1)
|
||||||
|
|
||||||
|
// 2. A JSON logger (what we want for Loki)
|
||||||
|
jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
jsonLogger.Info("this is json format", "user", "hamid", "attempt", 1)
|
||||||
|
|
||||||
|
// 3. Log levels
|
||||||
|
jsonLogger.Debug("debug message - hidden by default")
|
||||||
|
jsonLogger.Info("info message - shown")
|
||||||
|
jsonLogger.Warn("warn message - shown")
|
||||||
|
jsonLogger.Error("error message - shown", "err", "something broke")
|
||||||
|
|
||||||
|
// 4. Structured fields with types
|
||||||
|
jsonLogger.Info("user logged in",
|
||||||
|
slog.String("username", "hamid"),
|
||||||
|
slog.Int("user_id", 42),
|
||||||
|
slog.Duration("took", 150*time.Millisecond),
|
||||||
|
slog.Bool("success", true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. A logger with permanent fields attached
|
||||||
|
requestLogger := jsonLogger.With(
|
||||||
|
slog.String("request_id", "abc-123"),
|
||||||
|
slog.String("service", "go-simple-api"),
|
||||||
|
)
|
||||||
|
requestLogger.Info("handling request")
|
||||||
|
requestLogger.Info("finished request", slog.Int("status", 200))
|
||||||
|
|
||||||
|
// 6. Controlling minimum level explicitly
|
||||||
|
debugLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
}))
|
||||||
|
debugLogger.Debug("now debug shows up because we set the level")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
What to notice:
|
||||||
|
- `slog.New(handler)` — every logger is a `*slog.Logger` wrapping a
|
||||||
|
**Handler**, which decides output format and destination. Swap
|
||||||
|
`NewTextHandler` ↔ `NewJSONHandler` and everything else in your code
|
||||||
|
stays identical — this is the interface/implementation split from Go
|
||||||
|
Basics Part 3 in action: your code depends on `*slog.Logger`'s methods
|
||||||
|
(`Info`, `Error`, ...), not on which Handler is behind it.
|
||||||
|
- By default, `Debug(...)` calls are **silently dropped** unless you
|
||||||
|
explicitly set `Level: slog.LevelDebug` in `HandlerOptions` — that's why
|
||||||
|
section 3's debug line doesn't print, but section 6's does.
|
||||||
|
- `slog.String`, `slog.Int`, `slog.Duration`, `slog.Bool` are typed field
|
||||||
|
constructors. You *can* skip them and just pass raw `"key", value` pairs
|
||||||
|
(as in sections 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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
# Lesson 6 — Server-Side Sessions with scs + Redis
|
||||||
|
|
||||||
|
> **New Go concepts in this lesson:** working with a connection pool for a
|
||||||
|
> second kind of backing store (Redis, via redigo), middleware composing
|
||||||
|
> with something other than chi's own middleware. Nothing brand new at
|
||||||
|
> the language level here — mostly applying everything from the Go Basics
|
||||||
|
> lessons to a new library.
|
||||||
|
|
||||||
|
## What "server-side session" means, concretely
|
||||||
|
|
||||||
|
The browser only ever holds a random, meaningless token in a cookie. All
|
||||||
|
the actual session **data** (which user is logged in, etc.) lives in
|
||||||
|
Redis, keyed by that token. This is different from storing data directly
|
||||||
|
inside a signed/encrypted cookie: server-side sessions can be instantly
|
||||||
|
revoked (delete the Redis key), don't grow the cookie as you store more
|
||||||
|
data, and never expose their contents to the browser at all.
|
||||||
|
|
||||||
|
## Part A — standalone playground
|
||||||
|
|
||||||
|
First, run Redis:
|
||||||
|
```bash
|
||||||
|
docker run --name redis-demo -p 6379:6379 -d redis:8
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/go-playground/session-demo && cd ~/go-playground/session-demo
|
||||||
|
go mod init session-demo
|
||||||
|
go get github.com/alexedwards/scs/v2@latest
|
||||||
|
go get github.com/alexedwards/scs/redisstore@latest
|
||||||
|
go get github.com/gomodule/redigo@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**`main.go`**
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/redisstore"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/gomodule/redigo/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A package-level session manager - scs is designed to be created once
|
||||||
|
// and reused everywhere, similar to how we handle *sql.DB.
|
||||||
|
var sessionManager *scs.SessionManager
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. Build a Redis connection pool (redigo, not redis/go-redis - this
|
||||||
|
// is the client library scs's redisstore is built on).
|
||||||
|
pool := &redis.Pool{
|
||||||
|
MaxIdle: 10,
|
||||||
|
Dial: func() (redis.Conn, error) {
|
||||||
|
return redis.Dial("tcp", "127.0.0.1:6379")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the session manager and point its Store at Redis.
|
||||||
|
sessionManager = scs.New()
|
||||||
|
sessionManager.Store = redisstore.New(pool)
|
||||||
|
sessionManager.Lifetime = 24 * time.Hour
|
||||||
|
sessionManager.Cookie.Name = "session_id"
|
||||||
|
sessionManager.Cookie.HttpOnly = true
|
||||||
|
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/set", setHandler)
|
||||||
|
mux.HandleFunc("/get", getHandler)
|
||||||
|
mux.HandleFunc("/clear", clearHandler)
|
||||||
|
|
||||||
|
// 3. Wrap the whole mux with LoadAndSave - this is scs's own
|
||||||
|
// middleware, same shape as chi's: func(http.Handler) http.Handler.
|
||||||
|
log.Println("listening on :4000")
|
||||||
|
log.Fatal(http.ListenAndServe(":4000", sessionManager.LoadAndSave(mux)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Put stores a value in the session, keyed by string.
|
||||||
|
sessionManager.Put(r.Context(), "username", "hamid")
|
||||||
|
sessionManager.Put(r.Context(), "visits", 1)
|
||||||
|
fmt.Fprintln(w, "session data set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// GetString / GetInt read back typed values. If the key doesn't
|
||||||
|
// exist, they return the zero value ("" or 0), not an error.
|
||||||
|
username := sessionManager.GetString(r.Context(), "username")
|
||||||
|
visits := sessionManager.GetInt(r.Context(), "visits")
|
||||||
|
|
||||||
|
// Exists checks presence explicitly, useful to distinguish "never
|
||||||
|
// set" from "set to zero value".
|
||||||
|
if !sessionManager.Exists(r.Context(), "username") {
|
||||||
|
fmt.Fprintln(w, "no session data yet - try /set first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "username=%s visits=%d\n", username, visits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Destroy wipes the session entirely and tells the browser to delete
|
||||||
|
// the cookie.
|
||||||
|
if err := sessionManager.Destroy(r.Context()); err != nil {
|
||||||
|
http.Error(w, "failed to destroy session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "session destroyed")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, **use `-c cookies.txt -b cookies.txt`** so curl
|
||||||
|
remembers the session cookie between requests, just like a browser would:
|
||||||
|
```bash
|
||||||
|
curl -c cookies.txt -b cookies.txt http://localhost:4000/set
|
||||||
|
curl -c cookies.txt -b cookies.txt http://localhost:4000/get
|
||||||
|
curl -c cookies.txt -b cookies.txt http://localhost:4000/clear
|
||||||
|
curl -c cookies.txt -b cookies.txt http://localhost:4000/get # back to "no session data yet"
|
||||||
|
```
|
||||||
|
|
||||||
|
While it's running, peek into Redis directly to *see* the session data
|
||||||
|
living server-side:
|
||||||
|
```bash
|
||||||
|
docker exec -it redis-demo redis-cli KEYS '*'
|
||||||
|
docker exec -it redis-demo redis-cli GET "scs:session:<token-from-above>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Line by line:
|
||||||
|
|
||||||
|
- `redis.Pool{...}` — the same "connection pool" concept as `*sql.DB`
|
||||||
|
from Lesson 3, just for Redis instead of MySQL. `Dial` is a function the
|
||||||
|
pool calls whenever it needs a fresh connection.
|
||||||
|
- `scs.New()` — creates a `*scs.SessionManager` with sensible defaults
|
||||||
|
(in-memory store, no cookie config yet).
|
||||||
|
- `sessionManager.Store = redisstore.New(pool)` — by default scs stores
|
||||||
|
sessions **in memory** (lost on restart, useless across multiple server
|
||||||
|
instances). Setting `.Store` swaps the backend to Redis — same manager,
|
||||||
|
same API, completely different storage underneath. This is the same
|
||||||
|
"swap the implementation, keep the interface" idea from Lesson 2's
|
||||||
|
Text/JSON handler swap.
|
||||||
|
- `sessionManager.Lifetime = 24 * time.Hour` — how long a session stays
|
||||||
|
valid since it was created.
|
||||||
|
- `sessionManager.Cookie.HttpOnly = true` — the browser's JavaScript can't
|
||||||
|
read this cookie (`document.cookie` won't show it), blocking a large
|
||||||
|
class of XSS-based session theft.
|
||||||
|
- `sessionManager.Cookie.SameSite = http.SameSiteLaxMode` — restricts when
|
||||||
|
the browser sends this cookie on cross-site requests, mitigating CSRF
|
||||||
|
(more on this in Lesson 9).
|
||||||
|
- **How it all connects**: `sessionManager.LoadAndSave(mux)` wraps your
|
||||||
|
entire mux, same middleware pattern from Lesson 2. On every request: it
|
||||||
|
reads the session cookie, loads that session's data from Redis into the
|
||||||
|
request's context, lets your handler run (which reads/writes session
|
||||||
|
data via `sessionManager.Put`/`Get`, using `r.Context()` to know *which*
|
||||||
|
session it's operating on), then after your handler finishes, saves any
|
||||||
|
changes back to Redis and writes/refreshes the cookie on the response.
|
||||||
|
You never touch cookies or Redis directly.
|
||||||
|
- `sessionManager.Put(r.Context(), "username", "hamid")` — stores a value
|
||||||
|
under a string key, scoped to the session identified by this request's
|
||||||
|
cookie.
|
||||||
|
- `sessionManager.GetString(...)` / `GetInt(...)` — typed getters. There's
|
||||||
|
also `GetBool`, `GetFloat`, `GetTime`, and a generic `Get` returning
|
||||||
|
`any` for custom types.
|
||||||
|
- `sessionManager.Destroy(r.Context())` — deletes the session from Redis
|
||||||
|
and instructs the browser (via response headers) to expire the cookie.
|
||||||
|
|
||||||
|
Try stopping and restarting your Go program (Ctrl+C, `go run .` again)
|
||||||
|
*without* restarting Redis — set a session, restart the app, `GET` again.
|
||||||
|
The session survives, because it never lived in your Go process's memory
|
||||||
|
in the first place.
|
||||||
|
|
||||||
|
## Part B — apply it to the project
|
||||||
|
|
||||||
|
**Add the dependencies:**
|
||||||
|
```bash
|
||||||
|
go get github.com/alexedwards/scs/v2@latest
|
||||||
|
go get github.com/alexedwards/scs/redisstore@latest
|
||||||
|
go get github.com/gomodule/redigo@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extend `internal/config/config.go`** with Redis settings:
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
|
||||||
|
RedisAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
|
||||||
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||||
|
DBPort: getEnv("DB_PORT", "3306"),
|
||||||
|
DBUser: getEnv("DB_USER", "root"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||||||
|
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||||||
|
|
||||||
|
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`internal/session/session.go`** — builds the shared session manager:
|
||||||
|
```go
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/redisstore"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/gomodule/redigo/redis"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(cfg config.Config) *scs.SessionManager {
|
||||||
|
pool := &redis.Pool{
|
||||||
|
MaxIdle: 10,
|
||||||
|
Dial: func() (redis.Conn, error) {
|
||||||
|
return redis.Dial("tcp", cfg.RedisAddr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := scs.New()
|
||||||
|
manager.Store = redisstore.New(pool)
|
||||||
|
manager.Lifetime = 24 * time.Hour
|
||||||
|
manager.Cookie.Name = "session_id"
|
||||||
|
manager.Cookie.HttpOnly = true
|
||||||
|
manager.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Identical to Part A's setup, wrapped in `New(cfg)` so `main.go` builds it
|
||||||
|
the same way it builds `database.NewMySQL(...)` and `logging.New()`.
|
||||||
|
|
||||||
|
**`internal/session/keys.go`** — a central place for session data keys:
|
||||||
|
```go
|
||||||
|
package session
|
||||||
|
|
||||||
|
const UserIDKey = "user_id"
|
||||||
|
```
|
||||||
|
Defining this constant once avoids typos across files that would silently
|
||||||
|
break authentication (e.g. one file writes `"user_id"`, another reads
|
||||||
|
`"userId"` — the compiler can't catch that for you if they're raw
|
||||||
|
strings; a shared constant makes that class of bug impossible).
|
||||||
|
|
||||||
|
**Update `internal/handlers/auth.go`** — inject the session manager, and
|
||||||
|
actually start a session on login. Update the struct and constructor:
|
||||||
|
```go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
userRepo *models.UserRepository
|
||||||
|
sessions *scs.SessionManager
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *AuthHandler {
|
||||||
|
return &AuthHandler{userRepo: userRepo, sessions: sessions, logger: logger}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`Register` is unchanged from Lesson 5 — leave it as-is.)
|
||||||
|
|
||||||
|
Update `Login` to actually create a session, and add `Logout` + `Me`:
|
||||||
|
```go
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.FindByEmail(r.Context(), req.Email)
|
||||||
|
if errors.Is(err, models.ErrUserNotFound) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("find user by email failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent session fixation: issue a fresh session token now that the
|
||||||
|
// user's privilege level is about to change (anonymous -> authenticated).
|
||||||
|
if err := h.sessions.RenewToken(r.Context()); err != nil {
|
||||||
|
h.logger.Error("renew token failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.sessions.Destroy(r.Context()); err != nil {
|
||||||
|
h.logger.Error("destroy session failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.sessions.GetInt(r.Context(), session.UserIDKey)
|
||||||
|
if userID == 0 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not logged in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.FindByID(r.Context(), userID)
|
||||||
|
if errors.Is(err, models.ErrUserNotFound) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not logged in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("find user by id failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What's new:
|
||||||
|
|
||||||
|
- `h.sessions.RenewToken(r.Context())` — generates a brand-new session
|
||||||
|
token while keeping the session's existing data intact, invalidating
|
||||||
|
the old token. This is **preventing session fixation**: if an attacker
|
||||||
|
somehow got a victim to use a *known* session token before login,
|
||||||
|
renewing it at the moment of authentication makes the pre-login token
|
||||||
|
useless. Always call this right before a privilege change (login here).
|
||||||
|
- `h.sessions.Put(r.Context(), session.UserIDKey, user.ID)` — this is the
|
||||||
|
entire "session" from the server's perspective: we store the user's ID,
|
||||||
|
not the whole `User` struct. Anything else about the user (email, etc.)
|
||||||
|
is looked up fresh from the database when needed (as in `Me`) — this
|
||||||
|
keeps the session small and avoids serving *stale* cached user data.
|
||||||
|
- `Me` reads back `session.UserIDKey` via `GetInt`, then does a real
|
||||||
|
`FindByID` lookup. This route is your template for **any** future route
|
||||||
|
that needs "the current logged-in user" — in Lesson 8 we'll extract the
|
||||||
|
"check the session, else 401" part into reusable middleware instead of
|
||||||
|
repeating it in every handler.
|
||||||
|
|
||||||
|
**Update `internal/router/router.go`**:
|
||||||
|
```go
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager) *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
r.Use(middleware.RequestLogger(logger))
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
r.Use(chimw.Timeout(60 * time.Second))
|
||||||
|
|
||||||
|
// scs's own middleware must wrap every route that touches sessions.
|
||||||
|
// Simplest for now: wrap the whole router.
|
||||||
|
r.Use(sessions.LoadAndSave)
|
||||||
|
|
||||||
|
r.Get("/health", handlers.Health)
|
||||||
|
|
||||||
|
userRepo := models.NewUserRepository(db)
|
||||||
|
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||||||
|
|
||||||
|
r.Post("/register", authHandler.Register)
|
||||||
|
r.Post("/login", authHandler.Login)
|
||||||
|
r.Post("/logout", authHandler.Logout)
|
||||||
|
r.Get("/me", authHandler.Me)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`r.Use(sessions.LoadAndSave)` — exactly like Part A's manual wrapping, but
|
||||||
|
as chi middleware. `sessions.LoadAndSave` already has the
|
||||||
|
`func(http.Handler) http.Handler` shape chi's `Use` expects, so it's
|
||||||
|
passed directly (same as `chimw.Recoverer`).
|
||||||
|
|
||||||
|
**Update `cmd/api/main.go`**:
|
||||||
|
```go
|
||||||
|
sessions := session.New(cfg)
|
||||||
|
logger.Info("session manager configured", "redis_addr", cfg.RedisAddr)
|
||||||
|
|
||||||
|
r := router.New(logger, db, sessions)
|
||||||
|
```
|
||||||
|
(Add `"git.hamidsoltani.com/hamid/go-simple-api/internal/session"` to
|
||||||
|
imports.)
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name redis-api -p 6379:6379 -d redis:8
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -c cookies.txt -X POST http://localhost:8080/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"hamid@example.com","password":"secret123"}'
|
||||||
|
|
||||||
|
curl -b cookies.txt http://localhost:8080/me
|
||||||
|
|
||||||
|
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/logout
|
||||||
|
|
||||||
|
curl -b cookies.txt http://localhost:8080/me # should now be unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
Check Redis directly to see your real session sitting there server-side:
|
||||||
|
```bash
|
||||||
|
docker exec -it redis-api redis-cli KEYS '*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Once `/me` correctly returns your user after login and fails after
|
||||||
|
logout, move to Lesson 7 — Google OAuth login.
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
# Lesson 7 — Login with Google (OAuth2)
|
||||||
|
|
||||||
|
> **New Go concepts in this lesson:** anonymous structs for one-off JSON
|
||||||
|
> shapes, `crypto/rand` vs `math/rand`, working with an `*http.Client`
|
||||||
|
> returned by a library. Builds on JSON basics and error handling from
|
||||||
|
> the Go Basics lessons — nothing fundamentally new at the language
|
||||||
|
> level, but a new external flow (OAuth2) to understand conceptually.
|
||||||
|
|
||||||
|
## What OAuth2 actually does here
|
||||||
|
|
||||||
|
Your app never sees the user's Google password. Instead, your app
|
||||||
|
redirects the user to Google, the user logs into *Google* and approves
|
||||||
|
"let this app see your email," and Google redirects back to your app with
|
||||||
|
a temporary code. Your app exchanges that code (server-to-server, never
|
||||||
|
visible to the browser) for an access token, then uses that token to ask
|
||||||
|
Google "who is this user?" This is called the **Authorization Code
|
||||||
|
flow** — the standard, secure OAuth2 pattern.
|
||||||
|
|
||||||
|
## Setting up Google credentials (one-time, outside the code)
|
||||||
|
|
||||||
|
1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create a project if needed.
|
||||||
|
2. Create an **OAuth 2.0 Client ID** (Application type: Web application).
|
||||||
|
3. Add an **Authorized redirect URI**: `http://localhost:8080/auth/google/callback`.
|
||||||
|
4. You'll get a **Client ID** and **Client Secret** — treat the secret
|
||||||
|
like a password, never commit it to git.
|
||||||
|
|
||||||
|
## Part A — standalone playground
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/go-playground/oauth-demo && cd ~/go-playground/oauth-demo
|
||||||
|
go mod init oauth-demo
|
||||||
|
go get golang.org/x/oauth2@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**`main.go`**
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1. oauth2.Config describes everything needed to talk to Google's OAuth
|
||||||
|
// system. Fill in your own Client ID/Secret from the Cloud Console.
|
||||||
|
var googleOAuthConfig = &oauth2.Config{
|
||||||
|
ClientID: "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
||||||
|
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||||
|
RedirectURL: "http://localhost:4000/auth/google/callback",
|
||||||
|
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
// In real code this state should be stored per-session, not a global -
|
||||||
|
// we use a global here ONLY to keep this playground minimal.
|
||||||
|
var expectedState string
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.HandleFunc("/login", loginHandler)
|
||||||
|
http.HandleFunc("/auth/google/callback", callbackHandler)
|
||||||
|
|
||||||
|
log.Println("visit http://localhost:4000/login")
|
||||||
|
log.Fatal(http.ListenAndServe(":4000", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 2. Generate a random "state" value - CSRF protection: we'll check
|
||||||
|
// that the state Google sends back matches what we generated, proving
|
||||||
|
// the callback really came from a login WE initiated.
|
||||||
|
state, err := generateState()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to generate state", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expectedState = state
|
||||||
|
|
||||||
|
// 3. AuthCodeURL builds the full URL to Google's consent screen,
|
||||||
|
// embedding our client ID, redirect URL, scopes, and state.
|
||||||
|
url := googleOAuthConfig.AuthCodeURL(state)
|
||||||
|
|
||||||
|
// 4. Send the browser there.
|
||||||
|
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 5. Google redirects back here with ?state=...&code=... in the URL.
|
||||||
|
if r.URL.Query().Get("state") != expectedState {
|
||||||
|
http.Error(w, "invalid state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "missing code", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Exchange the temporary code for an actual access token. This is
|
||||||
|
// a direct server-to-server HTTPS call to Google - the code is
|
||||||
|
// single-use and expires quickly.
|
||||||
|
token, err := googleOAuthConfig.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "code exchange failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Use the access token to call Google's userinfo endpoint and find
|
||||||
|
// out who actually logged in.
|
||||||
|
client := googleOAuthConfig.Client(r.Context(), token)
|
||||||
|
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to fetch user info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to read user info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var googleUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &googleUser); err != nil {
|
||||||
|
http.Error(w, "failed to parse user info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "logged in as: id=%s email=%s\n", googleUser.ID, googleUser.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateState() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it, plug in your real Client ID/Secret, then visit
|
||||||
|
`http://localhost:4000/login` **in a real browser** (curl can't drive
|
||||||
|
Google's login page).
|
||||||
|
|
||||||
|
Line by line:
|
||||||
|
|
||||||
|
- `oauth2.Config` — a struct holding everything needed to run the flow:
|
||||||
|
your app's identity (`ClientID`/`ClientSecret`), where Google sends the
|
||||||
|
user back (`RedirectURL` — **must exactly match** the Cloud Console
|
||||||
|
registration), what data you're requesting (`Scopes`), and which
|
||||||
|
provider's endpoints to use (`Endpoint: google.Endpoint`, a predefined
|
||||||
|
value pointing at Google's real auth/token URLs).
|
||||||
|
- `var googleUser struct { ID string \`json:"id"\`; Email string \`json:"email"\` }`
|
||||||
|
— this is an **anonymous struct**: a struct type defined inline,
|
||||||
|
without a `type Name struct` declaration, used because we only need
|
||||||
|
this shape once, right here, to decode one specific JSON response.
|
||||||
|
- `generateState()` — `crypto/rand` (NOT `math/rand`!) generates
|
||||||
|
cryptographically secure random bytes, unpredictable even if an
|
||||||
|
attacker knows previous outputs. This matters because `state` is a
|
||||||
|
security mechanism, not just a random label. `math/rand` is fine for
|
||||||
|
games/simulations, never for anything security-sensitive.
|
||||||
|
- **Why `state` matters**: without it, an attacker could craft their own
|
||||||
|
malicious callback link using *their own* Google account's code, trick
|
||||||
|
a victim into clicking it while logged into your app, and potentially
|
||||||
|
link the attacker's Google account to the victim's session. Checking
|
||||||
|
that `state` matches what *we* generated closes that hole.
|
||||||
|
- `googleOAuthConfig.AuthCodeURL(state)` — builds the actual Google
|
||||||
|
consent-screen URL; you never hand-construct this.
|
||||||
|
- `http.Redirect(w, r, url, http.StatusTemporaryRedirect)` — sends an
|
||||||
|
HTTP 302-style response telling the browser "go here instead." The
|
||||||
|
browser follows it to Google.
|
||||||
|
- `googleOAuthConfig.Exchange(r.Context(), code)` — server-to-server: your
|
||||||
|
Go program makes an HTTPS POST to Google, presenting `code` plus your
|
||||||
|
`ClientSecret` (proving it's really your registered app), and gets back
|
||||||
|
an `oauth2.Token`.
|
||||||
|
- `googleOAuthConfig.Client(r.Context(), token)` — returns a regular
|
||||||
|
`*http.Client`, pre-configured to automatically attach the access token
|
||||||
|
as an `Authorization: Bearer ...` header on every request — no manual
|
||||||
|
header handling needed.
|
||||||
|
- `io.ReadAll(resp.Body)` then `json.Unmarshal(body, &googleUser)` —
|
||||||
|
slightly different from `json.NewDecoder(r.Body).Decode(&x)` used
|
||||||
|
elsewhere. Both work; `Unmarshal` needs the full byte slice up front
|
||||||
|
(hence `ReadAll` first), `Decode` streams directly. Either is fine for
|
||||||
|
small responses — you'll see both styles in real Go code.
|
||||||
|
- `defer resp.Body.Close()` — same rule as `defer rows.Close()` from
|
||||||
|
Lesson 3: any "reader" resource (HTTP body, SQL rows, open file) should
|
||||||
|
always be closed when you're done with it.
|
||||||
|
|
||||||
|
Try it end to end, confirm you see your real Google email printed back.
|
||||||
|
Try mangling the `state` value in the URL manually and confirm "invalid
|
||||||
|
state."
|
||||||
|
|
||||||
|
## Part B — apply it to the project
|
||||||
|
|
||||||
|
**Add the dependency:**
|
||||||
|
```bash
|
||||||
|
go get golang.org/x/oauth2@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extend `internal/config/config.go`:**
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
|
||||||
|
RedisAddr string
|
||||||
|
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
GoogleRedirectURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
|
||||||
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||||
|
DBPort: getEnv("DB_PORT", "3306"),
|
||||||
|
DBUser: getEnv("DB_USER", "root"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||||||
|
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||||||
|
|
||||||
|
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||||
|
|
||||||
|
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||||
|
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||||
|
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
```
|
||||||
|
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||||
|
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
**`internal/oauth/google.go`** — builds the `*oauth2.Config` from our
|
||||||
|
app's `Config`:
|
||||||
|
```go
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewGoogleConfig(cfg config.Config) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: cfg.GoogleClientID,
|
||||||
|
ClientSecret: cfg.GoogleClientSecret,
|
||||||
|
RedirectURL: cfg.GoogleRedirectURL,
|
||||||
|
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`internal/handlers/oauth_google.go`** — the real handler, reusing the
|
||||||
|
flow from Part A but wired into our session/repository system:
|
||||||
|
```go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GoogleOAuthHandler struct {
|
||||||
|
config *oauth2.Config
|
||||||
|
userRepo *models.UserRepository
|
||||||
|
sessions *scs.SessionManager
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGoogleOAuthHandler(config *oauth2.Config, userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *GoogleOAuthHandler {
|
||||||
|
return &GoogleOAuthHandler{config: config, userRepo: userRepo, sessions: sessions, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthStateSessionKey = "oauth_state"
|
||||||
|
|
||||||
|
func (h *GoogleOAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := generateState()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("generate oauth state failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store state in the SESSION instead of a global variable (Part A cut
|
||||||
|
// this corner deliberately, having no session system yet). This
|
||||||
|
// means state survives correctly even with multiple users hitting
|
||||||
|
// /auth/google/login concurrently.
|
||||||
|
h.sessions.Put(r.Context(), oauthStateSessionKey, state)
|
||||||
|
|
||||||
|
url := h.config.AuthCodeURL(state)
|
||||||
|
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
|
||||||
|
if expectedState == "" || r.URL.Query().Get("state") != expectedState {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid oauth state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.sessions.Remove(r.Context(), oauthStateSessionKey) // one-time use
|
||||||
|
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.config.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("oauth exchange failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := h.config.Client(r.Context(), token)
|
||||||
|
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("fetch google userinfo failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("read google userinfo failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var googleUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &googleUser); err != nil {
|
||||||
|
h.logger.Error("parse google userinfo failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.findOrCreateGoogleUser(r, googleUser.ID, googleUser.Email)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("find or create google user failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.sessions.RenewToken(r.Context()); err != nil {
|
||||||
|
h.logger.Error("renew token failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateGoogleUser links a Google identity to a local user. Three
|
||||||
|
// cases: an existing user with this email but no google_id yet (link
|
||||||
|
// it), a user already linked to this google_id (just fetch it), or
|
||||||
|
// nobody with this email exists yet (create a new user).
|
||||||
|
func (h *GoogleOAuthHandler) findOrCreateGoogleUser(r *http.Request, googleID, email string) (*models.User, error) {
|
||||||
|
existing, err := h.userRepo.FindByEmail(r.Context(), email)
|
||||||
|
|
||||||
|
if errors.Is(err, models.ErrUserNotFound) {
|
||||||
|
newUser := &models.User{
|
||||||
|
Email: email,
|
||||||
|
GoogleID: googleID,
|
||||||
|
// PasswordHash stays empty - this user can only log in via Google.
|
||||||
|
}
|
||||||
|
if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
|
||||||
|
return nil, createErr
|
||||||
|
}
|
||||||
|
return newUser, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// User exists by email. If they haven't linked Google yet, link it now.
|
||||||
|
if existing.GoogleID == "" {
|
||||||
|
if linkErr := h.userRepo.SetGoogleID(r.Context(), existing.ID, googleID); linkErr != nil {
|
||||||
|
return nil, linkErr
|
||||||
|
}
|
||||||
|
existing.GoogleID = googleID
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateState() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notable differences from Part A, and why:
|
||||||
|
|
||||||
|
- **State stored in the session, not a global variable.** Part A's
|
||||||
|
`var expectedState string` only works for one user at a time — a real
|
||||||
|
server handles many concurrent users, and a shared global would let one
|
||||||
|
user's login attempt clobber another's state. Storing it via
|
||||||
|
`h.sessions.Put(...)` scopes it correctly per-visitor.
|
||||||
|
- `h.sessions.Remove(...)` — a new scs method: removes a single key from
|
||||||
|
the session (as opposed to `Destroy`, which wipes the whole session).
|
||||||
|
`state` is only needed for this one round trip, so we clean it up
|
||||||
|
immediately after checking it.
|
||||||
|
- `h.findOrCreateGoogleUser(...)` — the **account linking** logic. Three
|
||||||
|
distinct paths, each built entirely from repository methods you already
|
||||||
|
know from Lesson 4/5.
|
||||||
|
|
||||||
|
**Add one more repository method — `internal/models/user_repository.go`:**
|
||||||
|
```go
|
||||||
|
func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set google id: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `internal/router/router.go`:**
|
||||||
|
```go
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
r.Use(middleware.RequestLogger(logger))
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
r.Use(chimw.Timeout(60 * time.Second))
|
||||||
|
r.Use(sessions.LoadAndSave)
|
||||||
|
|
||||||
|
r.Get("/health", handlers.Health)
|
||||||
|
|
||||||
|
userRepo := models.NewUserRepository(db)
|
||||||
|
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||||||
|
|
||||||
|
r.Post("/register", authHandler.Register)
|
||||||
|
r.Post("/login", authHandler.Login)
|
||||||
|
r.Post("/logout", authHandler.Logout)
|
||||||
|
r.Get("/me", authHandler.Me)
|
||||||
|
|
||||||
|
googleConfig := oauth.NewGoogleConfig(cfg)
|
||||||
|
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
|
||||||
|
|
||||||
|
r.Get("/auth/google/login", googleHandler.Login)
|
||||||
|
r.Get("/auth/google/callback", googleHandler.Callback)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`New` now also takes `cfg config.Config` (needed to build the Google
|
||||||
|
OAuth config).
|
||||||
|
|
||||||
|
**Update `cmd/api/main.go`** — change the `router.New(...)` call:
|
||||||
|
```go
|
||||||
|
r := router.New(logger, db, sessions, cfg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
Visit `http://localhost:8080/auth/google/login` **in a browser**. After
|
||||||
|
approving, you should land on `/auth/google/callback` and see JSON back
|
||||||
|
with your id/email, plus a session cookie:
|
||||||
|
```bash
|
||||||
|
# copy the session_id cookie value from your browser's devtools
|
||||||
|
curl -b "session_id=<paste-cookie-value-here>" http://localhost:8080/me
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm in MySQL:
|
||||||
|
```bash
|
||||||
|
docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "SELECT id, email, google_id FROM users;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Once a full Google login round-trip works, move to Lesson 8 — auth
|
||||||
|
middleware & route protection.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# Lesson 9 — Rate Limiting & Security Hardening
|
||||||
|
|
||||||
|
> **New Go concepts in this lesson:** almost none new at the language
|
||||||
|
> level — this lesson is mostly about correctly configuring existing
|
||||||
|
> tools (`httprate`, `cors`, cookie flags) rather than new syntax. A good
|
||||||
|
> lesson to consolidate everything from Go Basics so far.
|
||||||
|
|
||||||
|
Four separate concerns, each small on its own: **rate limiting** (stop
|
||||||
|
abuse/brute-force), **secure cookie flags** (protect the session cookie
|
||||||
|
itself), **CORS** (control which websites can call your API from a
|
||||||
|
browser), and a basic **CSRF** mitigation for our cookie-based sessions.
|
||||||
|
|
||||||
|
## Part A — standalone playgrounds
|
||||||
|
|
||||||
|
### 1. Rate limiting with `httprate`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/go-playground/security-demo && cd ~/go-playground/security-demo
|
||||||
|
go mod init security-demo
|
||||||
|
go get github.com/go-chi/httprate@latest
|
||||||
|
go get github.com/go-chi/chi/v5@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**`main.go`**
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// 1. Limit EVERY client to 5 requests per 10 seconds, keyed by IP.
|
||||||
|
r.Use(httprate.LimitByIP(5, 10*time.Second))
|
||||||
|
|
||||||
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintln(w, "pong")
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Println("listening on :4000")
|
||||||
|
log.Fatal(http.ListenAndServe(":4000", r))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it and hammer it:
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
|
||||||
|
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/ping; done
|
||||||
|
```
|
||||||
|
You should see `200` five times, then `429` (Too Many Requests) for the
|
||||||
|
rest, until 10 seconds pass.
|
||||||
|
|
||||||
|
- `httprate.LimitByIP(5, 10*time.Second)` — a ready-made middleware (same
|
||||||
|
`func(http.Handler) http.Handler` shape you already know) tracking
|
||||||
|
request counts **per client IP**, in a sliding window. Exceeding the
|
||||||
|
limit auto-responds with `429 Too Many Requests` — you don't write that
|
||||||
|
logic yourself.
|
||||||
|
- Why keyed by IP: without a key, one abusive client could exhaust the
|
||||||
|
"budget" for every other user too. `LimitByIP` isolates each caller's
|
||||||
|
own quota. (Other keying strategies exist too — `LimitByRealIP`, or
|
||||||
|
custom keys like "by user ID" once authenticated.) This matters most on
|
||||||
|
`/login` and `/register` — without it, someone could script thousands
|
||||||
|
of password guesses per second against `/login`.
|
||||||
|
|
||||||
|
### 2. Cookie security flags
|
||||||
|
|
||||||
|
No need to run this one — just understand each flag, since we set these
|
||||||
|
on `scs`'s cookie config (already partly done in Lesson 6), not by hand:
|
||||||
|
|
||||||
|
```go
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: "abc123",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true, // JS cannot read this cookie
|
||||||
|
Secure: true, // browser only sends it over HTTPS
|
||||||
|
SameSite: http.SameSiteLaxMode, // restricts cross-site sending
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- `HttpOnly: true` — blocks `document.cookie` access from JavaScript.
|
||||||
|
Defeats a whole class of XSS attacks that try to steal the session
|
||||||
|
cookie via injected script.
|
||||||
|
- `Secure: true` — the browser will refuse to send this cookie over plain
|
||||||
|
HTTP, only HTTPS. **Important gotcha**: if you set this while
|
||||||
|
developing locally over `http://localhost`, the cookie won't be sent at
|
||||||
|
all — you'll be confused why sessions "don't work." We'll make this
|
||||||
|
environment-dependent in Part B.
|
||||||
|
- `SameSite: http.SameSiteLaxMode` — controls whether the cookie is sent
|
||||||
|
on cross-site requests. `Lax` (a good default) sends the cookie on
|
||||||
|
top-level navigations (clicking a link to your site) but not on
|
||||||
|
cross-site `POST`s triggered by another page (like a malicious
|
||||||
|
`<form>` auto-submitting to your `/logout`) — this is your main defense
|
||||||
|
against CSRF for cookie-based auth. `Strict` is even tighter but can
|
||||||
|
break legitimate cross-site navigation flows (like our own OAuth
|
||||||
|
callback from Google!). `None` disables the protection entirely and
|
||||||
|
requires `Secure: true`.
|
||||||
|
|
||||||
|
### 3. CORS
|
||||||
|
|
||||||
|
CORS only matters for requests made **from browser JavaScript running on
|
||||||
|
a different origin** than your API (e.g., a React app on
|
||||||
|
`http://localhost:3000` calling your API on `http://localhost:8080`). It
|
||||||
|
does **not** protect your API from curl, mobile apps, or server-to-server
|
||||||
|
calls — CORS is a browser-enforced rule, not a server-side security
|
||||||
|
boundary. It controls *which websites* a browser will let call your API
|
||||||
|
with the user's cookies/credentials attached.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/go-chi/cors@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: []string{"http://localhost:3000"}, // your frontend's origin
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||||
|
AllowedHeaders: []string{"Content-Type"},
|
||||||
|
AllowCredentials: true, // required for cookies to be sent cross-origin
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("pong"))
|
||||||
|
})
|
||||||
|
|
||||||
|
http.ListenAndServe(":4000", r)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `AllowedOrigins` — an explicit allowlist. **Never** use `"*"` (wildcard)
|
||||||
|
together with `AllowCredentials: true` — browsers actually forbid that
|
||||||
|
combination outright, and even without credentials it's a bad default
|
||||||
|
for anything handling auth.
|
||||||
|
- `AllowCredentials: true` — without this, the browser won't include
|
||||||
|
cookies on cross-origin requests to your API at all, so session-based
|
||||||
|
auth from a separate frontend wouldn't work.
|
||||||
|
|
||||||
|
## Part B — apply it all to the project
|
||||||
|
|
||||||
|
**Get the dependencies:**
|
||||||
|
```bash
|
||||||
|
go get github.com/go-chi/httprate@latest
|
||||||
|
go get github.com/go-chi/cors@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `internal/router/router.go`** — apply a general limit to
|
||||||
|
everything, and a stricter one specifically to auth endpoints:
|
||||||
|
```go
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
r.Use(middleware.RequestLogger(logger))
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
r.Use(chimw.Timeout(60 * time.Second))
|
||||||
|
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: cfg.AllowedOrigins,
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||||
|
AllowedHeaders: []string{"Content-Type"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A generous global limit - mostly to stop runaway scripts/bots.
|
||||||
|
r.Use(httprate.LimitByIP(100, time.Minute))
|
||||||
|
|
||||||
|
r.Use(sessions.LoadAndSave)
|
||||||
|
|
||||||
|
r.Get("/health", handlers.Health)
|
||||||
|
|
||||||
|
userRepo := models.NewUserRepository(db)
|
||||||
|
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||||||
|
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
|
||||||
|
|
||||||
|
// A much stricter limit specifically on login/register, since these
|
||||||
|
// are exactly what a credential-stuffing / brute-force script targets.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(httprate.LimitByIP(5, time.Minute))
|
||||||
|
r.Post("/register", authHandler.Register)
|
||||||
|
r.Post("/login", authHandler.Login)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Post("/logout", authHandler.Logout)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(requireAuth)
|
||||||
|
r.Get("/me", authHandler.Me)
|
||||||
|
})
|
||||||
|
|
||||||
|
googleConfig := oauth.NewGoogleConfig(cfg)
|
||||||
|
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
|
||||||
|
|
||||||
|
r.Get("/auth/google/login", googleHandler.Login)
|
||||||
|
r.Get("/auth/google/callback", googleHandler.Callback)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Two separate `httprate.LimitByIP` calls at different scopes — the
|
||||||
|
global `100/minute` is a loose safety net for the whole API, while the
|
||||||
|
`r.Group` around `/register` and `/login` layers a *much* tighter
|
||||||
|
`5/minute` on top. Both limits apply simultaneously to requests inside
|
||||||
|
the group (they stack).
|
||||||
|
- `/logout` deliberately sits *outside* that strict group — you don't
|
||||||
|
want to rate-limit a legitimate logged-in user trying to log out.
|
||||||
|
- `cors.Handler(...)` now reads `cfg.AllowedOrigins` instead of a
|
||||||
|
hardcoded value.
|
||||||
|
|
||||||
|
**Extend `internal/config/config.go`** for CORS origins and cookie
|
||||||
|
security:
|
||||||
|
```go
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
Env string // "development" or "production"
|
||||||
|
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
|
||||||
|
RedisAddr string
|
||||||
|
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
GoogleRedirectURL string
|
||||||
|
|
||||||
|
AllowedOrigins []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
Env: getEnv("ENV", "development"),
|
||||||
|
|
||||||
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||||
|
DBPort: getEnv("DB_PORT", "3306"),
|
||||||
|
DBUser: getEnv("DB_USER", "root"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||||||
|
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||||||
|
|
||||||
|
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||||
|
|
||||||
|
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||||
|
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||||
|
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
|
||||||
|
|
||||||
|
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `Env` — distinguishes development from production, used next for the
|
||||||
|
cookie's `Secure` flag.
|
||||||
|
- `strings.Split(getEnv(...), ",")` — lets you configure multiple allowed
|
||||||
|
origins via one comma-separated env var (see Go Basics Part 3 on
|
||||||
|
slices), e.g. `ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com`.
|
||||||
|
|
||||||
|
**Update `internal/session/session.go`** — make `Secure` environment-aware,
|
||||||
|
fixing the localhost gotcha from Part A:
|
||||||
|
```go
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/redisstore"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/gomodule/redigo/redis"
|
||||||
|
|
||||||
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(cfg config.Config) *scs.SessionManager {
|
||||||
|
pool := &redis.Pool{
|
||||||
|
MaxIdle: 10,
|
||||||
|
Dial: func() (redis.Conn, error) {
|
||||||
|
return redis.Dial("tcp", cfg.RedisAddr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := scs.New()
|
||||||
|
manager.Store = redisstore.New(pool)
|
||||||
|
manager.Lifetime = 24 * time.Hour
|
||||||
|
manager.Cookie.Name = "session_id"
|
||||||
|
manager.Cookie.HttpOnly = true
|
||||||
|
manager.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
manager.Cookie.Secure = cfg.Env == "production" // only require HTTPS in prod
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`manager.Cookie.Secure = cfg.Env == "production"` — in development
|
||||||
|
(`ENV` unset or `"development"`), the cookie works over plain
|
||||||
|
`http://localhost`. In production, set `ENV=production` and the cookie
|
||||||
|
will refuse to be sent over anything but HTTPS.
|
||||||
|
|
||||||
|
**Update `cmd/api/main.go`** — no change needed; `router.New(logger, db,
|
||||||
|
sessions, cfg)` already passes `cfg`, which now carries `AllowedOrigins`
|
||||||
|
and `Env`.
|
||||||
|
|
||||||
|
**Add to your `.env`:**
|
||||||
|
```
|
||||||
|
ENV=development
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rate limiting:**
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 7); do
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"nope@example.com","password":"wrong"}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
You should see `401` (wrong credentials) for the first 5, then `429`
|
||||||
|
(rate limited) for the rest.
|
||||||
|
|
||||||
|
**CORS:**
|
||||||
|
```bash
|
||||||
|
curl -i -X OPTIONS http://localhost:8080/login \
|
||||||
|
-H "Origin: http://localhost:3000" \
|
||||||
|
-H "Access-Control-Request-Method: POST"
|
||||||
|
```
|
||||||
|
Look for `Access-Control-Allow-Origin: http://localhost:3000` in the
|
||||||
|
response headers.
|
||||||
|
|
||||||
|
A note on what we're *not* doing yet: full CSRF-token-based protection (a
|
||||||
|
token embedded in forms and checked server-side) is a deeper topic on its
|
||||||
|
own, and `SameSite=Lax` already covers the most common cookie-based CSRF
|
||||||
|
vector for a JSON API like this. If you later build a traditional
|
||||||
|
HTML-form frontend served from the same origin, that's when a dedicated
|
||||||
|
CSRF token library becomes worth adding — treat current protections as
|
||||||
|
sufficient for this course's scope.
|
||||||
|
|
||||||
|
Once rate limiting and CORS both check out, move to Lesson 10 — Docker,
|
||||||
|
docker-compose, and the full course wrap-up.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user