diff --git a/README.md b/README.md index 942efdd..798b4af 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,65 @@ 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. + +`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 +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..236b19c --- /dev/null +++ b/operations.go @@ -0,0 +1,405 @@ +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"} + } + // 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.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..e30aa5f --- /dev/null +++ b/operations_test.go @@ -0,0 +1,117 @@ +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"` + Optional *Message[*annotations.HttpRule] `json:"optional,omitempty"` + 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) + } + 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) { + 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..533ad7c 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. @@ -2187,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) @@ -2227,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. @@ -2241,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) + } +} 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) + } +}