From 9b7c4c5941b8eb6c5b112e0f612d09943ca95988 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:40:52 -0400 Subject: [PATCH 1/3] feat: call composed modules over their REST binding; documented operations A solution reaches a composed module only under the REST prefix it federates (/v1//*), so the module's generated gRPC/Connect client cannot be pointed at the gateway, and solutions were hand-building paths, query strings and wire structs for every module read. Gateway.Transcoded calls an RPC with the module's own generated messages over the method's google.api.http binding under the consumed prefix. Everything about the request comes from the descriptor: the caller names no path, and a method with no binding under the prefix is refused before anything is sent. Alongside it, what a handler needs to hand an owner's message on: Message[T] encodes a generated message as protobuf JSON inside a response, and FieldMask/Apply pass on only a declared allowlist of its fields, so an owner field added later reaches no browser until someone names it. Operation, Server.Operations and InterfaceDocument move the documented-route declaration and its Swagger 2.0 artifact into the runtime, so one list is the source of what a solution serves and what it publishes. A Message[T] field is described from its descriptor. Gateway.OrgID exposes the active organization ForModule mints in, so a handler scopes a module read to the same tenant its capability names. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 53 ++++++ go.mod | 2 +- operations.go | 401 +++++++++++++++++++++++++++++++++++++++++++++ operations_test.go | 112 +++++++++++++ protowire.go | 150 +++++++++++++++++ solution.go | 7 + transcode.go | 378 ++++++++++++++++++++++++++++++++++++++++++ transcode_test.go | 311 +++++++++++++++++++++++++++++++++++ 8 files changed, 1413 insertions(+), 1 deletion(-) create mode 100644 operations.go create mode 100644 operations_test.go create mode 100644 protowire.go create mode 100644 transcode.go create mode 100644 transcode_test.go diff --git a/README.md b/README.md index 942efdd..187a3c1 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,59 @@ This traffic is not proxied, for the same reason registration traffic is not: every gateway target is composition-local, and these requests carry the viewer's bearer and the capability minted for them in headers. +### Calling a composed module over its REST binding + +The gateway reaches a composed module only under the REST prefix the solution +federates for it (`/v1//*`), so the module's generated gRPC or Connect +client cannot be pointed at it: its procedure paths are not under that prefix. +What the module does publish is each RPC's `google.api.http` binding, in its +generated descriptor. `Gateway.Transcoded` speaks that binding with the +module's own generated messages: + +```go +docs, err := gw.ForModule(ctx, "documents", + solution.Scope{ResourceKind: "documents", Actions: []string{"read"}}) +if err != nil { + return nil, err +} +var page documentsv1.ListCollectionResponse +err = docs.Transcoded(ctx, "/v1/documents", documentsv1.DocumentsService_ListCollection_FullMethodName, + &documentsv1.ListCollectionRequest{PageSize: 500}, &page) +``` + +The call uses the method's first binding — the primary rule, then its +additional bindings in order — whose path lies under the prefix. The URL, the +HTTP method, and which request fields go into the path, the query or the body +all come from the descriptor. The caller can name no path of its own, and a +method with no binding under the prefix is refused before anything is sent, as +is a multi-segment path variable, an empty or dot-segment path value, and a +request or response of the wrong type. The response is decoded with unknown +fields discarded and is bounded (`MaxResponseBytes`, 8 MiB by default). A +non-2xx answer is a `*GatewayError` carrying the status. + +`Gateway.OrgID` is the viewer's active organization, the same one `ForModule` +mints in. A handler scoping a module read to a tenant names this one. + +### Generated messages in a response + +A handler's value is encoded with `encoding/json`, which reads a generated +message's Go struct tags rather than producing protobuf JSON. Wrap an owner's +message in `solution.MessageOf(m)` (`solution.Message[T]`) wherever it sits in +the response, and it encodes as protobuf JSON. To pass on only part of an +owner's message, declare a `FieldMask` once from the paths allowed and +`solution.Apply` it. A field the owner adds later reaches nobody until it is +named, and a path naming no field fails at declaration. + +### Documented operations + +`Server.Operations` registers a list of `solution.Operation`s — each a route, +its handler, and the documentation a reader needs (summary, callers, behaviour, +query parameters, request and response types). `solution.InterfaceDocument` +renders the same list as a Swagger 2.0 artifact, so one declaration is the +source of what is served and what is published, and an operation missing its +documentation is refused. A `Message[T]` field is described from its +descriptor, with protobuf JSON names. + > **Note:** SDK in-process endpoint resolution for a solution composed on an > out-of-repo host depends on codefly-core accepting the composed module path in > its workspace loader (codefly-dev/core#365, merged); the `core`/`sdk-go` pins diff --git a/go.mod b/go.mod index 223cd21..78c871c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/codefly-dev/core v0.3.41 github.com/codefly-dev/sdk-go v0.1.65 github.com/google/uuid v1.6.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 google.golang.org/protobuf v1.36.12 ) @@ -47,7 +48,6 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect google.golang.org/grpc v1.83.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..dca9db4 --- /dev/null +++ b/operations.go @@ -0,0 +1,401 @@ +package solution + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "unicode" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// --- Documented operations --- +// +// A solution's backend surface is its routes. Declaring each route as an +// Operation — the path, the handler, and the documentation a reader needs — +// makes one list the source of both what is served and what is published: +// Operations registers exactly these handlers, and InterfaceDocument renders +// exactly these into the interface artifact a solution commits and CI +// re-generates. A route cannot be served undocumented, and an operation cannot +// be documented without being served. + +// Operation is one public route of a solution backend. +// +// Summary, Callers and Behavior are all required: InterfaceDocument refuses an +// operation missing any of them, which keeps an undocumented route out of the +// artifact. Errors are not declared per operation, because the runtime gives +// every operation the same failures. +type Operation struct { + // Path is the gateway-relative route, e.g. "/sources". + Path string + // Method is the documented HTTP method, lowercase; empty means "get". + Method string + // Summary is the one-sentence functional statement of what the operation + // does, written for a reader who will never see the code: no leading + // article, no trailing period. + Summary string + // Callers states who may call the operation. + Callers string + // Behavior states what the caller gets back, and the pagination or limits + // that shape it. + Behavior string + // Query lists the documented query parameters. + Query []QueryParameter + // Request is the zero value of the JSON body the operation reads, if any. + Request any + // Response is the zero value of the body the operation returns on 200; + // the artifact's schema is derived from its type. A generated protobuf + // message is carried as Message[T] and described from its descriptor. + Response any + // Exactly one of Handler and RequestHandler answers the route. + Handler Handler + RequestHandler RequestHandler +} + +// QueryParameter is one documented query parameter of an Operation. +type QueryParameter struct { + Name string + Required bool +} + +// Operations registers each operation's handler at its path. +func (s *Server) Operations(ops ...Operation) *Server { + for _, op := range ops { + if op.RequestHandler != nil { + s.HandleRequest(op.Path, op.RequestHandler) + } else { + s.Handle(op.Path, op.Handler) + } + } + return s +} + +// InterfaceInfo is the document-level description of a solution's interface +// artifact. +type InterfaceInfo struct { + // Title and Description head the document; Version is the service version + // the artifact describes. + Title string + Version string + Description string + // BasePath is where the host gateway publishes the routes, e.g. + // "/solutions/": the backend serves them at its own root, but a caller + // only ever reaches them prefixed. + BasePath string + // OperationPrefix starts every operationId ("Wiki" renders "/sources" as + // "Wiki_Sources"); Tag is the one tag every operation carries. + OperationPrefix string + Tag string +} + +// InterfaceDocument renders ops as a Swagger 2.0 document — the dialect the +// platform's proto-generated modules emit, so one reader handles every +// repository's artifact — indented, with a trailing newline. +func InterfaceDocument(info InterfaceInfo, ops []Operation) ([]byte, error) { + doc, err := swaggerDocument(info, ops) + if err != nil { + return nil, err + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +func swaggerDocument(info InterfaceInfo, ops []Operation) (map[string]any, error) { + definitions := map[string]any{ + "Error": map[string]any{ + "type": "object", + "properties": map[string]any{"error": map[string]any{"type": "string"}}, + }, + } + paths := map[string]any{} + for _, op := range ops { + if err := op.validate(); err != nil { + return nil, err + } + method := op.Method + if method == "" { + method = "get" + } + endpoint := map[string]any{ + "operationId": operationID(info.OperationPrefix, op.Path), + "summary": op.Summary, + "description": op.Callers + "\n\n" + op.Behavior, + "tags": []string{info.Tag}, + "responses": operationResponses(op, definitions), + } + parameters := []any{} + if op.Request != nil { + parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": schemaFor(reflect.TypeOf(op.Request), definitions)}) + } + for _, query := range op.Query { + parameters = append(parameters, map[string]any{"name": query.Name, "in": "query", "required": query.Required, "type": "string"}) + } + if len(parameters) > 0 { + endpoint["parameters"] = parameters + } + paths[op.Path] = map[string]any{method: endpoint} + } + return map[string]any{ + "swagger": "2.0", + "info": map[string]any{ + "title": info.Title, + "version": info.Version, + "description": info.Description, + }, + "basePath": info.BasePath, + "consumes": []string{"application/json"}, + "produces": []string{"application/json"}, + "securityDefinitions": map[string]any{ + "bearer": map[string]any{ + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "The signed-in person's access token, forwarded by the host. Every operation requires it.", + }, + }, + "security": []any{map[string]any{"bearer": []string{}}}, + "paths": paths, + "definitions": definitions, + }, nil +} + +// operationResponses describes what one operation answers. The 200 schema is +// derived from the operation's response type; the failures are the runtime's, +// not the handler's: it rejects a request with no bearer, and turns an error a +// handler returns into a failure status. +func operationResponses(op Operation, definitions map[string]any) map[string]any { + return map[string]any{ + "200": map[string]any{ + "description": "A successful response.", + "schema": schemaFor(reflect.TypeOf(op.Response), definitions), + }, + "401": map[string]any{ + "description": "The request carried no bearer token.", + "schema": map[string]any{"$ref": "#/definitions/Error"}, + }, + "502": map[string]any{ + "description": "A call this operation makes through the host gateway failed.", + "schema": map[string]any{"$ref": "#/definitions/Error"}, + }, + } +} + +// validate reports an operation that cannot be rendered because it is +// undocumented or unserved. +func (o Operation) validate() error { + switch { + case !strings.HasPrefix(o.Path, "/"): + return fmt.Errorf("operation path %q must be gateway-relative", o.Path) + case o.Summary == "": + return fmt.Errorf("operation %s has no summary", o.Path) + case o.Callers == "": + return fmt.Errorf("operation %s does not say who may call it", o.Path) + case o.Behavior == "": + return fmt.Errorf("operation %s does not say what it returns, or its pagination and limits", o.Path) + case o.Response == nil: + return fmt.Errorf("operation %s declares no response body", o.Path) + case o.Handler == nil && o.RequestHandler == nil: + return fmt.Errorf("operation %s has no handler", o.Path) + case o.Handler != nil && o.RequestHandler != nil: + return fmt.Errorf("operation %s declares two handlers", o.Path) + } + return nil +} + +// operationID renders "/sources" as "_Sources"; a nested path +// contributes one segment per level rather than carrying a slash into an +// identifier. +func operationID(prefix, path string) string { + id := prefix + for _, segment := range strings.Split(strings.Trim(path, "/"), "/") { + id += "_" + title(segment) + } + return id +} + +// messageDescribed is a response value carrying a generated message +// (Message[T]); its schema comes from the descriptor, not the Go struct. +type messageDescribed interface { + messageDescriptor() protoreflect.MessageDescriptor +} + +var ( + messageDescribedType = reflect.TypeOf((*messageDescribed)(nil)).Elem() + rawMessageType = reflect.TypeOf(json.RawMessage{}) +) + +// schemaFor derives a JSON schema from a response type, registering a +// definition for every struct it reaches so a self-referencing shape +// terminates in a $ref instead of recursing forever. +func schemaFor(t reflect.Type, definitions map[string]any) map[string]any { + if t == rawMessageType { + return map[string]any{"type": "object"} + } + if t.Implements(messageDescribedType) { + return messageSchema(reflect.Zero(t).Interface().(messageDescribed).messageDescriptor(), definitions) + } + switch t.Kind() { + case reflect.Pointer: + return schemaFor(t.Elem(), definitions) + case reflect.Struct: + name := title(t.Name()) + // An unnamed struct has no definition to be referenced by: keyed on "" + // it would register a definition nothing can address and emit a $ref + // that resolves to nothing. Naming the type is the fix. + if name == "" { + panic(fmt.Sprintf("no definition name for the anonymous struct %s; give the field a named type", t)) + } + if _, seen := definitions[name]; !seen { + definitions[name] = map[string]any{} + definitions[name] = structSchema(t, definitions) + } + return map[string]any{"$ref": "#/definitions/" + name} + case reflect.Slice, reflect.Array: + return map[string]any{"type": "array", "items": schemaFor(t.Elem(), definitions)} + case reflect.Map: + return map[string]any{"type": "object", "additionalProperties": schemaFor(t.Elem(), definitions)} + case reflect.Interface: + // The empty interface is a value of any JSON shape, which an empty + // schema describes exactly. An interface with methods describes a Go + // contract, not a JSON one. + if t.NumMethod() > 0 { + panic(fmt.Sprintf("no schema for the interface %s; a response field must be a JSON shape", t)) + } + return map[string]any{} + case reflect.String: + return map[string]any{"type": "string"} + case reflect.Bool: + return map[string]any{"type": "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return map[string]any{"type": "integer"} + case reflect.Float32, reflect.Float64: + return map[string]any{"type": "number"} + } + // Generation-time only: a response field of a kind with no JSON schema is + // a mistake to fix, not a case to degrade for. + panic(fmt.Sprintf("no schema for %s (kind %s)", t, t.Kind())) +} + +func structSchema(t reflect.Type, definitions map[string]any) map[string]any { + properties := map[string]any{} + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + properties[name] = schemaFor(field.Type, definitions) + } + return map[string]any{"type": "object", "properties": properties} +} + +// messageSchema describes a generated message as its protobuf JSON: lowerCamel +// field names, 64-bit integers as strings, enums by name, and the well-known +// types in their JSON form. Each message is one definition named after its +// full name. +func messageSchema(md protoreflect.MessageDescriptor, definitions map[string]any) map[string]any { + if wkt, ok := wellKnownSchema(md); ok { + return wkt + } + name := definitionName(md.FullName()) + if _, seen := definitions[name]; !seen { + definitions[name] = map[string]any{} + properties := map[string]any{} + fields := md.Fields() + for i := range fields.Len() { + fd := fields.Get(i) + properties[fd.JSONName()] = fieldSchema(fd, definitions) + } + definitions[name] = map[string]any{"type": "object", "properties": properties} + } + return map[string]any{"$ref": "#/definitions/" + name} +} + +func fieldSchema(fd protoreflect.FieldDescriptor, definitions map[string]any) map[string]any { + if fd.IsMap() { + return map[string]any{"type": "object", "additionalProperties": singularSchema(fd.MapValue(), definitions)} + } + if fd.IsList() { + return map[string]any{"type": "array", "items": singularSchema(fd, definitions)} + } + return singularSchema(fd, definitions) +} + +func singularSchema(fd protoreflect.FieldDescriptor, definitions map[string]any) map[string]any { + switch fd.Kind() { + case protoreflect.StringKind: + return map[string]any{"type": "string"} + case protoreflect.BytesKind: + return map[string]any{"type": "string", "format": "byte"} + case protoreflect.BoolKind: + return map[string]any{"type": "boolean"} + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + return map[string]any{"type": "integer", "format": "int32"} + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + return map[string]any{"type": "integer", "format": "int64"} + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return map[string]any{"type": "string", "format": "int64"} + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return map[string]any{"type": "string", "format": "uint64"} + case protoreflect.FloatKind: + return map[string]any{"type": "number", "format": "float"} + case protoreflect.DoubleKind: + return map[string]any{"type": "number", "format": "double"} + case protoreflect.EnumKind: + values := fd.Enum().Values() + names := make([]string, 0, values.Len()) + for i := range values.Len() { + names = append(names, string(values.Get(i).Name())) + } + return map[string]any{"type": "string", "enum": names} + case protoreflect.MessageKind, protoreflect.GroupKind: + return messageSchema(fd.Message(), definitions) + } + panic(fmt.Sprintf("no schema for field %s of kind %s", fd.FullName(), fd.Kind())) +} + +func wellKnownSchema(md protoreflect.MessageDescriptor) (map[string]any, bool) { + switch md.FullName() { + case "google.protobuf.Timestamp": + return map[string]any{"type": "string", "format": "date-time"}, true + case "google.protobuf.Duration", "google.protobuf.FieldMask": + return map[string]any{"type": "string"}, true + case "google.protobuf.Struct": + return map[string]any{"type": "object"}, true + case "google.protobuf.Value", "google.protobuf.Any": + return map[string]any{}, true + case "google.protobuf.ListValue": + return map[string]any{"type": "array", "items": map[string]any{}}, true + case "google.protobuf.Empty": + return map[string]any{"type": "object"}, true + } + return nil, false +} + +// definitionName renders "documents.v1.Document" as "DocumentsV1Document". +func definitionName(name protoreflect.FullName) string { + var out strings.Builder + for _, part := range strings.Split(string(name), ".") { + out.WriteString(title(part)) + } + return out.String() +} + +func title(s string) string { + if s == "" { + return s + } + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} diff --git a/operations_test.go b/operations_test.go new file mode 100644 index 0000000..c75c84d --- /dev/null +++ b/operations_test.go @@ -0,0 +1,112 @@ +package solution + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/api/annotations" +) + +type documentedTree struct { + ID string `json:"id"` + Rule Message[*annotations.HttpRule] `json:"rule"` + Children []*documentedTree `json:"children"` + Facets map[string]any `json:"facets"` +} + +type documentedResponse struct { + Nodes []*documentedTree `json:"nodes"` +} + +func documentedOperations() []Operation { + return []Operation{{ + Path: "/nodes/tree", + Summary: "List the nodes", + Callers: "Signed-in viewers.", + Behavior: "Returns every node.", + Query: []QueryParameter{{Name: "pageToken"}}, + Response: documentedResponse{}, + Handler: func(context.Context, *Gateway) (any, error) { return documentedResponse{}, nil }, + }} +} + +func TestInterfaceDocumentDescribesGeneratedMessagesFromTheirDescriptor(t *testing.T) { + raw, err := InterfaceDocument(InterfaceInfo{Title: "example backend", Version: "1.2.3", BasePath: "/solutions/example", OperationPrefix: "Example", Tag: "example"}, documentedOperations()) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(string(raw), "}\n") { + t.Fatalf("the artifact has no trailing newline") + } + var doc struct { + BasePath string `json:"basePath"` + Paths map[string]map[string]map[string]any `json:"paths"` + Definitions map[string]struct { + Properties map[string]map[string]any `json:"properties"` + } `json:"definitions"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if doc.BasePath != "/solutions/example" || doc.Paths["/nodes/tree"]["get"]["operationId"] != "Example_Nodes_Tree" { + t.Fatalf("document = %s", raw) + } + rule, ok := doc.Definitions["GoogleApiHttpRule"] + if !ok { + t.Fatalf("the generated message has no definition: %v", doc.Definitions) + } + // Protobuf JSON names, not Go struct tags. + if _, ok := rule.Properties["responseBody"]; !ok { + t.Fatalf("definition does not use JSON names: %v", rule.Properties) + } + if items := rule.Properties["additionalBindings"]["items"].(map[string]any); items["$ref"] != "#/definitions/GoogleApiHttpRule" { + t.Fatalf("a self-referencing message does not terminate in a $ref: %v", items) + } + if _, ok := doc.Definitions["DocumentedTree"]; !ok { + t.Fatalf("struct definitions are missing: %v", doc.Definitions) + } +} + +func TestInterfaceDocumentRefusesAnUndocumentedOperation(t *testing.T) { + for _, broken := range []func(*Operation){ + func(o *Operation) { o.Summary = "" }, + func(o *Operation) { o.Callers = "" }, + func(o *Operation) { o.Behavior = "" }, + func(o *Operation) { o.Response = nil }, + func(o *Operation) { o.Handler = nil }, + func(o *Operation) { o.Path = "nodes" }, + func(o *Operation) { + o.RequestHandler = func(*http.Request, *Gateway) (any, error) { return nil, nil } + }, + } { + ops := documentedOperations() + broken(&ops[0]) + if _, err := InterfaceDocument(InterfaceInfo{}, ops); err == nil { + t.Fatalf("an invalid operation rendered: %+v", ops[0]) + } + } +} + +func TestOperationsServesEachDeclaredRoute(t *testing.T) { + s := New(Manifest{ID: "example"}).Operations(documentedOperations()...) + handler, ok := s.handlers["/nodes/tree"] + if !ok { + t.Fatalf("the declared route was not registered: %v", s.handlers) + } + srv := httptest.NewServer(s.wrapRequest(handler)) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/nodes/tree", nil) + req.Header.Set("authorization", "Bearer viewer") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/protowire.go b/protowire.go new file mode 100644 index 0000000..f487705 --- /dev/null +++ b/protowire.go @@ -0,0 +1,150 @@ +package solution + +import ( + "encoding/json" + "fmt" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// --- Generated messages on a handler's wire --- +// +// A handler returns any JSON-serializable value and the runtime encodes it with +// encoding/json. A generated protobuf message is not one: encoding/json reads +// its Go struct tags (snake_case names, int64 as numbers, oneofs as Go +// interfaces), which is not the protobuf JSON a browser SDK parses. A handler +// that returns an owner's message therefore wraps it in Message, which encodes +// with protojson wherever the value sits in the response. + +// Message carries a generated protobuf message inside a handler's response and +// encodes it as protobuf JSON (lowerCamel names, int64 as strings, enums by +// name, well-known types in their JSON form). A nil message encodes as null. +type Message[T proto.Message] struct { + Value T +} + +// MessageOf wraps m for a handler's response. +func MessageOf[T proto.Message](m T) Message[T] { return Message[T]{Value: m} } + +// MarshalJSON encodes the message as protobuf JSON. +func (m Message[T]) MarshalJSON() ([]byte, error) { + if any(m.Value) == nil || !m.Value.ProtoReflect().IsValid() { + return []byte("null"), nil + } + return protojson.Marshal(m.Value) +} + +// messageDescriptor lets the operation documentation describe the wrapped +// message from its descriptor rather than from its Go struct. +func (m Message[T]) messageDescriptor() protoreflect.MessageDescriptor { + return m.Value.ProtoReflect().Descriptor() +} + +var _ json.Marshaler = Message[proto.Message]{} + +// FieldMask is a declared allowlist of the fields of one message type that a +// handler may pass on. Build it once, at package level, from the paths it +// allows; Apply then drops everything else. An owner adding a field to its +// message later does not widen what reaches the browser: a new field is +// outside every mask until someone names it. +// +// A path is a dotted chain of proto field names ("entry.entry_id"). Naming a +// message field allows the whole submessage; naming a path inside it allows +// only that part. Paths into a repeated message apply to every element. +type FieldMask struct { + desc protoreflect.MessageDescriptor + root *maskNode +} + +type maskNode struct { + all bool + children map[protoreflect.Name]*maskNode +} + +// NewFieldMask declares an allowlist over the message type of example. Every +// path must name fields that exist; an unknown path is an error, so a mask +// cannot silently allow nothing after a rename. +func NewFieldMask(example proto.Message, paths ...string) (FieldMask, error) { + desc := example.ProtoReflect().Descriptor() + root := &maskNode{children: map[protoreflect.Name]*maskNode{}} + for _, path := range paths { + node, current := root, desc + names := strings.Split(path, ".") + for i, name := range names { + fd := current.Fields().ByName(protoreflect.Name(name)) + if fd == nil { + return FieldMask{}, fmt.Errorf("field mask over %s: %q has no field %q", desc.FullName(), path, name) + } + child := node.children[fd.Name()] + if child == nil { + child = &maskNode{children: map[protoreflect.Name]*maskNode{}} + node.children[fd.Name()] = child + } + node = child + if i == len(names)-1 { + node.all = true + break + } + if fd.Kind() != protoreflect.MessageKind || fd.IsMap() { + return FieldMask{}, fmt.Errorf("field mask over %s: %q descends into %q, which is not a message", desc.FullName(), path, name) + } + current = fd.Message() + } + } + return FieldMask{desc: desc, root: root}, nil +} + +// MustFieldMask is NewFieldMask for package-level declarations: an invalid +// mask is a programming error found at start-up. +func MustFieldMask(example proto.Message, paths ...string) FieldMask { + mask, err := NewFieldMask(example, paths...) + if err != nil { + panic(err) + } + return mask +} + +// Apply returns a copy of m carrying only the allowed fields. m is not +// modified. It panics if m is not the type the mask was declared over. +func Apply[T proto.Message](mask FieldMask, m T) T { + if any(m) == nil || !m.ProtoReflect().IsValid() { + return m + } + if got := m.ProtoReflect().Descriptor().FullName(); got != mask.desc.FullName() { + panic(fmt.Sprintf("field mask over %s applied to %s", mask.desc.FullName(), got)) + } + out := proto.Clone(m).(T) + prune(out.ProtoReflect(), mask.root) + return out +} + +func prune(msg protoreflect.Message, node *maskNode) { + if node.all { + return + } + // Clearing is deferred past Range: a message is not mutated while it is + // being ranged over. + var disallowed []protoreflect.FieldDescriptor + msg.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + child := node.children[fd.Name()] + switch { + case child == nil: + disallowed = append(disallowed, fd) + case child.all: + case fd.IsList(): + list := v.List() + for i := 0; i < list.Len(); i++ { + prune(list.Get(i).Message(), child) + } + default: + prune(v.Message(), child) + } + return true + }) + for _, fd := range disallowed { + msg.Clear(fd) + } +} diff --git a/solution.go b/solution.go index d74d1b5..15d169a 100644 --- a/solution.go +++ b/solution.go @@ -1917,6 +1917,13 @@ func newGateway(baseURL, bearer, orgID, sessionID string) *Gateway { func (g *Gateway) BaseURL() string { return g.baseURL } +// OrgID is the viewer's active organization: the x-org-id identity header the +// gateway stamped from the verified bearer, never anything the browser sent. +// It is empty for a viewer with no organization selected and for an org-less +// API key. It is the organization ForModule mints in, so a handler that scopes +// a module read to a tenant names this one rather than resolving another. +func (g *Gateway) OrgID() string { return g.orgID } + // HTTPClient returns an http.Client that injects the caller's bearer — and, on // a gateway derived by ForModule, the viewer's Work Context — on every request. // Satisfies connect.HTTPClient. Advanced escape hatch — prefer Unary. diff --git a/transcode.go b/transcode.go new file mode 100644 index 0000000..e0b1d69 --- /dev/null +++ b/transcode.go @@ -0,0 +1,378 @@ +package solution + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// --- Transcoded module calls --- +// +// The gateway reaches the host's own API over Connect, but a composed module +// only by the REST prefix the solution federates for it (/v1//*, see +// "Consumed-module federation" in the README). A module's generated client — +// gRPC or Connect — therefore cannot be pointed at the gateway: its procedure +// paths are not under that prefix. What a module does publish is the +// google.api.http binding of each RPC, carried in the generated descriptor. +// Transcoded speaks that binding, so a solution calls a composed module with +// the module's own generated request and response messages, and never builds a +// path, a query string or a wire struct by hand. + +// DefaultTranscodedResponseLimit bounds a transcoded response body unless the +// call sets its own bound with MaxResponseBytes. +const DefaultTranscodedResponseLimit = 8 << 20 + +// TranscodeOption adjusts one Transcoded call. +type TranscodeOption func(*transcodeOptions) + +type transcodeOptions struct { + maxResponseBytes int64 +} + +// MaxResponseBytes bounds the response body of one call. A larger response is +// an error, never a truncated message. +func MaxResponseBytes(n int64) TranscodeOption { + return func(o *transcodeOptions) { o.maxResponseBytes = n } +} + +// Transcoded calls one RPC of a composed module through the gateway, over the +// module's HTTP/JSON binding. +// +// method is the RPC's full name as its generated code names it — the +// "/package.Service/Method" constant a gRPC or Connect stub declares — and is +// resolved in the global protobuf registry, so the module's generated package +// must be linked in. prefix is the REST prefix the solution consumes the module +// under ("/v1/documents"): the call uses the method's first HTTP binding whose +// path lies under it. The URL, the HTTP method, which request fields go into the +// path, the query or the body all come from the descriptor; the caller can name +// no path of its own. A method with no binding under prefix is refused before +// anything is sent. +// +// req and resp must be the method's input and output message types. The +// response is decoded with unknown fields discarded, so a module adding a +// field does not break its consumers. A non-2xx answer is a *GatewayError +// carrying the status, so a handler that returns it keeps an actionable status. +func (g *Gateway) Transcoded(ctx context.Context, prefix, method string, req, resp proto.Message, opts ...TranscodeOption) error { + options := transcodeOptions{maxResponseBytes: DefaultTranscodedResponseLimit} + for _, opt := range opts { + opt(&options) + } + call, err := transcodeRequest(prefix, method, req, resp) + if err != nil { + return err + } + var body io.Reader + if call.body != nil { + body = bytes.NewReader(call.body) + } + httpReq, err := http.NewRequestWithContext(ctx, call.verb, g.baseURL+call.path, body) + if err != nil { + return err + } + httpReq.Header.Set("accept", "application/json") + if call.body != nil { + httpReq.Header.Set("content-type", "application/json") + } + httpResp, err := g.HTTPClient().Do(httpReq) + if err != nil { + return fmt.Errorf("%s %s: %w", call.verb, call.template, err) + } + defer drainAndClose(httpResp) + if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 { + return fmt.Errorf("%s %s: %w", call.verb, call.template, &GatewayError{StatusCode: httpResp.StatusCode}) + } + raw, err := io.ReadAll(io.LimitReader(httpResp.Body, options.maxResponseBytes+1)) + if err != nil { + return fmt.Errorf("%s %s: %w", call.verb, call.template, err) + } + if int64(len(raw)) > options.maxResponseBytes { + return fmt.Errorf("%s %s: response exceeds %d bytes", call.verb, call.template, options.maxResponseBytes) + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, resp); err != nil { + return fmt.Errorf("%s %s: invalid %s: %w", call.verb, call.template, resp.ProtoReflect().Descriptor().FullName(), err) + } + return nil +} + +// transcodedCall is one request, fully resolved from the descriptor. +type transcodedCall struct { + verb string + template string // the binding's path template, for errors + path string // path and query, relative to the gateway + body []byte // nil when the binding carries no body +} + +func transcodeRequest(prefix, method string, req, resp proto.Message) (transcodedCall, error) { + if !cleanPrefix(prefix) { + return transcodedCall{}, fmt.Errorf("transcoded call: prefix %q must be an absolute path such as /v1/, with no trailing slash, dot segment, query or fragment", prefix) + } + md, err := methodDescriptor(method) + if err != nil { + return transcodedCall{}, err + } + if got, want := req.ProtoReflect().Descriptor().FullName(), md.Input().FullName(); got != want { + return transcodedCall{}, fmt.Errorf("transcoded call %s: request is %s, want %s", md.FullName(), got, want) + } + if got, want := resp.ProtoReflect().Descriptor().FullName(), md.Output().FullName(); got != want { + return transcodedCall{}, fmt.Errorf("transcoded call %s: response is %s, want %s", md.FullName(), got, want) + } + rule, verb, template, err := bindingUnder(md, prefix) + if err != nil { + return transcodedCall{}, err + } + msg := req.ProtoReflect() + path, bound, err := expandTemplate(md, template, msg) + if err != nil { + return transcodedCall{}, err + } + call := transcodedCall{verb: verb, template: template} + var queryExcluded protoreflect.FieldDescriptor + switch rule.GetBody() { + case "": + case "*": + call.body, err = protojson.Marshal(req) + if err != nil { + return transcodedCall{}, err + } + call.path = path + return call, nil + default: + fd := msg.Descriptor().Fields().ByName(protoreflect.Name(rule.GetBody())) + if fd == nil || fd.Kind() != protoreflect.MessageKind || fd.IsList() || fd.IsMap() { + return transcodedCall{}, fmt.Errorf("transcoded call %s: body %q is not a singular message field", md.FullName(), rule.GetBody()) + } + call.body, err = protojson.Marshal(msg.Get(fd).Message().Interface()) + if err != nil { + return transcodedCall{}, err + } + queryExcluded = fd + } + query := url.Values{} + if err := encodeQuery(query, "", msg, bound, queryExcluded); err != nil { + return transcodedCall{}, fmt.Errorf("transcoded call %s: %w", md.FullName(), err) + } + call.path = path + if encoded := query.Encode(); encoded != "" { + call.path += "?" + encoded + } + return call, nil +} + +func cleanPrefix(prefix string) bool { + if !strings.HasPrefix(prefix, "/") || strings.HasSuffix(prefix, "/") || strings.ContainsAny(prefix, "?#{}*:\\") { + return false + } + for _, segment := range strings.Split(prefix[1:], "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + } + return true +} + +// methodDescriptor resolves "/package.Service/Method" (or the dotted +// "package.Service.Method") in the global registry. +func methodDescriptor(method string) (protoreflect.MethodDescriptor, error) { + name := strings.TrimPrefix(method, "/") + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[:i] + "." + name[i+1:] + } + d, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(name)) + if err != nil { + return nil, fmt.Errorf("transcoded call: method %q is not in the protobuf registry (is its generated package linked in?): %w", method, err) + } + md, ok := d.(protoreflect.MethodDescriptor) + if !ok { + return nil, fmt.Errorf("transcoded call: %q names a %T, not a method", method, d) + } + return md, nil +} + +// bindingUnder returns the first HTTP binding of md — the primary rule, then +// its additional bindings in order — whose path lies under prefix. +func bindingUnder(md protoreflect.MethodDescriptor, prefix string) (*annotations.HttpRule, string, string, error) { + primary, _ := proto.GetExtension(md.Options(), annotations.E_Http).(*annotations.HttpRule) + if primary == nil { + return nil, "", "", fmt.Errorf("transcoded call %s: the method declares no google.api.http binding", md.FullName()) + } + rules := append([]*annotations.HttpRule{primary}, primary.GetAdditionalBindings()...) + for _, rule := range rules { + verb, template := ruleVerb(rule) + if template == prefix || strings.HasPrefix(template, prefix+"/") || strings.HasPrefix(template, prefix+":") { + return rule, verb, template, nil + } + } + return nil, "", "", fmt.Errorf("transcoded call %s: no google.api.http binding under %s", md.FullName(), prefix) +} + +func ruleVerb(rule *annotations.HttpRule) (string, string) { + switch pattern := rule.GetPattern().(type) { + case *annotations.HttpRule_Get: + return http.MethodGet, pattern.Get + case *annotations.HttpRule_Post: + return http.MethodPost, pattern.Post + case *annotations.HttpRule_Put: + return http.MethodPut, pattern.Put + case *annotations.HttpRule_Delete: + return http.MethodDelete, pattern.Delete + case *annotations.HttpRule_Patch: + return http.MethodPatch, pattern.Patch + case *annotations.HttpRule_Custom: + return pattern.Custom.GetKind(), pattern.Custom.GetPath() + } + return "", "" +} + +// expandTemplate fills each {field} variable of template from msg. Only +// single-segment variables are supported ({field} and {field=*}); a +// multi-segment one is refused rather than guessed. Each value must be set and +// is path-escaped, so a value can never add a segment of its own. +func expandTemplate(md protoreflect.MethodDescriptor, template string, msg protoreflect.Message) (string, map[string]bool, error) { + bound := map[string]bool{} + var out strings.Builder + rest := template + for { + open := strings.IndexByte(rest, '{') + if open < 0 { + out.WriteString(rest) + return out.String(), bound, nil + } + end := strings.IndexByte(rest[open:], '}') + if end < 0 { + return "", nil, fmt.Errorf("transcoded call %s: malformed path template %q", md.FullName(), template) + } + out.WriteString(rest[:open]) + variable := rest[open+1 : open+end] + field, pattern, hasPattern := strings.Cut(variable, "=") + if hasPattern && pattern != "*" { + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s} is multi-segment, which is not supported", md.FullName(), variable) + } + value, err := scalarAt(msg, field) + if err != nil { + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s}: %w", md.FullName(), field, err) + } + if value == "" { + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s} is empty", md.FullName(), field) + } + if value == "." || value == ".." { + // Escaping leaves a dot segment as it is, and a normalizing hop + // would resolve it to a different route. + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s} is a dot segment", md.FullName(), field) + } + out.WriteString(url.PathEscape(value)) + bound[field] = true + rest = rest[open+end+1:] + } +} + +// scalarAt reads the scalar at a dotted field path of msg as its query/path +// text form. +func scalarAt(msg protoreflect.Message, path string) (string, error) { + names := strings.Split(path, ".") + for i, name := range names { + fd := msg.Descriptor().Fields().ByName(protoreflect.Name(name)) + if fd == nil { + return "", fmt.Errorf("no field %q in %s", name, msg.Descriptor().FullName()) + } + if fd.IsList() || fd.IsMap() { + return "", fmt.Errorf("field %q is repeated", name) + } + if i < len(names)-1 { + if fd.Kind() != protoreflect.MessageKind { + return "", fmt.Errorf("field %q is not a message", name) + } + msg = msg.Get(fd).Message() + continue + } + return scalarText(fd, msg.Get(fd)) + } + return "", fmt.Errorf("empty field path") +} + +func scalarText(fd protoreflect.FieldDescriptor, v protoreflect.Value) (string, error) { + switch fd.Kind() { + case protoreflect.StringKind: + return v.String(), nil + case protoreflect.BoolKind: + return strconv.FormatBool(v.Bool()), nil + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return strconv.FormatInt(v.Int(), 10), nil + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return strconv.FormatUint(v.Uint(), 10), nil + case protoreflect.FloatKind, protoreflect.DoubleKind: + return strconv.FormatFloat(v.Float(), 'g', -1, 64), nil + case protoreflect.BytesKind: + return base64.URLEncoding.EncodeToString(v.Bytes()), nil + case protoreflect.EnumKind: + if ev := fd.Enum().Values().ByNumber(v.Enum()); ev != nil { + return string(ev.Name()), nil + } + return strconv.FormatInt(int64(v.Enum()), 10), nil + } + return "", fmt.Errorf("field %q of kind %s has no query form", fd.Name(), fd.Kind()) +} + +// encodeQuery adds every populated field of msg not bound by the path (or sent +// as the body) as query parameters, by proto field name — the form the +// HTTP/JSON transcoder reads. Nested messages flatten to dotted names; repeated +// scalars repeat the key. A map or a repeated message has no query form and is +// refused rather than silently dropped. +func encodeQuery(query url.Values, parent string, msg protoreflect.Message, bound map[string]bool, excluded protoreflect.FieldDescriptor) error { + var err error + msg.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + if excluded != nil && fd == excluded { + return true + } + name := string(fd.Name()) + if parent != "" { + name = parent + "." + name + } + if bound[name] { + return true + } + switch { + case fd.IsMap(): + err = fmt.Errorf("map field %q has no query form", name) + case fd.IsList(): + if fd.Kind() == protoreflect.MessageKind || fd.Kind() == protoreflect.GroupKind { + err = fmt.Errorf("repeated message field %q has no query form", name) + break + } + list := v.List() + for i := 0; i < list.Len() && err == nil; i++ { + var text string + if text, err = scalarText(fd, list.Get(i)); err == nil { + query.Add(name, text) + } + } + case fd.Kind() == protoreflect.MessageKind || fd.Kind() == protoreflect.GroupKind: + if strings.HasPrefix(string(fd.Message().FullName()), "google.protobuf.") { + // A well-known type has a JSON form of its own (a timestamp is + // RFC 3339 text), which flattening its fields would not produce. + err = fmt.Errorf("well-known type field %q has no query form here; send it in a body", name) + break + } + err = encodeQuery(query, name, v.Message(), bound, nil) + default: + var text string + if text, err = scalarText(fd, v); err == nil { + query.Set(name, text) + } + } + return err == nil + }) + return err +} diff --git a/transcode_test.go b/transcode_test.go new file mode 100644 index 0000000..2dc95ce --- /dev/null +++ b/transcode_test.go @@ -0,0 +1,311 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +// transcodeTestFile is a module API the way a generated package registers +// one: a service whose methods carry google.api.http bindings, one of them +// with an additional binding under a different prefix. +var transcodeTestFile = func() protoreflect.FileDescriptor { + str := descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum() + i32 := descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum() + i64 := descriptorpb.FieldDescriptorProto_TYPE_INT64.Enum() + msg := descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum() + opt := descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum() + rep := descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + field := func(name string, number int32, label *descriptorpb.FieldDescriptorProto_Label, typ *descriptorpb.FieldDescriptorProto_Type, typeName string) *descriptorpb.FieldDescriptorProto { + f := &descriptorpb.FieldDescriptorProto{Name: proto.String(name), Number: proto.Int32(number), Label: label, Type: typ, JsonName: proto.String(jsonName(name))} + if typeName != "" { + f.TypeName = proto.String(typeName) + } + return f + } + http := func(rule *annotations.HttpRule) *descriptorpb.MethodOptions { + options := &descriptorpb.MethodOptions{} + proto.SetExtension(options, annotations.E_Http, rule) + return options + } + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("solution_runtime_test/things.proto"), + Package: proto.String("things.v1"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Sub"), Field: []*descriptorpb.FieldDescriptorProto{field("name", 1, opt, str, "")}}, + {Name: proto.String("GetThingRequest"), Field: []*descriptorpb.FieldDescriptorProto{ + field("tenant", 1, opt, str, ""), + field("entry_id", 2, opt, str, ""), + field("page_size", 3, opt, i32, ""), + field("ids", 4, rep, str, ""), + field("filter", 5, opt, msg, ".things.v1.Sub"), + }}, + {Name: proto.String("Thing"), Field: []*descriptorpb.FieldDescriptorProto{ + field("entry_id", 1, opt, str, ""), + field("big", 2, opt, i64, ""), + field("secret", 3, opt, str, ""), + field("sub", 4, opt, msg, ".things.v1.Sub"), + field("subs", 5, rep, msg, ".things.v1.Sub"), + }}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{{ + Name: proto.String("Things"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("Get"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing"), + Options: http(&annotations.HttpRule{ + Pattern: &annotations.HttpRule_Get{Get: "/v1/tenants/{tenant}/things/{entry_id}"}, + AdditionalBindings: []*annotations.HttpRule{{Pattern: &annotations.HttpRule_Get{Get: "/v1/things/{entry_id}"}}}, + })}, + {Name: proto.String("Search"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing"), + Options: http(&annotations.HttpRule{Pattern: &annotations.HttpRule_Post{Post: "/v1/things/search"}, Body: "*"})}, + {Name: proto.String("Unbound"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing")}, + {Name: proto.String("Wide"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing"), + Options: http(&annotations.HttpRule{Pattern: &annotations.HttpRule_Get{Get: "/v1/things/{entry_id=**}"}})}, + }, + }}, + } + fd, err := protodesc.NewFile(file, protoregistry.GlobalFiles) + if err != nil { + panic(err) + } + if err := protoregistry.GlobalFiles.RegisterFile(fd); err != nil { + panic(err) + } + return fd +}() + +func jsonName(name string) string { + parts := strings.Split(name, "_") + for i := 1; i < len(parts); i++ { + parts[i] = title(parts[i]) + } + return strings.Join(parts, "") +} + +func thingMessage(name string) *dynamicpb.Message { + return dynamicpb.NewMessage(transcodeTestFile.Messages().ByName(protoreflect.Name(name))) +} + +func setField(m *dynamicpb.Message, name string, v protoreflect.Value) { + m.Set(m.Descriptor().Fields().ByName(protoreflect.Name(name)), v) +} + +type recorded struct { + method, uri, body string +} + +func transcodeServer(t *testing.T, status int, reply string) (*Gateway, *recorded, *atomic.Int32) { + t.Helper() + got := &recorded{} + hits := &atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + body, _ := io.ReadAll(r.Body) + got.method, got.uri, got.body = r.Method, r.URL.RequestURI(), string(body) + w.WriteHeader(status) + _, _ = io.WriteString(w, reply) + })) + t.Cleanup(srv.Close) + return newGateway(srv.URL, "Bearer viewer", "org-1", "session-1"), got, hits +} + +func TestTranscodedUsesTheBindingUnderThePrefix(t *testing.T) { + gw, got, _ := transcodeServer(t, http.StatusOK, `{"entryId":"e/1","big":"9007199254740993","added":"by a newer owner"}`) + req := thingMessage("GetThingRequest") + setField(req, "tenant", protoreflect.ValueOfString("org-1")) + setField(req, "entry_id", protoreflect.ValueOfString("e/1")) + setField(req, "page_size", protoreflect.ValueOfInt32(100)) + ids := req.Mutable(req.Descriptor().Fields().ByName("ids")).List() + ids.Append(protoreflect.ValueOfString("a")) + ids.Append(protoreflect.ValueOfString("b")) + filter := req.Mutable(req.Descriptor().Fields().ByName("filter")).Message() + filter.Set(filter.Descriptor().Fields().ByName("name"), protoreflect.ValueOfString("x")) + resp := thingMessage("Thing") + + if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, resp); err != nil { + t.Fatal(err) + } + if got.method != http.MethodGet { + t.Fatalf("method = %s", got.method) + } + // The additional binding is the one under the prefix; the path variable is + // escaped so its slash cannot add a segment; every other set field is a + // query parameter by proto name. + want := "/v1/things/e%2F1?filter.name=x&ids=a&ids=b&page_size=100&tenant=org-1" + if got.uri != want { + t.Fatalf("uri = %s, want %s", got.uri, want) + } + if got.body != "" { + t.Fatalf("a GET binding sent a body: %q", got.body) + } + if v := resp.Get(resp.Descriptor().Fields().ByName("big")).Int(); v != 9007199254740993 { + t.Fatalf("big = %d", v) + } +} + +func TestTranscodedSendsTheBodyTheBindingNames(t *testing.T) { + gw, got, _ := transcodeServer(t, http.StatusOK, `{}`) + req := thingMessage("GetThingRequest") + setField(req, "tenant", protoreflect.ValueOfString("org-1")) + setField(req, "page_size", protoreflect.ValueOfInt32(5)) + if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Search", req, thingMessage("Thing")); err != nil { + t.Fatal(err) + } + if got.method != http.MethodPost || got.uri != "/v1/things/search" { + t.Fatalf("request = %s %s", got.method, got.uri) + } + var body map[string]any + if err := json.Unmarshal([]byte(got.body), &body); err != nil || body["tenant"] != "org-1" || body["pageSize"] != float64(5) { + t.Fatalf("body = %s (%v)", got.body, err) + } +} + +func TestTranscodedRefusesBeforeSending(t *testing.T) { + get := thingMessage("GetThingRequest") + setField(get, "entry_id", protoreflect.ValueOfString("e1")) + cases := []struct { + name, prefix, method string + req, resp proto.Message + want string + }{ + {"no binding under the prefix", "/v1/other", "/things.v1.Things/Get", get, thingMessage("Thing"), "no google.api.http binding under /v1/other"}, + {"a prefix that is only a string prefix", "/v1/thing", "/things.v1.Things/Get", get, thingMessage("Thing"), "no google.api.http binding"}, + {"no binding at all", "/v1/things", "/things.v1.Things/Unbound", get, thingMessage("Thing"), "declares no google.api.http binding"}, + {"a multi-segment variable", "/v1/things", "/things.v1.Things/Wide", get, thingMessage("Thing"), "multi-segment"}, + {"an unknown method", "/v1/things", "/things.v1.Things/Missing", get, thingMessage("Thing"), "not in the protobuf registry"}, + {"the wrong request type", "/v1/things", "/things.v1.Things/Get", thingMessage("Thing"), thingMessage("Thing"), "request is things.v1.Thing"}, + {"the wrong response type", "/v1/things", "/things.v1.Things/Get", get, thingMessage("Sub"), "response is things.v1.Sub"}, + {"an empty path variable", "/v1/things", "/things.v1.Things/Get", thingMessage("GetThingRequest"), thingMessage("Thing"), "{entry_id} is empty"}, + {"a dot-segment value", "/v1/things", "/things.v1.Things/Get", func() proto.Message { + m := thingMessage("GetThingRequest") + setField(m, "entry_id", protoreflect.ValueOfString("..")) + return m + }(), thingMessage("Thing"), "is a dot segment"}, + {"a trailing slash", "/v1/things/", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + {"a relative prefix", "v1/things", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + {"a dot segment", "/v1/../things", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + {"a query", "/v1/things?x=1", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gw, _, hits := transcodeServer(t, http.StatusOK, `{}`) + err := gw.Transcoded(context.Background(), tc.prefix, tc.method, tc.req, tc.resp) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + if hits.Load() != 0 { + t.Fatalf("a refused call reached the gateway") + } + }) + } +} + +func TestTranscodedKeepsTheStatusOfAFailure(t *testing.T) { + gw, _, _ := transcodeServer(t, http.StatusForbidden, `{"code":7,"message":"denied"}`) + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing")) + var status *GatewayError + if !errors.As(err, &status) || status.StatusCode != http.StatusForbidden { + t.Fatalf("err = %v, want a GatewayError with 403", err) + } +} + +func TestTranscodedBoundsTheResponse(t *testing.T) { + gw, _, _ := transcodeServer(t, http.StatusOK, `{"entryId":"`+strings.Repeat("x", 64)+`"}`) + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing"), MaxResponseBytes(32)) + if err == nil || !strings.Contains(err.Error(), "exceeds 32 bytes") { + t.Fatalf("err = %v", err) + } +} + +func TestTranscodedPresentsTheViewersCredentials(t *testing.T) { + var authorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("authorization") + _, _ = io.WriteString(w, `{}`) + })) + defer srv.Close() + gw := newGateway(srv.URL, "Bearer viewer", "org-1", "session-1") + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing")); err != nil { + t.Fatal(err) + } + if authorization != "Bearer viewer" { + t.Fatalf("authorization = %q", authorization) + } +} + +func TestFieldMaskKeepsOnlyWhatItNames(t *testing.T) { + thing := thingMessage("Thing") + setField(thing, "entry_id", protoreflect.ValueOfString("e1")) + setField(thing, "big", protoreflect.ValueOfInt64(7)) + setField(thing, "secret", protoreflect.ValueOfString("storage://bucket/key")) + sub := thing.Mutable(thing.Descriptor().Fields().ByName("sub")).Message() + sub.Set(sub.Descriptor().Fields().ByName("name"), protoreflect.ValueOfString("kept")) + subs := thing.Mutable(thing.Descriptor().Fields().ByName("subs")).List() + element := subs.NewElement() + element.Message().Set(element.Message().Descriptor().Fields().ByName("name"), protoreflect.ValueOfString("kept too")) + subs.Append(element) + + mask := MustFieldMask(thing, "entry_id", "sub", "subs.name") + out := Apply(mask, thing) + fields := out.Descriptor().Fields() + if out.Has(fields.ByName("secret")) || out.Has(fields.ByName("big")) { + t.Fatalf("unnamed fields survived: %v", out) + } + if out.Get(fields.ByName("entry_id")).String() != "e1" || out.Get(fields.ByName("sub")).Message().Get(sub.Descriptor().Fields().ByName("name")).String() != "kept" { + t.Fatalf("named fields were dropped: %v", out) + } + if out.Get(fields.ByName("subs")).List().Len() != 1 { + t.Fatalf("repeated field was dropped: %v", out) + } + if !thing.Has(fields.ByName("secret")) { + t.Fatalf("Apply modified its input") + } + if _, err := NewFieldMask(thing, "entry_id", "storage_uri"); err == nil { + t.Fatalf("a mask naming an unknown field was accepted") + } + if _, err := NewFieldMask(thing, "entry_id.x"); err == nil { + t.Fatalf("a mask descending into a scalar was accepted") + } +} + +func TestMessageEncodesProtobufJSON(t *testing.T) { + rule := &annotations.HttpRule{Pattern: &annotations.HttpRule_Get{Get: "/v1/x"}, ResponseBody: "items"} + out, err := json.Marshal(struct { + Rule Message[*annotations.HttpRule] `json:"rule"` + None Message[*annotations.HttpRule] `json:"none"` + }{Rule: MessageOf(rule)}) + if err != nil { + t.Fatal(err) + } + var decoded map[string]map[string]any + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatal(err) + } + if decoded["rule"]["responseBody"] != "items" || decoded["rule"]["get"] != "/v1/x" { + t.Fatalf("not protobuf JSON: %s", out) + } + if !strings.Contains(string(out), `"none":null`) { + t.Fatalf("a nil message did not encode as null: %s", out) + } +} From d5e5c50e17123da9d89822764a991bd4ee546456 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:51:54 -0400 Subject: [PATCH 2/3] feat: report whom a ForModule capability was issued for A handler that must check a claimed owner against the capability it reads with had to mint its own Task Work Context (with an invented session) to see accounts' owner and actor. Gateway.WorkContextPrincipals returns the organization, owner and current actor accounts answered the ForModule mint with, reusing the cached capability. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 ++++++ solution.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++-- solution_test.go | 35 +++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 187a3c1..798b4af 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,12 @@ non-2xx answer is a `*GatewayError` carrying the status. `Gateway.OrgID` is the viewer's active organization, the same one `ForModule` mints in. A handler scoping a module read to a tenant names this one. +`Gateway.WorkContextPrincipals` reports whom a `ForModule` capability was +issued for — the organization, the Task's owner and its current actor, as +accounts answered the mint — so a handler compares a claimed owner against +accounts' own resolution instead of trusting it. It reuses the cached +capability rather than minting again. + ### Generated messages in a response A handler's value is encoded with `encoding/json`, which reads a generated diff --git a/solution.go b/solution.go index 15d169a..533ad7c 100644 --- a/solution.go +++ b/solution.go @@ -2194,6 +2194,7 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (codefly.WorkC var issued struct { Token string `json:"token"` ExpiresAt time.Time `json:"expiresAt"` + WorkContextPrincipals } if err := json.NewDecoder(resp.Body).Decode(&issued); err != nil { return codefly.WorkContextToken{}, time.Time{}, fmt.Errorf("work context mint for %q returned invalid json: %w", ask.Audience, err) @@ -2234,9 +2235,40 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (codefly.WorkC lifetime.Round(time.Second), workContextRenewal, lead.Round(time.Second)) }) } + g.contexts.remember(token, issued.WorkContextPrincipals) return token, issued.ExpiresAt.Add(-lead), nil } +// WorkContextPrincipals is who accounts says a minted capability acts for, as +// it answered the mint: the organization, the Task's owner, and its current +// actor. They are accounts' own resolution of the forwarded bearer, never +// anything the handler or the browser supplied. +type WorkContextPrincipals struct { + OrgID string `json:"orgId"` + OwnerPrincipalID string `json:"ownerPrincipalId"` + CurrentActorPrincipalID string `json:"currentActorPrincipalId"` +} + +// WorkContextPrincipals reports whom the capability this gateway acts under was +// issued for — on a gateway derived by ForModule, resolving (and if need be +// minting) the capability exactly as a request would. A handler compares them +// against an identity it was handed rather than trusting that identity. On a +// gateway that acts under no capability it is an error. +func (g *Gateway) WorkContextPrincipals(ctx context.Context) (WorkContextPrincipals, error) { + if g.delegation == nil { + return WorkContextPrincipals{}, errors.New("this gateway acts under no work context; derive one with ForModule") + } + token, err := g.workContext(ctx) + if err != nil { + return WorkContextPrincipals{}, err + } + principals, ok := g.contexts.principals(token) + if !ok || principals.OwnerPrincipalID == "" || principals.CurrentActorPrincipalID == "" || principals.OrgID == "" { + return WorkContextPrincipals{}, errors.New("the work context issuance named no organization, owner or actor") + } + return principals, nil +} + // workContextCache holds the capabilities minted while serving one request. The // gateway a handler receives owns it and every gateway derived from that one // shares it, so the cache lives exactly as long as the viewer's request. @@ -2248,12 +2280,28 @@ type workContextCache struct { // fans out would otherwise spend one audited mint per goroutine for a // capability they all share. minting map[string]*pendingMint + // issuedFor holds, per issued capability, whom accounts said it acts for. + issuedFor map[string]WorkContextPrincipals +} + +func (c *workContextCache) remember(token codefly.WorkContextToken, principals WorkContextPrincipals) { + c.mu.Lock() + defer c.mu.Unlock() + c.issuedFor[token.Encoded()] = principals +} + +func (c *workContextCache) principals(token codefly.WorkContextToken) (WorkContextPrincipals, bool) { + c.mu.Lock() + defer c.mu.Unlock() + principals, ok := c.issuedFor[token.Encoded()] + return principals, ok } func newWorkContextCache() *workContextCache { return &workContextCache{ - minted: map[string]issuedWorkContext{}, - minting: map[string]*pendingMint{}, + minted: map[string]issuedWorkContext{}, + minting: map[string]*pendingMint{}, + issuedFor: map[string]WorkContextPrincipals{}, } } diff --git a/solution_test.go b/solution_test.go index 2042079..da17a0d 100644 --- a/solution_test.go +++ b/solution_test.go @@ -1845,7 +1845,7 @@ func (g *workContextGateway) serve(w http.ResponseWriter, r *http.Request) { if g.mintDelay > 0 { time.Sleep(g.mintDelay) } - issued := map[string]any{"token": token} + issued := map[string]any{"token": token, "orgId": mint.OrgID, "ownerPrincipalId": "viewer-principal", "currentActorPrincipalId": "viewer-principal"} if !g.omitExpiry { issued["expiresAt"] = time.Now().Add(g.tokenTTL).UTC().Format(time.RFC3339Nano) } @@ -2526,3 +2526,36 @@ func TestManifestURLIsNeverTheListenAddress(t *testing.T) { } } } + +// TestWorkContextPrincipalsReportsWhomAccountsIssuedFor: a handler comparing a +// claimed owner against the capability reads accounts' own answer to the mint, +// and a gateway acting under no capability has none to report. +func TestWorkContextPrincipalsReportsWhomAccountsIssuedFor(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + var got WorkContextPrincipals + var bare error + solution := serveHandler(t, gw.URL, func(ctx context.Context, g *Gateway) (any, error) { + _, bare = g.WorkContextPrincipals(ctx) + docs, err := g.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}) + if err != nil { + return nil, err + } + got, err = docs.WorkContextPrincipals(ctx) + return "done", err + }) + resp := viewerRequest(t, solution.URL) + defer drainAndClose(resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("solution answered %d, want 200", resp.StatusCode) + } + want := WorkContextPrincipals{OrgID: viewerOrg, OwnerPrincipalID: "viewer-principal", CurrentActorPrincipalID: "viewer-principal"} + if got != want { + t.Fatalf("principals = %+v, want %+v", got, want) + } + if bare == nil { + t.Fatal("a gateway acting under no work context reported principals") + } + if n := gw.mintCount(); n != 1 { + t.Fatalf("minted %d capabilities, want 1: reading the principals reuses the capability", n) + } +} From 2007f853dcfbc2fca718ee1539f37d39093165db Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:01:46 -0400 Subject: [PATCH 3/3] fix: describe a pointer to a generated message by the message *Message[T] carries Message[T]'s methods, so the interface check matched it first and called the descriptor on a nil pointer. Unwrap pointers before it. Co-Authored-By: Claude Opus 5.5 (1M context) --- operations.go | 8 ++++++-- operations_test.go | 15 ++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/operations.go b/operations.go index dca9db4..236b19c 100644 --- a/operations.go +++ b/operations.go @@ -235,12 +235,16 @@ func schemaFor(t reflect.Type, definitions map[string]any) map[string]any { if t == rawMessageType { return map[string]any{"type": "object"} } + // A pointer is described by what it points to. It is unwrapped first: + // *Message[T] also has Message[T]'s methods, and its zero value is a nil + // pointer no method can be called on. + if t.Kind() == reflect.Pointer { + return schemaFor(t.Elem(), definitions) + } if t.Implements(messageDescribedType) { return messageSchema(reflect.Zero(t).Interface().(messageDescribed).messageDescriptor(), definitions) } switch t.Kind() { - case reflect.Pointer: - return schemaFor(t.Elem(), definitions) case reflect.Struct: name := title(t.Name()) // An unnamed struct has no definition to be referenced by: keyed on "" diff --git a/operations_test.go b/operations_test.go index c75c84d..e30aa5f 100644 --- a/operations_test.go +++ b/operations_test.go @@ -12,10 +12,11 @@ import ( ) type documentedTree struct { - ID string `json:"id"` - Rule Message[*annotations.HttpRule] `json:"rule"` - Children []*documentedTree `json:"children"` - Facets map[string]any `json:"facets"` + ID string `json:"id"` + Rule Message[*annotations.HttpRule] `json:"rule"` + Optional *Message[*annotations.HttpRule] `json:"optional,omitempty"` + Children []*documentedTree `json:"children"` + Facets map[string]any `json:"facets"` } type documentedResponse struct { @@ -66,9 +67,13 @@ func TestInterfaceDocumentDescribesGeneratedMessagesFromTheirDescriptor(t *testi if items := rule.Properties["additionalBindings"]["items"].(map[string]any); items["$ref"] != "#/definitions/GoogleApiHttpRule" { t.Fatalf("a self-referencing message does not terminate in a $ref: %v", items) } - if _, ok := doc.Definitions["DocumentedTree"]; !ok { + tree, ok := doc.Definitions["DocumentedTree"] + if !ok { t.Fatalf("struct definitions are missing: %v", doc.Definitions) } + if tree.Properties["optional"]["$ref"] != "#/definitions/GoogleApiHttpRule" { + t.Fatalf("a pointer to a generated message is not described by it: %v", tree.Properties["optional"]) + } } func TestInterfaceDocumentRefusesAnUndocumentedOperation(t *testing.T) {