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/httprouting (method +{wildcard}patterns) and chi middleware - Protobuf service definition · generated gRPC server ·
contextas first param
📖 Reading / Sources¶
- Go 1.22 release notes — enhanced ServeMux
- gRPC-Go — Basics tutorial
- Protocol Buffers — proto3 language guide
- chi router
📝 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, generatedpb.Link) are separate fromlink.Link; transports map between them. This lets the API and the schema evolve independently. - Stdlib routing (Go 1.22) already does
"POST /links"andGET /links/{code}withr.PathValue("code"). I use chi mainly for composable middleware (request ID, recoverer, logger) and route groups → [[http-middleware]]. - gRPC: define
LinkServicein a.proto, runprotoc(viabuf) to generate the server interface, then implement it. Each RPC takescontext.Contextfirst and a request message → [[context]]. - Errors map to status. Service sentinels translate per transport: REST
ErrNotFound→404, validation→422; gRPCErrNotFound→codes.NotFound, validation→codes.InvalidArgumentviastatus.Error. json.Decoder.DisallowUnknownFields()turns a client typo into a400instead of a silently-ignored field → fail loud at the boundary.- gRPC and REST share the same
Serviceinstance, 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.Errorffrom a gRPC method → the client sawcodes.Unknown. Always wrap instatus.Error/status.Errorfso 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)¶
- Q: Why embed
UnimplementedXxxServerin 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)