Skip to content

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.

proto/user/v1/login.proto
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;
}
  1. Write the method in proto/<service>/v1/. Comment the service and every method: say who may call it and what each failure means.

  2. 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: v2
    modules:
    - path: proto
    lint:
    use:
    - STANDARD
    breaking:
    use:
    - FILE

    buf runs the generators pinned in the tool block of go.mod, so everyone generates with the same versions:

    buf.gen.yaml
    version: v2
    managed:
    enabled: true
    override:
    - file_option: go_package_prefix
    value: github.com/vardius/go-api-boilerplate/gen
    plugins:
    - local: [go, tool, protoc-gen-go]
    out: gen
    opt: paths=source_relative
    - local: [go, tool, protoc-gen-connect-go]
    out: gen
    opt: paths=source_relative
    clean: true
    Terminal window
    make check-contracts

    That runs make gen, which regenerates gen/ 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.

  3. Implement the server in the called service, under internal/<service>/rpc, and mount it on its RPC listener (RPC_ADDR), not its public one. The gateway forwards only to the public listeners.

  4. Require a scope, and the caller. Wrap the handler in connectx.Errors(log) and connectx.Authenticate(verifier, scope) from internal/platform/connectx, as internal/user/rpc does with login-codes:redeem. It also checks the token’s client_id, so a token issued to anyone but auth is refused with permission_denied even 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 is keys.ServiceScope, which app.GrantableScopes never offers to an OAuth client and Signer.Sign refuses, so only auth’s own keys.ServiceToken carries it.

  5. Call it from the other service with the generated client and the connectx.Bearer client interceptor, which attaches the token. connectx.FromConnect maps the Connect error codes back to apperr kinds, through the same table connectx.Errors uses on the server.

  6. Test both sides: the server with tokens from authntest, including one without the scope, as internal/user/rpc/rpc_test.go does, and the client against a fake server, as internal/auth/app/apptest fakes the user service for auth’s tests.