diff --git a/.golangci.yaml b/.golangci.yaml index 274f2303aa..6eda7add33 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -102,6 +102,7 @@ linters: path: _test\.go paths: - internal/gojsonschema + - node_modules issues: # don't hide issues in CI runs because they are the same type max-same-issues: 0 diff --git a/docs/.gitignore b/docs/.gitignore index be9fae749f..903ff5cd24 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -6,3 +6,4 @@ package-lock.json # this is generated by the build process static/data/versions.json static/schemas/ir/v1/plan.schema.json +static/schemas/bundle/v1/manifest.schema.json diff --git a/docs/docs/management-bundles/index.md b/docs/docs/management-bundles/index.md index 0250cd83af..5a9a8fd2ed 100644 --- a/docs/docs/management-bundles/index.md +++ b/docs/docs/management-bundles/index.md @@ -363,6 +363,34 @@ expect for policy files `/policy1.rego` and those under the folder `foo`. } ``` +### Manifest JSON Schema + +A machine-readable JSON Schema (Draft 2020-12) describing the manifest format +is published at +[`https://openpolicyagent.org/schemas/bundle/v1/manifest.schema.json`](https://openpolicyagent.org/schemas/bundle/v1/manifest.schema.json). +The schema is generated from the Go type definitions in +[`v1/bundle/bundle.go`](https://github.com/open-policy-agent/opa/blob/main/v1/bundle/bundle.go) +and stays in sync with them via a CI drift test, so it always reflects what the +current `opa build` actually emits. Backward-incompatible changes will be +published under a new path (e.g., `/v2/`). + +Use it to validate manifests, generate typed bindings in non-Go languages, or +feed it into JSON Schema-aware tooling. Example with [`ajv`](https://ajv.js.org/): + +```bash +opa build -b bundle/ +tar -xzf bundle.tar.gz .manifest +ajv validate \ + -s https://openpolicyagent.org/schemas/bundle/v1/manifest.schema.json \ + -d .manifest +``` + +The top-level `Manifest` object does not set `additionalProperties: false`: +the bundle loader has always ignored unknown top-level keys, and embedders +that wrap OPA sometimes attach their own configuration alongside the +documented fields. Sub-records like `WasmResolver` remain closed, since +their shape is fully specified. + Some important details for bundle files: - OPA will only load data files named `data.json` or `data.yaml` (which contain diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 10fc856643..e293ecd076 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -669,6 +669,19 @@ The Linux Foundation has registered trademarks and uses trademarks. For a list o }, }; }, + + async function bundleManifestSchema(context) { + return { + name: "bundle-manifest-schema", + + async loadContent() { + const sourcePath = path.resolve(__dirname, "../v1/bundle/manifest.schema.json"); + const targetDir = path.join(context.siteDir, "static/schemas/bundle/v1"); + await fs.mkdir(targetDir, { recursive: true }); + await fs.copyFile(sourcePath, path.join(targetDir, "manifest.schema.json")); + }, + }; + }, ], clientModules: [ require.resolve("./src/lib/playground.js"), diff --git a/internal/cmd/genmanifestschema/main.go b/internal/cmd/genmanifestschema/main.go new file mode 100644 index 0000000000..074a656d1b --- /dev/null +++ b/internal/cmd/genmanifestschema/main.go @@ -0,0 +1,80 @@ +// Command genmanifestschema writes the bundle Manifest JSON Schema, generated +// from the Go type definitions in v1/bundle, to the path given as its single +// argument. +// +// Invoked via go:generate from main.go. +package main + +import ( + "bytes" + "encoding/json" + "log" + "os" + "reflect" + + "github.com/open-policy-agent/opa/internal/genjsonschema" + "github.com/open-policy-agent/opa/v1/bundle" +) + +func main() { + if len(os.Args) != 2 { + log.Fatalf("usage: %s path/to/manifest.schema.json", os.Args[0]) + } + bs, err := reflectSchema() + if err != nil { + log.Fatalf("reflect schema: %v", err) + } + if err := os.WriteFile(os.Args[1], bs, 0o644); err != nil { + log.Fatalf("write %s: %v", os.Args[1], err) + } +} + +// reflectSchema generates a JSON Schema describing the bundle manifest emitted +// by `opa build`. +func reflectSchema() ([]byte, error) { + b := genjsonschema.NewBuilder(manifestResolver) + // The bundle loader accepts unknown top-level keys (no + // DisallowUnknownFields), and embedders rely on this to attach custom + // configuration alongside the documented fields. Keep the schema in + // step with that contract; sub-records like WasmResolver stay strict. + b.AllowAdditionalProperties(reflect.TypeOf(bundle.Manifest{})) + rootRef, err := b.AddStruct(reflect.TypeOf(bundle.Manifest{})) + if err != nil { + return nil, err + } + + root := genjsonschema.Map( + "$schema", "https://json-schema.org/draft/2020-12/schema", + "$id", "https://openpolicyagent.org/schemas/bundle/v1/manifest.schema.json", + "title", "OPA Bundle Manifest", + "description", "JSON Schema for the bundle `.manifest` file produced by `opa build`. Generated from v1/bundle/bundle.go.", + "$ref", rootRef, + "$defs", b.DefsOrdered(), + ) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(root); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// manifestResolver intercepts types whose JSON shape isn't usefully derivable +// from straight reflection. Today that's just ast.Annotations: it ships a +// hand-written MarshalJSON whose nested types (Ref, Location, etc.) carry +// their own custom encoders; modeling the full shape is out of scope per the +// issue's "good enough" criteria. +func manifestResolver(_ *genjsonschema.Builder, t reflect.Type) (any, bool, error) { + if t.Kind() == reflect.Struct && + t.PkgPath() == "github.com/open-policy-agent/opa/v1/ast" && + t.Name() == "Annotations" { + return genjsonschema.Map( + "type", "object", + "description", "Rego annotations; opaque in this schema. See the OPA documentation for the full annotation shape.", + ), true, nil + } + return nil, false, nil +} diff --git a/internal/cmd/genmanifestschema/main_test.go b/internal/cmd/genmanifestschema/main_test.go new file mode 100644 index 0000000000..d745eb3e5c --- /dev/null +++ b/internal/cmd/genmanifestschema/main_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/open-policy-agent/opa/v1/bundle" +) + +const committedSchemaPath = "../../../v1/bundle/manifest.schema.json" + +func TestSchemaDoesNotDrift(t *testing.T) { + got, err := reflectSchema() + if err != nil { + t.Fatalf("reflectSchema: %v", err) + } + want, err := os.ReadFile(filepath.FromSlash(committedSchemaPath)) + if err != nil { + t.Fatalf("read %s: %v", committedSchemaPath, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("%s is stale; run `make generate` to update", committedSchemaPath) + } +} + +func TestSchemaValidatesRealManifests(t *testing.T) { + schemaBytes, err := reflectSchema() + if err != nil { + t.Fatalf("reflectSchema: %v", err) + } + var schemaDoc any + if err := json.Unmarshal(schemaBytes, &schemaDoc); err != nil { + t.Fatalf("unmarshal schema: %v", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("manifest.schema.json", schemaDoc); err != nil { + t.Fatalf("add schema resource: %v", err) + } + schema, err := compiler.Compile("manifest.schema.json") + if err != nil { + t.Fatalf("compile schema: %v", err) + } + + regoV1 := 1 + + cases := []struct { + note string + manifest bundle.Manifest + }{ + { + note: "empty manifest", + manifest: bundle.Manifest{}, + }, + { + note: "revision and roots", + manifest: func() bundle.Manifest { + m := bundle.Manifest{Revision: "abc123"} + m.Init() + m.AddRoot("roles") + m.AddRoot("http/example/authz") + return m + }(), + }, + { + note: "rego version and per-file overrides", + manifest: bundle.Manifest{ + Revision: "abc123", + RegoVersion: ®oV1, + FileRegoVersions: map[string]int{ + "/foo/*.rego": 0, + "/policy1.rego": 0, + }, + }, + }, + { + note: "wasm resolvers", + manifest: bundle.Manifest{ + Revision: "abc123", + WasmResolvers: []bundle.WasmResolver{ + {Entrypoint: "http/example/authz/allow", Module: "/policy.wasm"}, + }, + }, + }, + { + note: "metadata", + manifest: bundle.Manifest{ + Revision: "abc123", + Metadata: map[string]any{ + "build_id": "ci-1234", + "tags": []any{"prod", "us-east-1"}, + "nested": map[string]any{"k": 1.5, "ok": true}, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + bs, err := json.Marshal(tc.manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + var doc any + if err := json.Unmarshal(bs, &doc); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + if err := schema.Validate(doc); err != nil { + t.Fatalf("manifest does not validate:\n%s\n\nmanifest JSON:\n%s", + strings.TrimSpace(err.Error()), string(bs)) + } + }) + } + + // The bundle loader silently accepts unknown top-level keys, and + // embedders rely on this to attach custom configuration alongside the + // documented fields. The schema must not regress that. + t.Run("manifest with extra top-level keys", func(t *testing.T) { + raw := `{"revision": "x", "custom_app_setting": {"foo": {"enabled": true}}}` + var doc any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := schema.Validate(doc); err != nil { + t.Fatalf("expected extras to validate:\n%s", strings.TrimSpace(err.Error())) + } + }) +} + +func TestSchemaRejectsInvalidManifests(t *testing.T) { + schemaBytes, err := reflectSchema() + if err != nil { + t.Fatalf("reflectSchema: %v", err) + } + var schemaDoc any + if err := json.Unmarshal(schemaBytes, &schemaDoc); err != nil { + t.Fatalf("unmarshal schema: %v", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("manifest.schema.json", schemaDoc); err != nil { + t.Fatalf("add schema resource: %v", err) + } + schema, err := compiler.Compile("manifest.schema.json") + if err != nil { + t.Fatalf("compile schema: %v", err) + } + + cases := []struct { + note string + raw string + }{ + { + note: "missing revision", + raw: `{"roots": ["a"]}`, + }, + { + note: "wrong type for revision", + raw: `{"revision": 123}`, + }, + { + note: "wrong type for roots entry", + raw: `{"revision": "x", "roots": [1]}`, + }, + { + note: "wrong type for rego_version", + raw: `{"revision": "x", "rego_version": "1"}`, + }, + { + note: "wrong value type in file_rego_versions", + raw: `{"revision": "x", "file_rego_versions": {"/a.rego": "1"}}`, + }, + { + note: "wasm entry with unknown field", + raw: `{"revision": "x", "wasm": [{"module": "/a.wasm", "extra": 1}]}`, + }, + } + + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + var doc any + if err := json.Unmarshal([]byte(tc.raw), &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := schema.Validate(doc); err == nil { + t.Fatalf("expected validation failure, got success for: %s", tc.raw) + } + }) + } +} diff --git a/internal/cmd/genplanschema/main.go b/internal/cmd/genplanschema/main.go index 9292eaf609..0d2fd5cd45 100644 --- a/internal/cmd/genplanschema/main.go +++ b/internal/cmd/genplanschema/main.go @@ -7,14 +7,13 @@ package main import ( "bytes" "encoding/json" - "errors" "fmt" "log" "os" "reflect" "sort" - "strings" + "github.com/open-policy-agent/opa/internal/genjsonschema" "github.com/open-policy-agent/opa/v1/ir" ) @@ -33,20 +32,29 @@ func main() { // reflectSchema generates a JSON Schema describing the IR plan produced by `opa build -t plan`. func reflectSchema() ([]byte, error) { - b := newSchemaBuilder() - rootRef, err := b.addStruct(reflect.TypeOf(ir.Policy{})) + b := genjsonschema.NewBuilder(planResolver) + + // MakeNumberRefStmt's MarshalJSON emits both the canonical "index" key + // and the deprecated "Index" key for backwards compatibility. Pre-register + // the hand-written schema so any AddStruct that would otherwise reflect + // the type short-circuits to it. + if _, err := b.AddNamedDef("MakeNumberRefStmt", makeNumberRefStmtSchema()); err != nil { + return nil, err + } + + rootRef, err := b.AddStruct(reflect.TypeOf(ir.Policy{})) if err != nil { return nil, err } - root := orderedMap{ - {"$schema", "https://json-schema.org/draft/2020-12/schema"}, - {"$id", "https://openpolicyagent.org/schemas/ir/v1/plan.schema.json"}, - {"title", "OPA IR Plan"}, - {"description", "JSON Schema for the IR plan produced by `opa build -t plan`. Generated from v1/ir/ir.go."}, - {"$ref", rootRef}, - {"$defs", b.defsOrdered()}, - } + root := genjsonschema.Map( + "$schema", "https://json-schema.org/draft/2020-12/schema", + "$id", "https://openpolicyagent.org/schemas/ir/v1/plan.schema.json", + "title", "OPA IR Plan", + "description", "JSON Schema for the IR plan produced by `opa build -t plan`. Generated from v1/ir/ir.go.", + "$ref", rootRef, + "$defs", b.DefsOrdered(), + ) var buf bytes.Buffer enc := json.NewEncoder(&buf) @@ -58,330 +66,170 @@ func reflectSchema() ([]byte, error) { return buf.Bytes(), nil } -type schemaBuilder struct { - defs map[string]orderedMap -} - -func newSchemaBuilder() *schemaBuilder { - return &schemaBuilder{defs: map[string]orderedMap{}} -} - -func (b *schemaBuilder) defsOrdered() orderedMap { - names := make([]string, 0, len(b.defs)) - for n := range b.defs { - names = append(names, n) - } - sort.Strings(names) - out := make(orderedMap, 0, len(names)) - for _, n := range names { - out = append(out, orderedEntry{n, b.defs[n]}) - } - return out -} - -func (b *schemaBuilder) addStruct(t reflect.Type) (string, error) { - for t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t.Kind() != reflect.Struct { - return "", fmt.Errorf("addStruct: expected struct, got %s", t.Kind()) - } - name := t.Name() - if name == "" { - return "", errors.New("addStruct: anonymous struct not supported") - } - if _, ok := b.defs[name]; ok { - return "#/$defs/" + name, nil - } - // Some structs ship a hand-written MarshalJSON whose JSON shape diverges - // from what reflection alone can infer. Override those. - if override, ok := structOverrides[name]; ok { - b.defs[name] = override() - return "#/$defs/" + name, nil - } - b.defs[name] = nil - - schema, err := b.reflectStructBody(t) - if err != nil { - return "", err - } - b.defs[name] = schema - return "#/$defs/" + name, nil -} - -func (b *schemaBuilder) reflectStructBody(t reflect.Type) (orderedMap, error) { - properties := orderedMap{} - var required []string - - if err := b.collectFields(t, &properties, &required); err != nil { - return nil, err - } - - sort.Strings(required) - - out := orderedMap{ - {"type", "object"}, - {"properties", properties}, - } - if len(required) > 0 { - out = append(out, orderedEntry{"required", required}) - } - out = append(out, orderedEntry{"additionalProperties", false}) - return out, nil -} - -func (b *schemaBuilder) collectFields(t reflect.Type, properties *orderedMap, required *[]string) error { - type pendingField struct { - name string - schema any - required bool - } - var fields []pendingField - - for i := range t.NumField() { - f := t.Field(i) - if !f.IsExported() { - continue - } - if f.Anonymous { - ft := f.Type - for ft.Kind() == reflect.Pointer { - ft = ft.Elem() - } - if ft.Kind() == reflect.Struct { - if err := b.collectFields(ft, properties, required); err != nil { - return err - } - continue - } - } - name, opts := parseJSONTag(f.Tag.Get("json"), f.Name) - if name == "-" { - continue - } - schema, err := b.reflectType(f.Type) - if err != nil { - return fmt.Errorf("field %s.%s: %w", t.Name(), f.Name, err) - } - // encoding/json emits "null" for nil slices, maps, pointers, and - // interfaces. When such a field is not tagged omitempty, the encoder - // includes it (as null) rather than skipping it, so the schema must - // admit null in addition to the field's nominal type. - if !opts.omitempty && fieldCanBeNull(f.Type) { - schema = makeNullable(schema) - } - fields = append(fields, pendingField{ - name: name, - schema: schema, - required: !opts.omitempty, - }) - } - - sort.Slice(fields, func(i, j int) bool { return fields[i].name < fields[j].name }) - for _, f := range fields { - *properties = append(*properties, orderedEntry{f.name, f.schema}) - if f.required { - *required = append(*required, f.name) - } - } - return nil -} - -func (b *schemaBuilder) reflectType(t reflect.Type) (any, error) { - for t.Kind() == reflect.Pointer { - t = t.Elem() - } - +// planResolver intercepts IR-specific types whose JSON shape isn't derivable +// from straight reflection: the polymorphic Stmt/Val unions, the discriminated +// Operand/Block envelopes, and the opaque types.Function declaration on +// BuiltinFunc. +func planResolver(b *genjsonschema.Builder, t reflect.Type) (any, bool, error) { switch t.Kind() { - case reflect.String: - return orderedMap{{"type", "string"}}, nil - case reflect.Bool: - return orderedMap{{"type", "boolean"}}, nil - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return orderedMap{{"type", "integer"}}, nil - case reflect.Float32, reflect.Float64: - return orderedMap{{"type", "number"}}, nil - case reflect.Slice, reflect.Array: - items, err := b.reflectType(t.Elem()) - if err != nil { - return nil, err - } - return orderedMap{ - {"type", "array"}, - {"items", items}, - }, nil case reflect.Struct: switch { case t == reflect.TypeOf(ir.Operand{}): - ref, err := b.addOperand() + ref, err := addOperand(b) if err != nil { - return nil, err + return nil, false, err } - return orderedMap{{"$ref", ref}}, nil + return genjsonschema.Map("$ref", ref), true, nil case t == reflect.TypeOf(ir.Block{}): - ref, err := b.addBlock() + ref, err := addBlock(b) if err != nil { - return nil, err + return nil, false, err } - return orderedMap{{"$ref", ref}}, nil + return genjsonschema.Map("$ref", ref), true, nil case t.PkgPath() == "github.com/open-policy-agent/opa/v1/types" && t.Name() == "Function": // BuiltinFunc.Decl: opaque slot. The full types.Function shape is // out of scope per the issue's "good enough" criteria. - return orderedMap{ - {"type", "object"}, - {"description", "BuiltinFunc declaration; opaque in this schema."}, - }, nil + return genjsonschema.Map( + "type", "object", + "description", "BuiltinFunc declaration; opaque in this schema.", + ), true, nil } - ref, err := b.addStruct(t) - if err != nil { - return nil, err - } - return orderedMap{{"$ref", ref}}, nil case reflect.Interface: switch { case t == reflect.TypeOf((*ir.Stmt)(nil)).Elem(): - ref, err := b.addStmtUnion() + ref, err := addStmtUnion(b) if err != nil { - return nil, err + return nil, false, err } - return orderedMap{{"$ref", ref}}, nil + return genjsonschema.Map("$ref", ref), true, nil case t == reflect.TypeOf((*ir.Val)(nil)).Elem(): - ref, err := b.addValUnion() + ref, err := addValUnion(b) if err != nil { - return nil, err + return nil, false, err } - return orderedMap{{"$ref", ref}}, nil + return genjsonschema.Map("$ref", ref), true, nil } } - return nil, fmt.Errorf("unsupported type %s (kind %s)", t.String(), t.Kind()) + return nil, false, nil } -func (b *schemaBuilder) addOperand() (string, error) { - if _, ok := b.defs["Operand"]; ok { - return "#/$defs/Operand", nil +func addOperand(b *genjsonschema.Builder) (string, error) { + const name = "Operand" + if !b.Reserve(name) { + return b.DefRef(name), nil } - b.defs["Operand"] = nil - ref, err := b.addValUnion() + ref, err := addValUnion(b) if err != nil { return "", err } - b.defs["Operand"] = orderedMap{{"$ref", ref}} - return "#/$defs/Operand", nil + b.SetDef(name, genjsonschema.Map("$ref", ref)) + return b.DefRef(name), nil } -func (b *schemaBuilder) addValUnion() (string, error) { +func addValUnion(b *genjsonschema.Builder) (string, error) { const name = "Val" - if _, ok := b.defs[name]; ok { - return "#/$defs/" + name, nil + if !b.Reserve(name) { + return b.DefRef(name), nil } vals := ir.ValKinds() kinds := sortedKeys(vals) branches := make([]any, 0, len(kinds)) for _, kind := range kinds { - valueSchema, err := b.reflectType(reflect.TypeOf(vals[kind])) + valueSchema, err := b.ReflectType(reflect.TypeOf(vals[kind])) if err != nil { return "", fmt.Errorf("val %q: %w", kind, err) } - branches = append(branches, orderedMap{ - {"type", "object"}, - {"properties", orderedMap{ - {"type", orderedMap{{"const", kind}}}, - {"value", valueSchema}, - }}, - {"required", []string{"type", "value"}}, - {"additionalProperties", false}, - }) + branches = append(branches, genjsonschema.Map( + "type", "object", + "properties", genjsonschema.Map( + "type", genjsonschema.Map("const", kind), + "value", valueSchema, + ), + "required", []string{"type", "value"}, + "additionalProperties", false, + )) } - b.defs[name] = orderedMap{{"oneOf", branches}} - return "#/$defs/" + name, nil + b.SetDef(name, genjsonschema.Map("oneOf", branches)) + return b.DefRef(name), nil } -func (b *schemaBuilder) addBlock() (string, error) { - if _, ok := b.defs["Block"]; ok { - return "#/$defs/Block", nil +func addBlock(b *genjsonschema.Builder) (string, error) { + const name = "Block" + if !b.Reserve(name) { + return b.DefRef(name), nil } - b.defs["Block"] = nil - stmtRef, err := b.addStmtUnion() + stmtRef, err := addStmtUnion(b) if err != nil { return "", err } - b.defs["Block"] = orderedMap{ - {"type", "object"}, - {"properties", orderedMap{ - {"stmts", orderedMap{ - {"type", "array"}, - {"items", orderedMap{{"$ref", stmtRef}}}, - }}, - }}, - {"required", []string{"stmts"}}, - {"additionalProperties", false}, - } - return "#/$defs/Block", nil + b.SetDef(name, genjsonschema.Map( + "type", "object", + "properties", genjsonschema.Map( + "stmts", genjsonschema.Map( + "type", "array", + "items", genjsonschema.Map("$ref", stmtRef), + ), + ), + "required", []string{"stmts"}, + "additionalProperties", false, + )) + return b.DefRef(name), nil } -func (b *schemaBuilder) addStmtUnion() (string, error) { +func addStmtUnion(b *genjsonschema.Builder) (string, error) { const name = "Stmt" - if _, ok := b.defs[name]; ok { - return "#/$defs/" + name, nil + if !b.Reserve(name) { + return b.DefRef(name), nil } - // Reserve before recursing — concrete stmt bodies may contain *Block, - // which recurses back through addStmtUnion. - b.defs[name] = nil stmts := ir.StmtKinds() kinds := sortedKeys(stmts) branches := make([]any, 0, len(kinds)) for _, kind := range kinds { - bodyRef, err := b.addStruct(reflect.TypeOf(stmts[kind])) + bodyRef, err := b.AddStruct(reflect.TypeOf(stmts[kind])) if err != nil { return "", fmt.Errorf("stmt %q: %w", kind, err) } - branches = append(branches, orderedMap{ - {"type", "object"}, - {"properties", orderedMap{ - {"type", orderedMap{{"const", kind}}}, - {"stmt", orderedMap{{"$ref", bodyRef}}}, - }}, - {"required", []string{"type", "stmt"}}, - {"additionalProperties", false}, - }) + branches = append(branches, genjsonschema.Map( + "type", "object", + "properties", genjsonschema.Map( + "type", genjsonschema.Map("const", kind), + "stmt", genjsonschema.Map("$ref", bodyRef), + ), + "required", []string{"type", "stmt"}, + "additionalProperties", false, + )) } - b.defs[name] = orderedMap{{"oneOf", branches}} - return "#/$defs/" + name, nil -} - -// structOverrides maps struct type names to schema builders that bypass -// reflection. Use sparingly — only for types whose JSON form is governed by -// a hand-written MarshalJSON whose shape reflection alone cannot infer. -var structOverrides = map[string]func() orderedMap{ - "MakeNumberRefStmt": makeNumberRefStmtSchema, + b.SetDef(name, genjsonschema.Map("oneOf", branches)) + return b.DefRef(name), nil } // makeNumberRefStmtSchema mirrors MakeNumberRefStmt's MarshalJSON, which // emits both the canonical "index" key and the deprecated "Index" key for // backwards compatibility. "index" is required; "Index" is permitted but // flagged deprecated so consumers know not to depend on it. -func makeNumberRefStmtSchema() orderedMap { - return orderedMap{ - {"type", "object"}, - {"properties", orderedMap{ - {"col", orderedMap{{"type", "integer"}}}, - {"file", orderedMap{{"type", "integer"}}}, - {"row", orderedMap{{"type", "integer"}}}, - {"index", orderedMap{{"type", "integer"}}}, - {"Index", orderedMap{ - {"type", "integer"}, - {"deprecated", true}, - {"description", "Deprecated alias for `index`. Both keys are emitted by current OPA versions for backwards compatibility; will be removed in a future major release. Read `index` instead."}, - }}, - {"target", orderedMap{{"type", "integer"}}}, - }}, - {"required", []string{"col", "file", "index", "row", "target"}}, - {"additionalProperties", false}, - } +func makeNumberRefStmtSchema() genjsonschema.OrderedMap { + return genjsonschema.Map( + "type", "object", + "properties", genjsonschema.Map( + "col", genjsonschema.Map("type", "integer"), + "file", genjsonschema.Map("type", "integer"), + "row", genjsonschema.Map("type", "integer"), + "index", genjsonschema.Map("type", "integer"), + "Index", genjsonschema.Map( + "type", "integer", + "deprecated", true, + "description", "Deprecated alias for `index`. Both keys are emitted by current OPA versions for backwards compatibility; will be removed in a future major release. Read `index` instead.", + ), + "target", genjsonschema.Map("type", "integer"), + ), + "required", []string{"col", "file", "index", "row", "target"}, + "additionalProperties", false, + ) } +// sortedKeys returns the keys of m in lexicographic order so the polymorphic +// Stmt/Val unions render their branches in a byte-stable order. func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -390,87 +238,3 @@ func sortedKeys[V any](m map[string]V) []string { sort.Strings(keys) return keys } - -func fieldCanBeNull(t reflect.Type) bool { - switch t.Kind() { - case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface: - return true - } - return false -} - -// makeNullable returns a schema equivalent to the input that also accepts the -// JSON null value. For schemas built around "type": "X", the type field is -// widened to ["X", "null"]; for $ref schemas (which can't be widened in -// place), the result is a oneOf over the original and {"type": "null"}. -func makeNullable(schema any) any { - m, ok := schema.(orderedMap) - if !ok { - return schema - } - for i, e := range m { - if e.Key == "type" { - if s, ok := e.Value.(string); ok { - m[i] = orderedEntry{"type", []string{s, "null"}} - return m - } - } - } - return orderedMap{ - {"oneOf", []any{m, orderedMap{{"type", "null"}}}}, - } -} - -type jsonTagOpts struct { - omitempty bool -} - -func parseJSONTag(tag, fieldName string) (string, jsonTagOpts) { - if tag == "" { - return fieldName, jsonTagOpts{} - } - parts := strings.Split(tag, ",") - name := parts[0] - if name == "" { - name = fieldName - } - var opts jsonTagOpts - for _, p := range parts[1:] { - if p == "omitempty" { - opts.omitempty = true - } - } - return name, opts -} - -// orderedMap preserves insertion order for JSON object encoding so the -// generated schema is byte-stable across runs. -type orderedMap []orderedEntry - -type orderedEntry struct { - Key string - Value any -} - -func (m orderedMap) MarshalJSON() ([]byte, error) { - var buf bytes.Buffer - buf.WriteByte('{') - for i, e := range m { - if i > 0 { - buf.WriteByte(',') - } - k, err := json.Marshal(e.Key) - if err != nil { - return nil, err - } - buf.Write(k) - buf.WriteByte(':') - v, err := json.Marshal(e.Value) - if err != nil { - return nil, err - } - buf.Write(v) - } - buf.WriteByte('}') - return buf.Bytes(), nil -} diff --git a/internal/genjsonschema/genjsonschema.go b/internal/genjsonschema/genjsonschema.go new file mode 100644 index 0000000000..973358a574 --- /dev/null +++ b/internal/genjsonschema/genjsonschema.go @@ -0,0 +1,482 @@ +// Package genjsonschema builds a JSON Schema (Draft 2020-12) by reflecting +// over Go type definitions. It powers OPA's `genplanschema` and +// `genmanifestschema` commands: each generator wraps a Builder, plugs in a +// TypeResolver for its domain-specific shapes, walks a root struct, and +// renders the accumulated $defs. +// +// The Builder treats struct types as named definitions referenced via +// `#/$defs/`, and reflects fields based on their `json:"..."` tags +// (handling `omitempty`, embedded structs, and skipping unexported fields). +package genjsonschema + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strings" +) + +// TypeResolver returns a JSON Schema for t and reports handled=true to short +// circuit the Builder's default handling. A resolver runs first on every +// type the Builder visits via ReflectType (after pointer unwrapping), so it +// can intercept polymorphic interfaces, opaque types, and named structs +// whose JSON shape isn't derivable from reflection alone. +// +// A resolver that reports handled=true must return a non-nil schema; the +// Builder treats a nil schema with handled=true as an error so silent JSON +// `null` output is impossible. +// +// The Builder is passed in so resolvers can recurse — e.g., to translate the +// underlying type of a polymorphic union and accumulate further $defs. +type TypeResolver func(b *Builder, t reflect.Type) (schema any, handled bool, err error) + +// Builder accumulates struct definitions in $defs and offers ReflectType / +// AddStruct entry points used by both the caller and its TypeResolver. +type Builder struct { + defs map[string]OrderedMap + resolver TypeResolver + openAdditionalProps map[reflect.Type]bool +} + +// NewBuilder returns a Builder. resolver may be nil, in which case only +// the built-in cases are used. +func NewBuilder(resolver TypeResolver) *Builder { + return &Builder{ + defs: map[string]OrderedMap{}, + resolver: resolver, + openAdditionalProps: map[reflect.Type]bool{}, + } +} + +// AllowAdditionalProperties opts t out of the default `additionalProperties: +// false` constraint, so the generated schema accepts unknown keys on that +// struct. Use this for top-level types whose runtime decoder is lenient and +// where embedders are known to attach custom fields (e.g. the bundle +// Manifest). +func (b *Builder) AllowAdditionalProperties(t reflect.Type) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + b.openAdditionalProps[t] = true +} + +// DefsOrdered returns the accumulated $defs in name-sorted order so the +// rendered schema is byte-stable across runs. +func (b *Builder) DefsOrdered() OrderedMap { + names := make([]string, 0, len(b.defs)) + for n := range b.defs { + names = append(names, n) + } + sort.Strings(names) + out := make(OrderedMap, 0, len(names)) + for _, n := range names { + out = append(out, Entry{n, b.defs[n]}) + } + return out +} + +// DefRef returns the JSON pointer ref ("#/$defs/Name") for name. It does not +// check that the def exists — useful for forward references that will be +// filled in later. +func (*Builder) DefRef(name string) string { + return "#/$defs/" + name +} + +// HasDef reports whether name is currently registered (including reserved +// but not yet filled in). +func (b *Builder) HasDef(name string) bool { + _, ok := b.defs[name] + return ok +} + +// Reserve marks name as in-flight so recursive references emitted while +// building its body can resolve to a $ref instead of looping. Returns true +// if the name was newly reserved, false if it already existed (in which +// case the caller should not call SetDef). +func (b *Builder) Reserve(name string) bool { + if _, ok := b.defs[name]; ok { + return false + } + b.defs[name] = nil + return true +} + +// SetDef stores schema under name. Typically paired with Reserve. +func (b *Builder) SetDef(name string, schema OrderedMap) { + b.defs[name] = schema +} + +// AddNamedDef stores schema under name and returns DefRef(name). Use when +// the body has no recursive references back to itself. Returns an error if +// name is already registered (whether via Reserve, SetDef, AddNamedDef, or +// AddStruct) — collisions are always a programming error in this API. +func (b *Builder) AddNamedDef(name string, schema OrderedMap) (string, error) { + if _, ok := b.defs[name]; ok { + return "", fmt.Errorf("AddNamedDef: %q is already registered", name) + } + b.defs[name] = schema + return b.DefRef(name), nil +} + +// AddStruct ensures t (a struct or pointer-to-struct) has a definition in +// $defs and returns its $ref. Recurses through fields, consulting the +// resolver for each. +func (b *Builder) AddStruct(t reflect.Type) (string, error) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return "", fmt.Errorf("AddStruct: expected struct, got %s", t.Kind()) + } + name := t.Name() + if name == "" { + return "", errors.New("AddStruct: anonymous struct not supported") + } + if _, ok := b.defs[name]; ok { + return b.DefRef(name), nil + } + // Reserve before recursing so cyclic structs (or sibling structs that + // reference back) emit a $ref instead of looping. + b.defs[name] = nil + + schema, err := b.reflectStructBody(t) + if err != nil { + return "", err + } + b.defs[name] = schema + return b.DefRef(name), nil +} + +func (b *Builder) reflectStructBody(t reflect.Type) (OrderedMap, error) { + properties := OrderedMap{} + var required []string + + if err := b.collectFields(t, &properties, &required); err != nil { + return nil, err + } + + sort.Strings(required) + + out := OrderedMap{ + {"type", "object"}, + {"properties", properties}, + } + if len(required) > 0 { + out = append(out, Entry{"required", required}) + } + if !b.openAdditionalProps[t] { + out = append(out, Entry{"additionalProperties", false}) + } + return out, nil +} + +func (b *Builder) collectFields(t reflect.Type, properties *OrderedMap, required *[]string) error { + type pendingField struct { + name string + schema any + required bool + } + var fields []pendingField + + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + if f.Anonymous { + ft := f.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + if err := b.collectFields(ft, properties, required); err != nil { + return err + } + continue + } + } + name, opts := parseJSONTag(f.Tag.Get("json"), f.Name) + if name == "-" { + continue + } + schema, err := b.ReflectType(f.Type) + if err != nil { + return fmt.Errorf("field %s.%s: %w", t.Name(), f.Name, err) + } + // encoding/json emits "null" for nil slices, maps, pointers, and + // interfaces. When such a field is not tagged omitempty, the encoder + // includes it (as null) rather than skipping it, so the schema must + // admit null in addition to the field's nominal type. + if !opts.omitEmpty && fieldCanBeNull(f.Type) { + schema = MakeNullable(schema) + } + fields = append(fields, pendingField{ + name: name, + schema: schema, + required: !opts.omitEmpty, + }) + } + + sort.Slice(fields, func(i, j int) bool { return fields[i].name < fields[j].name }) + for _, f := range fields { + *properties = append(*properties, Entry{f.name, f.schema}) + if f.required { + *required = append(*required, f.name) + } + } + return nil +} + +// ReflectType returns a JSON Schema fragment describing t. Pointers are +// unwrapped. The resolver, if any, is consulted before built-in handling. +// +// Built-in handling covers: bool, all integer kinds, all float kinds, string, +// slice/array (item type recurses), map (string-keyed only; values recurse), +// the bare-`any` interface (matches anything), and named structs (which +// recurse through AddStruct). +func (b *Builder) ReflectType(t reflect.Type) (any, error) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if b.resolver != nil { + schema, handled, err := b.resolver(b, t) + if err != nil { + return nil, err + } + if handled { + if schema == nil { + return nil, fmt.Errorf("resolver returned nil schema for %s; resolvers that report handled=true must return a non-nil schema", t.String()) + } + return schema, nil + } + } + + switch t.Kind() { + case reflect.String: + return OrderedMap{{"type", "string"}}, nil + case reflect.Bool: + return OrderedMap{{"type", "boolean"}}, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return OrderedMap{{"type", "integer"}}, nil + case reflect.Float32, reflect.Float64: + return OrderedMap{{"type", "number"}}, nil + case reflect.Slice, reflect.Array: + items, err := b.ReflectType(t.Elem()) + if err != nil { + return nil, err + } + return OrderedMap{ + {"type", "array"}, + {"items", items}, + }, nil + case reflect.Map: + if t.Key().Kind() != reflect.String { + return nil, fmt.Errorf("unsupported map key type %s; only string keys are supported", t.Key()) + } + // `map[string]any` carries no value-side constraint; emit a plain + // object schema rather than `additionalProperties: {}` so the JSON + // stays compact and readable. + if isEmptyInterface(t.Elem()) { + return OrderedMap{{"type", "object"}}, nil + } + elem, err := b.ReflectType(t.Elem()) + if err != nil { + return nil, err + } + return OrderedMap{ + {"type", "object"}, + {"additionalProperties", elem}, + }, nil + case reflect.Interface: + if t.NumMethod() == 0 { + // Bare `any` accepts any JSON value; an empty schema {} matches + // everything per JSON Schema semantics. + return OrderedMap{}, nil + } + return nil, fmt.Errorf("unsupported interface type %s (no resolver match)", t.String()) + case reflect.Struct: + ref, err := b.AddStruct(t) + if err != nil { + return nil, err + } + return OrderedMap{{"$ref", ref}}, nil + } + return nil, fmt.Errorf("unsupported type %s (kind %s)", t.String(), t.Kind()) +} + +// MakeNullable returns a schema equivalent to the input that also accepts +// the JSON null value. +func MakeNullable(schema any) any { + m, ok := schema.(OrderedMap) + if !ok { + return schema + } + for i, e := range m { + if e.Key == "type" { + switch v := e.Value.(type) { + case string: + if v == "null" { + return m + } + out := cloneOrderedMap(m) + out[i] = Entry{"type", []string{v, "null"}} + return out + case []string: + for _, s := range v { + if s == "null" { + return m + } + } + out := cloneOrderedMap(m) + widened := make([]string, len(v)+1) + copy(widened, v) + widened[len(v)] = "null" + out[i] = Entry{"type", widened} + return out + } + } + } + if oneOfHasNullBranch(m) { + return m + } + return OrderedMap{ + {"oneOf", []any{m, OrderedMap{{"type", "null"}}}}, + } +} + +func cloneOrderedMap(m OrderedMap) OrderedMap { + out := make(OrderedMap, len(m)) + copy(out, m) + return out +} + +// oneOfHasNullBranch reports whether m is an OrderedMap whose top-level +// `oneOf` already includes a `{"type": "null"}` branch — i.e., MakeNullable +// has already been applied. +func oneOfHasNullBranch(m OrderedMap) bool { + for _, e := range m { + if e.Key != "oneOf" { + continue + } + branches, ok := e.Value.([]any) + if !ok { + return false + } + for _, br := range branches { + bm, ok := br.(OrderedMap) + if !ok { + continue + } + for _, be := range bm { + if be.Key == "type" { + if s, ok := be.Value.(string); ok && s == "null" { + return true + } + } + } + } + } + return false +} + +// jsonTagOpts holds the parsed flags from a `json:"..."` struct tag. Only +// flags the schema generator cares about are tracked. +type jsonTagOpts struct { + omitEmpty bool +} + +// parseJSONTag splits the contents of a struct's `json:"..."` tag into the +// JSON field name and option flags. If the tag is empty or names an empty +// field, fieldName is used as the JSON name. +func parseJSONTag(tag, fieldName string) (string, jsonTagOpts) { + if tag == "" { + return fieldName, jsonTagOpts{} + } + parts := strings.Split(tag, ",") + name := parts[0] + if name == "" { + name = fieldName + } + var opts jsonTagOpts + for _, p := range parts[1:] { + if p == "omitempty" { + opts.omitEmpty = true + } + } + return name, opts +} + +func isEmptyInterface(t reflect.Type) bool { + return t.Kind() == reflect.Interface && t.NumMethod() == 0 +} + +func fieldCanBeNull(t reflect.Type) bool { + switch t.Kind() { + case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface: + return true + } + return false +} + +// OrderedMap preserves insertion order for JSON object encoding so the +// generated schema is byte-stable across runs. +type OrderedMap []Entry + +// Entry is a single key/value pair in an OrderedMap. +type Entry struct { + Key string + Value any +} + +// Map builds an OrderedMap from alternating key/value arguments. Keys must +// be strings; the function panics on an odd number of arguments or on a +// non-string key. Use this from outside the package to avoid the govet +// "composites" warning that fires on cross-package struct literals. +// +// Map panics rather than returning an error so it can be used as a literal +// constructor in deeply nested expressions without breaking the call-site +// readability that is its whole point. The conditions it panics on are +// programming errors in literal arguments, not runtime data conditions a +// caller could usefully handle. +func Map(pairs ...any) OrderedMap { + if len(pairs)%2 != 0 { + panic(fmt.Sprintf("genjsonschema.Map: odd number of arguments (%d)", len(pairs))) + } + m := make(OrderedMap, 0, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + k, ok := pairs[i].(string) + if !ok { + panic(fmt.Sprintf("genjsonschema.Map: key at position %d is %T, want string", i, pairs[i])) + } + m = append(m, Entry{Key: k, Value: pairs[i+1]}) + } + return m +} + +func (m OrderedMap) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + for i, e := range m { + if i > 0 { + buf.WriteByte(',') + } + k, err := json.Marshal(e.Key) + if err != nil { + return nil, err + } + buf.Write(k) + buf.WriteByte(':') + v, err := json.Marshal(e.Value) + if err != nil { + return nil, err + } + buf.Write(v) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/internal/genjsonschema/genjsonschema_test.go b/internal/genjsonschema/genjsonschema_test.go new file mode 100644 index 0000000000..4a300fd053 --- /dev/null +++ b/internal/genjsonschema/genjsonschema_test.go @@ -0,0 +1,442 @@ +package genjsonschema + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func mustMarshal(t *testing.T, v any) string { + t.Helper() + bs, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(bs) +} + +func TestOrderedMapPreservesInsertionOrder(t *testing.T) { + m := OrderedMap{ + {"z", 1}, + {"a", 2}, + {"m", 3}, + } + got := mustMarshal(t, m) + want := `{"z":1,"a":2,"m":3}` + if got != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestMapBuildsOrderedMap(t *testing.T) { + got := mustMarshal(t, Map("z", 1, "a", 2, "m", 3)) + want := `{"z":1,"a":2,"m":3}` + if got != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestMapPanicsOnOddArgs(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic") + } + }() + args := []any{"z", 1, "a"} + _ = Map(args...) //nolint:staticcheck // SA5012: deliberately odd to test the panic path +} + +func TestMapPanicsOnNonStringKey(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic") + } + }() + args := []any{42, "value"} + _ = Map(args...) +} + +func TestParseJSONTag(t *testing.T) { + cases := []struct { + tag, fieldName, wantName string + wantOmit bool + }{ + {"", "Foo", "Foo", false}, + {"foo", "Foo", "foo", false}, + {",omitempty", "Foo", "Foo", true}, + {"foo,omitempty", "Foo", "foo", true}, + {"foo,string,omitempty", "Foo", "foo", true}, + {"-", "Foo", "-", false}, + } + for _, tc := range cases { + t.Run(tc.tag, func(t *testing.T) { + name, opts := parseJSONTag(tc.tag, tc.fieldName) + if name != tc.wantName || opts.omitEmpty != tc.wantOmit { + t.Fatalf("got (%q, omit=%v), want (%q, omit=%v)", + name, opts.omitEmpty, tc.wantName, tc.wantOmit) + } + }) + } +} + +func TestMakeNullableWidensTypeKey(t *testing.T) { + in := OrderedMap{{"type", "string"}} + got := mustMarshal(t, MakeNullable(in)) + want := `{"type":["string","null"]}` + if got != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestMakeNullableWrapsRefInOneOf(t *testing.T) { + in := OrderedMap{{"$ref", "#/$defs/Foo"}} + got := mustMarshal(t, MakeNullable(in)) + if !strings.Contains(got, `"oneOf"`) || !strings.Contains(got, `"$ref":"#/$defs/Foo"`) || + !strings.Contains(got, `"type":"null"`) { + t.Fatalf("unexpected nullable wrap: %s", got) + } +} + +func TestMakeNullableIsIdempotent(t *testing.T) { + cases := []struct { + note string + in OrderedMap + }{ + { + note: "type already string slice with null", + in: OrderedMap{{"type", []string{"string", "null"}}}, + }, + { + note: "type is already null", + in: OrderedMap{{"type", "null"}}, + }, + { + note: "already wrapped in oneOf with null branch", + in: OrderedMap{ + {"oneOf", []any{ + OrderedMap{{"$ref", "#/$defs/Foo"}}, + OrderedMap{{"type", "null"}}, + }}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + before := mustMarshal(t, tc.in) + after := mustMarshal(t, MakeNullable(tc.in)) + if before != after { + t.Fatalf("MakeNullable not idempotent:\n before: %s\n after: %s", before, after) + } + // And widen-by-applying-twice (composed) should match applying once. + once := mustMarshal(t, MakeNullable(OrderedMap{{"type", "string"}})) + twice := mustMarshal(t, MakeNullable(MakeNullable(OrderedMap{{"type", "string"}}))) + if once != twice { + t.Fatalf("double-application diverges:\n once: %s\n twice: %s", once, twice) + } + }) + } +} + +func TestMakeNullableDoesNotMutateInput(t *testing.T) { + // Widening a string type must not mutate the caller's OrderedMap. + in := OrderedMap{{"type", "string"}} + before := mustMarshal(t, in) + _ = MakeNullable(in) + if after := mustMarshal(t, in); before != after { + t.Fatalf("string-type input was mutated:\n before: %s\n after: %s", before, after) + } + + // Widening a []string type must neither mutate the OrderedMap nor write + // into the spare capacity of the original slice's backing array — a slice + // with len < cap would otherwise see "null" appear at types[len:cap]. + types := make([]string, 1, 4) + types[0] = "string" + in2 := OrderedMap{{"type", types}} + before2 := mustMarshal(t, in2) + _ = MakeNullable(in2) + if after2 := mustMarshal(t, in2); before2 != after2 { + t.Fatalf("[]string-type input was mutated:\n before: %s\n after: %s", before2, after2) + } + expanded := types[:cap(types)] + for i := 1; i < len(expanded); i++ { + if expanded[i] != "" { + t.Fatalf("MakeNullable wrote into backing slice at index %d: %v", i, expanded) + } + } +} + +type primitives struct { + S string `json:"s"` + I int `json:"i"` + B bool `json:"b"` + F float64 +} + +func TestReflectStructPrimitives(t *testing.T) { + b := NewBuilder(nil) + if _, err := b.AddStruct(reflect.TypeOf(primitives{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + // Fields are sorted alphabetically by JSON name; F has no tag so falls + // back to the Go field name. + want := `{"primitives":{"type":"object","properties":{"F":{"type":"number"},"b":{"type":"boolean"},"i":{"type":"integer"},"s":{"type":"string"}},"required":["F","b","i","s"],"additionalProperties":false}}` + if got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } +} + +type omitFields struct { + Required string `json:"required"` + Optional string `json:"optional,omitempty"` + PtrOpt *string `json:"ptr_opt,omitempty"` + PtrReq *string `json:"ptr_req"` +} + +func TestOmitEmptyAndNullability(t *testing.T) { + b := NewBuilder(nil) + if _, err := b.AddStruct(reflect.TypeOf(omitFields{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + // PtrReq is required and nullable (pointer, not omitempty). + // PtrOpt is omitempty so neither required nor nullable. + // Required is required; Optional is not. + want := `{"omitFields":{"type":"object","properties":{"optional":{"type":"string"},"ptr_opt":{"type":"string"},"ptr_req":{"type":["string","null"]},"required":{"type":"string"}},"required":["ptr_req","required"],"additionalProperties":false}}` + if got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } +} + +type withMaps struct { + Counts map[string]int `json:"counts"` + Bag map[string]any `json:"bag"` +} + +func TestMapHandling(t *testing.T) { + b := NewBuilder(nil) + if _, err := b.AddStruct(reflect.TypeOf(withMaps{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + if !strings.Contains(got, `"counts":{"type":["object","null"],"additionalProperties":{"type":"integer"}}`) { + t.Fatalf("typed-value map not as expected: %s", got) + } + if !strings.Contains(got, `"bag":{"type":["object","null"]}`) { + t.Fatalf("any-value map not as expected: %s", got) + } +} + +type inner struct { + X int `json:"x"` +} +type outer struct { + A inner `json:"a"` + B *inner `json:"b,omitempty"` +} + +func TestNestedStructsAndPointer(t *testing.T) { + b := NewBuilder(nil) + if _, err := b.AddStruct(reflect.TypeOf(outer{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + if !strings.Contains(got, `"$ref":"#/$defs/inner"`) { + t.Fatalf("expected nested ref to inner: %s", got) + } + if !strings.Contains(got, `"inner":{"type":"object","properties":{"x":{"type":"integer"}}`) { + t.Fatalf("inner def missing or malformed: %s", got) + } +} + +type EmbedBase struct { + BaseField string `json:"base_field"` +} +type embedder struct { + EmbedBase + Own int `json:"own"` +} + +func TestEmbeddedStructFieldsArePromoted(t *testing.T) { + b := NewBuilder(nil) + if _, err := b.AddStruct(reflect.TypeOf(embedder{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + // Promoted field appears at top level; no separate def for EmbedBase. + if !strings.Contains(got, `"base_field":{"type":"string"}`) { + t.Fatalf("expected promoted base_field: %s", got) + } + if strings.Contains(got, `"EmbedBase"`) { + t.Fatalf("did not expect EmbedBase to get its own def: %s", got) + } +} + +type marker interface{ marker() } + +type withInterface struct { + M marker `json:"m,omitempty"` +} + +func TestResolverInterceptsTypes(t *testing.T) { + resolver := func(_ *Builder, t reflect.Type) (any, bool, error) { + if t == reflect.TypeOf((*marker)(nil)).Elem() { + return OrderedMap{{"description", "opaque marker"}}, true, nil + } + return nil, false, nil + } + b := NewBuilder(resolver) + if _, err := b.AddStruct(reflect.TypeOf(withInterface{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + if !strings.Contains(got, `"m":{"description":"opaque marker"}`) { + t.Fatalf("resolver result not propagated: %s", got) + } +} + +type withRequiredInterface struct { + M marker `json:"m"` +} + +func TestResolverResultIsNullableWhenFieldCanBeNull(t *testing.T) { + // When the field type can encode as JSON null and isn't omitempty, the + // resolver's bare schema gets wrapped in a nullable form so the + // generated schema admits null in addition to the resolved shape. + resolver := func(_ *Builder, t reflect.Type) (any, bool, error) { + if t == reflect.TypeOf((*marker)(nil)).Elem() { + return OrderedMap{{"description", "opaque marker"}}, true, nil + } + return nil, false, nil + } + b := NewBuilder(resolver) + if _, err := b.AddStruct(reflect.TypeOf(withRequiredInterface{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + if !strings.Contains(got, `"oneOf"`) || !strings.Contains(got, `"opaque marker"`) || + !strings.Contains(got, `"type":"null"`) { + t.Fatalf("expected nullable wrap: %s", got) + } +} + +func TestInterfaceWithoutResolverErrors(t *testing.T) { + b := NewBuilder(nil) + _, err := b.AddStruct(reflect.TypeOf(withInterface{})) + if err == nil { + t.Fatal("expected error for unresolved non-empty interface") + } +} + +func TestResolverHandledWithNilSchemaErrors(t *testing.T) { + // A resolver that reports handled=true must return a non-nil schema; + // otherwise the field would marshal as JSON null, silently corrupting + // output. + resolver := func(_ *Builder, t reflect.Type) (any, bool, error) { + if t == reflect.TypeOf((*marker)(nil)).Elem() { + return nil, true, nil + } + return nil, false, nil + } + b := NewBuilder(resolver) + _, err := b.AddStruct(reflect.TypeOf(withInterface{})) + if err == nil || !strings.Contains(err.Error(), "nil schema") { + t.Fatalf("expected nil-schema error, got: %v", err) + } +} + +func TestAddNamedDefRejectsCollision(t *testing.T) { + b := NewBuilder(nil) + if _, err := b.AddNamedDef("Foo", OrderedMap{{"type", "string"}}); err != nil { + t.Fatalf("first AddNamedDef: %v", err) + } + if _, err := b.AddNamedDef("Foo", OrderedMap{{"type", "integer"}}); err == nil { + t.Fatal("expected error on duplicate AddNamedDef") + } + // Reserved-but-not-yet-set name also collides. + b.Reserve("Bar") + if _, err := b.AddNamedDef("Bar", OrderedMap{{"type", "string"}}); err == nil { + t.Fatal("expected error on AddNamedDef colliding with Reserve") + } +} + +func TestReserveAndSetDefBreakCycles(t *testing.T) { + b := NewBuilder(nil) + const name = "RecursiveBranch" + if !b.Reserve(name) { + t.Fatal("Reserve returned false on first call") + } + if b.Reserve(name) { + t.Fatal("Reserve returned true on second call") + } + if !b.HasDef(name) { + t.Fatal("HasDef false after Reserve") + } + b.SetDef(name, OrderedMap{{"type", "string"}}) + got := mustMarshal(t, b.DefsOrdered()) + if !strings.Contains(got, `"RecursiveBranch":{"type":"string"}`) { + t.Fatalf("def not stored: %s", got) + } +} + +type unsupportedKind struct { + Ch chan int `json:"ch"` +} + +func TestUnsupportedKindReportsFieldPath(t *testing.T) { + b := NewBuilder(nil) + _, err := b.AddStruct(reflect.TypeOf(unsupportedKind{})) + if err == nil || !strings.Contains(err.Error(), "unsupportedKind.Ch") { + t.Fatalf("expected error mentioning field path, got: %v", err) + } +} + +type intKeyMap struct { + M map[int]string `json:"m"` +} + +func TestNonStringMapKeyErrors(t *testing.T) { + b := NewBuilder(nil) + _, err := b.AddStruct(reflect.TypeOf(intKeyMap{})) + if err == nil || !strings.Contains(err.Error(), "map key") { + t.Fatalf("expected map-key error, got: %v", err) + } +} + +func TestAllowAdditionalPropertiesSkipsClosedClause(t *testing.T) { + b := NewBuilder(nil) + b.AllowAdditionalProperties(reflect.TypeOf(primitives{})) + if _, err := b.AddStruct(reflect.TypeOf(primitives{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + if strings.Contains(got, `"additionalProperties":false`) { + t.Fatalf("did not expect additionalProperties:false in opt-out type:\n%s", got) + } + // Sanity check: opting in by pointer type also works. + b2 := NewBuilder(nil) + b2.AllowAdditionalProperties(reflect.TypeOf(&primitives{})) + if _, err := b2.AddStruct(reflect.TypeOf(primitives{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got2 := mustMarshal(t, b2.DefsOrdered()) + if strings.Contains(got2, `"additionalProperties":false`) { + t.Fatalf("did not expect additionalProperties:false when opted in via pointer type:\n%s", got2) + } +} + +func TestAllowAdditionalPropertiesIsPerType(t *testing.T) { + // Opting `outer` in must not loosen the constraint on its nested + // `inner` def — additionalProperties:false is the right default for + // sub-records. + b := NewBuilder(nil) + b.AllowAdditionalProperties(reflect.TypeOf(outer{})) + if _, err := b.AddStruct(reflect.TypeOf(outer{})); err != nil { + t.Fatalf("AddStruct: %v", err) + } + got := mustMarshal(t, b.DefsOrdered()) + if !strings.Contains(got, `"inner":{"type":"object","properties":{"x":{"type":"integer"}},"required":["x"],"additionalProperties":false}`) { + t.Fatalf("expected inner to remain strict: %s", got) + } +} diff --git a/main.go b/main.go index 42ea088eef..3b0c8ae5dc 100644 --- a/main.go +++ b/main.go @@ -34,3 +34,4 @@ func main() { //go:generate build/gen-run-go.sh internal/cmd/genbuiltinmetadata/main.go builtin_metadata.json //go:generate build/gen-run-go.sh internal/cmd/genversionindex/main.go v1/ast/version_index.json //go:generate build/gen-run-go.sh internal/cmd/genplanschema/main.go v1/ir/plan.schema.json +//go:generate build/gen-run-go.sh internal/cmd/genmanifestschema/main.go v1/bundle/manifest.schema.json diff --git a/v1/bundle/manifest.schema.json b/v1/bundle/manifest.schema.json new file mode 100644 index 0000000000..ff060aeea4 --- /dev/null +++ b/v1/bundle/manifest.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openpolicyagent.org/schemas/bundle/v1/manifest.schema.json", + "title": "OPA Bundle Manifest", + "description": "JSON Schema for the bundle `.manifest` file produced by `opa build`. Generated from v1/bundle/bundle.go.", + "$ref": "#/$defs/Manifest", + "$defs": { + "Manifest": { + "type": "object", + "properties": { + "file_rego_versions": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "metadata": { + "type": "object" + }, + "rego_version": { + "type": "integer" + }, + "revision": { + "type": "string" + }, + "roots": { + "type": "array", + "items": { + "type": "string" + } + }, + "wasm": { + "type": "array", + "items": { + "$ref": "#/$defs/WasmResolver" + } + } + }, + "required": [ + "revision" + ] + }, + "WasmResolver": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": { + "type": "object", + "description": "Rego annotations; opaque in this schema. See the OPA documentation for the full annotation shape." + } + }, + "entrypoint": { + "type": "string" + }, + "module": { + "type": "string" + } + }, + "additionalProperties": false + } + } +}