From 8077f892b123eecde714c7cf0c004c241a801bab Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 19 Sep 2026 20:02:51 +0500 Subject: [PATCH] digrpc: a gRPC adapter as its own module The counterpart of dihttp for grpc: an Interceptor opens a child scope per call holding a *Call (method and incoming context), Module provides it, and Options gives the two interceptors as chained server options. It is a separate module requiring a released di, so the library keeps no dependency; it is tagged as digrpc/vX.Y.Z outside release.yml. Tests use grpc's own health service over bufconn, so nothing is generated. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 11 +++++ CHANGELOG.md | 8 ++++ CLAUDE.md | 12 ++++- README.md | 19 ++++++++ digrpc/digrpc.go | 94 ++++++++++++++++++++++++++++++++++++++++ digrpc/digrpc_test.go | 93 +++++++++++++++++++++++++++++++++++++++ digrpc/example_test.go | 77 ++++++++++++++++++++++++++++++++ digrpc/go.mod | 20 +++++++++ digrpc/go.sum | 20 +++++++++ 9 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 digrpc/digrpc.go create mode 100644 digrpc/digrpc_test.go create mode 100644 digrpc/example_test.go create mode 100644 digrpc/go.mod create mode 100644 digrpc/go.sum diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db85e09..5deb292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: check-latest: true cache-dependency-path: | benchmarks/go.sum + digrpc/go.sum examples/go.sum - name: gofmt run: test -z "$(gofmt -l .)" || (gofmt -d . && exit 1) @@ -28,6 +29,16 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: v2.13.1 + - name: lint digrpc + uses: golangci/golangci-lint-action@v9 + with: + version: v2.13.1 + working-directory: digrpc + - name: digrpc vet and test + working-directory: digrpc + run: | + go vet ./... + go test -race -count=1 ./... - name: README examples are in sync run: go run github.com/campoy/embedmd@v1.0.0 -d README.md - name: examples vet, test and run diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a52e7..fb1228f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ below says plainly whether an upgrade can break a caller. ## [Unreleased] +### Added + +- `digrpc`, the gRPC counterpart of `dihttp`, as a separate module so the + library keeps no dependency: `digrpc.Interceptor` opens a child scope per + call holding a `*digrpc.Call`, `digrpc.Module` provides it, and + `Interceptor.Options()` gives the server options. It is versioned on its + own, as `digrpc/vX.Y.Z`. + ## [0.17.1] - 2026-09-19 `go doc -all` against 0.17.0 adds `dihttp.HandleFunc` and changes nothing diff --git a/CLAUDE.md b/CLAUDE.md index 726948b..86fb58e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,8 @@ registry, `freeze`, parent-chain readers), `resolve.go` (resolution path, both cycle detectors, build step, `Get`), `lifecycle.go` (instance phase machine, hooks, `Start`, `Stop`), `run.go` (`Run`, `Shutdown`), `explain.go` (renders the recorded graph), `validate.go` (checks the declared graph). Plus the net/http -adapter `dihttp/`, the slog bridge `dislog/`, tests, and two separate modules, -`examples/` and `benchmarks/`. +adapter `dihttp/`, the slog bridge `dislog/`, tests, and three separate modules: +the gRPC adapter `digrpc/`, `examples/` and `benchmarks/`. ## Working rules @@ -49,6 +49,7 @@ go run github.com/campoy/embedmd@v1.0.0 -w README.md # re-embed after editing go run scripts/og.go # redraw the social card after a logo change cd benchmarks && go test -bench . -benchmem # separate module cd examples && go test ./... # separate module +cd digrpc && go test -race ./... && golangci-lint run ./... # separate module cd examples && go test ./guide -update # rewrite testdata/ after rewiring the guide app cd site && npm ci && npm run check && npm run build # guide site; BASE_PATH=/di for Pages @@ -557,6 +558,13 @@ reverse: caught by the fuzzer in 0.06s, missed by 400 seeded sequences). the root module keeps zero requires. A root `go test ./...` does not cover the examples and `golangci-lint run ./...` does not lint them; CI runs them in their own step. `gofmt -l .` still walks both. +- **`digrpc/` is a separate module too, but one that is imported**, so it has + no `replace`: it requires a released `di`, and bumping that requirement is + how it picks up a library change. Tag it as a nested module, + `digrpc/vX.Y.Z`; `release.yml` matches `v*` only, so those tags publish no + GitHub release and need no CHANGELOG section. Its tests use grpc's own + health service over `bufconn`, so nothing is generated from protobuf. The + coverage badge does not include it. - **`dislog/` is the slog bridge for `Observe`** and imports nothing but `log/slog` and the library. `dislog.New` returns the `func(di.Event)` that `Observe` takes, not a `slog.Handler`. Failed steps log at Error with the site diff --git a/README.md b/README.md index 9ecc4c5..3c3ee38 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,25 @@ mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { srv := &http.Server{Handler: dihttp.NewMiddleware(app)(mux)} ``` +For gRPC, [`digrpc`](digrpc) is the same adapter as a separate module, so +the library itself does not depend on grpc: `go get github.com/floatdrop/di/digrpc`. +Its `Interceptor` opens a scope per call holding a `*digrpc.Call`, the method +and the incoming context; `Module` provides it; a service implementation +reaches the scope with `di.FromContext`: + +```go +app.Use(digrpc.Module) +app.Wire[*Caller](func(c *digrpc.Call) *Caller { + md, _ := metadata.FromIncomingContext(c.Context) + return &Caller{Name: strings.Join(md.Get("x-caller"), ",")} +}).Scoped() +app.Wire[*grpc.Server](NewServer) + +func NewServer(ic digrpc.Interceptor) *grpc.Server { + return grpc.NewServer(ic.Options()...) +} +``` + `di.WithScope` and `di.FromContext` are the primitives without `net/http`. [`examples/guide`](examples/guide) is a complete application, module by module, and [the guide](https://floatdrop.github.io/di/) walks through it. diff --git a/digrpc/digrpc.go b/digrpc/digrpc.go new file mode 100644 index 0000000..7535bb8 --- /dev/null +++ b/digrpc/digrpc.go @@ -0,0 +1,94 @@ +// Package digrpc connects a di.Scope to google.golang.org/grpc. +// +// An [Interceptor] gives every call its own child scope holding a [*Call], +// so services that depend on the call are declared once in the application +// scope as Scoped and built per call. A handler reaches the scope through +// [di.FromContext] on the context it is given. [Module] registers the +// interceptor as a service; [New] makes one directly. +// +// It is a separate module, so the library itself does not depend on grpc: +// +// go get github.com/floatdrop/di/digrpc +package digrpc + +import ( + "context" + + "github.com/floatdrop/di" + "google.golang.org/grpc" +) + +// Call is what a call's scope holds: the method being served and the +// context the call arrived with, which carries its metadata, peer and +// deadline. It is registered as *Call, so Validate takes +// di.Provided[*Call](). +type Call struct { + // Method is the full method name, "/package.Service/Method". + Method string + // Context is the handler's context, with the call's scope attached. + Context context.Context +} + +// Interceptor gives every call its own child scope of the application scope: +// a *Call is registered in it, the scope is attached to the handler's +// context, and it is stopped and detached when the handler returns. Stop +// failures reach the application scope's observers as EventStop with Err +// set. Unary and Stream are the two grpc interceptor shapes; Options wraps +// both as server options. Make one with New. +type Interceptor struct{ scope *di.Scope } + +// New makes an Interceptor whose call scopes are children of s. +func New(s *di.Scope) Interceptor { return Interceptor{scope: s} } + +// Module registers an Interceptor over the scope it is applied to, so that a +// constructor wired into that scope can take one as a parameter: +// +// app.Use(digrpc.Module, api.Module) +// +// func NewServer(ic digrpc.Interceptor) *grpc.Server { +// return grpc.NewServer(ic.Options()...) +// } +// +// This is a Provide closure rather than a wired constructor because the +// interceptor needs the scope itself, to open a child per call. +func Module(s *di.Scope) { + s.Provide(func(s *di.Scope) Interceptor { return New(s) }) +} + +// Unary is a grpc.UnaryServerInterceptor. +func (i Interceptor) Unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + ctx, stop := i.open(ctx, info.FullMethod) + defer stop() + return handler(ctx, req) +} + +// Stream is a grpc.StreamServerInterceptor. The handler's stream returns the +// call's context from Context. +func (i Interceptor) Stream(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + ctx, stop := i.open(ss.Context(), info.FullMethod) + defer stop() + return handler(srv, &stream{ServerStream: ss, ctx: ctx}) +} + +// Options returns Unary and Stream as server options. They chain, so other +// interceptors can be given to the same server before or after them. +func (i Interceptor) Options() []grpc.ServerOption { + return []grpc.ServerOption{grpc.ChainUnaryInterceptor(i.Unary), grpc.ChainStreamInterceptor(i.Stream)} +} + +// open makes the call's scope and returns the context to hand the handler +// and the function that stops the scope once it returns. +func (i Interceptor) open(ctx context.Context, method string) (context.Context, func()) { + call := i.scope.Child("call") + ctx = di.WithScope(ctx, call) + call.Value(&Call{Method: method, Context: ctx}) + return ctx, func() { _ = call.Stop(context.WithoutCancel(ctx)) } +} + +// stream is ss with the call's context. +type stream struct { + grpc.ServerStream + ctx context.Context +} + +func (s *stream) Context() context.Context { return s.ctx } diff --git a/digrpc/digrpc_test.go b/digrpc/digrpc_test.go new file mode 100644 index 0000000..41dd2b1 --- /dev/null +++ b/digrpc/digrpc_test.go @@ -0,0 +1,93 @@ +package digrpc_test + +import ( + "context" + "testing" + + "github.com/floatdrop/di" + "github.com/floatdrop/di/digrpc" + "google.golang.org/grpc" +) + +// perCall is built once per call scope; register counts its builds and stops. +type perCall struct{ method string } + +type counts struct{ built, stopped int } + +func register(t *testing.T) (*di.Scope, *counts) { + t.Helper() + app := di.Test(t) + n := &counts{} + app.Wire[*perCall](func(c *digrpc.Call) *perCall { n.built++; return &perCall{method: c.Method} }).Scoped(). + OnStop(func(context.Context, *perCall) error { n.stopped++; return nil }) + if err := app.Validate(di.Provided[*digrpc.Call]()).Err(); err != nil { + t.Fatal(err) + } + return app, n +} + +func TestUnaryOpensAScopePerCall(t *testing.T) { + app, n := register(t) + ic := digrpc.New(app) + info := &grpc.UnaryServerInfo{FullMethod: "/pkg.Svc/Method"} + handler := func(ctx context.Context, req any) (any, error) { + s, ok := di.FromContext(ctx) + if !ok { + t.Fatal("no scope on the handler's context") + } + if got := s.Get[*digrpc.Call]().Context; got != ctx { + t.Error("Call.Context is not the handler's context") + } + if got := s.Get[*perCall]().method; got != info.FullMethod { + t.Errorf("method %q, want %q", got, info.FullMethod) + } + return req, nil + } + for i := range 2 { + res, err := ic.Unary(t.Context(), i, info, handler) + if err != nil || res != i { + t.Fatalf("call %d: %v, %v", i, res, err) + } + } + if *n != (counts{built: 2, stopped: 2}) { + t.Errorf("two calls built and stopped %+v, want 2 and 2", *n) + } +} + +// fakeStream is a ServerStream with a context and nothing else. +type fakeStream struct { + grpc.ServerStream + ctx context.Context +} + +func (f fakeStream) Context() context.Context { return f.ctx } + +func TestStreamOpensAScopePerCall(t *testing.T) { + app, n := register(t) + ic := digrpc.New(app) + info := &grpc.StreamServerInfo{FullMethod: "/pkg.Svc/Stream"} + var outer context.Context + handler := func(_ any, ss grpc.ServerStream) error { + s, ok := di.FromContext(ss.Context()) + if !ok { + t.Fatal("no scope on the stream's context") + } + if s.Get[*digrpc.Call]().Context != ss.Context() { + t.Error("Call.Context is not the stream's context") + } + if got := s.Get[*perCall]().method; got != info.FullMethod { + t.Errorf("method %q, want %q", got, info.FullMethod) + } + if _, ok := di.FromContext(outer); ok { + t.Error("the scope leaked into the incoming context") + } + return nil + } + outer = t.Context() + if err := ic.Stream(nil, fakeStream{ctx: outer}, info, handler); err != nil { + t.Fatal(err) + } + if *n != (counts{built: 1, stopped: 1}) { + t.Errorf("one call built and stopped %+v, want 1 and 1", *n) + } +} diff --git a/digrpc/example_test.go b/digrpc/example_test.go new file mode 100644 index 0000000..47664a7 --- /dev/null +++ b/digrpc/example_test.go @@ -0,0 +1,77 @@ +package digrpc_test + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/floatdrop/di" + "github.com/floatdrop/di/digrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/test/bufconn" +) + +// Caller is who is making the call. It depends on the *digrpc.Call, which +// only a call scope provides, so it is Scoped and built per call. +type Caller struct{ Name string } + +func NewCaller(c *digrpc.Call) *Caller { + md, _ := metadata.FromIncomingContext(c.Context) + return &Caller{Name: strings.Join(md.Get("x-caller"), ",")} +} + +// Health is a service implementation: one value for the whole server, as +// grpc registers it, reaching per-call services through the scope on the +// context. +type Health struct { + grpc_health_v1.UnimplementedHealthServer +} + +func (Health) Check(ctx context.Context, _ *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { + scope, _ := di.FromContext(ctx) + fmt.Println("check from", scope.Get[*Caller]().Name) + return &grpc_health_v1.HealthCheckResponse{Status: grpc_health_v1.HealthCheckResponse_SERVING}, nil +} + +// NewServer is a plain constructor: the interceptor arrives as a dependency. +func NewServer(ic digrpc.Interceptor) *grpc.Server { + srv := grpc.NewServer(ic.Options()...) + grpc_health_v1.RegisterHealthServer(srv, Health{}) + return srv +} + +func ExampleModule() { + app := di.New() + app.Use(digrpc.Module) + app.Wire[*Caller](NewCaller).Scoped() + app.Wire[*grpc.Server](NewServer) + + // The graph is checked as a call scope would resolve it: the interceptor + // is provided by the module, the call by each call. + fmt.Println(app.Validate(di.Provided[*digrpc.Call]()).Err()) + + lis := bufconn.Listen(1 << 20) + srv := app.Get[*grpc.Server]() + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + panic(err) + } + defer func() { _ = conn.Close() }() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "x-caller", "ada") + res, err := grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{}) + fmt.Println(res.GetStatus(), err) + // Output: + // + // check from ada + // SERVING +} diff --git a/digrpc/go.mod b/digrpc/go.mod new file mode 100644 index 0000000..bc76123 --- /dev/null +++ b/digrpc/go.mod @@ -0,0 +1,20 @@ +// Separate module, like examples/ and benchmarks/, so the library itself +// stays dependency-free: google.golang.org/grpc is a dependency of this +// adapter only. It requires a released di rather than a replace, because a +// replace is ignored by whoever imports this module. +module github.com/floatdrop/di/digrpc + +go 1.27 + +require ( + github.com/floatdrop/di v0.17.1 + google.golang.org/grpc v1.84.0 +) + +require ( + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/digrpc/go.sum b/digrpc/go.sum new file mode 100644 index 0000000..06da05e --- /dev/null +++ b/digrpc/go.sum @@ -0,0 +1,20 @@ +github.com/floatdrop/di v0.17.1 h1:gMx+4HZtpUTJIh1ukj5QzAuWNKBoSpNcIv3BEOB3cjs= +github.com/floatdrop/di v0.17.1/go.mod h1:ym4EvAoAfWiUz6oYDM6hQdfz0wfT4H8K4N7n7518xEQ= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=