Add an RPC
Services call each other over Connect, which speaks the gRPC protocol and plain HTTP with the same generated code. Add an RPC only when a request can’t be served without the other service. Reading another service’s data per request couples them; a token or a projection usually does the job.
Today there is exactly one: auth redeems login codes with user.
syntax = "proto3";
package user.v1;
// LoginService turns one-time login codes into the users they sign in. Only// the auth service calls it, with a token carrying the login-codes:redeem// scope; it isn't reachable through the gateway.service LoginService { // RedeemLoginCode consumes a login code and returns the user it signs in, // registering them first when the code was sent to a new email. A code // works once: an unknown, used or expired code fails with UNAUTHENTICATED. rpc RedeemLoginCode(RedeemLoginCodeRequest) returns (RedeemLoginCodeResponse);}
message RedeemLoginCodeRequest { // The code from the sign-in link, as the browser sent it. string code = 1;}
message RedeemLoginCodeResponse { string user_id = 1; string email = 2;}-
Write the method in
proto/. Comment the service and every method: say who may call it and what each failure means.<service>/ v1/ -
Generate the code and lint it. buf enforces the standard lint rules and, on a pull request, rejects breaking changes against the base branch:
buf.yaml version: v2modules:- path: protolint:use:- STANDARDbreaking:use:- FILEbuf runs the generators pinned in the
toolblock ofgo.mod, so everyone generates with the same versions:buf.gen.yaml version: v2managed:enabled: trueoverride:- file_option: go_package_prefixvalue: github.com/vardius/go-api-boilerplate/genplugins:- local: [go, tool, protoc-gen-go]out: genopt: paths=source_relative- local: [go, tool, protoc-gen-connect-go]out: genopt: paths=source_relativeclean: trueTerminal window make check-contractsThat runs
make gen, which regeneratesgen/and the environment-variable reference, then lints the proto and fails if a generated file changed. To check compatibility before CI does:Terminal window go run github.com/bufbuild/buf/cmd/buf@v1.75.0 breaking --against '.git#branch=master'A field once released is never renumbered or reused. To change one, add a new field and deprecate the old. The output in
gen/is committed, so the build needs no generator, and CI fails if it is out of date. -
Implement the server in the called service, under
internal/, and mount it on its RPC listener (<service>/ rpc RPC_ADDR), not its public one. The gateway forwards only to the public listeners. -
Require a scope, and the caller. Wrap the handler in
connectx.Errors(log)andconnectx.Authenticate(verifier, scope)frominternal/, asplatform/ connectx internal/does withuser/ rpc login-codes:redeem. It also checks the token’sclient_id, so a token issued to anyone but auth is refused withpermission_deniedeven if the scope ever leaked to a client. The caller sends a short-lived bearer token carrying that scope, verified through JWKS like any other token. Today only auth calls an RPC: the scope iskeys.ServiceScope, whichapp.GrantableScopesnever offers to an OAuth client andSigner.Signrefuses, so only auth’s ownkeys.ServiceTokencarries it. -
Call it from the other service with the generated client and the
connectx.Bearerclient interceptor, which attaches the token.connectx.FromConnectmaps the Connect error codes back toapperrkinds, through the same tableconnectx.Errorsuses on the server. -
Test both sides: the server with tokens from
authntest, including one without the scope, asinternal/does, and the client against a fake server, asuser/ rpc/ rpc_test.go internal/fakes the user service for auth’s tests.auth/ app/ apptest