Documentation
¶
Overview ¶
Package client provides a remote-backed protobuf descriptor resolver that fetches schemas from a running protoregistry server over gRPC.
A Resolver is bound to a single namespace and implements the standard protobuf-go reflection interfaces (protoregistry.MessageTypeResolver, protoregistry.ExtensionTypeResolver, protodesc.Resolver) so it composes with anything that takes a resolver: dynamicpb, protojson, anypb, and external codec libraries such as github.com/trendvidia/protowire-go's PXF and SBE encoders.
Concrete defaults ¶
- Eager population. New / Dial fetch every schema in the namespace up front. Lookup misses surface at startup, not in the request path.
- Polling refresh. A background goroutine calls ListSchemas on a fixed interval (default 30s) and re-fetches descriptors only for schemas whose current version advanced. Hot-swaps are atomic; readers in flight see a consistent snapshot. Failures during refresh are logged and survived — callers see stale-but-consistent state until the next successful tick.
- Fail-loud collisions. If two schemas in the namespace export the same fully-qualified type name, New returns an error rather than silently picking one.
These choices mirror the in-process resolve.Resolver semantics where possible. Streaming refresh, lazy population, and other strategies are out of scope for v0.
Example ¶
Dial a registry, fetch a message descriptor by fully-qualified name, and use it to decode a PXF payload via protowire-go:
ctx := context.Background()
r, err := client.Dial(ctx, "registry.internal:50051", "billing")
if err != nil {
log.Fatal(err)
}
defer r.Close()
desc, err := r.FindDescriptorByName("billing.v1.Config")
if err != nil {
log.Fatal(err)
}
msg, err := pxf.UnmarshalDescriptor(pxfBytes, desc.(protoreflect.MessageDescriptor))
if err != nil {
log.Fatal(err)
}
_ = msg
The Resolver also drops into protojson and anypb without adapter code:
opts := protojson.UnmarshalOptions{Resolver: r}
err := opts.Unmarshal(jsonBytes, msg)
Example ¶
Example demonstrates the canonical wiring: Dial a registry, fetch a message descriptor by fully-qualified name, and decode a PXF payload against it via protowire-go.
The example compiles but is not executed (no // Output: directive), since it dials a server that is not running here. It serves as a godoc-rendered, vet-checked source of truth for the API shape.
package main
import (
"context"
"log"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/trendvidia/protoregistry/client"
"github.com/trendvidia/protowire-go/encoding/pxf"
)
func main() {
var pxfBytes []byte // payload produced elsewhere
ctx := context.Background()
r, err := client.Dial(ctx, "registry.internal:50051", "billing")
if err != nil {
log.Print(err)
return
}
defer func() { _ = r.Close() }()
desc, err := r.FindDescriptorByName("billing.v1.Config")
if err != nil {
log.Print(err)
return
}
msg, err := pxf.UnmarshalDescriptor(pxfBytes, desc.(protoreflect.MessageDescriptor))
if err != nil {
log.Print(err)
return
}
_ = msg
}
Output:
Index ¶
- Constants
- type Option
- func WithFallback(files *protoregistry.NamespacedFiles, types *protoregistry.NamespacedTypes) Option
- func WithGlobalFallback() Option
- func WithLogger(l *slog.Logger) Option
- func WithParent(parent *Resolver) Option
- func WithRefreshInterval(d time.Duration) Option
- func WithSchemas(ids ...string) Option
- func WithToken(token string) Option
- type Resolver
- func (r *Resolver) Close() error
- func (r *Resolver) FindDescriptorByName(name protoreflect.FullName) (protoreflect.Descriptor, error)
- func (r *Resolver) FindExtensionByName(name protoreflect.FullName) (protoreflect.ExtensionType, error)
- func (r *Resolver) FindExtensionByNumber(message protoreflect.FullName, field protoreflect.FieldNumber) (protoreflect.ExtensionType, error)
- func (r *Resolver) FindFileByPath(path string) (protoreflect.FileDescriptor, error)
- func (r *Resolver) FindMessageByName(name protoreflect.FullName) (protoreflect.MessageType, error)
- func (r *Resolver) FindMessageByURL(url string) (protoreflect.MessageType, error)
- func (r *Resolver) Namespace() string
- func (r *Resolver) NewMessage(name protoreflect.FullName) (*dynamicpb.Message, error)
- func (r *Resolver) Pin(ctx context.Context, versions map[string]uint64) (*Resolver, error)
- func (r *Resolver) Refresh(ctx context.Context) error
- func (r *Resolver) Schema(schemaID string) *SchemaResolver
- type SchemaResolver
Examples ¶
Constants ¶
const DefaultRefreshInterval = 30 * time.Second
DefaultRefreshInterval is the cadence at which a Resolver polls the server for current-version changes when no explicit interval is set.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Option ¶
type Option func(*config)
Option configures a Resolver at construction time.
func WithFallback ¶ added in v0.70.1
func WithFallback(files *protoregistry.NamespacedFiles, types *protoregistry.NamespacedTypes) Option
WithFallback configures parent registries that the Resolver falls back to when a local lookup misses. The Resolver's namespace-wide aggregate (FindFileByPath / FindExtensionByNumber) and each per-schema view (Schema(...) lookups) both inherit the same parent, so well-known or shared types are visible at every lookup tier.
Parent registries are read-only from the Resolver's perspective; the Resolver never writes to them, so callers manage their lifecycle. Passing the same pair to multiple Resolvers shares the parent across namespaces.
Calling WithFallback twice — or combining it with WithParent / WithGlobalFallback — overrides the previous setting (last writer wins).
func WithGlobalFallback ¶ added in v0.70.1
func WithGlobalFallback() Option
WithGlobalFallback configures the Resolver to fall back to upstream protoregistry.GlobalFiles / protoregistry.GlobalTypes when a lookup misses. Useful when the binary also has generated proto types compiled in (which auto-register into the globals at init time); the Resolver can then resolve both registry-managed and statically-known types through the same lookup paths.
The globals are read-only through this fallback — the Resolver never writes to them.
Equivalent to calling WithFallback with a pair of global-wrapping registries derived from protoregistry.NewNamespaceOverGlobal.
func WithLogger ¶
WithLogger sets a structured logger for refresh activity, cache swaps, and stale-while-error events. Nil falls back to slog.Default; pass a discard logger to silence output.
func WithParent ¶ added in v0.70.1
WithParent makes this Resolver fall back to another Resolver's namespace-wide aggregate when local lookups miss. Useful for modeling a "common types" namespace as the parent of per-tenant namespaces — the parent Resolver continues to refresh independently and the child sees its current state via the fork's fallback chain.
The parent must outlive every child. Closing the parent does not invalidate the child's fallback chain — operations after the parent is closed will still attempt to read its registries — so call sites should be careful with lifecycle ordering.
Equivalent to calling WithFallback with the parent's nsFiles / nsTypes.
func WithRefreshInterval ¶
WithRefreshInterval sets the polling cadence for current-version changes. Passing 0 disables refresh entirely (the Resolver becomes effectively pinned to its initial population).
Default: DefaultRefreshInterval.
func WithSchemas ¶
WithSchemas restricts the Resolver to a subset of schemas in the namespace. Useful when a service only consumes a known set of types and wants to skip fetching the rest.
When unset, the Resolver tracks every schema in the namespace.
type Resolver ¶
type Resolver struct {
// contains filtered or unexported fields
}
Resolver resolves protobuf descriptors for a single namespace from a remote protoregistry server.
It implements protoregistry.MessageTypeResolver, protoregistry.ExtensionTypeResolver, and the descriptor lookup half of protodesc.Resolver, so it drops into protojson, anypb, dynamicpb, and protowire-go without adapter code.
A Resolver is namespace-scoped to mirror the server model. Construct one Resolver per namespace.
func Dial ¶
Dial is a convenience constructor that opens an insecure gRPC connection and constructs a Resolver in one call. Production callers should usually build a *grpc.ClientConn themselves and pass it to New.
func New ¶
func New(ctx context.Context, conn *grpc.ClientConn, namespace string, opts ...Option) (*Resolver, error)
New constructs a Resolver bound to the given namespace on an already-dialed gRPC connection. The conn is owned by the caller, who is responsible for its lifecycle, transport credentials, interceptors, and observability hooks.
On success, the returned Resolver has eagerly populated descriptors for every schema in the namespace (or the subset selected via WithSchemas) and started its background refresh goroutine. Call Resolver.Close to stop it.
func (*Resolver) Close ¶
Close stops the background refresh goroutine. If the Resolver was created via Dial it also closes the underlying gRPC connection; otherwise the conn was passed in by the caller and is left alone.
func (*Resolver) FindDescriptorByName ¶
func (r *Resolver) FindDescriptorByName(name protoreflect.FullName) (protoreflect.Descriptor, error)
FindDescriptorByName looks up any descriptor (message, enum, service, extension, etc.) by its fully-qualified name. Falls back to the parent registry chain when configured.
func (*Resolver) FindExtensionByName ¶
func (r *Resolver) FindExtensionByName(name protoreflect.FullName) (protoreflect.ExtensionType, error)
FindExtensionByName looks up an extension by its fully-qualified name. Falls back to the parent registry chain when configured.
func (*Resolver) FindExtensionByNumber ¶
func (r *Resolver) FindExtensionByNumber(message protoreflect.FullName, field protoreflect.FieldNumber) (protoreflect.ExtensionType, error)
FindExtensionByNumber looks up an extension by the message it extends and its field number. Goes through the Resolver's namespace-wide aggregate, which is mutated incrementally on each refresh.
func (*Resolver) FindFileByPath ¶
func (r *Resolver) FindFileByPath(path string) (protoreflect.FileDescriptor, error)
FindFileByPath looks up a file descriptor by its proto path (e.g. "billing/v1/billing.proto"). Goes through the Resolver's namespace-wide aggregate, which is mutated incrementally on each refresh.
func (*Resolver) FindMessageByName ¶
func (r *Resolver) FindMessageByName(name protoreflect.FullName) (protoreflect.MessageType, error)
FindMessageByName looks up a message type by its fully-qualified name across all schemas in the namespace. Falls back to the parent registry chain when configured via WithFallback / WithParent / WithGlobalFallback.
Returns protoregistry.NotFound when neither the local namespace nor any configured parent defines the name.
func (*Resolver) FindMessageByURL ¶
func (r *Resolver) FindMessageByURL(url string) (protoreflect.MessageType, error)
FindMessageByURL looks up a message type by its type URL (e.g. "type.googleapis.com/billing.v1.Config"). Enables use with google.golang.org/protobuf/types/known/anypb.
func (*Resolver) NewMessage ¶
NewMessage constructs an empty dynamic message for the given fully-qualified name. Equivalent to looking up the descriptor and passing it to dynamicpb.NewMessage, but bundled into one call because callers almost always want the dynamic message, not the descriptor itself.
func (*Resolver) Pin ¶
Pin returns a derived Resolver frozen at the given (schemaID -> version) mapping. The parent Resolver is unaffected and continues to track current versions. Pinned Resolvers are intended for reproducible reads, e.g. replaying a captured PXF stream against the exact schema version it was produced with.
The returned Resolver shares the parent's gRPC connection. Closing the pinned Resolver does not affect the parent or the conn.
func (*Resolver) Refresh ¶
Refresh forces a freshness check now, outside the regular polling cadence. Useful in tests and after a known publish/promote cycle.
Refresh is safe to call concurrently with itself and with the background refresh loop — calls are serialized internally. Lookups never block on Refresh; they read the snapshot atomically.
On error, the previous snapshot is preserved (stale-while-error).
Incremental aggregate updates ¶
Refresh applies the per-schema diff to the namespace-wide aggregate in place — UnregisterFile / UnregisterMessage / UnregisterEnum / UnregisterExtension for schemas that were removed or replaced, then UpdateFile / Update* for schemas that were added or replaced. This avoids the O(N) cost of rebuilding the aggregate when only a small number of schemas changed.
During the brief window between the aggregate mutation and the snapshot.Store call, lookups via Resolver.FindFileByPath / Resolver.FindExtensionByNumber may observe the new state while per-schema views (via SchemaResolver) still reflect the old. For schema-consistent reads, route through SchemaResolver or use [Pin].
func (*Resolver) Schema ¶
func (r *Resolver) Schema(schemaID string) *SchemaResolver
Schema returns a SchemaResolver scoped to a single schema in the namespace. The returned resolver shares the parent's cache and refresh loop.
Example ¶
ExampleResolver_Schema shows the SchemaResolver path, useful when the caller already knows which schema in the namespace owns the type. It's cheaper (skips the cross-schema name index) and immune to collisions across schemas.
package main
import (
"context"
"log"
"github.com/trendvidia/protoregistry/client"
)
func main() {
ctx := context.Background()
r, err := client.Dial(ctx, "registry.internal:50051", "billing")
if err != nil {
log.Print(err)
return
}
defer func() { _ = r.Close() }()
configSchema := r.Schema("config")
msg, err := configSchema.NewMessage("billing.v1.Config")
if err != nil {
log.Print(err)
return
}
_ = msg
}
Output:
type SchemaResolver ¶
type SchemaResolver struct {
// contains filtered or unexported fields
}
SchemaResolver narrows lookups to a single schema within a namespace. Use it when the caller knows which schema owns a type — it skips the cross-schema name index and is unaffected by collisions across schemas.
func (*SchemaResolver) FindMessageByName ¶
func (s *SchemaResolver) FindMessageByName(name protoreflect.FullName) (protoreflect.MessageType, error)
FindMessageByName looks up a message type within the bound schema.
func (*SchemaResolver) NewMessage ¶
func (s *SchemaResolver) NewMessage(name protoreflect.FullName) (*dynamicpb.Message, error)
NewMessage constructs an empty dynamic message from the bound schema.
func (*SchemaResolver) SchemaID ¶
func (s *SchemaResolver) SchemaID() string
SchemaID returns the schema this resolver is scoped to.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
clienttest
Package clienttest is a test-only harness that wires a real protoregistry server (Postgres + gRPC over bufconn) and exposes a *grpc.ClientConn ready to be passed to client.New, plus helpers for the publish/promote dance.
|
Package clienttest is a test-only harness that wires a real protoregistry server (Postgres + gRPC over bufconn) and exposes a *grpc.ClientConn ready to be passed to client.New, plus helpers for the publish/promote dance. |