Files
go-simple-api/docs/ARCHITECTURE.md
T

206 lines
10 KiB
Markdown
Raw Normal View History

2026-07-16 10:13:46 +03:30
# 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.