Day 21 — Auth, Validation & gRPC

Updated

July 30, 2025

Day 21 — Auth, Validation & gRPC

Day 54 — auth basics (JWT / session)

Stage VI · ~3h
Goal: Protect API routes with either HTTP sessions (cookie) or JWT bearer tokens—know the trade-offs, implement one end-to-end with middleware, and avoid classic secret mistakes.

Why this day exists

Open APIs are demos. Real services need:

  • Authentication — who are you?
  • Authorization — what may you do?

Today focuses on authn + a simple “authenticated vs not” gate; role-based authz can be a thin claim/role check. Stage VI gate expects protected write paths.

Warning

This is literacy, not a full threat model. Do not invent crypto. Prefer proven libraries for JWT parsing when you leave pure-stdlib demos.


Theory 1 — Session vs JWT

Server session JWT access token
State Server stores session id → user Token carries claims; server verifies signature
Revocation Delete session row/store Hard until expiry (need blocklist or short TTL)
Cookies Often HttpOnly session cookie Can be cookie or Authorization header
Scaling Shared session store Stateless verify (until refresh/revocation needs)
Size Small cookie id Larger token

Pick one for the lab. Document why. Capstone may use sessions for browsers and tokens for service clients—today keep one path clear.

Mental model

Session:  browser → cookie(session_id) → server lookup → user_id
JWT:      client  → Authorization: Bearer <jwt> → verify sig+exp → user_id

Theory 2 — Password handling (minimum)

import "golang.org/x/crypto/bcrypt"

hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
    return err
}
err = bcrypt.CompareHashAndPassword(hash, []byte(password))

Use golang.org/x/crypto/bcrypt (not stdlib, but standard practice). Never store plaintext passwords. Never log passwords. Cost factor trades CPU for hardness—default is fine for labs.

For pure-stdlib-only constraint days you can use a dev fixed token—but label it non-production.

// DEV ONLY — refuse in production builds
if password != os.Getenv("DEV_PASSWORD") {
    return errAuth
}

Theory 3 — JWT shape (conceptual)

header.payload.signature

Claims (examples):

{
  "sub": "user-123",
  "exp": 1893456000,
  "iat": 1893452000,
  "role": "user"
}

Verify:

  1. Signature with secret/public key
  2. exp not in the past
  3. iss/aud if you set them

HS256 with a long random secret is common for labs; asymmetric keys for multi-service prod.

// sketch with github.com/golang-jwt/jwt/v5
token, err := jwt.ParseWithClaims(tok, &Claims{}, func(t *jwt.Token) (any, error) {
    if t.Method != jwt.SigningMethodHS256 {
        return nil, fmt.Errorf("unexpected alg")
    }
    return secret, nil
})
if err != nil || !token.Valid {
    return "", errAuth
}

Reject alg=none. Do not trust unverified claims. Set short exp (e.g. 15–60 minutes) even in labs so expiry code paths get exercised.


Theory 4 — Session cookies

http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    sessionID,
    Path:     "/",
    HttpOnly: true,
    Secure:   true, // HTTPS only in real deploy; false only on local HTTP with eyes open
    SameSite: http.SameSiteLaxMode,
    MaxAge:   int((24 * time.Hour).Seconds()),
})

Store sessionID → userID, expires in DB or memory map (memory dies on restart—ok for lab). On logout: delete server row and expire cookie.

http.SetCookie(w, &http.Cookie{
    Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})

Theory 5 — Auth middleware

type userKey struct{}

func (s *Server) requireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID, err := s.authenticate(r)
        if err != nil {
            writeErr(w, http.StatusUnauthorized, "unauthorized", "login required")
            return
        }
        ctx := context.WithValue(r.Context(), userKey{}, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func UserID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(userKey{}).(string)
    return id, ok && id != ""
}

Route strategy

  • Public: POST /v1/login, POST /v1/register, GET /healthz
  • Protected: mount subtree or wrap individual handlers
s.mux.Handle("GET /v1/me", s.requireAuth(http.HandlerFunc(s.me)))
s.mux.Handle("POST /v1/items", s.requireAuth(http.HandlerFunc(s.createItem)))

Note: Handle + middleware-wrapped Handler vs HandleFunc—be consistent with method patterns on Go 1.22+ mux.


Theory 6 — Authn vs authz (do not merge them)

Concern Example
Authn failure Missing/invalid token → 401
Authz failure Valid user, not owner → 403
it, err := s.repo.Get(ctx, id)
// ...
if it.OwnerID != userID {
    writeErr(w, http.StatusForbidden, "forbidden", "not your item")
    return
}

Logging: record user_id after auth, never the raw password or full bearer token.


Worked example — bearer token (simple HMAC JWT lab)

Login:

func (s *Server) login(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := readJSON(r, &req); err != nil {
        writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
        return
    }
    u, err := s.users.ByEmail(r.Context(), req.Email)
    if err != nil || bcrypt.CompareHashAndPassword(u.Hash, []byte(req.Password)) != nil {
        // constant-ish message; avoid user enumeration in messages if you can
        writeErr(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
        return
    }
    tok, err := s.issueToken(u.ID)
    if err != nil {
        writeErr(w, http.StatusInternalServerError, "internal", "internal error")
        return
    }
    writeJSON(w, http.StatusOK, map[string]string{
        "access_token": tok,
        "token_type":   "Bearer",
    })
}

Authenticate:

func (s *Server) authenticate(r *http.Request) (string, error) {
    h := r.Header.Get("Authorization")
    const p = "Bearer "
    if !strings.HasPrefix(h, p) {
        return "", errAuth
    }
    return s.parseToken(strings.TrimPrefix(h, p))
}

Lab 1 — Choose track

mkdir -p ~/lab/90daysofx/01-go/day54
cd ~/lab/90daysofx/01-go/day54
go mod init example.com/day54

Write in README: Track = JWT or Track = Session. One paragraph trade-off.


Lab 2 — User store

Table users(id, email, password_hash, created_at). Register + login endpoints. Unique email → 409.

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash BLOB NOT NULL,
  created_at TEXT NOT NULL
);

Lab 3 — Protect routes

  • GET /v1/me returns current user public profile (no hash!)
  • POST /v1/items requires auth; set owner_id from context
  • GET /v1/items lists only own items or all public—document policy

Lab 4 — Tests

  1. No token → 401 on protected route
  2. Login → token/cookie → 200 on /v1/me
  3. Wrong password → 401
  4. Tampered JWT → 401 (JWT track)
  5. Access another user’s item → 403 (if ownership enforced)
go test ./... -race -count=1

Lab 5 — Secrets

Load signing secret / session keys from env AUTH_SECRET. Refuse to start if missing or too short (< 32 bytes) in non-dev mode. Dev default only with loud log warning:

secret := os.Getenv("AUTH_SECRET")
if secret == "" {
    if dev {
        log.Warn("AUTH_SECRET empty; using insecure dev secret")
        secret = "dev-only-change-me-dev-only-change-me"
    } else {
        log.Error("AUTH_SECRET required")
        os.Exit(1)
    }
}

Common gotchas

Gotcha Fix
JWT without exp Always set expiry
Secrets in git Env / secret manager
alg confusion attacks Enforce expected algorithm
Cookie without HttpOnly Set HttpOnly
Logging Authorization header Never
Authz = authn Separate role/owner checks
Timing-safe compare for tokens Use hmac.Equal / library
Returning password_hash in JSON Explicit public DTO

Checkpoint

  • Register + login works
  • Passwords hashed
  • Middleware enforces auth
  • 401 paths tested
  • Secret from env
  • Trade-off documented (JWT vs session)

Commit

git add .
git commit -m "day54: auth middleware JWT or session"

Write three personal gotchas before continuing.


Tomorrow

Day 55 — validation & problem details: consistent input validation and structured API errors clients can trust.


Day 55 — validation & API errors

Stage VI · ~3h
Goal: Centralize input validation and emit consistent problem/error JSON—field-level details, stable error codes, and clean mapping from domain errors to HTTP.

Why this day exists

APIs rot when every handler invents its own error shape:

{"err":"bad"}
{"message":"Name missing"}
{"error":"not found"}

Clients need predictability. Operators need codes. Security needs to avoid leaking internals. Today turns Day 53/54 handlers into a single error language.


Theory 1 — Error envelope

Adopt one envelope for the service:

{
  "error": {
    "code": "validation_failed",
    "message": "request validation failed",
    "details": [
      {"field": "name", "issue": "required"},
      {"field": "email", "issue": "invalid_format"}
    ]
  }
}
Field Purpose
code Stable machine token (snake_case)
message Human summary
details Optional field issues

Optional RFC 9457 Problem Details (application/problem+json)—same idea, standard fields (type, title, status, detail, instance). Lab may use either; document choice.

{
  "type": "about:blank",
  "title": "validation_failed",
  "status": 400,
  "detail": "request validation failed",
  "errors": [{"field":"name","issue":"required"}]
}

Theory 2 — Validation layers

  1. Transport: JSON syntax, Content-Type, body size
  2. Schema: required fields, types, ranges, formats
  3. Domain: unique email, inventory rules, authz

Do not conflate layers—malformed JSON is 400; “item not found” is 404; “not owner” is 403.

Small validator without frameworks

type FieldError struct {
    Field string `json:"field"`
    Issue string `json:"issue"`
}

type ValidationError struct {
    Fields []FieldError
}

func (e *ValidationError) Error() string { return "validation failed" }

func (e *ValidationError) Add(field, issue string) {
    e.Fields = append(e.Fields, FieldError{Field: field, Issue: issue})
}

func (e *ValidationError) Err() error {
    if len(e.Fields) == 0 {
        return nil
    }
    return e
}
func validateCreateItem(name string) error {
    var v ValidationError
    name = strings.TrimSpace(name)
    if name == "" {
        v.Add("name", "required")
    } else if len(name) > 100 {
        v.Add("name", "too_long")
    }
    return v.Err()
}

Accumulate all field errors when cheap—clients fix multiple issues in one round-trip.


Theory 3 — Mapping domain → HTTP

func writeDomainErr(w http.ResponseWriter, log *slog.Logger, err error) {
    var ve *ValidationError
    switch {
    case errors.As(err, &ve):
        writeError(w, http.StatusBadRequest, "validation_failed", "request validation failed", ve.Fields)
    case errors.Is(err, store.ErrNotFound):
        writeError(w, http.StatusNotFound, "not_found", "resource not found", nil)
    case errors.Is(err, store.ErrConflict):
        writeError(w, http.StatusConflict, "conflict", "resource already exists", nil)
    case errors.Is(err, ErrUnauthorized):
        writeError(w, http.StatusUnauthorized, "unauthorized", "login required", nil)
    case errors.Is(err, ErrForbidden):
        writeError(w, http.StatusForbidden, "forbidden", "forbidden", nil)
    default:
        log.Error("handler", "err", err)
        writeError(w, http.StatusInternalServerError, "internal", "internal error", nil)
    }
}

Never return raw err.Error() for unknown 500s to clients. Log the real error server-side with request id.


Theory 4 — Content negotiation lite

  • Require Content-Type: application/json on bodies that need JSON
  • Respond 415 if wrong type
  • Always set response Content-Type to your error/success type
  • Limit body size before decode
func readJSON(r *http.Request, dst any) error {
    ct := r.Header.Get("Content-Type")
    if ct != "" && !strings.HasPrefix(ct, "application/json") {
        return errUnsupportedMedia
    }
    defer r.Body.Close()
    dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("%w: %v", errBadJSON, err)
    }
    return nil
}

Theory 5 — Idempotent validation messages

Stable issue tokens (required, too_long, invalid_format) beat free-form sentences for i18n and client logic. Message field can be English for humans.

Regexp from Day 48 for email/slug validation—compile once at package init:

var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)

func validateEmail(s string) error {
    var v ValidationError
    s = strings.TrimSpace(s)
    if s == "" {
        v.Add("email", "required")
    } else if !emailRe.MatchString(s) {
        v.Add("email", "invalid_format")
    }
    return v.Err()
}

Theory 6 — Sentinel errors and wrapping

var (
    ErrUnauthorized = errors.New("unauthorized")
    ErrForbidden    = errors.New("forbidden")
)

// store package
var ErrNotFound = errors.New("not found")
var ErrConflict  = errors.New("conflict")

Handlers and services return wrapped sentinels (fmt.Errorf("get item: %w", store.ErrNotFound)). Mapping uses errors.Is / errors.As only—string matching error text is fragile.


Worked example — writeError helper

type errorBody struct {
    Error errorPayload `json:"error"`
}

type errorPayload struct {
    Code    string       `json:"code"`
    Message string       `json:"message"`
    Details []FieldError `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, code, msg string, details []FieldError) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{
        Code: code, Message: msg, Details: details,
    }})
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

Lab 1 — Integrate into service

mkdir -p ~/lab/90daysofx/01-go/day55
cd ~/lab/90daysofx/01-go/day55
go mod init example.com/day55

Upgrade Day 53/54 handlers to use central writeDomainErr. Delete ad-hoc http.Error and one-off JSON maps for failures.


Lab 2 — Validation matrix tests

Case Expect
Missing name 400 validation_failed + field detail
Name too long 400
Bad email on register 400
Unknown JSON field 400 (if DisallowUnknownFields)
Not found 404 not_found no details
No auth 401
Duplicate email 409

Assert JSON paths with encoding/json into structs—not only substrings.

var body struct {
    Error struct {
        Code    string `json:"code"`
        Details []struct {
            Field string `json:"field"`
            Issue string `json:"issue"`
        } `json:"details"`
    } `json:"error"`
}

Lab 3 — Internal error hygiene

Force a repo error (inject fake repo in test) → client body must not contain SQL text; server logs must contain the real error (slog with "err" key).


Lab 4 — Optional problem+json

Implement writeProblem with Content-Type: application/problem+json for one endpoint; compare DX to your envelope. Pick a standard for the rest of Stage VI and document it in README.


Lab 5 — OpenAPI lite note

Write a short markdown table of error codes used by the service—proto-catalog for clients:

code HTTP meaning
validation_failed 400 field details present
unauthorized 401 missing/invalid credentials
forbidden 403 authenticated but not allowed
not_found 404 resource missing
conflict 409 unique constraint / duplicate
internal 500 unexpected

Common gotchas

Gotcha Fix
Different shapes per handler One helper
Leaking internals on 500 Generic message + slog
Validation only client-side Server always validates
Stringly field paths Nested field like address.zip
Using 500 for bad input 400/422
Ignoring multiple field errors Accumulate in ValidationError
Matching error strings errors.Is / As
Forgetting body limit io.LimitReader

Checkpoint

  • Single error envelope everywhere
  • Field-level validation details
  • Domain mapping switch complete
  • Tests cover 400/401/404/409
  • 500 does not leak internals
  • Error code catalog documented

Commit

git add .
git commit -m "day55: validation and consistent API error envelope"

Write three personal gotchas before continuing.


Tomorrow

Day 56 — elective branch A: either a gRPC unary intro or deepen REST (OpenAPI notes, ETags, bulk endpoints)—choose deliberately.


Day 56 — gRPC or deepen REST (A)

Stage VI · ~3h · elective branch
Goal: Choose Track gRPC or Track REST-deep—implement a meaningful slice today; Day 57 continues the same branch on Go 1.26.

Important

Pick one track for Days 56–57. Switching mid-stream wastes the elective. Record the choice in README on day one.

Why this day exists

Not every service needs gRPC. Not every REST API is “done” after CRUD. Experts choose deliberately:

  • Multi-language internal mesh, streaming, strict contracts → gRPC often wins
  • Public HTTP/JSON, browser clients, simple edge caching → REST/JSON often wins

Both are valid. Cargo-culting either is not. Today you implement a real slice of one track so Day 57 can polish (interceptors/streaming lite or gateway/docs polish)—not start from zero.


Theory 1 — Track selection guide

Signal Lean gRPC Lean REST-deep
Browser first-class
Mobile/web public API
Internal service-to-service
Bi-di streaming needs SSE/WS (Day 59)
Team already JSON/HTTP
Strong schema + codegen culture OpenAPI
Load balancers / HTTP-only edge ✓ (or grpc-gateway)
Strict binary contracts + versions possible with OpenAPI

Decision record (required)

## Elective track (Days 56–57)

Choice: gRPC | REST-deep
Why (3 bullets):
1.
2.
3.
Out of scope this elective:

Paste into README or ELECTIVE-TRACK.md.


Track G — gRPC unary intro

Theory G1 — Stack overview

.proto
  → protoc
  → protoc-gen-go       (messages)
  → protoc-gen-go-grpc  (service interfaces)
  → generated stubs in module
server implements FooServer interface
client dials (transport creds) and calls RPC methods
Piece Role
Transport HTTP/2
Payload Protobuf (binary)
Stubs Typed Go interfaces + structs
Errors google.golang.org/grpc/status + codes

Toolchain (record versions)

protoc --version
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
which protoc-gen-go protoc-gen-go-grpc

Keep plugin versions compatible with your module’s google.golang.org/grpc and protobuf requires. Pin in notes if CI must reproduce.


Theory G2 — Minimal proto

syntax = "proto3";
package items.v1;
option go_package = "example.com/day56/gen/itemsv1;itemsv1";

service ItemService {
  rpc GetItem(GetItemRequest) returns (GetItemResponse);
  rpc CreateItem(CreateItemRequest) returns (CreateItemResponse);
}

message GetItemRequest { string id = 1; }
message GetItemResponse {
  string id = 1;
  string name = 2;
}
message CreateItemRequest { string name = 1; }
message CreateItemResponse {
  string id = 1;
  string name = 2;
}

Generate (adjust paths to your layout):

mkdir -p gen/itemsv1
protoc \
  --go_out=gen --go_opt=paths=source_relative \
  --go-grpc_out=gen --go-grpc_opt=paths=source_relative \
  api/proto/items/v1/items.proto

Document whether generated code is committed or produced in CI—pick one policy and stick to it.


Theory G3 — Server implementation

Embed Unimplemented*Server for forward compatibility:

type Server struct {
    itemsv1.UnimplementedItemServiceServer
    repo ItemRepo // your Stage VI store interface
}

func (s *Server) GetItem(ctx context.Context, req *itemsv1.GetItemRequest) (*itemsv1.GetItemResponse, error) {
    if req.GetId() == "" {
        return nil, status.Error(codes.InvalidArgument, "id required")
    }
    it, err := s.repo.Get(ctx, req.GetId())
    if errors.Is(err, store.ErrNotFound) {
        return nil, status.Error(codes.NotFound, "item not found")
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "internal")
    }
    return &itemsv1.GetItemResponse{Id: it.ID, Name: it.Name}, nil
}

func (s *Server) CreateItem(ctx context.Context, req *itemsv1.CreateItemRequest) (*itemsv1.CreateItemResponse, error) {
    name := strings.TrimSpace(req.GetName())
    if name == "" {
        return nil, status.Error(codes.InvalidArgument, "name required")
    }
    it, err := s.repo.Create(ctx, name)
    if err != nil {
        return nil, status.Error(codes.Internal, "internal")
    }
    return &itemsv1.CreateItemResponse{Id: it.ID, Name: it.Name}, nil
}

Listen and serve

lis, err := net.Listen("tcp", ":50051")
if err != nil {
    log.Fatal(err)
}
gs := grpc.NewServer()
itemsv1.RegisterItemServiceServer(gs, &Server{repo: repo})
log.Info("grpc listen", "addr", lis.Addr().String())
if err := gs.Serve(lis); err != nil {
    log.Fatal(err)
}

Graceful stop (wire to signal later):

go func() {
    <-ctx.Done()
    gs.GracefulStop()
}()

Theory G4 — Status codes map

Situation codes.*
Bad client input InvalidArgument
Missing entity NotFound
Duplicate AlreadyExists
No/invalid auth Unauthenticated
Authenticated but forbidden PermissionDenied
Unexpected server bug Internal
Deadline exceeded DeadlineExceeded

Avoid overusing Unknown. Never panic for “not found.”


Theory G5 — Client smoke + tests

conn, err := grpc.NewClient("127.0.0.1:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()))
client := itemsv1.NewItemServiceClient(conn)
_, err = client.GetItem(ctx, &itemsv1.GetItemRequest{Id: "missing"})
st, _ := status.FromError(err)
// expect codes.NotFound

Manual: grpcurl -plaintext localhost:50051 list and invoke GetItem/CreateItem. Prefer bufconn in-process for unit tests; network smoke is fine for the lab.


Lab G — multi-step

  1. Install protoc + plugins; record versions in README.
  2. Author proto; generate stubs; document commit-vs-CI policy.
  3. Implement Get + Create unary against your existing repo/store.
  4. Map validation → InvalidArgument, missing → NotFound.
  5. Smoke with grpcurl or Go client test (go test ./... -count=1).
  6. Write run instructions (port, generate command, insecure local-only note).
  7. Plan Day 57: interceptors (logging/auth) and/or server streaming lite.

Track R — deepen REST

Theory R1 — Conditional requests & ETags

ETags let clients and caches avoid shipping unchanged bodies:

func etagFor(body []byte) string {
    sum := sha256.Sum256(body)
    return fmt.Sprintf(`"%x"`, sum[:8])
}

func writeItemJSON(w http.ResponseWriter, r *http.Request, it Item) {
    body, err := json.Marshal(it)
    if err != nil {
        httpapi.WriteInternal(w)
        return
    }
    tag := etagFor(body)
    w.Header().Set("ETag", tag)
    w.Header().Set("Content-Type", "application/json")
    if match := r.Header.Get("If-None-Match"); match == tag {
        w.WriteHeader(http.StatusNotModified)
        return
    }
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write(body)
}

Useful for GET caching (ties Day 58). Weak ETags (W/"...") exist; strong is fine for lab JSON.


Theory R2 — Bulk / batch endpoints

POST /v1/items:batchGet
{"ids":["1","2","3"]}

Rules:

  • Hard max (e.g. 100)
  • Partial success policy documented (all-or-nothing vs per-id errors)
  • Auth still required if single-get is protected
const maxBatch = 100

type batchGetReq struct {
    IDs []string `json:"ids"`
}

func (h *Handler) BatchGet(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    var req batchGetReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.writeErr(w, errInvalid)
        return
    }
    if len(req.IDs) == 0 || len(req.IDs) > maxBatch {
        h.writeErr(w, errInvalid)
        return
    }
    // fetch; return {"items":[...]} 
}

Theory R3 — Partial representation

Options:

Approach Example Tradeoff
Query fields ?fields=id,name Flexible; parser cost
Separate DTOs list DTO vs detail DTO Clear; more types
Sparse fieldsets later JSON:API-style Heavy for this volume

For the lab, list DTO vs detail DTO is enough:

type ItemListDTO struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}
type ItemDetailDTO struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
    // owner, etc.
}

Theory R4 — OpenAPI sketch + Idempotency-Key

Author a honest openapi.yaml for core v1 routes (paths, schemas, bearerAuth). Generating clients is optional; authoring discipline is the skill—do not document routes that 404.

Clients retry POSTs; without idempotency keys, retries create duplicates:

key := r.Header.Get("Idempotency-Key")
if key != "" {
    if resp, ok := h.idem.Get(key); ok {
        writeCached(w, resp)
        return
    }
}
// create ... then h.idem.Put(key, status, body)  // lab: in-memory + TTL note

Lab R — multi-step

  1. Add ETag + If-None-Match on GET /v1/items/{id}; httptest 304.
  2. Add batch get with hard limit; test over-limit → 400.
  3. Draft openapi.yaml for core routes (auth scheme included).
  4. Idempotency-Key on create (in-memory map OK); test replay returns same body/id.
  5. Plan Day 57: middleware polish, docs publish, tiny client SDK snippet.

Shared requirements (both tracks)

Requirement gRPC REST-deep
README choice declared
Reuses store/repo from Stage VI
Tests client RPC or bufconn httptest
Error mapping discipline codes.* Day 55 envelope
Body/input limits message size awareness MaxBytesReader
No secret commits
Go 1.26 module

Shared lab wrap-up

  1. Update architecture paragraph: “public surface is gRPC on :50051” or “REST deepened with ETag/batch/idempotency.”
  2. Note what Day 62 gate will require if you keep this elective.
  3. Do not half-merge the other track into the binary.

Hour-by-hour suggestion

Time Focus
0:00–0:20 Choose track; write decision record
0:20–1:40 Core implementation (proto+server or ETag+batch)
1:40–2:20 Error mapping + tests
2:20–2:45 Docs (generate policy / openapi)
2:45–3:00 Day 57 plan bullets

Common gotchas

Track Gotcha Fix
G Checking in generated code inconsistently Document generate or commit deliberately
G Using panic for not found status.Error
G Insecure dial in prod notes TLS Day 60 awareness; local-only disclaimer
G Forgetting Unimplemented*Server embed Embed for forward compat
R Unbounded batch Max N
R Weak ETag over mutable unprotected data Auth + ownership
R OpenAPI drift Only document implemented routes
R Idempotency store unbounded growth TTL + max entries in lab notes
Both New store instead of reusing Wire existing repo

Checkpoint

  • Track chosen and documented
  • End-to-end happy path works
  • Error mapping tested (at least one non-happy path)
  • Reuses existing persistence/auth where applicable
  • Plan written for Day 57 continuation
  • README run instructions cold-tested

Commit

git add .
git commit -m "day56: elective track A (grpc or rest-deep)"

Write three personal gotchas before continuing.


Tomorrow

Day 57 — continue branch B: gRPC interceptors/streaming lite or REST gateway polish (middleware, docs, client SDK snippet). Same track—do not switch.


Day 57 — gRPC or deepen REST (B)

Stage VI · ~3h · elective continuation
Goal: Continue the same track as Day 56—add production-shaped edges (interceptors/streaming lite or REST polish with docs, hardening, and a tiny client).

Why this day exists

Day 56 proved the stack boots. Day 57 adds the pieces that separate demos from services:

  • Cross-cutting concerns (auth, logging, deadlines)
  • Streaming or polish that matches your track
  • Client experience (grpcurl/Go client or OpenAPI-driven curl SDK)

Track G — interceptors & streaming lite

Theory G1 — Unary interceptor

func UnaryServerLogging(log *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        start := time.Now()
        resp, err := handler(ctx, req)
        log.Info("rpc",
            "method", info.FullMethod,
            "dur_ms", time.Since(start).Milliseconds(),
            "err", err,
        )
        return resp, err
    }
}

gs := grpc.NewServer(grpc.UnaryInterceptor(UnaryServerLogging(log)))

Chain with grpc.ChainUnaryInterceptor(auth, logging, recover).

Theory G2 — Auth interceptor

Read metadata for authorization bearer token; validate; put user in context. Reject with codes.Unauthenticated.

md, ok := metadata.FromIncomingContext(ctx)
// md.Get("authorization")

Theory G3 — Server streaming (lite)

rpc ListItems(ListItemsRequest) returns (stream Item);
func (s *Server) ListItems(req *itemsv1.ListItemsRequest, stream itemsv1.ItemService_ListItemsServer) error {
    items, err := s.repo.List(stream.Context())
    if err != nil {
        return status.Error(codes.Internal, "internal")
    }
    for _, it := range items {
        if err := stream.Send(&itemsv1.Item{Id: it.ID, Name: it.Name}); err != nil {
            return err
        }
    }
    return nil
}

Client-streaming / bidi: awareness only unless time allows.

Theory G4 — Deadlines

Clients should set:

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

Servers honor ctx.Done() in handlers and DB calls.

Worked example G — recover interceptor

func UnaryRecover(log *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Error("panic", "method", info.FullMethod, "recover", rec)
                err = status.Error(codes.Internal, "internal")
            }
        }()
        return handler(ctx, req)
    }
}

Wire order (outermost first in chain helpers—check docs for ChainUnaryInterceptor application order):

recover → logging → auth → handler

Lab G

mkdir -p ~/lab/90daysofx/01-go/day57
# continue day56 module; same go.mod
  1. Logging + auth + recover unary interceptors
  2. One streaming RPC or richer status.WithDetails validation errors
  3. Client test with timeout asserting cancellation
  4. README: interceptors order diagram (ASCII)
  5. grpcurl -H 'authorization: Bearer …' list/call examples
  6. Prove unauthenticated call returns Unauthenticated
# illustrative
grpcurl -plaintext -d '{"id":"1"}' 127.0.0.1:50051 items.v1.ItemService/GetItem
# expect auth error after interceptor lands

Track R — REST gateway polish

Theory R1 — Middleware completeness

Ensure chain: Recover → RequestID → AccessLog → Auth (where needed) → handlers.
Add maxBytes middleware:

func MaxBytes(n int64) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Body != nil {
                r.Body = http.MaxBytesReader(w, r.Body, n)
            }
            next.ServeHTTP(w, r)
        })
    }
}

Also reject non-JSON Content-Type on write methods:

func RequireJSON(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case http.MethodPost, http.MethodPut, http.MethodPatch:
            ct := r.Header.Get("Content-Type")
            if !strings.HasPrefix(ct, "application/json") {
                writeErr(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json")
                return
            }
        }
        next.ServeHTTP(w, r)
    })
}

Theory R2 — Consistent CORS (if browser)

Only if you have a browser client—default deny. For lab APIs, document “no CORS” or tight allowlist. Never Access-Control-Allow-Origin: * with credentialed cookies.

Theory R3 — Client package

Ship package apiclient wrapping Day 46 patterns:

type Client struct {
    base  string
    http  *http.Client
    token string
}

func New(base string, opts ...Option) *Client { /* timeouts from Day 46 */ }

func (c *Client) WithToken(tok string) *Client {
    cp := *c
    cp.token = tok
    return &cp
}

func (c *Client) CreateItem(ctx context.Context, name string) (Item, error) {
    // POST /v1/items with Authorization if token set
}

Tests against httptest.NewServer using your real api.Handler().

Theory R4 — Docs polish

  • Expand OpenAPI with securitySchemes (http bearer or cookie)
  • scripts/smoke.sh with auth login → create → get
  • Example error payloads from Day 55 copied into docs

Theory R5 — Observability hooks (light)

Log request_id on every error path; ensure 5xx always slog.Error with err. Add a /v1/version endpoint returning git SHA or build time injected via -ldflags (optional stretch).

Worked example R — smoke script

#!/usr/bin/env bash
set -euo pipefail
base="${BASE_URL:-http://127.0.0.1:8080}"
tok=$(curl -sf -X POST "$base/v1/login" \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"secret"}' | jq -r .access_token)
curl -sf -X POST "$base/v1/items" \
  -H "Authorization: Bearer $tok" \
  -H 'Content-Type: application/json' \
  -d '{"name":"widget"}' | jq .

Lab R

  1. Hardening middleware (max bytes, strict Content-Type)
  2. Typed Go API client package + tests
  3. OpenAPI security + examples updated
  4. Smoke script with JWT/session
  5. Optional: ETag on list if not done Day 56
  6. go test ./... -race on client + api packages

Shared hardening (both tracks)

Concern gRPC REST
Auth on mutations interceptor metadata middleware
Timeouts client ctx + server keepalive settings http.Server timeouts + client Timeout
Validation InvalidArgument 400 envelope
Logging unary interceptor access log middleware
Shutdown gs.GracefulStop srv.Shutdown

gRPC graceful stop sketch:

go func() { _ = gs.Serve(lis) }()
<-ctx.Done()
stopped := make(chan struct{})
go func() { gs.GracefulStop(); close(stopped) }()
select {
case <-stopped:
case <-time.After(10 * time.Second):
    gs.Stop()
}

Decision checkpoint (both)

Write 10–15 lines in README:

  1. Would you keep this track for the capstone? Why?
  2. What would you drop if time-constrained?
  3. What dependency (protoc plugins / OpenAPI tools) hurt the most?

Common gotchas

Gotcha Fix
Interceptor order wrong Auth before handler; recover outermost
Stream without ctx checks Select on ctx.Done for long lists
Client without timeout Always
OpenAPI not matching routes Diff manually once
CORS * + credentials Never
Leaving Day 56 half-broken after “polish” Keep green tests before new features
Committing huge generated trees without docs Document make generate

Checkpoint

  • Same track as Day 56 continued
  • Cross-cutting auth/logging improved
  • Stream or client package shipped
  • Hardening (timeouts / max bytes / recover) present
  • Tests green including at least one auth failure case
  • Capstone lean note written

Commit

git add .
git commit -m "day57: elective track B continuation"

Write three personal gotchas before continuing.


Tomorrow

Day 58 — caching: cache-aside pattern, in-memory (and optional Redis) behind an interface, invalidation discipline.