first commit
This commit is contained in:
+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.
|
||||
Reference in New Issue
Block a user