diff --git a/README.md b/README.md index ca3fc83..6b8d689 100644 --- a/README.md +++ b/README.md @@ -641,6 +641,36 @@ is published, and an operation missing its documentation or its policy is refused, at generation and at boot. A `Message[T]` field is described from its descriptor, with protobuf JSON names. +Declare required top-level request keys with +`Request: solution.RequiredFields(reviewRequest{}, "recordId", "revision", "typeRevision")`. +Names are exact JSON property names, not Go field names. This emits a separate +request definition for the operation with Swagger `required`; Core projects those fields as +`optional: false`. Other operations, responses and nested uses of the same type +remain unchanged, including recursive occurrences. No names means no change. +This declares key presence only; handlers still own nonblank, non-null and +business validation. It does not install request validation middleware. + +The opt-in supports a named ordinary struct or non-nil pointer to it with +direct, uniquely named exported fields. Its reachable fields may use named +structs, strings, booleans, signed integers, floats, pointers, arrays, non-byte +slices and string-keyed maps. At generation and boot it refuses unknown, blank +or repeated required names, and rejects embedding, invalid JSON names, the JSON +`string` option, JSON/text codecs (including `time.Time`), protobuf `Message`, +byte slices and interfaces anywhere in the request graph. Two distinct types +whose names produce the same definition within that graph are also refused. +The graph has its own definition namespace, so another operation or response +using the same short type name cannot change it, nor can the built-in `Error`. +Unexported and `json:"-"` fields cannot be named. `omitempty` does not imply +input optionality. Use the wrapper only in `Request`; nested requirements are +outside this API. + +The generated contract retains the generator's **non-null input subset**: +pointer and collection properties may be absent when optional, but a present +value must have the declared JSON type. Go can encode nil pointers and nil +collections as `null`; that value is outside this contract even if a handler +accepts it. This API neither adds nullability nor claims to describe every +input Go's decoder can accept. + The artifact's security scheme is the `x-codefly-work-context` header the edge sends. Every operation also declares its `Policy`, what a call to the route must hold: `solution.Requires(kind, actions...)` or `solution.AnyCheckedCaller()`, diff --git a/internal/requestschematest/first/types.go b/internal/requestschematest/first/types.go new file mode 100644 index 0000000..2c15a43 --- /dev/null +++ b/internal/requestschematest/first/types.go @@ -0,0 +1,6 @@ +// Package first supplies a distinct Go identity with the same short name as second. +package first + +type Shared struct { + First string `json:"first"` +} diff --git a/internal/requestschematest/second/types.go b/internal/requestschematest/second/types.go new file mode 100644 index 0000000..61f20d7 --- /dev/null +++ b/internal/requestschematest/second/types.go @@ -0,0 +1,6 @@ +// Package second supplies a distinct Go identity with the same short name as first. +package second + +type Shared struct { + Second string `json:"second"` +} diff --git a/operations.go b/operations.go index fb9ccf4..ac0cc61 100644 --- a/operations.go +++ b/operations.go @@ -58,6 +58,7 @@ type Operation struct { // Query lists the documented query parameters. Query []QueryParameter // Request is the zero value of the JSON body the operation reads, if any. + // RequiredFields wraps it to declare top-level JSON key presence. 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 @@ -410,8 +411,22 @@ func swaggerDocument(info InterfaceInfo, ops []Operation, audience string) (map[ endpoint[OperationExtension] = marker } parameters := []any{} - if op.Request != nil { - parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": valueSchema(op.Request, definitions)}) + if requestValue(op.Request) != nil { + schema, err := requestSchema(op.Request, definitions, requiredRequestDefinition(op)) + if err != nil { + return nil, fmt.Errorf("operation %s %s: %w", op.verb(), op.Path, err) + } + if d, wrapped := op.Request.(RequiredRequest); wrapped && len(d.names) > 0 { + // Core records the identity of a published payload definition; + // an inline body has none. Keep this operation's copy separate. + name := requiredRequestDefinition(op) + if _, exists := definitions[name]; exists { + return nil, fmt.Errorf("operation %s %s: request definition %q already exists", op.verb(), op.Path, name) + } + definitions[name] = schema + schema = map[string]any{"$ref": "#/definitions/" + name} + } + parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": schema}) } for _, query := range op.Query { parameters = append(parameters, map[string]any{"name": query.Name, "in": "query", "required": query.Required, "type": "string"}) @@ -531,6 +546,25 @@ func (o Operation) validate() error { if err := o.Policy.validate(); err != nil { return fmt.Errorf("operation %s %s %w", o.verb(), o.Path, err) } + if _, wrapped := o.Response.(RequiredRequest); wrapped { + return fmt.Errorf("operation %s %s: RequiredFields belongs in Request, not Response", o.verb(), o.Path) + } + if _, pointer := o.Request.(*RequiredRequest); pointer { + return fmt.Errorf("operation %s %s: use the RequiredFields value directly, not a *RequiredRequest", o.verb(), o.Path) + } + if _, pointer := o.Response.(*RequiredRequest); pointer { + return fmt.Errorf("operation %s %s: RequiredFields belongs in Request, not Response", o.verb(), o.Path) + } + if d, wrapped := o.Request.(RequiredRequest); wrapped { + if _, nested := d.request.(RequiredRequest); nested { + return fmt.Errorf("operation %s %s: RequiredFields cannot wrap another RequiredRequest", o.verb(), o.Path) + } + if len(d.names) > 0 { + if _, err := requestSchema(o.Request, map[string]any{}, requiredRequestDefinition(o)); err != nil { + return fmt.Errorf("operation %s %s: %w", o.verb(), o.Path, err) + } + } + } return nil } diff --git a/qualification/installreceiver/README.md b/qualification/installreceiver/README.md new file mode 100644 index 0000000..8ffcfdf --- /dev/null +++ b/qualification/installreceiver/README.md @@ -0,0 +1,20 @@ +# Installation receiver process fixture + +This test-only consumer lets Accounts' `TestCanonicalCombinedInstallQueueThroughRuntime` exercise the real runtime listener and Authority Check transport. It does not implement an installation verifier. Its `OnInstall` callback appends the verified synthetic installation to the specified JSONL file, then acknowledges, refuses, or fails as directed by the test. + +When `INSTALL_RECEIVER_SCHEMA` names a protobuf-JSON RecordType, acknowledgement also requires two `DefineRecordType` calls through a declared setup binding, with the second confirming the same identity and revision without minting another revision. An undeclared `DefineProjection` call must be refused. The composing test must provide the `records/ENDPOINT` and `records/BINDING` workspace configuration slots and deliver matching setup, method and owner-aware policies. This mode is not evidence until run through a real composed Authority, hop and Records service. + +The Accounts test owns all process configuration, including the solution ID, log path, outcome, Authority endpoint, workload token file and native listener. Run it only with disposable owner test dependencies. + +Build with a Go workspace selecting the desired compatible runtime, Core, Authority client and hop revisions, then pass the resulting executable to the Accounts test: + +```sh +cd qualification/installreceiver +go build -o /absolute/path/installreceiver . +shasum -a 256 /absolute/path/installreceiver +# From module-saas-starter/module/services/accounts/code, with the same cohort: +INSTALL_RECEIVER_BINARY=/absolute/path/installreceiver go test -count=1 -v -run '^TestCanonicalCombinedInstallQueueThroughRuntime$' ./pkg/adapters +``` + +Record the source and dependency revisions, workspace selection, binary hash, Codefly version, and test output alongside the run. A skipped test is not a crossing proof. +The nested module uses the published Records client; qualifying unreleased owner changes requires an explicit local Go workspace selecting the matching client, runtime, Core and Authority libraries. Do not commit local replacement paths. diff --git a/qualification/installreceiver/go.mod b/qualification/installreceiver/go.mod new file mode 100644 index 0000000..295abdc --- /dev/null +++ b/qualification/installreceiver/go.mod @@ -0,0 +1,9 @@ +module github.com/codefly-dev/solution-runtime-go/qualification/installreceiver + +go 1.27.3 + +require ( + github.com/codefly-dev/solution-runtime-go v0.0.17-0.20261031211909-86f9d9eb3da8 + github.com/obin-ai/module-record-store/clients/go v0.4.4 + google.golang.org/protobuf v1.36.12 +) diff --git a/qualification/installreceiver/go.sum b/qualification/installreceiver/go.sum new file mode 100644 index 0000000..18ba355 --- /dev/null +++ b/qualification/installreceiver/go.sum @@ -0,0 +1,2 @@ +github.com/obin-ai/module-record-store/clients/go v0.4.4 h1:vPBygkiUx1uvfXoWkg0D8BMXEabih+ZgJd7PcHNfwWI= +github.com/obin-ai/module-record-store/clients/go v0.4.4/go.mod h1:EozaQ6pnMueL1IvuvH/tbcn7apw0YQqnsPmpKX0nR9k= diff --git a/qualification/installreceiver/main.go b/qualification/installreceiver/main.go new file mode 100644 index 0000000..8d9af79 --- /dev/null +++ b/qualification/installreceiver/main.go @@ -0,0 +1,84 @@ +// installreceiver is a process fixture for the Accounts installation crossing. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + + solution "github.com/codefly-dev/solution-runtime-go" + recordsv1 "github.com/obin-ai/module-record-store/clients/go/records/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +func main() { + id := os.Getenv("SOLUTION_ID") + path := os.Getenv("INSTALL_RECEIVER_LOG") + outcome := os.Getenv("INSTALL_RECEIVER_OUTCOME") + schemaPath := os.Getenv("INSTALL_RECEIVER_SCHEMA") + if id == "" || path == "" || (outcome != "ack" && outcome != "refuse" && outcome != "fail") { + log.Fatal("SOLUTION_ID, INSTALL_RECEIVER_LOG and a valid INSTALL_RECEIVER_OUTCOME are required") + } + var schema recordsv1.RecordType + if schemaPath != "" { + data, err := os.ReadFile(schemaPath) + if err != nil { + log.Fatal(err) + } + if err := protojson.Unmarshal(data, &schema); err != nil { + log.Fatal(err) + } + } + step := func(ctx context.Context, installation solution.Installation) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("open observation log: %w", err) + } + err = json.NewEncoder(file).Encode(installation) + closeErr := file.Close() + if err != nil { + return fmt.Errorf("write observation: %w", err) + } + if closeErr != nil { + return fmt.Errorf("close observation log: %w", closeErr) + } + switch outcome { + case "refuse": + return solution.RefuseInstall("fixture refused installation") + case "fail": + return errors.New("fixture installation failed") + default: + if schemaPath == "" { + return nil + } + request := &recordsv1.DefineRecordTypeRequest{Name: schema.GetName(), Type: &schema} + first := &recordsv1.DefineRecordTypeResponse{} + if err := installation.Call(ctx, "records", recordsv1.RecordsService_DefineRecordType_FullMethodName, request, first); err != nil { + return fmt.Errorf("define schema: %w", err) + } + again := &recordsv1.DefineRecordTypeResponse{} + if err := installation.Call(ctx, "records", recordsv1.RecordsService_DefineRecordType_FullMethodName, request, again); err != nil { + return fmt.Errorf("repeat schema: %w", err) + } + if first.GetType().GetTypeId() == "" || first.GetType().GetTypeRevision() == "" || again.GetMinted() || first.GetType().GetTypeId() != again.GetType().GetTypeId() || first.GetType().GetTypeRevision() != again.GetType().GetTypeRevision() { + return errors.New("schema definition did not converge") + } + if err := installation.Call(ctx, "records", recordsv1.RecordsService_DefineProjection_FullMethodName, &recordsv1.DefineProjectionRequest{}, &recordsv1.DefineProjectionResponse{}); err == nil { + return errors.New("undeclared method was accepted") + } + return nil + } + } + server := solution.New(solution.Manifest{ID: id}) + if schemaPath == "" { + server.OnInstall(step) + } else { + server.OnInstall(step, solution.SetupModule{As: "records", Authority: solution.AuthorityRelay{Group: "records", EndpointKey: "ENDPOINT", BindingKey: "BINDING"}, Methods: []string{recordsv1.RecordsService_DefineRecordType_FullMethodName}}) + } + if err := server.Serve(); err != nil { + log.Fatal(err) + } +} diff --git a/required_fields.go b/required_fields.go new file mode 100644 index 0000000..9b2b5a6 --- /dev/null +++ b/required_fields.go @@ -0,0 +1,210 @@ +package solution + +import ( + "crypto/sha256" + "encoding" + "encoding/json" + "fmt" + "maps" + "reflect" + "slices" + "strings" + "unicode" +) + +// RequiredFields wraps an Operation.Request to declare top-level JSON keys +// that must be present. It describes key presence only: it neither validates +// requests nor requires non-null, nonzero or nonblank values. The handler +// retains those rules. Names are exact JSON property names, in output order. +// +// With no names the request schema is unchanged. Otherwise request must be a named +// ordinary struct or a non-nil pointer to one, with direct, uniquely named JSON +// fields. Reachable types may be named structs, strings, booleans, signed +// integers, floats, pointers, arrays, non-byte slices and string-keyed maps. +// Embedding, custom JSON/text codecs, protobuf Messages, byte slices, interfaces +// and ambiguous definition names are unsupported anywhere in that graph. +// Schemas retain the generator's non-null input subset, even for pointers and +// collections; a handler may accept more. Invalid declarations fail generation +// and boot. +// +// The requirement applies only to this operation's root request object. Other +// uses of the type, including nested recursive occurrences and responses, keep +// their original schemas. RequiredFields is not a response declaration. +func RequiredFields(request any, names ...string) RequiredRequest { + return RequiredRequest{request: request, names: slices.Clone(names)} +} + +// RequiredRequest is the request declaration returned by RequiredFields. Its +// zero value declares no request body. Use it only as Operation.Request. +type RequiredRequest struct { + request any + names []string +} + +func requestValue(request any) any { + if d, ok := request.(RequiredRequest); ok { + return d.request + } + return request +} + +// A dot/hyphen namespace cannot be a Go identifier or the generator's flattened +// protobuf definition name. Hashing the served method/path avoids JSON Pointer +// escaping and keeps identity stable regardless of operation order. +func requiredRequestDefinition(op Operation) string { + return fmt.Sprintf("codefly.required-request.%x", sha256.Sum256([]byte(op.verb()+" "+servedRoute(op.Path)))) +} + +func requestSchema(request any, definitions map[string]any, namespace string) (map[string]any, error) { + declaration, wrapped := request.(RequiredRequest) + if !wrapped { + return valueSchema(request, definitions), nil + } + if len(declaration.names) == 0 { + return valueSchema(declaration.request, definitions), nil + } + if err := declaration.validateShape(); err != nil { + return nil, err + } + local := map[string]any{} + schema := valueSchema(declaration.request, local) + root, ok := local[strings.TrimPrefix(schema["$ref"].(string), "#/definitions/")].(map[string]any) + if !ok || root["type"] != "object" || root["additionalProperties"] != false { + return nil, fmt.Errorf("RequiredFields needs a fixed request object") + } + properties := root["properties"].(map[string]any) + seen := map[string]bool{} + for _, name := range declaration.names { + switch { + case strings.TrimSpace(name) == "": + return nil, fmt.Errorf("RequiredFields property %q is blank", name) + case seen[name]: + return nil, fmt.Errorf("RequiredFields property %q is duplicated", name) + case properties[name] == nil: + return nil, fmt.Errorf("RequiredFields property %q is not emitted by the request", name) + } + seen[name] = true + } + // Each request graph is generated independently: the legacy generator keys + // by short type name, which may already mean a different type in the + // shared document (including its built-in Error). Relocate every local + // reference, keeping the optional recursive root distinct from this root. + for name, definition := range local { + definitions[namespace+"."+name] = relocateRequestSchema(definition.(map[string]any), namespace) + } + detached := relocateRequestSchema(root, namespace) + detached["required"] = slices.Clone(declaration.names) + return detached, nil +} + +func (d RequiredRequest) validateShape() error { + t := reflect.TypeOf(d.request) + if t == nil { + return fmt.Errorf("RequiredFields needs a named struct request, not nil") + } + if t.Kind() == reflect.Pointer { + if reflect.ValueOf(d.request).IsNil() { + return fmt.Errorf("RequiredFields needs a non-nil request pointer") + } + t = t.Elem() + } + if t.Kind() != reflect.Struct || t.Name() == "" || t == reflect.TypeFor[RequiredRequest]() || t.Implements(messageDescribedType) { + return fmt.Errorf("RequiredFields needs a named ordinary struct request, got %s", t) + } + return validateRequestType(t, "request", map[reflect.Type]bool{}, map[string]reflect.Type{}) +} + +// This checks the subset already represented faithfully by schemaFor; it does +// not derive a second schema or infer business validation from Go zero values. +func validateRequestType(t reflect.Type, path string, seen map[reflect.Type]bool, definitions map[string]reflect.Type) error { + if seen[t] { + return nil + } + seen[t] = true + for _, codec := range []reflect.Type{reflect.TypeFor[json.Unmarshaler](), reflect.TypeFor[json.Marshaler](), reflect.TypeFor[encoding.TextUnmarshaler](), reflect.TypeFor[encoding.TextMarshaler]()} { + if t.Implements(codec) || reflect.PointerTo(t).Implements(codec) { + return fmt.Errorf("RequiredFields %s (%s) has a custom JSON/text codec", path, t) + } + } + if t.Implements(messageDescribedType) || t == reflect.TypeFor[RequiredRequest]() { + return fmt.Errorf("RequiredFields %s has unsupported type %s", path, t) + } + switch t.Kind() { + case reflect.Pointer: + return validateRequestType(t.Elem(), path, seen, definitions) + case reflect.Slice, reflect.Array: + if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + return fmt.Errorf("RequiredFields %s is a byte slice: base64 JSON is outside the supported request shapes", path) + } + return validateRequestType(t.Elem(), path+"[]", seen, definitions) + case reflect.Map: + if t.Key().Kind() != reflect.String { + return fmt.Errorf("RequiredFields %s needs string map keys", path) + } + return validateRequestType(t.Elem(), path+"{}", seen, definitions) + case reflect.String, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: + return nil + case reflect.Struct: + if t.Name() == "" { + return fmt.Errorf("RequiredFields %s needs a named struct", path) + } + name := title(t.Name()) + if previous, exists := definitions[name]; exists && previous != t { + return fmt.Errorf("RequiredFields %s: types %s and %s collide at definition %q", path, previous, t, name) + } + definitions[name] = t + default: + return fmt.Errorf("RequiredFields %s has unsupported type %s", path, t) + } + names := map[string]bool{} + for i := range t.NumField() { + field := t.Field(i) + tag := field.Tag.Get("json") + if tag == "-" { + continue + } + if field.Anonymous { + return fmt.Errorf("RequiredFields %s field %s is embedded", path, field.Name) + } + if !field.IsExported() { + continue + } + name, options, _ := strings.Cut(tag, ",") + // encoding/json accepts letters, numbers and the punctuation below in + // tag names (encode.go, isValidTag). The old generator does not model + // invalid names or the string option; refuse those only on this path. + for _, r := range name { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !strings.ContainsRune("!#$%&()*+-./:;<=>?@[]^_{|}~ ", r) { + return fmt.Errorf("RequiredFields %s field %s has invalid JSON name %q", path, field.Name, name) + } + } + if name == "-" || slices.Contains(strings.Split(options, ","), "string") { + return fmt.Errorf("RequiredFields %s field %s has unsupported JSON tag %q", path, field.Name, tag) + } + if name == "" { + name = field.Name + } + if names[name] { + return fmt.Errorf("RequiredFields %s has colliding JSON property %q", path, name) + } + names[name] = true + if err := validateRequestType(field.Type, path+"."+name, seen, definitions); err != nil { + return err + } + } + return nil +} + +// Generated schemas contain maps and scalar/required values, never live types. +// Clone maps while moving refs; no shared response or unwrapped schema changes. +func relocateRequestSchema(schema map[string]any, namespace string) map[string]any { + out := maps.Clone(schema) + for key, value := range out { + if ref, ok := value.(string); key == "$ref" && ok { + out[key] = "#/definitions/" + namespace + "." + strings.TrimPrefix(ref, "#/definitions/") + } else if nested, ok := value.(map[string]any); ok { + out[key] = relocateRequestSchema(nested, namespace) + } + } + return out +} diff --git a/required_fields_test.go b/required_fields_test.go new file mode 100644 index 0000000..50c0c71 --- /dev/null +++ b/required_fields_test.go @@ -0,0 +1,353 @@ +package solution + +import ( + "bytes" + "encoding/json" + "github.com/codefly-dev/solution-runtime-go/internal/requestschematest/first" + "github.com/codefly-dev/solution-runtime-go/internal/requestschematest/second" + "github.com/santhosh-tekuri/jsonschema/v6" + "reflect" + "strings" + "testing" + "time" + + "google.golang.org/genproto/googleapis/api/annotations" +) + +type requiredRequest struct { + RecordID string `json:"recordId"` + Revision string `json:"revision,omitempty"` + TypeRevision string `json:"typeRevision"` + Optional string `json:"optional,omitempty"` + DefaultName string + Ignored string `json:"-"` + hidden string + Child *requiredRequest `json:"child,omitempty"` +} +type requiredEmbedded struct{ requiredRequest } +type requiredCollision struct { + A string + B string `json:"A"` +} +type requiredInvalidTag struct { + A string `json:"bad\\name"` +} +type requiredStringTag struct { + A int `json:"a,string"` +} +type requiredDashTag struct { + A string `json:"-,omitempty"` +} +type requiredCustom struct { + A string `json:"a"` +} + +func (*requiredCustom) UnmarshalJSON([]byte) error { return nil } + +type requiredCustomText struct { + A string `json:"a"` +} + +func (*requiredCustomText) UnmarshalText([]byte) error { return nil } + +func requiredOperation(request any) Operation { + op := documentedOperations()[0] + op.Path = "/review" + op.Method = "post" + op.Request = request + op.Response = requiredRequest{} + return op +} +func requiredArtifact(t *testing.T, ops ...Operation) []byte { + t.Helper() + raw, err := New(Manifest{ID: "test"}).Operations(ops...).InterfaceArtifact("", InterfaceInfo{Version: "1", OperationPrefix: "Test"}) + if err != nil { + t.Fatal(err) + } + return raw +} +func TestRequiredFieldsAreOperationLocal(t *testing.T) { + names := []string{"recordId", "revision", "typeRevision", "DefaultName"} + wrapped := RequiredFields(&requiredRequest{}, names...) + names[0] = "changed-by-caller" + required := requiredOperation(wrapped) + optional := requiredOperation(requiredRequest{}) + optional.Path = "/optional" + for _, ops := range [][]Operation{{required, optional}, {optional, required}} { + raw := requiredArtifact(t, ops...) + if !bytes.Equal(raw, requiredArtifact(t, ops...)) { + t.Fatal("unstable artifact") + } + var doc struct { + Paths map[string]map[string]struct { + Parameters []struct{ Schema map[string]any } + Responses map[string]struct{ Schema map[string]any } + } + Definitions map[string]map[string]any + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + ref := doc.Paths["/review"]["post"].Parameters[0].Schema["$ref"].(string) + root := doc.Definitions[strings.TrimPrefix(ref, "#/definitions/")] + if root["$ref"] != nil || !reflect.DeepEqual(root["required"], []any{"recordId", "revision", "typeRevision", "DefaultName"}) { + t.Fatalf("root = %v", root) + } + definition := doc.Definitions["RequiredRequest"] + if definition["required"] != nil { + t.Fatal("shared definition changed") + } + if doc.Paths["/optional"]["post"].Parameters[0].Schema["$ref"] != "#/definitions/RequiredRequest" { + t.Fatal("other request changed") + } + if doc.Paths["/review"]["post"].Responses["200"].Schema["$ref"] != "#/definitions/RequiredRequest" { + t.Fatal("response changed") + } + if doc.Definitions[requiredRequestDefinition(required)+".RequiredRequest"]["required"] != nil { + t.Fatal("recursive definition became required") + } + child := root["properties"].(map[string]any)["child"].(map[string]any) + if child["$ref"] != "#/definitions/"+requiredRequestDefinition(required)+".RequiredRequest" { + t.Fatal("recursive occurrence did not use its isolated optional definition") + } + } +} +func TestRequiredFieldsEmptyLeavesExactArtifactUnchanged(t *testing.T) { + for _, request := range []any{nil, requiredRequest{}, (*requiredRequest)(nil), Message[*annotations.HttpRule]{}} { + baseline := requiredArtifact(t, requiredOperation(request)) + for _, names := range [][]string{nil, {}} { + actual := requiredArtifact(t, requiredOperation(RequiredFields(request, names...))) + if !bytes.Equal(baseline, actual) { + t.Fatal("empty declaration changed artifact") + } + } + } +} +func TestRequiredFieldsRefuseInvalidDeclarationsAtGenerationAndBoot(t *testing.T) { + tests := []struct { + name string + request any + fields []string + want string + }{ + {"blank", requiredRequest{}, []string{" "}, `property " " is blank`}, + {"duplicate", requiredRequest{}, []string{"recordId", "recordId"}, `property "recordId" is duplicated`}, + {"misspelling", requiredRequest{}, []string{"recordID"}, `property "recordID" is not emitted`}, + {"ignored", requiredRequest{}, []string{"Ignored"}, `property "Ignored" is not emitted`}, + {"unexported", requiredRequest{}, []string{"hidden"}, `property "hidden" is not emitted`}, + {"nil", nil, []string{"a"}, "not nil"}, + {"typed nil", (*requiredRequest)(nil), []string{"a"}, "non-nil request pointer"}, + {"scalar", "", []string{"a"}, "ordinary struct"}, + {"array", [1]string{}, []string{"a"}, "ordinary struct"}, + {"map", map[string]string{}, []string{"a"}, "ordinary struct"}, + {"anonymous", struct{ A string }{}, []string{"A"}, "ordinary struct"}, + {"embedded", requiredEmbedded{}, []string{"recordId"}, "is embedded"}, + {"collision", requiredCollision{}, []string{"A"}, `colliding JSON property "A"`}, + {"tag", requiredInvalidTag{}, []string{"a"}, "invalid JSON name"}, + {"string tag", requiredStringTag{}, []string{"a"}, "unsupported JSON tag"}, + {"dash tag", requiredDashTag{}, []string{"-"}, "unsupported JSON tag"}, + {"custom", requiredCustom{}, []string{"a"}, "custom JSON/text codec"}, + {"custom text", requiredCustomText{}, []string{"a"}, "custom JSON/text codec"}, + {"proto", Message[*annotations.HttpRule]{}, []string{"selector"}, "ordinary struct"}, + {"nested wrapper", RequiredFields(requiredRequest{}, "recordId"), []string{"recordId"}, "cannot wrap another RequiredRequest"}, + {"empty nested wrapper", RequiredFields(requiredRequest{}, "recordId"), nil, "cannot wrap another RequiredRequest"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := requiredOperation(RequiredFields(tt.request, tt.fields...)) + _, artifactErr := New(Manifest{ID: "test"}).Operations(op).InterfaceArtifact("", InterfaceInfo{Version: "1"}) + _, bootErr := New(Manifest{ID: "test"}).Operations(op).operationTable() + for _, err := range []error{artifactErr, bootErr} { + if err == nil || !strings.Contains(err.Error(), tt.want) || !strings.Contains(err.Error(), "operation POST /review") { + t.Fatalf("error = %v; want %s", err, tt.want) + } + } + if artifactErr.Error() != bootErr.Error() { + t.Fatal("generation and boot disagree") + } + }) + } + op := requiredOperation(requiredRequest{}) + op.Response = RequiredFields(requiredRequest{}, "recordId") + if _, err := InterfaceDocument(InterfaceInfo{}, []Operation{op}); err == nil || !strings.Contains(err.Error(), "not Response") { + t.Fatalf("response wrapper: %v", err) + } +} + +// Error deliberately collides with the document's built-in failure definition. +type Error struct { + Error string `json:"error"` + Extra string `json:"extra"` +} +type requestWithTime struct { + Key string `json:"key"` + At time.Time `json:"at"` +} +type requestWithCodec struct { + Key string `json:"key"` + Nested []requiredCustom `json:"nested"` +} +type requestWithTextCodec struct { + Key string `json:"key"` + Nested map[string]*requiredCustomText `json:"nested"` +} +type requestWithBytes struct { + Key string `json:"key"` + Bytes []byte `json:"bytes"` +} +type requestWithInterface struct { + Key string `json:"key"` + Any any `json:"any"` +} +type requestWithCollision struct { + Key string `json:"key"` + A first.Shared `json:"a"` + B second.Shared `json:"b"` +} +type requestCollections struct { + Key string `json:"key"` + Pointer *string `json:"pointer,omitempty"` + Slice []first.Shared `json:"slice,omitempty"` + Map map[string]first.Shared `json:"map,omitempty"` + Array [2]int `json:"array"` + Ref string `json:"$ref"` +} + +func TestRequiredFieldsIsolateDefinitionIdentity(t *testing.T) { + for _, request := range []struct { + value any + required string + properties []string + }{ + {Error{}, "error", []string{"error", "extra"}}, + {first.Shared{}, "first", []string{"first"}}, + } { + marked := requiredOperation(RequiredFields(request.value, request.required)) + marked.Response = second.Shared{} + other := requiredOperation(second.Shared{}) + other.Path = "/other" + other.Response = second.Shared{} + for _, ops := range [][]Operation{{marked, other}, {other, marked}} { + if _, err := New(Manifest{ID: "test"}).Operations(ops...).operationTable(); err != nil { + t.Fatal(err) + } + raw := requiredArtifact(t, ops...) + var doc struct{ Definitions map[string]map[string]any } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + root := doc.Definitions[requiredRequestDefinition(marked)] + properties := root["properties"].(map[string]any) + if len(properties) != len(request.properties) { + t.Fatalf("properties = %v", properties) + } + for _, name := range request.properties { + if properties[name] == nil { + t.Fatalf("missing %s", name) + } + } + if len(doc.Definitions["Error"]["properties"].(map[string]any)) != 1 { + t.Fatal("failure definition changed") + } + if doc.Definitions["Shared"]["properties"].(map[string]any)["second"] == nil { + t.Fatal("control response/request changed") + } + } + } +} + +func TestRequiredFieldsCheckTheReachableWireShape(t *testing.T) { + for _, tt := range []struct { + name string + value any + want string + }{ + {"time", requestWithTime{}, "request.at (time.Time) has a custom JSON/text codec"}, + {"JSON codec in slice", requestWithCodec{}, "request.nested[]"}, + {"text codec in map pointer", requestWithTextCodec{}, "request.nested{}"}, + {"bytes", requestWithBytes{}, "request.bytes is a byte slice"}, + {"dynamic interface", requestWithInterface{}, "request.any has unsupported type"}, + {"colliding reachable names", requestWithCollision{}, "collide at definition"}, + } { + t.Run(tt.name, func(t *testing.T) { + op := requiredOperation(RequiredFields(tt.value, "key")) + _, generationErr := InterfaceDocument(InterfaceInfo{}, []Operation{op}) + _, bootErr := New(Manifest{ID: "test"}).Operations(op).operationTable() + if generationErr == nil || bootErr == nil || generationErr.Error() != bootErr.Error() || !strings.Contains(generationErr.Error(), tt.want) { + t.Fatalf("generation %v, boot %v; want %q", generationErr, bootErr, tt.want) + } + // Unwrapped declarations deliberately retain their previous behavior. + // Byte slices already panic in the legacy generator (uint8 unsupported). + if tt.name == "bytes" { + return + } + op.Request = tt.value + if _, err := InterfaceDocument(InterfaceInfo{}, []Operation{op}); err != nil { + t.Fatal(err) + } + }) + } + value := "present" + request := requestCollections{Key: "key", Pointer: &value, Slice: []first.Shared{{First: "a"}}, Map: map[string]first.Shared{"a": {First: "b"}}, Array: [2]int{1, 2}, Ref: "literal property"} + op := requiredOperation(RequiredFields(request, "key")) + if _, err := New(Manifest{ID: "test"}).Operations(op).operationTable(); err != nil { + t.Fatal(err) + } + raw := requiredArtifact(t, op) + var doc struct{ Definitions map[string]map[string]any } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + root := doc.Definitions[requiredRequestDefinition(op)] + properties := root["properties"].(map[string]any) + for _, name := range []string{"pointer", "slice", "map", "array", "$ref"} { + if properties[name] == nil { + t.Fatalf("missing %s", name) + } + } + if properties["pointer"].(map[string]any)["type"] != "string" || properties["$ref"].(map[string]any)["type"] != "string" { + t.Fatal("pointer or literal $ref property misdescribed") + } + wire, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + var valueOnWire map[string]any + if err = json.Unmarshal(wire, &valueOnWire); err != nil { + t.Fatal(err) + } + definitions := map[string]any{} + for name, definition := range doc.Definitions { + definitions[name] = definition + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("request.json", map[string]any{"$ref": "#/definitions/" + requiredRequestDefinition(op), "definitions": definitions}); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile("request.json") + if err != nil { + t.Fatal(err) + } + if err = schema.Validate(valueOnWire); err != nil { + t.Fatalf("supported wire value refused: %v", err) + } + valueOnWire["pointer"] = nil + if err = schema.Validate(valueOnWire); err == nil { + t.Fatal("non-null input subset unexpectedly accepted null") + } + delete(valueOnWire, "pointer") + if err = schema.Validate(valueOnWire); err != nil { + t.Fatalf("optional pointer omission refused: %v", err) + } + if request.Pointer == nil || *request.Pointer != "present" { + t.Fatal("non-null pointer wire value differs") + } + // This API deliberately retains the generator's non-null input subset; + // nil pointers/collections can encode null, which that schema does not admit. + nilWire, err := json.Marshal(struct { + P *string `json:"p"` + }{}) + if err != nil || string(nilWire) != `{"p":null}` { + t.Fatalf("nil pointer wire=%s, %v", nilWire, err) + } +}