Day 21 — Auth, Validation & gRPC
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.
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:
- Signature with secret/public key
expnot in the past
iss/audif 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 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/day54Write 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/mereturns current user public profile (no hash!)
POST /v1/itemsrequires auth; setowner_idfrom context
GET /v1/itemslists only own items or all public—document policy
Lab 4 — Tests
- No token → 401 on protected route
- Login → token/cookie → 200 on
/v1/me
- Wrong password → 401
- Tampered JWT → 401 (JWT track)
- Access another user’s item → 403 (if ownership enforced)
go test ./... -race -count=1Lab 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
- Transport: JSON syntax, Content-Type, body size
- Schema: required fields, types, ranges, formats
- 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/jsonon bodies that need JSON
- Respond
415if wrong type
- Always set response
Content-Typeto 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/day55Upgrade 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.
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-grpcKeep 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.protoDocument 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.NotFoundManual: 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
- Install
protoc+ plugins; record versions in README.
- Author proto; generate stubs; document commit-vs-CI policy.
- Implement Get + Create unary against your existing repo/store.
- Map validation →
InvalidArgument, missing →NotFound.
- Smoke with
grpcurlor Go client test (go test ./... -count=1).
- Write run instructions (port, generate command, insecure local-only note).
- Plan Day 57: interceptors (logging/auth) and/or server streaming lite.
Track R — deepen REST
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 noteLab R — multi-step
- Add ETag +
If-None-MatchonGET /v1/items/{id}; httptest 304.
- Add batch get with hard limit; test over-limit → 400.
- Draft
openapi.yamlfor core routes (auth scheme included).
- Idempotency-Key on create (in-memory map OK); test replay returns same body/id.
- Plan Day 57: middleware polish, docs publish, tiny client SDK snippet.
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- Logging + auth + recover unary interceptors
- One streaming RPC or richer
status.WithDetailsvalidation errors
- Client test with timeout asserting cancellation
- README: interceptors order diagram (ASCII)
grpcurl -H 'authorization: Bearer …' list/callexamples
- 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 landsTrack 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.shwith 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
- Hardening middleware (max bytes, strict Content-Type)
- Typed Go API client package + tests
- OpenAPI security + examples updated
- Smoke script with JWT/session
- Optional: ETag on list if not done Day 56
go test ./... -raceon client + api packages
Decision checkpoint (both)
Write 10–15 lines in README:
- Would you keep this track for the capstone? Why?
- What would you drop if time-constrained?
- 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.