Skip to content

APIs

gRPC

Serve gRPC, gRPC-Web and Connect from the app router with connect-go, with auth, validation, error mapping, health checks, reflection and code generation.

Last updated:

Terminal
go get github.com/AlexGalhardo/vukem/contrib/grpc

contrib/grpc serves connect-go services from the app router. connect-go handlers are plain net/http, so gRPC, gRPC-Web and Connect share one port, the router's middleware (request id, logging, rate limiting, auth guards) and the app; there is no separate gRPC server.

Go
func routes(r *router.Router, app *vukem.App) error {
	opts := grpc.HandlerOptions(grpc.Options{
		Rules: []grpc.Rule{grpc.Validate(z.Struct[greetv1.GreetRequest](z.Shape{"name": z.String().Min(1)}))},
	})

	path, h := greetv1connect.NewGreetServiceHandler(&greeter{}, opts...)
	grpc.Mount(r, path, h).Use(grpc.Required(apiGuard), ratelimit.Middleware(limiter, ratelimit.ByToken))

	grpc.Health(r, grpchealth.NewStaticChecker(greetv1connect.GreetServiceName))
	grpc.Reflection(app, r, greetv1connect.GreetServiceName) // not in production

	return nil
}

func (g *greeter) Greet(ctx context.Context, req *connect.Request[greetv1.GreetRequest]) (*connect.Response[greetv1.GreetResponse], error) {
	user, err := auth.UserFrom[*models.User](ctx, "api")
	if err != nil {
		return nil, err
	}
	return connect.NewResponse(&greetv1.GreetResponse{Greeting: "Hello, " + user.Name}), nil
}

Mounting and middleware#

  • grpc.Mount(r, path, h) registers the generated handler on a router or group and returns the route. Middleware added with .Use runs inside the error bridge: rejections (401, 403, 429 with Retry-After, 413, 503) reach the client as RPC errors with the matching code. Add grpc.Errors() to a group to get the same mapping for group middleware.
  • grpc.Required(guards...) is auth.Required for RPCs: Unauthenticated instead of a login redirect. Methods read the user with auth.UserFrom(ctx, guard).
  • grpc.HandlerOptions(grpc.Options{...}) bundles request validation (Rules, plus messages with a Validate() error method), error mapping, one rpc log line per call, panic recovery and a 4 MiB message limit (MaxMessageBytes). Your Interceptors run inside these.
  • Avoid middleware.Timeout and Gzip on RPC routes; use deadlines and connect-go compression.

Errors#

grpc.Error(err) maps errors to codes: context.Canceled/DeadlineExceeded as is; z.Issues and *errs.ValidationError → InvalidArgument with a google.rpc.BadRequest detail; *errs.HTTPError by status (400/422 InvalidArgument, 401 Unauthenticated, 403 PermissionDenied, 404 NotFound, 409 AlreadyExists, 429 ResourceExhausted, 503 Unavailable, …), with "internal error" as the message for any 5xx (the cause is logged, and only Retry-After becomes metadata); anything else → Internal "internal error", with the cause only in the log. A *connect.Error you return with a non-server-fault code keeps its text, so keep internals out of it. Validate() error gives InvalidArgument only for z.Issues, *errs.ValidationError and Field()/Reason() errors (protoc-gen-validate); other errors are masked and logged like handler errors. grpc.ErrorContext(ctx, err) also translates keyed errors (errs.NewKey) into the request locale; Mount and HandlerOptions use it.

Health, reflection and HTTP/2#

  • grpc.Health(r, checker) mounts grpc.health.v1.Health for load balancers and probes.
  • grpc.Reflection(app, r, services...) mounts server reflection for grpcurl and buf curl. It publishes the whole API, so in production it registers nothing unless GRPC_INSECURE_REFLECTION_IN_PRODUCTION=true.
  • Native gRPC needs HTTP/2. Outside production the app server also speaks h2c, so grpcurl -plaintext localhost:3000 list works with vukem serve. In production terminate TLS at a proxy that speaks HTTP/2 to the app (HTTP_H2C=true), or serve TLS yourself. gRPC-Web and Connect also work over HTTP/1.1, from browsers too.

Own port#

grpc.Serve(app, addr, h, grpc.ServerOptions{TLS: cfg}) adds a grpc runner serving h on its own address (for example GRPC_PORT); it starts and stops with the app and drains in-flight RPCs within SHUTDOWN_TIMEOUT. Build h as a router holding only the RPC services, so RPC paths stay off the HTTP port:

Go
rpc := router.New(app.Router.Services())
if err := vukem.APIMiddleware(app, rpc); err != nil {
	return err
}

// MaxMessageBytes sets connect.WithReadMaxBytes (4 MiB by default).
opts := grpc.HandlerOptions(grpc.Options{Log: app.Log, MaxMessageBytes: 1 << 20})
path, h := greetv1connect.NewGreetServiceHandler(svc, opts...)
grpc.Mount(rpc, path, h).Use(grpc.Required(apiGuard), ratelimit.Middleware(limiter, ratelimit.ByToken))

return grpc.Serve(app, ":"+app.Source.Get("GRPC_PORT"), rpc, grpc.ServerOptions{})
  • With TLS set the port speaks HTTP/2 and HTTP/1.1 over TLS; without it, h2c and HTTP/1.1. In production a cleartext port is refused (grpc.ErrCleartextInProduction) unless HTTP_H2C=true.
  • The port does not inherit the HTTP router's middleware (rate limiting, headers, body limits, guards): install them on h as above. vukem.APIMiddleware adds recover, request id, logging, headers and a body limit; HandlerOptions caps messages with connect.WithReadMaxBytes (4 MiB unless MaxMessageBytes says otherwise). The server bounds headers and timeouts, not bodies.
  • grpc.Server(&app.Config, addr, h, opts) is the same server as a plain vukem.Runner, with the same production rule (a nil config counts as production). Both use the secure timeouts of vukem.NewHTTPServer (60s write timeout), so long-lived streams need a server of their own.

Code generation#

The framework never needs protoc itself; your app pins the generators as Go tools:

Terminal
vukem make:proto Invoice   # proto/invoice/v1/invoice.proto, buf.yaml, buf.gen.yaml
go get -tool google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12
go get -tool connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.21.1
vukem codegen:rpc          # gen/invoice/v1/*.pb.go and invoicev1connect/*.connect.go
  • With buf (recommended; go get -tool github.com/bufbuild/buf/cmd/buf@v1.74.0 or buf on PATH), codegen:rpc runs buf generate with the generated buf.gen.yaml. buf lint and buf breaking use buf.yaml.
  • Without buf (or with --protoc), it calls protoc -I proto with the two plugins built by go tool.
  • Commit the generated code; regenerate after editing a .proto.

Testing#

Go
h, _ := app.Handler()
srv := grpctest.NewServer(t, h) // TLS + HTTP/2; NewH2CServer for cleartext

for _, p := range grpctest.Protocols() { // grpc, grpc-web, connect, connect+json
	client := greetv1connect.NewGreetServiceClient(srv.Client, srv.URL, p.Options...)
	res, err := client.Greet(t.Context(), connect.NewRequest(&greetv1.GreetRequest{Name: "Ada"}))
	// ...
}