Day 57 — gRPC or deepen REST (B)

Updated

July 30, 2026

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.