first commit

This commit is contained in:
2026-07-16 10:13:46 +03:30
commit 423c528b2f
42 changed files with 7298 additions and 0 deletions
+64
View File
@@ -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 (110) 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.
+291
View File
@@ -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 12) 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.
+372
View File
@@ -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(&noteID, &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(&noteID, &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.
+410
View File
@@ -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`.
+471
View File
@@ -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.
+542
View File
@@ -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.
+359
View File
@@ -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.
+383
View File
@@ -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.
+330
View File
@@ -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 19, 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.