Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions digrpc/digrpc.go
Original file line number Diff line number Diff line change
@@ -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 }
93 changes: 93 additions & 0 deletions digrpc/digrpc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
77 changes: 77 additions & 0 deletions digrpc/example_test.go
Original file line number Diff line number Diff line change
@@ -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:
// <nil>
// check from ada
// SERVING <nil>
}
20 changes: 20 additions & 0 deletions digrpc/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading