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
- Variables
- type Option
- func WithDiskCache(path string) 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 WithServerChain() Option
- func WithToken(token string) Option
- func WithTransportCredentials(creds credentials.TransportCredentials) 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) FindFileByPathWithOrigin(path string) (protoreflect.FileDescriptor, string, error)
- func (r *Resolver) FindMessageByName(name protoreflect.FullName) (protoreflect.MessageType, error)
- func (r *Resolver) FindMessageByNameWithOrigin(name protoreflect.FullName) (protoreflect.MessageType, string, error)
- func (r *Resolver) FindMessageByURL(url string) (protoreflect.MessageType, error)
- func (r *Resolver) GetSource(ctx context.Context, filePath string) ([]byte, error)
- func (r *Resolver) IsStale() bool
- 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) RangeMessages(f func(protoreflect.MessageType) bool)
- 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 ¶
var ErrStaleResolver = errors.New("protoregistry/client: resolver loaded from disk cache; refresh not possible")
ErrStaleResolver is returned by Refresh on a Resolver that was constructed from a disk cache. Recovering from stale mode requires constructing a fresh Resolver via Dial — there's no live gRPC connection to refresh against.
Functions ¶
This section is empty.
Types ¶
type Option ¶
type Option func(*config)
Option configures a Resolver at construction time.
func WithDiskCache ¶ added in v0.71.0
WithDiskCache configures an on-disk cache directory. Two effects:
- On every successful populate / Refresh, the Resolver writes each schema's FileDescriptorSet bytes plus a small manifest (namespace, schemas + versions, chain, save timestamp) under <path>/<namespace>/ . Writes are atomic (write-temp + rename).
- When Dial cannot reach the server, it falls back to loading the most recently persisted snapshot from this directory and returns a Resolver in "stale" mode — see Resolver.IsStale.
Stale resolvers serve lookups against the cached snapshot but do NOT run a refresh loop (nothing to dial against) and return an error from Resolver.Refresh. Recovering from stale mode requires the caller to construct a fresh Resolver — usually on the next editor restart, by which point the network may be back.
The cache is per-process: two Resolvers pointed at the same path will race on writes. Atomic rename keeps individual reads consistent, but you may lose intermediate updates. Document this behavior to callers; don't try to file-lock at this layer.
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).
Note: for namespace-hierarchy use cases (org-shared types living in a parent namespace on the server), prefer WithServerChain — it consults the registry's authoritative chain, so client and server can't disagree about what types are visible.
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.
Note: for namespace-hierarchy use cases (org-shared types living in a parent namespace on the server), prefer WithServerChain — it consults the registry's authoritative chain rather than relying on client-side configuration that could drift.
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.
Note: for namespace-hierarchy use cases (the parent namespace lives on the server as the registry-known ancestor), prefer WithServerChain — it sources the chain from the registry rather than the client, eliminating drift.
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.
func WithServerChain ¶ added in v0.71.0
func WithServerChain() Option
WithServerChain makes the Resolver consult the registry's GetNamespaceChain RPC at construction time and auto-configure ancestor Resolvers as parents. Each ancestor in the chain (parent, grandparent, …) is loaded as its own Resolver sharing the same gRPC connection, with its own refresh goroutine; the nearest ancestor's namespace-wide registries become the immediate parent of this Resolver.
This is the recommended way to consume org-shared types: the registry is the single source of truth for which namespaces are in the chain, so the client cannot disagree with the server about what types are visible.
Combining WithServerChain with WithParent / WithFallback / WithGlobalFallback is "last writer wins" on the parentFiles / parentTypes pair — if WithServerChain is the last option applied, the server chain overwrites whatever was previously configured. In practice the two patterns serve different use cases (server-derived org chain vs. ad-hoc test/library scenarios) and shouldn't be combined.
The constructed ancestor Resolvers are closed when the main Resolver's Close is called; refresh goroutines stop cleanly.
func WithToken ¶
WithToken attaches a bearer token to outgoing requests. Only honored by Dial; callers of New should configure auth on the *grpc.ClientConn directly (e.g. via grpc.WithPerRPCCredentials), which is more flexible and idiomatic.
func WithTransportCredentials ¶ added in v0.72.0
func WithTransportCredentials(creds credentials.TransportCredentials) Option
WithTransportCredentials supplies gRPC transport credentials for Dial. The default — no option set — is insecure transport, fine for loopback / local development and never appropriate for traffic that leaves the host. Production callers pass credentials.NewTLS(tlsCfg) (or any other TransportCredentials implementation) to enable TLS while preserving Dial's cache-fallback behavior on the failure path.
Only honored by Dial. Callers of New own the *grpc.ClientConn and configure credentials on it directly — this option is ignored in that path.
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.
When WithDiskCache is configured, Dial persists the snapshot after the initial populate and on every successful refresh; if the online attempt fails outright (network unreachable, server down), Dial falls back to loading the most recent persisted snapshot from the cache and returns a stale-mode Resolver. See Resolver.IsStale. When no cache is configured, the network failure is returned as-is.
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.
When the Resolver was constructed with WithServerChain, Close also stops the refresh goroutines of every ancestor Resolver it created. Ancestors share the same gRPC connection, so conn closure (if owned) happens once at the end.
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) FindFileByPathWithOrigin ¶ added in v0.71.0
func (r *Resolver) FindFileByPathWithOrigin(path string) (protoreflect.FileDescriptor, string, error)
FindFileByPathWithOrigin is like Resolver.FindFileByPath but also returns the ID of the namespace that contributed the file. Same origin semantics as [FindMessageByNameWithOrigin].
Typical use: protolsp's hover handler resolves a field's ParentFile().Path() through this method to label the hover with "defined in namespace X" alongside the bare file path.
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) FindMessageByNameWithOrigin ¶ added in v0.71.0
func (r *Resolver) FindMessageByNameWithOrigin(name protoreflect.FullName) (protoreflect.MessageType, string, error)
FindMessageByNameWithOrigin is like Resolver.FindMessageByName but also returns the ID of the namespace that contributed the type. Useful for editor integrations rendering provenance ("defined in namespace acme-shared") next to hover or completion results.
Origin semantics:
- The bound namespace's local types resolve with origin == r.Namespace().
- When WithServerChain is in effect, each ancestor tier resolves with its own namespace ID — walked nearest-first.
- When WithParent / WithFallback / WithGlobalFallback is in effect (no WithServerChain), the parent tier resolves the type but origin is the empty string — the ad-hoc parent passes only registries, not namespace identity. Use WithServerChain for full provenance.
On NotFound, returns ("", "", NotFound) — both return values are zero so callers can ignore the origin without a nil-check.
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) GetSource ¶ added in v0.71.0
GetSource fetches the original .proto source bytes for the given file path. The path matches what a protoreflect.FileDescriptor reports via Path() — relative within the schema (e.g. "acme/billing/v1/invoice.proto").
Resolution walks the same tiers as Resolver.FindFileByPathWithOrigin:
- Bound namespace's schemas first.
- With WithServerChain: each ancestor's schemas, nearest first.
WithParent / WithFallback / WithGlobalFallback tiers expose only pre-compiled registries, not a registry connection — files reachable only through such ad-hoc parents resolve via FindFileByPath but return NotFound from GetSource. Use WithServerChain to get source-fetch coverage across the whole org's chain.
The fetched version matches the currently-loaded snapshot — for a pinned Resolver, the pin's version; for a live one, the latest observed. The server is hit on every call; the client does not cache source bytes. Editor integrations should cache at their layer, keyed by the FileDescriptor's identity.
Useful for editor integrations building virtual documents for registry-only files: when go-to-definition resolves to a file that doesn't exist on disk, the editor fetches its bytes here.
func (*Resolver) IsStale ¶ added in v0.71.0
IsStale reports whether the Resolver is serving from a disk cache rather than a live registry connection. Stale resolvers' lookups work normally but won't reflect any server-side changes since the last successful refresh — callers surfacing freshness in their UI (e.g. an editor status bar) should consult this.
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) RangeMessages ¶ added in v0.71.0
func (r *Resolver) RangeMessages(f func(protoreflect.MessageType) bool)
RangeMessages iterates every message type currently visible to the Resolver — both the bound namespace's own types and types contributed by parent registries. f is invoked once per type; returning false stops the walk.
Tiers walked:
- The bound namespace's nsTypes (always).
- When WithServerChain was used: each ancestor's nsTypes, in chain order (nearest first).
- When WithParent / WithFallback / WithGlobalFallback was used without WithServerChain: the single parent tier supplied at construction. Recursive multi-tier walking through ad-hoc parents isn't supported — WithServerChain is the way to enumerate a full org hierarchy.
f may be called with the same FQN twice if two tiers both export it (a child intentionally shadowing a parent). Consumers building a deduplicated list should track names as they observe them.
Useful for editor integrations populating a completion list of known message FQNs (e.g. for the `@type` directive in a PXF document). The walk runs against the resolver's current snapshot; concurrent refreshes do not introduce torn views.
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. |