blob: ef941e244fb38ce5f3eb15e7cf89589af79dec3a [file] [log] [blame]
// Copyright 2019 CUE Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package jsonschema
import (
"fmt"
"math/big"
"path"
"regexp"
"cuelang.org/go/cue"
"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/errors"
"cuelang.org/go/cue/token"
"cuelang.org/go/internal"
)
// TODO: skip invalid regexps containing ?! and foes.
// alternatively, fall back to https://github.com/dlclark/regexp2
type constraint struct {
key string
// phase indicates on which pass c constraint should be added. This ensures
// that constraints are applied in the correct order. For instance, the
// "required" constraint validates that a listed field is contained in
// "properties". For this to work, "properties" must be processed before
// "required" and thus must have a lower phase number than the latter.
phase int
// Indicates the draft number in which this constraint is defined.
draft int
fn constraintFunc
}
// A constraintFunc converts a given JSON Schema constraint (specified in n)
// to a CUE constraint recorded in state.
type constraintFunc func(n cue.Value, s *state)
func p0(name string, f constraintFunc) *constraint {
return &constraint{key: name, fn: f}
}
func p1d(name string, draft int, f constraintFunc) *constraint {
return &constraint{key: name, phase: 1, draft: draft, fn: f}
}
func p1(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 1, fn: f}
}
func p2(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 2, fn: f}
}
func p3(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 3, fn: f}
}
// TODO:
// writeOnly, readOnly
var constraintMap = map[string]*constraint{}
func init() {
for _, c := range constraints {
constraintMap[c.key] = c
}
}
func addDefinitions(n cue.Value, s *state) {
if n.Kind() != cue.StructKind {
s.errf(n, `"definitions" expected an object, found %s`, n.Kind())
}
old := s.isSchema
s.isSchema = true
defer func() { s.isSchema = old }()
s.processMap(n, func(key string, n cue.Value) {
name := key
var f *ast.Field
ident := "#" + name
if ast.IsValidIdent(ident) {
f = &ast.Field{Value: s.schema(n, label{ident, true})}
f.Label = ast.NewIdent(ident)
} else {
f = &ast.Field{Value: s.schema(n, label{"#", true}, label{name: name})}
f.Label = ast.NewString(name)
ident = "#"
f = &ast.Field{
Label: ast.NewIdent("#"),
Value: ast.NewStruct(f),
}
}
ast.SetRelPos(f, token.NewSection)
s.definitions = append(s.definitions, f)
s.setField(label{name: ident, isDef: true}, f)
})
}
var constraints = []*constraint{
// Meta data.
p0("$schema", func(n cue.Value, s *state) {
// Identifies this as a JSON schema and specifies its version.
// TODO: extract version.
s.jsonschema, _ = s.strValue(n)
}),
p0("$id", func(n cue.Value, s *state) {
// URL: https://domain.com/schemas/foo.json
// anchors: #identifier
//
// TODO: mark identifiers.
// Resolution must be relative to parent $id
// https://tools.ietf.org/html/draft-handrews-json-schema-02#section-8.2.2
u := s.resolveURI(n)
if u == nil {
return
}
if u.Fragment != "" {
if s.cfg.Strict {
s.errf(n, "$id URI may not contain a fragment")
}
return
}
s.id = u
obj := s.object(n)
// TODO: handle the case where this is always defined and we don't want
// to include the default value.
obj.Elts = append(obj.Elts, &ast.Attribute{
Text: fmt.Sprintf("@jsonschema(id=%q)", u)})
}),
// Generic constraint
p1("type", func(n cue.Value, s *state) {
var types cue.Kind
set := func(n cue.Value) {
str, ok := s.strValue(n)
if !ok {
s.errf(n, "type value should be a string")
}
switch str {
case "null":
types |= cue.NullKind
s.setTypeUsed(n, nullType)
// TODO: handle OpenAPI restrictions.
case "boolean":
types |= cue.BoolKind
s.setTypeUsed(n, boolType)
case "string":
types |= cue.StringKind
s.setTypeUsed(n, stringType)
case "number":
types |= cue.NumberKind
s.setTypeUsed(n, numType)
case "integer":
types |= cue.IntKind
s.setTypeUsed(n, numType)
s.add(n, numType, ast.NewIdent("int"))
case "array":
types |= cue.ListKind
s.setTypeUsed(n, arrayType)
case "object":
types |= cue.StructKind
s.setTypeUsed(n, objectType)
default:
s.errf(n, "unknown type %q", n)
}
}
switch n.Kind() {
case cue.StringKind:
set(n)
case cue.ListKind:
for i, _ := n.List(); i.Next(); {
set(i.Value())
}
default:
s.errf(n, `value of "type" must be a string or list of strings`)
}
s.allowedTypes &= types
}),
p1("enum", func(n cue.Value, s *state) {
var a []ast.Expr
for _, x := range s.listItems("enum", n, true) {
a = append(a, s.value(x))
}
s.all.add(n, ast.NewBinExpr(token.OR, a...))
}),
// TODO: only allow for OpenAPI.
p1("nullable", func(n cue.Value, s *state) {
null := ast.NewNull()
setPos(null, n)
s.nullable = null
}),
p1d("const", 6, func(n cue.Value, s *state) {
s.all.add(n, s.value(n))
}),
p1("default", func(n cue.Value, s *state) {
sc := *s
s.default_ = sc.value(n)
// TODO: must validate that the default is subsumed by the normal value,
// as CUE will otherwise broaden the accepted values with the default.
s.examples = append(s.examples, s.default_)
}),
p1("deprecated", func(n cue.Value, s *state) {
if s.boolValue(n) {
s.deprecated = true
}
}),
p1("examples", func(n cue.Value, s *state) {
if n.Kind() != cue.ListKind {
s.errf(n, `value of "examples" must be an array, found %v`, n.Kind)
}
// TODO: implement examples properly.
// for _, n := range s.listItems("examples", n, true) {
// if ex := s.value(n); !isAny(ex) {
// s.examples = append(s.examples, ex)
// }
// }
}),
p1("description", func(n cue.Value, s *state) {
s.description, _ = s.strValue(n)
}),
p1("title", func(n cue.Value, s *state) {
s.title, _ = s.strValue(n)
}),
p1d("$comment", 7, func(n cue.Value, s *state) {
}),
p1("$defs", addDefinitions),
p1("definitions", addDefinitions),
p1("$ref", func(n cue.Value, s *state) {
s.usedTypes = allTypes
u := s.resolveURI(n)
if u.Fragment != "" && !path.IsAbs(u.Fragment) {
s.addErr(errors.Newf(n.Pos(), "anchors (%s) not supported", u.Fragment))
// TODO: support anchors
return
}
expr := s.makeCUERef(n, u)
if expr == nil {
expr = &ast.BadExpr{From: n.Pos()}
}
s.all.add(n, expr)
}),
// Combinators
// TODO: work this out in more detail: oneOf and anyOf below have the same
// implementation in CUE. The distinction is that for anyOf a result is
// allowed to be ambiguous at the end, whereas for oneOf a disjunction must
// be fully resolved. There is currently no easy way to set this distinction
// in CUE.
//
// One could correctly write oneOf like this once 'not' is implemented:
//
// oneOf(a, b, c) :-
// anyOf(
// allOf(a, not(b), not(c)),
// allOf(not(a), b, not(c)),
// allOf(not(a), not(b), c),
// ))
//
// This is not necessary if the values are mutually exclusive/ have a
// discriminator.
p2("allOf", func(n cue.Value, s *state) {
var a []ast.Expr
for _, v := range s.listItems("allOf", n, false) {
x, sub := s.schemaState(v, s.allowedTypes, nil, true)
s.allowedTypes &= sub.allowedTypes
s.usedTypes |= sub.usedTypes
if sub.hasConstraints() {
a = append(a, x)
}
}
if len(a) > 0 {
s.all.add(n, ast.NewBinExpr(token.AND, a...))
}
}),
p2("anyOf", func(n cue.Value, s *state) {
var types cue.Kind
var a []ast.Expr
for _, v := range s.listItems("anyOf", n, false) {
x, sub := s.schemaState(v, s.allowedTypes, nil, true)
types |= sub.allowedTypes
a = append(a, x)
}
s.allowedTypes &= types
if len(a) > 0 {
s.all.add(n, ast.NewBinExpr(token.OR, a...))
}
}),
p2("oneOf", func(n cue.Value, s *state) {
var types cue.Kind
var a []ast.Expr
hasSome := false
for _, v := range s.listItems("oneOf", n, false) {
x, sub := s.schemaState(v, s.allowedTypes, nil, true)
types |= sub.allowedTypes
// TODO: make more finegrained by making it two pass.
if sub.hasConstraints() {
hasSome = true
}
if !isAny(x) {
a = append(a, x)
}
}
s.allowedTypes &= types
if len(a) > 0 && hasSome {
s.usedTypes = allTypes
s.all.add(n, ast.NewBinExpr(token.OR, a...))
}
// TODO: oneOf({a:x}, {b:y}, ..., not(anyOf({a:x}, {b:y}, ...))),
// can be translated to {} | {a:x}, {b:y}, ...
}),
// String constraints
p1("pattern", func(n cue.Value, s *state) {
str, _ := n.String()
if _, err := regexp.Compile(str); err != nil {
if s.cfg.Strict {
s.errf(n, "unsupported regexp: %v", err)
}
return
}
s.usedTypes |= cue.StringKind
s.add(n, stringType, &ast.UnaryExpr{Op: token.MAT, X: s.string(n)})
}),
p1("minLength", func(n cue.Value, s *state) {
s.usedTypes |= cue.StringKind
min := s.number(n)
strings := s.addImport(n, "strings")
s.add(n, stringType, ast.NewCall(ast.NewSel(strings, "MinRunes"), min))
}),
p1("maxLength", func(n cue.Value, s *state) {
s.usedTypes |= cue.StringKind
max := s.number(n)
strings := s.addImport(n, "strings")
s.add(n, stringType, ast.NewCall(ast.NewSel(strings, "MaxRunes"), max))
}),
p1d("contentMediaType", 7, func(n cue.Value, s *state) {
// TODO: only mark as used if it generates something.
// s.usedTypes |= cue.StringKind
}),
p1d("contentEncoding", 7, func(n cue.Value, s *state) {
// TODO: only mark as used if it generates something.
// s.usedTypes |= cue.StringKind
// 7bit, 8bit, binary, quoted-printable and base64.
// RFC 2054, part 6.1.
// https://tools.ietf.org/html/rfc2045
// TODO: at least handle bytes.
}),
// Number constraints
p2("minimum", func(n cue.Value, s *state) {
s.usedTypes |= cue.NumberKind
op := token.GEQ
if s.exclusiveMin {
op = token.GTR
}
s.add(n, numType, &ast.UnaryExpr{Op: op, X: s.number(n)})
}),
p1("exclusiveMinimum", func(n cue.Value, s *state) {
if n.Kind() == cue.BoolKind {
s.exclusiveMin = true
return
}
s.usedTypes |= cue.NumberKind
s.add(n, numType, &ast.UnaryExpr{Op: token.GTR, X: s.number(n)})
}),
p2("maximum", func(n cue.Value, s *state) {
s.usedTypes |= cue.NumberKind
op := token.LEQ
if s.exclusiveMax {
op = token.LSS
}
s.add(n, numType, &ast.UnaryExpr{Op: op, X: s.number(n)})
}),
p1("exclusiveMaximum", func(n cue.Value, s *state) {
if n.Kind() == cue.BoolKind {
s.exclusiveMax = true
return
}
s.usedTypes |= cue.NumberKind
s.add(n, numType, &ast.UnaryExpr{Op: token.LSS, X: s.number(n)})
}),
p1("multipleOf", func(n cue.Value, s *state) {
s.usedTypes |= cue.NumberKind
multiple := s.number(n)
var x big.Int
_, _ = n.MantExp(&x)
if x.Cmp(big.NewInt(0)) != 1 {
s.errf(n, `"multipleOf" value must be < 0; found %s`, n)
}
math := s.addImport(n, "math")
s.add(n, numType, ast.NewCall(ast.NewSel(math, "MultipleOf"), multiple))
}),
// Object constraints
p1("properties", func(n cue.Value, s *state) {
s.usedTypes |= cue.StructKind
obj := s.object(n)
if n.Kind() != cue.StructKind {
s.errf(n, `"properties" expected an object, found %v`, n.Kind())
}
s.processMap(n, func(key string, n cue.Value) {
// property?: value
name := ast.NewString(key)
expr, state := s.schemaState(n, allTypes, []label{{name: key}}, false)
f := &ast.Field{Label: name, Value: expr}
state.doc(f)
f.Optional = token.Blank.Pos()
if len(obj.Elts) > 0 && len(f.Comments()) > 0 {
// TODO: change formatter such that either a a NewSection on the
// field or doc comment will cause a new section.
ast.SetRelPos(f.Comments()[0], token.NewSection)
}
if state.deprecated {
switch expr.(type) {
case *ast.StructLit:
obj.Elts = append(obj.Elts, addTag(name, "deprecated", ""))
default:
f.Attrs = append(f.Attrs, internal.NewAttr("deprecated", ""))
}
}
obj.Elts = append(obj.Elts, f)
s.setField(label{name: key}, f)
})
}),
p2("required", func(n cue.Value, s *state) {
if n.Kind() != cue.ListKind {
s.errf(n, `value of "required" must be list of strings, found %v`, n.Kind)
return
}
s.usedTypes |= cue.StructKind
// TODO: detect that properties is defined somewhere.
// s.errf(n, `"required" without a "properties" field`)
obj := s.object(n)
// Create field map
fields := map[string]*ast.Field{}
for _, d := range obj.Elts {
f, ok := d.(*ast.Field)
if !ok {
continue // Could be embedding? See cirrus.json
}
str, _, err := ast.LabelName(f.Label)
if err == nil {
fields[str] = f
}
}
for _, n := range s.listItems("required", n, true) {
str, ok := s.strValue(n)
f := fields[str]
if f == nil && ok {
f := &ast.Field{
Label: ast.NewString(str),
Value: ast.NewIdent("_"),
}
fields[str] = f
obj.Elts = append(obj.Elts, f)
continue
}
if f.Optional == token.NoPos {
s.errf(n, "duplicate required field %q", str)
}
f.Optional = token.NoPos
}
}),
p1d("propertyNames", 6, func(n cue.Value, s *state) {
// [=~pattern]: _
if names, _ := s.schemaState(n, cue.StringKind, nil, false); !isAny(names) {
s.usedTypes |= cue.StructKind
x := ast.NewStruct(ast.NewList(names), ast.NewIdent("_"))
s.add(n, objectType, x)
}
}),
// TODO: reenable when we have proper non-monotonic contraint validation.
// p1("minProperties", func(n cue.Value, s *state) {
// s.usedTypes |= cue.StructKind
// pkg := s.addImport(n, "struct")
// s.addConjunct(n, ast.NewCall(ast.NewSel(pkg, "MinFields"), s.uint(n)))
// }),
p1("maxProperties", func(n cue.Value, s *state) {
s.usedTypes |= cue.StructKind
pkg := s.addImport(n, "struct")
x := ast.NewCall(ast.NewSel(pkg, "MaxFields"), s.uint(n))
s.add(n, objectType, x)
}),
p1("dependencies", func(n cue.Value, s *state) {
s.usedTypes |= cue.StructKind
// Schema and property dependencies.
// TODO: the easiest implementation is with comprehensions.
// The nicer implementation is with disjunctions. This has to be done
// at the very end, replacing properties.
/*
*{ property?: _|_ } | {
property: _
schema
}
*/
}),
p2("patternProperties", func(n cue.Value, s *state) {
s.usedTypes |= cue.StructKind
if n.Kind() != cue.StructKind {
s.errf(n, `value of "patternProperties" must be an an object, found %v`, n.Kind)
}
obj := s.object(n)
existing := excludeFields(s.obj.Elts)
s.processMap(n, func(key string, n cue.Value) {
// [!~(properties) & pattern]: schema
s.patterns = append(s.patterns,
&ast.UnaryExpr{Op: token.NMAT, X: ast.NewString(key)})
f := internal.EmbedStruct(ast.NewStruct(&ast.Field{
Label: ast.NewList(ast.NewBinExpr(token.AND,
&ast.UnaryExpr{Op: token.MAT, X: ast.NewString(key)},
existing)),
Value: s.schema(n),
}))
ast.SetRelPos(f, token.NewSection)
obj.Elts = append(obj.Elts, f)
})
}),
p3("additionalProperties", func(n cue.Value, s *state) {
switch n.Kind() {
case cue.BoolKind:
s.closeStruct = !s.boolValue(n)
if !s.closeStruct {
s.usedTypes |= cue.StructKind
}
case cue.StructKind:
s.usedTypes |= cue.StructKind
s.closeStruct = true
obj := s.object(n)
if len(obj.Elts) == 0 {
obj.Elts = append(obj.Elts, &ast.Field{
Label: ast.NewList(ast.NewIdent("string")),
Value: s.schema(n),
})
return
}
// [!~(properties|patternProperties)]: schema
existing := append(s.patterns, excludeFields(obj.Elts))
f := internal.EmbedStruct(ast.NewStruct(&ast.Field{
Label: ast.NewList(ast.NewBinExpr(token.AND, existing...)),
Value: s.schema(n),
}))
obj.Elts = append(obj.Elts, f)
default:
s.errf(n, `value of "additionalProperties" must be an object or boolean`)
}
}),
// Array constraints.
p1("items", func(n cue.Value, s *state) {
s.usedTypes |= cue.ListKind
switch n.Kind() {
case cue.StructKind:
elem := s.schema(n)
ast.SetRelPos(elem, token.NoRelPos)
s.add(n, arrayType, ast.NewList(&ast.Ellipsis{Type: elem}))
case cue.ListKind:
var a []ast.Expr
for _, n := range s.listItems("items", n, true) {
v := s.schema(n) // TODO: label with number literal.
ast.SetRelPos(v, token.NoRelPos)
a = append(a, v)
}
s.list = ast.NewList(a...)
s.add(n, arrayType, s.list)
default:
s.errf(n, `value of "items" must be an object or array`)
}
}),
p1("additionalItems", func(n cue.Value, s *state) {
switch n.Kind() {
case cue.BoolKind:
// TODO: support
case cue.StructKind:
if s.list != nil {
s.usedTypes |= cue.ListKind
elem := s.schema(n)
s.list.Elts = append(s.list.Elts, &ast.Ellipsis{Type: elem})
}
default:
s.errf(n, `value of "additionalItems" must be an object or boolean`)
}
}),
p1("contains", func(n cue.Value, s *state) {
s.usedTypes |= cue.ListKind
list := s.addImport(n, "list")
// TODO: Passing non-concrete values is not yet supported in CUE.
if x := s.schema(n); !isAny(x) {
x := ast.NewCall(ast.NewSel(list, "Contains"), clearPos(x))
s.add(n, arrayType, x)
}
}),
// TODO: min/maxContains
p1("minItems", func(n cue.Value, s *state) {
s.usedTypes |= cue.ListKind
a := []ast.Expr{}
p, err := n.Uint64()
if err != nil {
s.errf(n, "invalid uint")
}
for ; p > 0; p-- {
a = append(a, ast.NewIdent("_"))
}
s.add(n, arrayType, ast.NewList(append(a, &ast.Ellipsis{})...))
// TODO: use this once constraint resolution is properly implemented.
// list := s.addImport(n, "list")
// s.addConjunct(n, ast.NewCall(ast.NewSel(list, "MinItems"), clearPos(s.uint(n))))
}),
p1("maxItems", func(n cue.Value, s *state) {
s.usedTypes |= cue.ListKind
list := s.addImport(n, "list")
x := ast.NewCall(ast.NewSel(list, "MaxItems"), clearPos(s.uint(n)))
s.add(n, arrayType, x)
}),
p1("uniqueItems", func(n cue.Value, s *state) {
s.usedTypes |= cue.ListKind
if s.boolValue(n) {
list := s.addImport(n, "list")
s.add(n, arrayType, ast.NewCall(ast.NewSel(list, "UniqueItems")))
}
}),
}
func clearPos(e ast.Expr) ast.Expr {
ast.SetRelPos(e, token.NoRelPos)
return e
}