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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ below says plainly whether an upgrade can break a caller.
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`.
- `digrpc.Register[H](srv, desc)`, in `digrpc/v0.2.0`, serves a generated
service with an `H` resolved from each call's scope: the implementation is
`Scoped` when it takes the call, is built after the server's interceptors
have run, and a constructor's status error fails the call with that status.

## [0.17.1] - 2026-09-19

Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,16 @@ reverse: caught by the fuzzer in 0.06s, missed by 400 seeded sequences).
health service over `bufconn`, so nothing is generated from protobuf. The
coverage badge does not include it. `examples/` reaches it through a second
`replace`, and `examples/grpc` is the lifecycle example the README embeds.
`Register[H]` copies the generated `ServiceDesc` with its handlers replaced:
a unary wrapper hands the generated handler a fake interceptor, so the
generated code still decodes and builds the info, then runs the server's
real chain with a handler that resolves `H` and calls the method through
`reflect`; a stream wrapper resolves `H` and passes it to the generated
handler unchanged, since stream interceptors run outside it. `H` is
therefore built after every interceptor, the info's `Server` is nil, and a
constructor's status is taken from inside the build error with
`errors.AsType` so the client never sees the registration site. That rests
on `grpc.MethodHandler` being the public contract it is documented as.
- **`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
37 changes: 20 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,8 +455,11 @@ 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`. `GracefulStop` is the drain hook.
and the incoming context, and `Module` provides it. `digrpc.Register[*Users](srv, &pb.Users_ServiceDesc)`
serves a generated service with an implementation resolved from that scope,
so the implementation is a service like a handler type under `dihttp.Handle`:
`Scoped()` when it takes the call, built after the interceptors have run, and
a constructor's status error is the call's. `GracefulStop` is the drain hook.

<details>
<summary><code>examples/grpc/main.go</code>, a gRPC server with a service built per call from the call's metadata</summary>
Expand All @@ -466,8 +469,9 @@ reaches the scope with `di.FromContext`. `GracefulStop` is the drain hook.
// Graceful shutdown of a gRPC server, with a service built per call.
//
// The digrpc interceptor opens a scope for every call and registers the
// *digrpc.Call in it, so a Caller declared Scoped is built per call from the
// call's metadata. Run starts the scope, waits for SIGINT/SIGTERM or a
// *digrpc.Call in it, and digrpc.Register serves the health service with an
// implementation resolved from that scope, so a Scoped Health is built per
// call with a Caller read from the call's metadata. Run starts the scope, waits for SIGINT/SIGTERM or a
// Shutdown call, then stops everything in reverse order with a bounded
// context. The server's OnDrain calls GracefulStop, which stops accepting
// calls and waits for in-flight ones. Draining runs before anything is torn
Expand Down Expand Up @@ -501,28 +505,27 @@ func NewCaller(c *digrpc.Call) *Caller {
return &Caller{Name: cmp.Or(strings.Join(md.Get("x-caller"), ","), "anonymous")}
}

// Health is a service implementation. grpc registers one value for the whole
// server, so it is a singleton that reaches per-call services through the
// scope on its context.
// Health is the service implementation, built per call because it takes the
// Caller. Methods it does not define fall through to the embedded type.
type Health struct {
grpc_health_v1.UnimplementedHealthServer
db *DB
db *DB
caller *Caller
}

func NewHealth(db *DB) *Health { return &Health{db: db} }
func NewHealth(db *DB, c *Caller) *Health { return &Health{db: db, caller: c} }

func (h *Health) Check(ctx context.Context, _ *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
scope, _ := di.FromContext(ctx)
caller := scope.Get[*Caller]()
func (h *Health) Check(context.Context, *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
time.Sleep(2 * time.Second) // simulate slow work that must not be cut short
log.Println("checked by", caller.Name, "against", h.db.dsn)
log.Println("checked by", h.caller.Name, "against", h.db.dsn)
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, h *Health) *grpc.Server {
// NewServer is a plain constructor: the interceptor arrives as a dependency,
// and Register resolves *Health from each call's scope.
func NewServer(ic digrpc.Interceptor) *grpc.Server {
srv := grpc.NewServer(ic.Options()...)
grpc_health_v1.RegisterHealthServer(srv, h)
digrpc.Register[*Health](srv, &grpc_health_v1.Health_ServiceDesc)
return srv
}

Expand All @@ -533,7 +536,7 @@ func main() {
app.Wire[*DB](func() *DB { return &DB{dsn: "postgres://localhost/app"} }).
OnStop(func(ctx context.Context, db *DB) error { log.Println("db closed"); return nil })
app.Wire[*Caller](NewCaller).Scoped()
app.Wire[*Health](NewHealth)
app.Wire[*Health](NewHealth).Scoped()

app.Wire[*grpc.Server](NewServer).
Eager().
Expand Down
4 changes: 3 additions & 1 deletion digrpc/digrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
//
// 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
// scope as Scoped and built per call. [Register] serves a generated service
// with an implementation resolved from that scope, so the implementation is
// such a service too; one registered the plain way reaches the scope through
// [di.FromContext] on the context it is given. [Module] registers the
// interceptor as a service; [New] makes one directly.
//
Expand Down
54 changes: 51 additions & 3 deletions digrpc/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ func NewCaller(c *digrpc.Call) *Caller {
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.
// Health is a service implementation registered the plain way: one value
// for the whole server, reaching per-call services through the scope on the
// context. See ExampleRegister for the implementation built per call.
type Health struct {
grpc_health_v1.UnimplementedHealthServer
}
Expand Down Expand Up @@ -75,3 +75,51 @@ func ExampleModule() {
// check from ada
// SERVING <nil>
}

// Greeter is the service implementation built per call: it takes the Caller,
// so it is Scoped, and Register resolves it from each call's scope.
type Greeter struct {
grpc_health_v1.UnimplementedHealthServer
caller *Caller
}

func NewGreeter(c *Caller) *Greeter { return &Greeter{caller: c} }

func (g *Greeter) Check(context.Context, *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
fmt.Println("check from", g.caller.Name)
return &grpc_health_v1.HealthCheckResponse{Status: grpc_health_v1.HealthCheckResponse_SERVING}, nil
}

func ExampleRegister() {
app := di.New()
app.Use(digrpc.Module)
app.Wire[*Caller](NewCaller).Scoped()
app.Wire[*Greeter](NewGreeter).Scoped()
app.Wire[*grpc.Server](func(ic digrpc.Interceptor) *grpc.Server {
srv := grpc.NewServer(ic.Options()...)
digrpc.Register[*Greeter](srv, &grpc_health_v1.Health_ServiceDesc)
return srv
})
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>
}
120 changes: 120 additions & 0 deletions digrpc/register.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package digrpc

import (
"context"
"errors"
"fmt"
"reflect"

"github.com/floatdrop/di"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// Register serves the service desc describes with an H resolved from each
// call's scope, so the implementation is a service like any other: declared
// Scoped when it needs the call, and once for the application when it does
// not. The desc is the generated one, and H must implement its interface:
//
// digrpc.Register[*Users](srv, &pb.Users_ServiceDesc)
//
// H may also be the service interface itself, resolved from whatever serves
// it. H is resolved after the server's interceptors have run, so what they
// attach to the context is there for its constructors. A constructor that
// returns a status error fails the call with that status; any other failure,
// or a nil H, is codes.Internal, with a failed build reported to the scope's
// observers. Interceptors see a nil Server in the call's info, since the
// implementation does not exist yet when they run. Register panics when H
// does not implement the service.
func Register[H any](srv grpc.ServiceRegistrar, desc *grpc.ServiceDesc) {
t := reflect.TypeFor[H]()
iface := reflect.TypeOf(desc.HandlerType).Elem()
if !t.Implements(iface) {
panic(fmt.Sprintf("digrpc: %s does not implement %s", t, iface))
}
wrapped := *desc
wrapped.Methods = make([]grpc.MethodDesc, len(desc.Methods))
for i, m := range desc.Methods {
wrapped.Methods[i] = grpc.MethodDesc{MethodName: m.MethodName, Handler: unary[H](m.Handler, m.MethodName)}
}
wrapped.Streams = make([]grpc.StreamDesc, len(desc.Streams))
for i, s := range desc.Streams {
s.Handler = streaming[H](s.Handler)
wrapped.Streams[i] = s
}
srv.RegisterService(&wrapped, placeholder[H]())
}

// placeholder is a value of H for RegisterService's implements check, or nil
// for an interface H, which RegisterService accepts. It is never called:
// every handler ignores the value it is given.
func placeholder[H any]() any {
if t := reflect.TypeFor[H](); t.Kind() == reflect.Pointer {
return reflect.New(t.Elem()).Interface()
}
var zero H
return zero
}

// unary lets the generated handler decode the request and build the call's
// info, then runs the server's interceptor chain with a handler that resolves
// H and calls the method. The method is looked up on the value, so H may be
// an interface. The generated handler always calls the interceptor it is
// given, so the value it is given as the server is never used.
func unary[H any](orig grpc.MethodHandler, name string) grpc.MethodHandler {
return func(_ any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
return orig(nil, ctx, dec, func(ctx context.Context, req any, info *grpc.UnaryServerInfo, _ grpc.UnaryHandler) (any, error) {
handler := func(ctx context.Context, req any) (any, error) {
impl, err := resolve[H](ctx)
if err != nil {
return nil, err
}
out := reflect.ValueOf(impl).MethodByName(name).Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(req)})
err, _ = out[1].Interface().(error)
return out[0].Interface(), err
}
if interceptor == nil {
return handler(ctx, req)
}
return interceptor(ctx, req, info, handler)
})
}
}

// streaming resolves H once the stream interceptors have run and hands it
// to the generated handler as the implementation.
func streaming[H any](orig grpc.StreamHandler) grpc.StreamHandler {
return func(_ any, ss grpc.ServerStream) error {
impl, err := resolve[H](ss.Context())
if err != nil {
return err
}
return orig(impl, ss)
}
}

// resolve is H from the call's scope, as a status error when it fails.
func resolve[H any](ctx context.Context) (H, error) {
var zero H
s, ok := di.FromContext(ctx)
if !ok {
return zero, status.Error(codes.Internal, "digrpc: no call scope on the context; is the Interceptor on this server?")
}
impl, err := s.Resolve[H]()
if err == nil {
if any(impl) == nil {
return zero, status.Error(codes.Internal, "digrpc: the service resolved to nil")
}
return impl, nil
}
// A constructor's own status, not the build error wrapping it, is what
// the client should see.
if se, ok := errors.AsType[interface {
error
GRPCStatus() *status.Status
}](err); ok {
return zero, se.GRPCStatus().Err()
}
return zero, status.Error(codes.Internal, "digrpc: the service could not be built")
}
Loading
Loading