mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+116
-352
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user