Files
2026-07-16 10:13:46 +03:30

10 KiB

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

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