Generate a JSON Schema for the bundle manifest (#8684)

resolve: https://github.com/open-policy-agent/opa/issues/8661

Similar to the [plan.schema.json
](https://github.com/open-policy-agent/opa/pull/8676)this manifest will
be published on the website under
https://openpolicyagent.org/schemas/bundle/v1/manifest.schema.json

---------

Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
Co-authored-by: Stephan Renatus <s_renatus@apple.com>
This commit is contained in:
Sebastian Spaink
2026-05-21 10:39:17 -05:00
committed by GitHub
parent 53d981c70f
commit 9d2c957c8b
11 changed files with 1421 additions and 352 deletions
+1
View File
@@ -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
+1
View File
@@ -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
+28
View File
@@ -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
+13
View File
@@ -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"),
+80
View File
@@ -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
}
+194
View File
@@ -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: &regoV1,
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)
}
})
}
}
+116 -352
View File
@@ -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
}
+482
View File
@@ -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/<TypeName>`, 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
}
@@ -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)
}
}
+1
View File
@@ -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
+63
View File
@@ -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
}
}
}