206 lines
10 KiB
Markdown
206 lines
10 KiB
Markdown
# 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.
|