blob: 0cef528a73155ca0dd4fa8375878031cd3998489 [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 openapi
import (
"fmt"
"math"
"path"
"strings"
"cuelang.org/go/cue"
)
type buildContext struct {
refPrefix string
path []string
expandRefs bool
schemas *orderedMap
}
type oaSchema = orderedMap
type typeFunc func(b *builder, a cue.Value)
func components(inst *cue.Instance, cfg *Config) (comps *orderedMap, err error) {
c := buildContext{
refPrefix: "components/schema",
expandRefs: cfg.ExpandReferences,
schemas: &orderedMap{},
}
defer func() {
if x := recover(); x != nil {
// TODO: check if it's one of our own.
path := strings.Join(c.path, ".")
err = fmt.Errorf("error: %s: %v", path, x)
}
}()
schemas := &orderedMap{}
schemas.Set("schema", c.schemas)
comps = &orderedMap{}
comps.Set("openapi", "3.0.0")
comps.Set("components", schemas)
i, err := inst.Value().Fields()
if err != nil {
return nil, err
}
for i.Next() {
// message, enum, or constant.
c.schemas.Set(i.Label(), c.build(i.Value()))
}
return comps, nil
}
func (c *buildContext) build(v cue.Value) *oaSchema {
return newRootBuilder(c).schema(v)
}
func (b *builder) schema(v cue.Value) *oaSchema {
c := newRootBuilder(b.ctx)
c.value(v, nil)
schema := c.finish()
doc := []string{}
for _, d := range v.Doc() {
doc = append(doc, d.Text())
}
if len(doc) > 0 {
str := strings.TrimSpace(strings.Join(doc, "\n"))
schema.Prepend("description", str)
}
return schema
}
func (b *builder) value(v cue.Value, f typeFunc) {
count := 0
var values cue.Value
if b.ctx.expandRefs {
values = v
count = 1
} else {
for _, v := range appendSplit(nil, cue.AndOp, v) {
// This may be a reference to an enum. So we need to check references before
// dissecting them.
switch r := v.Reference(); {
case r != nil:
b.addRef(r)
default:
count++
values = values.Unify(v)
}
}
}
if count > 0 { // TODO: implement IsAny.
// NOTE: Eval is not necessary here. Removing it will yield
// different results that also are correct. The difference is that
// Eval detects and eliminates impossible combinations at the
// expense of having potentially much larger configurations due to
// a combinatorial explosion. This rudimentary check picks the least
// fo the two extreme forms.
if eval := values.Eval(); countNodes(eval) < countNodes(values) {
values = eval
}
for _, v := range appendSplit(nil, cue.AndOp, values) {
switch {
case isConcrete(v):
b.dispatch(f, v)
b.set("enum", []interface{}{decode(v)})
default:
if a := appendSplit(nil, cue.OrOp, v); len(a) > 1 {
b.disjunction(a, f)
} else {
v = a[0]
if err := v.Err(); err != nil {
panic(err)
}
b.dispatch(f, v)
}
}
}
}
if v, ok := v.Default(); ok && v.IsConcrete() {
v.Default()
b.set("default", v)
}
}
func appendSplit(a []cue.Value, splitBy cue.Op, v cue.Value) []cue.Value {
op, args := v.Expr()
if op == cue.NoOp && len(args) > 0 {
// TODO: this is to deal with default value removal. This may change
// whe we completely separate default values from values.
a = append(a, args...)
} else if op != splitBy {
a = append(a, v)
} else {
for _, v := range args {
a = appendSplit(a, splitBy, v)
}
}
return a
}
func countNodes(v cue.Value) (n int) {
switch op, a := v.Expr(); op {
case cue.OrOp, cue.AndOp:
for _, v := range a {
n += countNodes(v)
}
n += len(a) - 1
default:
switch v.Kind() {
case cue.ListKind:
for i, _ := v.List(); i.Next(); {
n += countNodes(i.Value())
}
case cue.StructKind:
for i, _ := v.Fields(); i.Next(); {
n += countNodes(i.Value()) + 1
}
}
}
return n + 1
}
// isConcrete reports whether v is concrete and not a struct (recursively).
// structs are not supported as the result of a struct enum depends on how
// conjunctions and disjunctions are distributed. We could consider still doing
// this if we define a normal form.
func isConcrete(v cue.Value) bool {
if !v.IsConcrete() {
return false
}
if v.Kind() == cue.StructKind {
return false // TODO: handle struct kinds
}
for list, _ := v.List(); list.Next(); {
if !isConcrete(list.Value()) {
return false
}
}
return true
}
func (b *builder) disjunction(a []cue.Value, f typeFunc) {
disjuncts := []cue.Value{}
enums := []interface{}{} // TODO: unique the enums
nullable := false // Only supported in OpenAPI, not JSON schema
for _, v := range a {
switch {
case v.Null() == nil:
// TODO: for JSON schema, we need to fall through.
nullable = true
case isConcrete(v):
enums = append(enums, decode(v))
default:
disjuncts = append(disjuncts, v)
}
}
// Only one conjunct?
if len(disjuncts) == 0 || (len(disjuncts) == 1 && len(enums) == 0) {
if len(disjuncts) == 1 {
b.value(disjuncts[0], f)
}
if len(enums) > 0 {
b.set("enum", enums)
}
if nullable {
b.set("nullable", true)
}
return
}
b.addConjunct(func(b *builder) {
anyOf := []*oaSchema{}
if len(enums) > 0 {
anyOf = append(anyOf, b.kv("enum", enums))
}
for _, v := range disjuncts {
c := newOASBuilder(b)
c.value(v, f)
anyOf = append(anyOf, c.finish())
}
b.set("anyOf", anyOf)
if nullable {
b.set("nullable", true)
}
})
}
func (b *builder) dispatch(f typeFunc, v cue.Value) {
if f != nil {
f(b, v)
return
}
switch v.IncompleteKind() &^ cue.BottomKind {
case cue.NullKind:
// TODO: for JSON schema we would set the type here. For OpenAPI,
// it must be nullable.
b.set("nullable", true)
case cue.BoolKind:
b.setType("boolean", "")
// No need to call.
case cue.FloatKind, cue.NumberKind:
// TODO:
// Common Name type format Comments
// float number float
// double number double
b.setType("number", "") // may be overridden to integer
b.number(v)
case cue.IntKind:
// integer integer int32 signed 32 bits
// long integer int64 signed 64 bits
b.setType("integer", "") // may be overridden to integer
b.number(v)
case cue.BytesKind:
// byte string byte base64 encoded characters
// binary string binary any sequence of octets
b.setType("string", "byte")
b.bytes(v)
case cue.StringKind:
// date string date As defined by full-date - RFC3339
// dateTime string date-time As defined by date-time - RFC3339
// password string password A hint to UIs to obscure input
b.setType("string", "")
b.string(v)
case cue.StructKind:
b.setType("object", "")
b.object(v)
case cue.ListKind:
b.setType("array", "")
b.array(v)
}
}
// object supports the following
// - maxProperties: maximum allowed fields in this struct.
// - minProperties: minimum required fields in this struct.a
// - patternProperties: [regexp]: schema
// TODO: we can support this once .kv(key, value) allow
// foo [=~"pattern"]: type
// An instance field must match all schemas for which a regexp matches.
// Even though it is not supported in OpenAPI, we should still accept it
// when receiving from OpenAPI. We could possibly use disjunctions to encode
// this.
// - dependencies: what?
// - propertyNames: schema
// every property name in the enclosed schema matches that of
func (b *builder) object(v cue.Value) {
// TODO: discriminator objects: we could theoretically derive discriminator
// objects automatically: for every object in a oneOf/allOf/anyOf, or any
// object composed of the same type, if a property is required and set to a
// constant value for each type, it is a discriminator.
required := []string{}
for i, _ := v.Fields(cue.Optional(false), cue.Hidden(false)); i.Next(); {
required = append(required, i.Label())
}
if len(required) > 0 {
b.set("required", required)
}
properties := map[string]*oaSchema{}
for i, _ := v.Fields(cue.Optional(true), cue.Hidden(false)); i.Next(); {
properties[i.Label()] = b.schema(i.Value())
}
if len(properties) > 0 {
b.set("properties", properties)
}
if t, ok := v.Elem(); ok {
b.set("additionalProperties", b.schema(t))
}
// TODO: maxProperties, minProperties: can be done once we allow cap to
// unify with structs.
}
// List constraints:
//
// Max and min items.
// - maxItems: int (inclusive)
// - minItems: int (inclusive)
// - items (item type)
// schema: applies to all items
// array of schemas:
// schema at pos must match if both value and items are defined.
// - additional items:
// schema: where items must be an array of schemas, intstance elements
// succeed for if they match this value for any value at a position
// greater than that covered by items.
// - uniqueItems: bool
// TODO: support with list.Unique() unique() or comprehensions.
// For the latter, we need equality for all values, which is doable,
// but not done yet.
//
// NOT SUPPORTED IN OpenAPI:
// - contains:
// schema: an array instance is valid if at least one element matches
// this schema.
func (b *builder) array(v cue.Value) {
// Possible conjuncts:
// - one list (CUE guarantees merging all conjuncts)
// - no cap: is unified with list
// - unique items: at most one, but idempotent if multiple.
// There is never a need for allOf or anyOf. Note that a CUE list
// corresponds almost one-to-one to OpenAPI lists.
items := []*oaSchema{}
for i, _ := v.List(); i.Next(); {
items = append(items, b.schema(i.Value()))
}
if len(items) > 0 {
// TODO: per-item schema are not allowed in OpenAPI, only in JSON Schema.
// Perhaps we should turn this into an OR after first normalizing
// the entries.
b.set("items", items)
// panic("per-item types not supported in OpenAPI")
}
// TODO:
// A CUE cap can be a set of discontinuous ranges. If we encounter this,
// we can create an allOf(list type, anyOf(ranges)).
cap := v.Len()
hasMax := false
maxLength := int64(math.MaxInt64)
if n, capErr := cap.Int64(); capErr == nil {
maxLength = n
hasMax = true
} else {
b.value(cap, (*builder).listCap)
}
if !hasMax || int64(len(items)) < maxLength {
if typ, ok := v.Elem(); ok {
t := b.schema(typ)
if len(items) > 0 {
b.set("additionalItems", t)
} else {
b.set("items", t)
}
}
}
}
func (b *builder) listCap(v cue.Value) {
switch op, a := v.Expr(); op {
case cue.LessThanOp:
b.set("maxItems", b.int(a[0])-1)
case cue.LessThanEqualOp:
b.set("maxItems", b.int(a[0]))
case cue.GreaterThanOp:
b.set("minItems", b.int(a[0])+1)
case cue.GreaterThanEqualOp:
if b.int(a[0]) > 0 {
b.set("minItems", b.int(a[0]))
}
case cue.NoOp:
// must be type, so okay.
case cue.NotEqualOp:
i := b.int(a[0])
b.setNot("allOff", []*oaSchema{
b.kv("minItems", i),
b.kv("maxItems", i),
})
default:
panic(fmt.Sprintf("unsupported op for list capacity %v", op))
}
}
func (b *builder) number(v cue.Value) {
// Multiple conjuncts mostly means just additive constraints.
// Type may be number of float.
// TODO: deterimine integer kind.
// if v.IsInt() {
// b.typ = "integer"
// }
switch op, a := v.Expr(); op {
// TODO: support the following JSON schema constraints
// - multipleOf
// setIntConstraint(t, "multipleOf", a)
case cue.LessThanOp:
b.set("exclusiveMaximum", b.int(a[0]))
case cue.LessThanEqualOp:
b.set("maximum", b.int(a[0]))
case cue.GreaterThanOp:
b.set("exclusiveMinimum", b.int(a[0]))
case cue.GreaterThanEqualOp:
b.set("minimum", b.int(a[0]))
case cue.NotEqualOp:
i := b.int(a[0])
b.setNot("allOff", []*oaSchema{
b.kv("minItems", i),
b.kv("maxItems", i),
})
case cue.NoOp:
// TODO: extract format from specific type.
default:
// panic(fmt.Sprintf("unsupported of %v for number type", op))
}
}
// Multiple Regexp conjuncts are represented as allOf all other
// constraints can be combined unless in the even of discontinuous
// lengths.
// string supports the following options:
//
// - maxLenght (Unicode codepoints)
// - minLenght (Unicode codepoints)
// - pattern (a regexp)
//
// The regexp pattern is as follows, and is limited to be a strict subset of RE2:
// Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-01#section-3.3
//
// JSON schema requires ECMA 262 regular expressions, but
// limited to the following constructs:
// - simple character classes: [abc]
// - range character classes: [a-z]
// - complement character classes: [^abc], [^a-z]
// - simple quantifiers: +, *, ?, and lazy versions +? *? ??
// - range quantifiers: {x}, {x,y}, {x,}, {x}?, {x,y}?, {x,}?
// - begin and end anchors: ^ and $
// - simple grouping: (...)
// - alteration: |
// This is a subset of RE2 used by CUE.
//
// Most notably absent:
// - the '.' for any character (not sure if that is a doc bug)
// - character classes \d \D [[::]] \pN \p{Name} \PN \P{Name}
// - word boundaries
// - capturing directives.
// - flag setting
// - comments
//
// The capturing directives and comments can be removed without
// compromising the meaning of the regexp (TODO). Removing
// flag setting will be tricky. Unicode character classes,
// boundaries, etc can be compiled into simple character classes,
// although the resulting regexp will look cumbersome.
//
func (b *builder) string(v cue.Value) {
switch op, a := v.Expr(); op {
case cue.RegexMatchOp, cue.NotRegexMatchOp:
s, err := a[0].String()
if err != nil {
// TODO: this may be an unresolved interpolation or expression. Consider
// whether it is reasonable to treat unevaluated operands as wholes and
// generate a compound regular expression.
panic(err)
}
if op == cue.RegexMatchOp {
b.set("pattern", s)
} else {
b.setNot("pattern", s)
}
// TODO: support the following JSON schema constraints
// - maxLength
// - minLength
case cue.NoOp:
// TODO: determine formats from specific types.
default:
panic(fmt.Sprintf("unsupported of %v for bytes type", op))
}
}
func (b *builder) bytes(v cue.Value) {
switch op, a := v.Expr(); op {
case cue.RegexMatchOp, cue.NotRegexMatchOp:
s, err := a[0].Bytes()
if err != nil {
// TODO: this may be an unresolved interpolation or expression. Consider
// whether it is reasonable to treat unevaluated operands as wholes and
// generate a compound regular expression.
panic(err)
}
if op == cue.RegexMatchOp {
b.set("pattern", s)
} else {
b.setNot("pattern", s)
}
// TODO: support the following JSON schema constraints
// - maxLength
// - minLength
case cue.NoOp:
default:
panic(fmt.Sprintf("unsupported of %v for bytes type", op))
}
}
type builder struct {
ctx *buildContext
typ string
format string
current *oaSchema
allOf []*oaSchema
enums []interface{}
}
func newRootBuilder(c *buildContext) *builder {
return &builder{ctx: c}
}
func newOASBuilder(parent *builder) *builder {
b := &builder{
ctx: parent.ctx,
typ: parent.typ,
format: parent.format,
}
return b
}
func (b *builder) setType(t, format string) {
if b.typ == "" {
b.typ = t
b.format = format
}
}
func setType(t *oaSchema, b *builder) {
if b.typ != "" {
t.Set("type", b.typ)
if b.format != "" {
t.Set("format", b.format)
}
}
}
func (b *builder) set(key string, v interface{}) {
if b.current == nil {
b.current = &orderedMap{}
b.allOf = append(b.allOf, b.current)
setType(b.current, b)
} else if b.current.Exists(key) {
b.current = &orderedMap{}
b.allOf = append(b.allOf, b.current)
}
b.current.Set(key, v)
}
func (b *builder) kv(key string, value interface{}) *oaSchema {
constraint := &orderedMap{}
setType(constraint, b)
constraint.Set(key, value)
return constraint
}
func (b *builder) setNot(key string, value interface{}) {
not := &orderedMap{}
not.Set("not", b.kv(key, value))
b.add(not)
}
func (b *builder) finish() *oaSchema {
switch len(b.allOf) {
case 0:
if b.typ == "" {
panic("no type specified at finish")
}
t := &orderedMap{}
setType(t, b)
return t
case 1:
setType(b.allOf[0], b)
return b.allOf[0]
default:
t := &orderedMap{}
t.Set("allOf", b.allOf)
return t
}
}
func (b *builder) add(t *oaSchema) {
b.allOf = append(b.allOf, t)
}
func (b *builder) addConjunct(f func(*builder)) {
c := newOASBuilder(b)
f(c)
b.add(c.finish())
}
func (b *builder) addRef(ref []string) {
// TODO: validate path.
// TODO: map CUE types to OAPI types.
b.addConjunct(func(b *builder) {
a := append([]string{"#", b.ctx.refPrefix}, ref...)
b.set("$ref", path.Join(a...))
})
}
func (b *builder) int(v cue.Value) int64 {
i, err := v.Int64()
if err != nil {
panic("could not retrieve int")
}
return i
}
func decode(v cue.Value) interface{} {
var d interface{}
if err := v.Decode(&d); err != nil {
panic(err)
}
return d
}