Skip to content

Day 163 — Capstone: REST + gRPC APIs

Month 6 · Week 4 · ⬅ Day 162 · Day 164 ➡ · Journal index

🎯 Learning Objective

Expose the same link.Service over two transports — a JSON REST API (chi) for browsers/tools and a gRPC API (protobuf) for service-to-service calls — without duplicating business logic.

📚 Topics

  • Go 1.22 net/http routing (method + {wildcard} patterns) and chi middleware
  • Protobuf service definition · generated gRPC server · context as first param

📖 Reading / Sources

📝 Notes

  • One service, two adapters. Both the REST handler and the gRPC server are thin transports that decode a request, call svc.Shorten/svc.Resolve, and encode the result. The domain has zero knowledge of HTTP or protobuf → [[ports-and-adapters]].
  • DTOs ≠ domain. Wire types (linkReq, generated pb.Link) are separate from link.Link; transports map between them. This lets the API and the schema evolve independently.
  • Stdlib routing (Go 1.22) already does "POST /links" and GET /links/{code} with r.PathValue("code"). I use chi mainly for composable middleware (request ID, recoverer, logger) and route groups → [[http-middleware]].
  • gRPC: define LinkService in a .proto, run protoc (via buf) to generate the server interface, then implement it. Each RPC takes context.Context first and a request message → [[context]].
  • Errors map to status. Service sentinels translate per transport: REST ErrNotFound404, validation→422; gRPC ErrNotFoundcodes.NotFound, validation→codes.InvalidArgument via status.Error.
  • json.Decoder.DisallowUnknownFields() turns a client typo into a 400 instead of a silently-ignored field → fail loud at the boundary.
  • gRPC and REST share the same Service instance, so behaviour (validation, hit counting) is identical across both.

💻 Code Examples

REST handler (stdlib routing — the runnable example mirrors this):

mux.HandleFunc("POST /links", func(w http.ResponseWriter, r *http.Request) {
    var req linkReq
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()           // reject typos at the boundary
    if err := dec.Decode(&req); err != nil {
        writeJSON(w, http.StatusBadRequest, errResp{err.Error()})
        return
    }
    if err := svc.Shorten(r.Context(), req.Code, req.URL); err != nil {
        writeJSON(w, http.StatusUnprocessableEntity, errResp{err.Error()})
        return
    }
    writeJSON(w, http.StatusCreated, linkResp(req))
})

Full runnable REST demo: examples/month-06/restapi · Run: go run ./examples/month-06/restapi

gRPC side (needs google.golang.org/grpc — snippet only, not a stdlib example):

// proto/link/v1/link.proto
syntax = "proto3";
package link.v1;
service LinkService {
  rpc Shorten(ShortenRequest) returns (ShortenResponse);
  rpc Resolve(ResolveRequest) returns (ResolveResponse);
}
message ShortenRequest { string code = 1; string url = 2; }
message ShortenResponse { string code = 1; }
message ResolveRequest { string code = 1; }
message ResolveResponse { string url = 1; }
// internal/transport/grpc/server.go — thin adapter over the same Service.
type Server struct {
    pb.UnimplementedLinkServiceServer // forward-compatible embedding
    svc *link.Service
}

func (s *Server) Resolve(ctx context.Context, r *pb.ResolveRequest) (*pb.ResolveResponse, error) {
    url, err := s.svc.Resolve(ctx, r.GetCode())
    if errors.Is(err, link.ErrNotFound) {
        return nil, status.Errorf(codes.NotFound, "no link for %q", r.GetCode())
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "resolve failed")
    }
    return &pb.ResolveResponse{Url: url}, nil
}

🏋️ Exercises / Practice

Exercise Status Link
shortcode — base62 used by Shorten to mint codes exercises/month-06/week-4/shortcode
restapi example — JSON in/out over Go 1.22 routing examples/month-06/restapi

🐛 Mistakes Made

  • Forgot to embed pb.UnimplementedLinkServiceServer → adding a new RPC later broke compilation. Embedding it keeps the server forward-compatible with regenerated stubs.
  • Returned a raw fmt.Errorf from a gRPC method → the client saw codes.Unknown. Always wrap in status.Error/status.Errorf so the code is meaningful.

❓ Open Questions

  • Worth adding grpc-gateway to auto-generate REST from the proto, or keep the hand-written chi handlers? (For a learning repo, hand-written is clearer.)

🧠 Active Recall (answer without looking)

  1. Q: Why embed UnimplementedXxxServer in a gRPC service implementation?
    A

It provides default "unimplemented" methods, so when the proto gains a new RPC and you regenerate, your existing type still satisfies the interface and keeps compiling. It's the forward-compatibility contract of gRPC-Go. 2. Q: How does the same business error become a 404 in REST but codes.NotFound in gRPC?

A

The service returns a transport-neutral sentinel (link.ErrNotFound). Each transport adapter inspects it with errors.Is and maps it to its own protocol: REST writes HTTP 404, gRPC returns status.Error(codes.NotFound, …). The domain never knows about either.

🪶 Feynman Reflection

A transport is a translator. REST speaks JSON-over-HTTP, gRPC speaks protobuf-over-HTTP/2, but both translate to and from the one language my service understands: plain Go method calls on the domain. Because the translation is the only thing that differs, adding a second API costs a thin adapter, not a second copy of the rules.

🕳️ Knowledge Gaps

  • gRPC interceptors for the cross-cutting concerns (logging, auth) — the gRPC analogue of HTTP middleware; revisit on the observability day.

✅ Summary

linkr now exposes Shorten/Resolve over both a chi REST API and a gRPC API, each a thin transport over the shared Service, with consistent, protocol-appropriate error mapping.

⏭️ Next Steps / Prep for Tomorrow

  • Day 164: back the repository with Postgres (pgx) and add a Redis cache-aside layer.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(linkr): REST (chi) and gRPC transports over shared service (day 163)