rpc

package
v0.0.0-...-8df018f Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 2, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package rpc is the transport-agnostic controller layer for Sov.

A consumer declares router structs and methods, then hands them to an Engine. The engine reflects the method signatures, maps wire names to Go methods, and dispatches incoming requests by router + method name.

type WidgetRouter struct {
    Repo *widget.Repo
}

type CreateParams struct {
    Name string `json:"name"`
}

func (r *WidgetRouter) Create(ctx *rpc.Context, p *CreateParams) (*Widget, error) {
    if p.Name == "" {
        return nil, rpc.BadRequest("name required")
    }
    return r.Repo.Create(ctx, p.Name)
}

engine := rpc.NewEngine()
engine.Register(&WidgetRouter{Repo: repo})

Wire shape (the only contract):

POST /rpc/{Router}/{method}
Body:     {"args": [paramsObject]}
Resp 200: {"data": <result>}
Resp ≥400:{"error": {"message": "...", "code": "BAD_REQUEST"}}

The engine does not know about HTTP. Transport adapters in sov/rpc/httpx and sov/rpc/fiberx own the HTTP boundary; both call Engine.Dispatch. The gateway in sov/gateway wraps an Engine for in-process services (the modular-monolith case) AND a Resolver chain for remote services (the microservice case), simultaneously.

Auth is NOT the framework's concern. Consumers wire their own middleware that fills Context.User before the engine dispatches. Per-request integrity signing lives in sov/rpc/signing as a middleware that runs BEFORE the engine; it does not produce identity claims.

Index

Constants

View Source
const ContextKeyClaims = "sov.claims"

ContextKeyClaims is the key under which the gateway stashes *Claims on rpc.Context.State during local dispatch. Handlers reach in via (*Context).Claims() — never type-assert the value directly.

Variables

This section is empty.

Functions

func DecodeEnvelope

func DecodeEnvelope(body []byte, status int, out any) error

DecodeEnvelope is the canonical client-side response decoder. For status >= 400 it returns the decoded *Error (falling back to an INTERNAL error carrying the raw body when the envelope won't parse). For success it unmarshals the `data` field into out (a nil out, empty data, or null data is a no-op success).

func Handle

func Handle[P any, R any](e *Engine, router, method string, fn func(ctx *Context, p *P) (R, error))

Handle registers a typed RPC method with NO reflection in the dispatch hot path. The handler is called directly through a closure built once at boot — no reflect.Value.Call, no reflect.New per request. Reflection is used only here, at registration, to build the introspect descriptor and the param field map.

rpc.Handle(eng, "Chirp", "post",
    func(ctx *rpc.Context, p *chirps.PostParams) (*chirps.Chirp, error) { ... })

Two wins over the reflective Register path:

  • the handler signature is checked at COMPILE time (a wrong shape is a build error, not a boot panic); and
  • dispatch skips method-invoke reflection (the part that hurt).

Field decoding still uses the boot-built FieldMap, so both wire arg shapes (positional + named) and `sov` tags work identically to Register. Handle and Register coexist on the same Engine; use Handle for hot methods you want type-checked and reflection-free to call.

For methods that return only an error, use HandleErr. No-arg methods are cheap to dispatch reflectively — keep them on Register, or pass a zero-field params struct.

func HandleErr

func HandleErr[P any](e *Engine, router, method string, fn func(ctx *Context, p *P) error)

HandleErr registers a typed method that returns only an error (no result body). Same boot-time, reflection-free dispatch as Handle.

func IsReservedMarker

func IsReservedMarker(name string) bool

IsReservedMarker reports whether name is a framework marker or plugin hook method the reflection scanner skips rather than treating as an RPC method. Exposed so the gateway package's sanity test can assert that every plugin sub-interface method is covered here.

func MarshalError

func MarshalError(e *Error) []byte

MarshalError builds the JSON body for an Error. Transport adapters use this when writing the response.

func MarshalSuccess

func MarshalSuccess(data any) []byte

MarshalSuccess builds the JSON body for a successful result.

func OperationTitle

func OperationTitle(name string) string

OperationTitle turns a Go method name into a product-facing label: "ListInvoices" → "List invoices".

func RequireSubject

func RequireSubject(c *Context) (string, error)

RequireSubject returns the subject id stashed on ctx.User by the gateway, or 401 UNAUTHORIZED if the request was anonymous. The canonical one-liner gate at the top of any handler that needs an authenticated caller:

uid, err := rpc.RequireSubject(ctx)
if err != nil { return nil, err }

Identity-required policy lives next to the method that needs it, not centralized in main(). The AuthzService (when bound on the gateway) is the other layer: it sees every request including anonymous ones and can return {Authenticate: true} to surface 401 before dispatch.

func RouterTitle

func RouterTitle(name string) string

RouterTitle turns a router wire name into a product-facing label: "Workspace" → "Workspace", "TicketKey" → "Ticket Key".

func SplitRPCPath

func SplitRPCPath(p string) (router, method string, ok bool)

SplitRPCPath parses a request path of the form /rpc/{router}/{method} and returns the router + method segments, or ok=false on anything else. Rejects paths where method contains a `/` (defense against service-level _X smuggling — the gateway treats /rpc/Foo/_x as a distinct, refused branch).

One canonical implementation; gateway, signing middleware, and any future transport adapter consume it instead of forking three nearly identical helpers.

func TSPreviewForMethod

func TSPreviewForMethod(entry *methodEntry) (request, response string)

TSPreviewForMethod returns one-line TypeScript previews of the params type and the result type for an RPC method. Thin shim over tsrender.RenderInline — the shared renderer is the source of truth for both this preview path and the sovgen CLI's full `.d.ts` emission. Output unchanged from the pre-shim implementation.

func UserFromContext

func UserFromContext(c *Context) (any, error)

UserFromContext returns the authenticated user, or an Unauthorized Error if Context.User is nil. Routers reach for this rather than type-asserting Context.User directly so the error path is consistent.

Types

type Claims

type Claims struct {
	Subject   string         `json:"sub"`              // opaque caller id
	Issuer    string         `json:"iss,omitempty"`    // which AuthService minted this
	Scopes    []string       `json:"scopes,omitempty"` // OAuth scope semantics — what powers the TOKEN grants
	ExpiresAt time.Time      `json:"exp"`              // cache TTL upper bound
	Extra     map[string]any `json:"extra,omitempty"`  // escape hatch (tenant_id, device_id, ...)
}

Claims is the verified caller identity the AuthService returns. Shape is fixed by the framework so every downstream service knows what to expect — both on the wire (X-Sov-* headers, decomposed by the gateway) and in-process (ctx.Claims()).

Claims is identity + delegation only. Authorization state (role memberships, RBAC matrix) is queried by the AuthzService at decision time, not stamped here at issue time. Stuffing roles into Claims turns ambient identity into ambient policy and makes hot-reload of RBAC impossible — keep them separate.

type Context

type Context struct {
	context.Context
	User  any
	State map[string]any
}

Context is the per-request value handed to every router method. It embeds the standard library context.Context so it can be passed wherever a context.Context is expected.

User is the authenticated subject id (an opaque string the gateway resolved from the bearer token). The framework does not produce it — consumers wire whatever auth middleware they need (JWT, session cookie, upstream gateway headers) before dispatch and set it here. Handlers that need the subject call rpc.RequireSubject(ctx); handlers that want the full structured Claims call ctx.Claims().

State is a free-form bag for adapter- and consumer-specific values (database handles, fiber.Ctx, request id, etc.). The framework does not read it; it is provided so consumers do not need to subclass Context or thread their own context type through every handler.

func NewContext

func NewContext(ctx context.Context) *Context

NewContext returns a Context wrapping ctx.

func (*Context) Claims

func (c *Context) Claims() *Claims

Claims returns the gateway-stamped *Claims from the request context, or nil if the caller is anonymous (no bearer, or the AuthService returned nothing). Typed accessor — handlers don't import the gateway package just to read identity.

func (*Context) Get

func (c *Context) Get(key string) any

Get returns the value at key, or nil.

func (*Context) Set

func (c *Context) Set(key string, v any)

Set stashes a value in State under key, creating the map if needed.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine holds the registered routers and dispatches incoming requests to the right Go method by reflection. Safe for concurrent dispatch. Mutations (Register) are expected at boot, not under load.

func NewEngine

func NewEngine() *Engine

NewEngine returns an empty Engine.

func (*Engine) Describe

func (e *Engine) Describe() []RouterDescriptor

Describe returns one RouterDescriptor per registered router, in registration order. Each descriptor includes method signatures rendered as TypeScript-shaped previews and JSON-tagged params expanded to ParamField records.

The gateway's /rpc/_introspect endpoint stitches this output across every service in the resolver chain into one org-wide catalog.

func (*Engine) Dispatch

func (e *Engine) Dispatch(ctx *Context, router, method string, body []byte) (status int, respBody []byte)

Dispatch is the transport-agnostic entry. Adapters parse HTTP path/body, build a *Context, then call Dispatch. The return is the wire status and the JSON envelope already encoded — adapters set Content-Type and write the bytes.

body is the raw request body, typically `{"args":[...]}` or `{"args":{...}}`. An empty body is treated as no-args so methods that take no params can be invoked with no body.

func (*Engine) HardHiddenMethods

func (e *Engine) HardHiddenMethods(router string) []string

HardHiddenMethods returns the HARD-hidden wire method names the router declared via the HardHiddenLister marker, or nil.

func (*Engine) HasRouter

func (e *Engine) HasRouter(router string) bool

HasRouter reports whether a router by that name is registered.

func (*Engine) HiddenMethods

func (e *Engine) HiddenMethods(router string) []string

HiddenMethods returns the SOFT-hidden wire method names the router declared via the HiddenLister marker, or nil.

func (*Engine) Lookup

func (e *Engine) Lookup(router, method string) (*methodEntry, bool)

Lookup returns the method entry for router/method, or false.

func (*Engine) Methods

func (e *Engine) Methods(router string) []string

Methods returns the wire method names registered on router, in sorted order.

func (*Engine) PublicMethods

func (e *Engine) PublicMethods(router string) []string

PublicMethods returns the wire method names the router declared via the PublicLister marker interface, or nil if the router did not declare any. Used by the gateway/authz to default-allow without per-line configuration.

func (*Engine) Register

func (e *Engine) Register(router any)

Register reflects on the given router pointer and exposes its exported methods over the wire. The router type's name minus the "Router" suffix is used as the wire namespace.

Accepted method signatures:

func (r *X) Foo(ctx *rpc.Context) error
func (r *X) Foo(ctx *rpc.Context) (T, error)
func (r *X) Foo(ctx *rpc.Context, p *Params) error
func (r *X) Foo(ctx *rpc.Context, p *Params) (T, error)

Anything else panics at boot — fail fast, never at request time.

func (*Engine) Routers

func (e *Engine) Routers() []string

Routers returns a snapshot of registered router names in registration order.

type Error

type Error struct {
	Message   string `json:"message"`
	Code      string `json:"code,omitempty"`
	ErrorCode string `json:"error_code,omitempty"`
	Status    int    `json:"-"`
}

Error is the canonical error type returned by router methods.

Status maps to the HTTP status code the transport adapter sets. Code is the UPPERCASE_SNAKE category (BAD_REQUEST, NOT_FOUND, ...); it surfaces as JSON `"code"`. ErrorCode is an optional stable application-level reason ("WORKSPACE_SLUG_IN_USE") for client branching; it surfaces as JSON `"error_code"`.

func BadRequest

func BadRequest(msg string, args ...any) *Error

BadRequest returns 400 BAD_REQUEST.

func BadRequestCode

func BadRequestCode(errorCode, msg string, args ...any) *Error

BadRequestCode returns 400 BAD_REQUEST with a stable application error_code.

func Conflict

func Conflict(msg string, args ...any) *Error

Conflict returns 409 CONFLICT.

func DecodeErrorBody

func DecodeErrorBody(body []byte, status int) (e *Error, ok bool)

DecodeErrorBody parses an `{"error":{message,code,error_code}}` envelope into an *Error stamped with status. ok is false when body is not a valid JSON error envelope — the caller then supplies its own fallback. Shared by the client, the auth verifier, and rpctest so the error-envelope shape lives in one place.

func Forbidden

func Forbidden(msg string, args ...any) *Error

Forbidden returns 403 FORBIDDEN.

func ForbiddenCode

func ForbiddenCode(errorCode, msg string, args ...any) *Error

ForbiddenCode returns 403 FORBIDDEN with a stable application error_code.

func Internal

func Internal(msg string, args ...any) *Error

Internal returns 500 INTERNAL. The Message is logged server-side; the transport adapter substitutes a generic message on the wire so internal detail does not leak.

func NotFound

func NotFound(msg string, args ...any) *Error

NotFound returns 404 NOT_FOUND.

func NotImplemented

func NotImplemented(msg string, args ...any) *Error

NotImplemented returns 501 NOT_IMPLEMENTED. Use for RPC stubs.

func TooManyRequests

func TooManyRequests(msg string, args ...any) *Error

TooManyRequests returns 429 RATE_LIMITED.

func Unauthorized

func Unauthorized(msg string, args ...any) *Error

Unauthorized returns 401 UNAUTHORIZED.

func (*Error) Error

func (e *Error) Error() string

type ErrorResponse

type ErrorResponse struct {
	Error errorBody `json:"error"`
}

ErrorResponse is the canonical failure envelope.

type FieldInfo

type FieldInfo struct {
	GoName     string
	WireName   string // wire/JSON name
	StructIdx  int    // index into reflect.Type.Field
	Position   int    // -1 = no positional slot
	Required   bool
	Omitempty  bool
	Deprecated bool
	Type       reflect.Type

	// Human-facing metadata from the sov tag `key=value` pairs. None
	// affect dispatch — they flow into Describe(), the explorer UI,
	// and codegen JSDoc.
	Title   string // short label, e.g. "Username"
	Desc    string // one-line hint shown as placeholder / helper text
	Doc     string // long-form documentation surfaced as tooltip / JSDoc body
	Example string // example value the explorer can pre-fill
}

FieldInfo is the per-field resolution of the tag grammar.

type FieldMap

type FieldMap struct {
	Type   reflect.Type
	Fields []FieldInfo    // source order
	ByName map[string]int // wire name → index into Fields
	ByPos  []int          // position → index into Fields (-1 if no field at that position)
	MaxPos int            // highest positional slot, or -1 if no positional fields
	// Internal / InternalHard are set by a blank sentinel field
	//   _ struct{} `sov:"internal"`       → Internal     (soft hide)
	//   _ struct{} `sov:"internal,hard"`  → InternalHard (hard hide)
	// marking the method that takes this params struct as hidden from
	// introspection. Method-level directive, not a wire field.
	Internal     bool
	InternalHard bool
}

FieldMap is the boot-time-resolved layout of one params (or result) struct. It decouples wire shape from Go source shape so:

  • Field source order can be changed for cache alignment or readability without breaking clients.
  • The same struct can decode from EITHER `args:[positional]` OR `args:{named}` — clients pick the form that suits them.
  • Fields can be renamed (Go) while the wire name stays stable, or vice versa.
  • Introspection emits per-field metadata (required, omitempty, deprecated, position) so codegen and the explorer UI render the right thing without re-reading struct tags at request time.

FieldMap is built once per (Type) at Register time, validated, and cached on the methodEntry. Hot path is map / slice lookup, no reflection on struct tags per request.

func BuildFieldMap

func BuildFieldMap(t reflect.Type) (*FieldMap, error)

BuildFieldMap parses `sov:` (with `json:` fallback) tags on t and returns a validated FieldMap. Errors are reported with full field context so callers can panic at boot with a clear message.

t must be a struct type. Pointer-to-struct callers should pass t.Elem().

type HardHiddenLister

type HardHiddenLister interface {
	HardHiddenMethods() []string
}

HardHiddenLister is the optional marker a router implements to HARD-hide methods: the named methods are stripped from EVERY introspect payload — not even the X-Sov-Introspect-Internal header reveals them. Use for endpoints only callers who already know the path should find.

SECURITY: hard-hide removes discoverability, NOT access. The endpoint is still live and callable; authz, not hiding, is the access boundary.

type HiddenLister

type HiddenLister interface {
	HiddenMethods() []string
}

HiddenLister is the optional marker a router implements to SOFT-hide methods: the named methods are omitted from the default introspect report (and the explorer / codegen / federation) but remain in the full payload served under the X-Sov-Introspect-Internal header, so the explorer's "show internal" toggle can reveal them. The engine reads the list once at Register and skips the marker method when reflecting.

Hiding is discoverability only — the methods stay dispatchable.

type MethodDescriptor

type MethodDescriptor struct {
	Method             string       `json:"method"`   // wire name (camelCase) — URL segment
	Title              string       `json:"title"`    // product-facing label derived from goName
	PostPath           string       `json:"postPath"` // /rpc/{Router}/{method}
	HasParams          bool         `json:"hasParams"`
	Params             []ParamField `json:"params,omitempty"`
	RequestTypeScript  string       `json:"requestTypeScript"`
	ResponseTypeScript string       `json:"responseTypeScript"`
	// ResponseTypeName is the Go type name of the method's non-error
	// return when it's a named struct (possibly via pointer/slice).
	// Empty for primitive/map results. The type catalog uses it to tag
	// the type's usage role as "response" (data-ownership inference).
	ResponseTypeName string `json:"responseTypeName,omitempty"`
	// Internal marks a SOFT-hidden method: omitted from the default
	// introspect report, but present (with this flag set) in the full
	// payload served under the X-Sov-Introspect-Internal header so the
	// explorer's "show internal" toggle can reveal it.
	Internal bool `json:"internal,omitempty"`
	// HardHidden marks a method stripped from EVERY introspect payload —
	// the framework auth/authz hooks and any author HardHiddenMethods().
	// json:"-" because hard methods are removed before marshal; the flag
	// only needs to survive Describe() → the gateway's strip pass within a
	// single gateway and never crosses the wire.
	HardHidden bool `json:"-"`
	// NestedTypes are the named struct types referenced by Params
	// (transitively). Lets the IntrospectReport.Types catalog include
	// every type the generated client needs without losing reflect
	// access at catalog-build time. Keyed by the Go type's Name.
	NestedTypes map[string][]ParamField `json:"nestedTypes,omitempty"`
}

MethodDescriptor is one exported router method.

type ParamField

type ParamField struct {
	JSONName     string `json:"jsonName"`
	SchemaType   string `json:"schemaType"`             // OpenAPI-shaped: string|number|boolean|array|object
	DesignerHint string `json:"designerHint,omitempty"` // short human label
	Required     bool   `json:"required"`               // sov:"required" VALIDATION intent — NOT wire presence
	Position     int    `json:"position"`               // -1 = no positional slot
	Omitempty    bool   `json:"omitempty,omitempty"`
	// Nullable is true when the Go field is a pointer — it may be absent or
	// null on the wire. With Omitempty it drives codegen optionality: a
	// field is OPTIONAL in the generated type iff it can be absent
	// (Omitempty || Nullable); a non-omitempty non-pointer field is always
	// present and so required. (Required is validation-only and does NOT
	// imply presence — see the optionality note in WIRE_CONTRACT.)
	Nullable   bool   `json:"nullable,omitempty"`
	Deprecated bool   `json:"deprecated,omitempty"`
	TypeName   string `json:"typeName,omitempty"` // Go type name when SchemaType=="object", OR the NAMED slice-element type when SchemaType=="array"
	// ElemType is the element's OpenAPI schema when SchemaType=="array"
	// (string|number|boolean|object|array). Lets codegen type a primitive
	// slice ([]string → string[]) instead of falling back to unknown[].
	// For arrays of named structs, TypeName carries the element type name
	// and ElemType=="object".
	ElemType string `json:"elemType,omitempty"`

	// Human-facing metadata from the sov tag `key=value` pairs.
	// Surfaced by the explorer UI + codegen JSDoc; ignored by dispatch.
	Title   string `json:"title,omitempty"`
	Desc    string `json:"desc,omitempty"`
	Doc     string `json:"doc,omitempty"`
	Example string `json:"example,omitempty"`
}

ParamField describes one JSON field on a method's params object. Used by Explorer / codegen / OpenAPI emission downstream.

type PublicLister

type PublicLister interface {
	PublicMethods() []string
}

PublicLister is the optional marker a router implements to declare which methods are public (no authentication required). The engine reads the list once at Register, exposes it via PublicMethods(router) and Describe(), and skips the marker method itself when reflecting the RPC surface.

type Request

type Request struct {
	Args json.RawMessage `json:"args"`
}

Request is the canonical wire body. The `args` field accepts either of two shapes; clients pick per request:

  • Positional: {"args":[v0, v1, v2]} — bound by sov tag position (or source order when no sov tag is present)
  • Named: {"args":{"name":v, ...}} — bound by sov tag name (or json tag, or snake_case(GoFieldName))

Both decode into the same Go params struct; dispatch picks the path by inspecting the first non-whitespace byte (`[` vs `{`).

type RouterDescriptor

type RouterDescriptor struct {
	Router  string             `json:"router"` // wire name (URL segment)
	Title   string             `json:"title"`  // group label for explorers
	Methods []MethodDescriptor `json:"methods"`
}

RouterDescriptor describes one registered router.

type SuccessResponse

type SuccessResponse struct {
	Data any `json:"data"`
}

SuccessResponse is the canonical success envelope.

Directories

Path Synopsis
Package tsrender turns Go reflect types into TypeScript-shaped strings.
Package tsrender turns Go reflect types into TypeScript-shaped strings.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL