Working through complex rendering edgecases.

This commit is contained in:
Dave Shanley
2023-03-05 09:49:21 -05:00
parent da17b8df89
commit 38064123ed
10 changed files with 928 additions and 472 deletions

View File

@@ -65,5 +65,5 @@ func (d *DynamicValue[A, B]) MarshalYAML() (interface{}, error) {
case reflect.Int64: case reflect.Int64:
_ = n.Encode(value.(int64)) _ = n.Encode(value.(int64))
} }
return n, err return &n, err
} }

View File

@@ -5,6 +5,7 @@ package base
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
lowmodel "github.com/pb33f/libopenapi/datamodel/low" lowmodel "github.com/pb33f/libopenapi/datamodel/low"
@@ -171,8 +172,7 @@ version: 1.2.3
x-pizza: pepperoni x-pizza: pepperoni
x-cake: x-cake:
name: someone name: someone
url: nowhere url: nowhere`
`
// unmarshal yaml into a *yaml.Node instance // unmarshal yaml into a *yaml.Node instance
var cNode yaml.Node var cNode yaml.Node
@@ -198,7 +198,7 @@ x-cake:
// marshal high back to yaml, should be the same as the original, in same order. // marshal high back to yaml, should be the same as the original, in same order.
bytes, _ := highInfo.Render() bytes, _ := highInfo.Render()
assert.Equal(t, yml, string(bytes)) assert.Equal(t, yml, strings.TrimSpace(string(bytes)))
} }

View File

@@ -235,6 +235,8 @@ func NewSchema(schema *base.Schema) *Schema {
Value: addPropSchema, Value: addPropSchema,
}) })
} else { } else {
// TODO: check for slice and map types and unpack correctly.
s.AdditionalProperties = schema.AdditionalProperties.Value s.AdditionalProperties = schema.AdditionalProperties.Value
} }
} }

View File

@@ -5,6 +5,7 @@ package base
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
"github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low"
@@ -68,157 +69,146 @@ func TestNewSchemaProxy(t *testing.T) {
func TestNewSchemaProxy_WithObject(t *testing.T) { func TestNewSchemaProxy_WithObject(t *testing.T) {
testSpec := `type: object testSpec := `type: object
description: something object description: something object
discriminator:
propertyName: athing
mapping:
log: cat
pizza: party
allOf:
- type: object
description: an allof thing
properties:
allOfA:
type: string
description: allOfA description
example: 'allOfAExp'
allOfB:
type: string
description: allOfB description
example: 'allOfBExp'
oneOf:
type: object
description: a oneof thing
properties:
oneOfA:
type: string
description: oneOfA description
example: 'oneOfAExp'
oneOfB:
type: string
description: oneOfB description
example: 'oneOfBExp'
anyOf:
type: object
description: an anyOf thing
properties:
anyOfA:
type: string
description: anyOfA description
example: 'anyOfAExp'
anyOfB:
type: string
description: anyOfB description
example: 'anyOfBExp'
not:
type: object
description: a not thing
properties:
notA:
type: string
description: notA description
example: 'notAExp'
notB:
type: string
description: notB description
example: 'notBExp'
items:
type: object
description: an items thing
properties:
itemsA:
type: string
description: itemsA description
example: 'itemsAExp'
itemsB:
type: string
description: itemsB description
example: 'itemsBExp'
prefixItems:
type: object
description: an items thing
properties:
itemsA:
type: string
description: itemsA description
example: 'itemsAExp'
itemsB:
type: string
description: itemsB description
example: 'itemsBExp'
properties:
somethingBee:
type: number
somethingThree:
type: number
somethingTwo:
type: number
somethingOne:
type: number
somethingA:
type: number
description: a number
example: 2
somethingB:
type: object
description: an object
externalDocs:
description: the best docs
url: https://pb33f.io
properties:
somethingBProp:
type: string
description: something b subprop
example: picnics are nice.
xml:
name: an xml thing
namespace: an xml namespace
prefix: a prefix
attribute: true
wrapped: false
x-pizza: love
additionalProperties:
why: yes
thatIs: true
additionalProperties: true
xml:
name: XML Thing
externalDocs:
url: https://pb33f.io/docs
enum: [fish, cake]
required: [cake, fish]
maxLength: 10
minLength: 1
maxItems: 10
minItems: 1
maxProperties: 10
minProperties: 1
nullable: true
readOnly: true
writeOnly: false
deprecated: true
contains:
type: int
minContains: 1
maxContains: 10
if: if:
type: string type: string
else: else:
type: integer type: integer
then: then:
type: boolean type: boolean
dependentSchemas: dependentSchemas:
schemaOne: schemaOne:
type: string type: string
patternProperties: patternProperties:
patternOne: patternOne:
type: string type: string
propertyNames: propertyNames:
type: string type: string
unevaluatedItems: unevaluatedItems:
type: boolean type: boolean
unevaluatedProperties: unevaluatedProperties:
type: integer type: integer
` discriminator:
propertyName: athing
mapping:
log: cat
pizza: party
allOf:
- type: object
description: an allof thing
properties:
allOfA:
type: string
description: allOfA description
example: allOfAExp
allOfB:
type: string
description: allOfB description
example: allOfBExp
oneOf:
- type: object
description: a oneof thing
properties:
oneOfA:
type: string
description: oneOfA description
example: oneOfAExp
oneOfB:
type: string
description: oneOfB description
example: oneOfBExp
anyOf:
- type: object
description: an anyOf thing
properties:
anyOfA:
type: string
description: anyOfA description
example: anyOfAExp
anyOfB:
type: string
description: anyOfB description
example: anyOfBExp
not:
type: object
description: a not thing
properties:
notA:
type: string
description: notA description
example: notAExp
notB:
type: string
description: notB description
example: notBExp
items:
type: object
description: an items thing
properties:
itemsA:
type: string
description: itemsA description
example: itemsAExp
itemsB:
type: string
description: itemsB description
example: itemsBExp
prefixItems:
- type: object
description: an items thing
properties:
itemsA:
type: string
description: itemsA description
example: itemsAExp
itemsB:
type: string
description: itemsB description
example: itemsBExp
properties:
somethingA:
type: number
description: a number
example: "2"
additionalProperties:
- chicken
- nugget
- soup
somethingB:
type: object
exclusiveMinimum: true
exclusiveMaximum: true
description: an object
externalDocs:
description: the best docs
url: https://pb33f.io
properties:
somethingBProp:
type: string
description: something b subprop
example: picnics are nice.
xml:
name: an xml thing
namespace: an xml namespace
prefix: a prefix
attribute: true
x-pizza: love
additionalProperties:
why: yes
thatIs: true
additionalProperties: true
required:
- them
enum:
- one
- two
x-pizza: tasty
examples:
- hey
- hi!
contains:
type: int
maxContains: 10
minContains: 1`
var compNode yaml.Node var compNode yaml.Node
_ = yaml.Unmarshal([]byte(testSpec), &compNode) _ = yaml.Unmarshal([]byte(testSpec), &compNode)
@@ -252,15 +242,13 @@ unevaluatedProperties:
assert.Equal(t, "string", compiled.PropertyNames.Schema().Type[0]) assert.Equal(t, "string", compiled.PropertyNames.Schema().Type[0])
assert.Equal(t, "boolean", compiled.UnevaluatedItems.Schema().Type[0]) assert.Equal(t, "boolean", compiled.UnevaluatedItems.Schema().Type[0])
assert.Equal(t, "integer", compiled.UnevaluatedProperties.Schema().Type[0]) assert.Equal(t, "integer", compiled.UnevaluatedProperties.Schema().Type[0])
assert.NotNil(t, compiled.Nullable)
assert.True(t, *compiled.Nullable)
wentLow := compiled.GoLow() wentLow := compiled.GoLow()
assert.Equal(t, 114, wentLow.AdditionalProperties.ValueNode.Line) assert.Equal(t, 129, wentLow.AdditionalProperties.ValueNode.Line)
// now render it out! // now render it out!
schemaBytes, _ := compiled.Render() schemaBytes, _ := compiled.Render()
assert.Equal(t, testSpec, string(schemaBytes)) assert.Equal(t, testSpec, strings.TrimSpace(string(schemaBytes)))
} }
@@ -817,3 +805,278 @@ items:
schemaBytes, _ := compiled.Render() schemaBytes, _ := compiled.Render()
assert.Equal(t, testSpec, string(schemaBytes)) assert.Equal(t, testSpec, string(schemaBytes))
} }
func TestNewSchemaProxy_RenderSchemaEnsurePropertyOrdering(t *testing.T) {
testSpec := `properties:
somethingBee:
type: number
somethingThree:
type: number
somethingTwo:
type: number
somethingOne:
type: number
somethingA:
type: number
description: a number
example: "2"
somethingB:
type: object
description: an object
externalDocs:
description: the best docs
url: https://pb33f.io
properties:
somethingBProp:
type: string
description: something b subprop
example: picnics are nice.
xml:
name: an xml thing
namespace: an xml namespace
prefix: a prefix
attribute: true
x-pizza: love
additionalProperties:
why: yes
thatIs: true
additionalProperties: true
xml:
name: XML Thing`
var compNode yaml.Node
_ = yaml.Unmarshal([]byte(testSpec), &compNode)
sp := new(lowbase.SchemaProxy)
err := sp.Build(compNode.Content[0], nil)
assert.NoError(t, err)
lowproxy := low.NodeReference[*lowbase.SchemaProxy]{
Value: sp,
ValueNode: compNode.Content[0],
}
schemaProxy := NewSchemaProxy(&lowproxy)
compiled := schemaProxy.Schema()
// now render it out, it should be identical.
schemaBytes, _ := compiled.Render()
assert.Equal(t, testSpec, strings.TrimSpace(string(schemaBytes)))
}
func TestNewSchemaProxy_RenderSchemaCheckDiscriminatorMappingOrder(t *testing.T) {
testSpec := `discriminator:
mapping:
log: cat
pizza: party
chicken: nuggets
warm: soup
cold: heart`
var compNode yaml.Node
_ = yaml.Unmarshal([]byte(testSpec), &compNode)
sp := new(lowbase.SchemaProxy)
err := sp.Build(compNode.Content[0], nil)
assert.NoError(t, err)
lowproxy := low.NodeReference[*lowbase.SchemaProxy]{
Value: sp,
ValueNode: compNode.Content[0],
}
schemaProxy := NewSchemaProxy(&lowproxy)
compiled := schemaProxy.Schema()
// now render it out, it should be identical.
schemaBytes, _ := compiled.Render()
assert.Equal(t, testSpec, strings.TrimSpace(string(schemaBytes)))
}
func TestNewSchemaProxy_RenderSchemaCheckAdditionalPropertiesSlice(t *testing.T) {
testSpec := `additionalProperties:
- one
- two
- miss a few
- ninety nine
- hundred`
var compNode yaml.Node
_ = yaml.Unmarshal([]byte(testSpec), &compNode)
sp := new(lowbase.SchemaProxy)
err := sp.Build(compNode.Content[0], nil)
assert.NoError(t, err)
lowproxy := low.NodeReference[*lowbase.SchemaProxy]{
Value: sp,
ValueNode: compNode.Content[0],
}
schemaProxy := NewSchemaProxy(&lowproxy)
compiled := schemaProxy.Schema()
compiled.low.Hash()
// now render it out, it should be identical.
schemaBytes, _ := compiled.Render()
assert.Equal(t, testSpec, strings.TrimSpace(string(schemaBytes)))
}
/*
type: object
description: something object
discriminator:
propertyName: athing
mapping:
log: cat
pizza: party
allOf:
- type: object
description: an allof thing
properties:
allOfA:
type: string
description: allOfA description
example: 'allOfAExp'
allOfB:
type: string
description: allOfB description
example: 'allOfBExp'
oneOf:
type: object
description: a oneof thing
properties:
oneOfA:
type: string
description: oneOfA description
example: 'oneOfAExp'
oneOfB:
type: string
description: oneOfB description
example: 'oneOfBExp'
anyOf:
type: object
description: an anyOf thing
properties:
anyOfA:
type: string
description: anyOfA description
example: 'anyOfAExp'
anyOfB:
type: string
description: anyOfB description
example: 'anyOfBExp'
not:
type: object
description: a not thing
properties:
notA:
type: string
description: notA description
example: 'notAExp'
notB:
type: string
description: notB description
example: 'notBExp'
items:
type: object
description: an items thing
properties:
itemsA:
type: string
description: itemsA description
example: 'itemsAExp'
itemsB:
type: string
description: itemsB description
example: 'itemsBExp'
prefixItems:
type: object
description: an items thing
properties:
itemsA:
type: string
description: itemsA description
example: 'itemsAExp'
itemsB:
type: string
description: itemsB description
example: 'itemsBExp'
properties:
somethingBee:
type: number
somethingThree:
type: number
somethingTwo:
type: number
somethingOne:
type: number
somethingA:
type: number
description: a number
example: 2
somethingB:
type: object
description: an object
externalDocs:
description: the best docs
url: https://pb33f.io
properties:
somethingBProp:
type: string
description: something b subprop
example: picnics are nice.
xml:
name: an xml thing
namespace: an xml namespace
prefix: a prefix
attribute: true
wrapped: false
x-pizza: love
additionalProperties:
why: yes
thatIs: true
additionalProperties: true
xml:
name: XML Thing
externalDocs:
url: https://pb33f.io/docs
enum: [fish, cake]
required: [cake, fish]
maxLength: 10
minLength: 1
maxItems: 10
minItems: 1
maxProperties: 10
minProperties: 1
nullable: true
readOnly: true
writeOnly: false
deprecated: true
contains:
type: int
minContains: 1
maxContains: 10
if:
type: string
else:
type: integer
then:
type: boolean
dependentSchemas:
schemaOne:
type: string
patternProperties:
patternOne:
type: string
propertyNames:
type: string
unevaluatedItems:
type: boolean
unevaluatedProperties:
type: integer
*/

View File

@@ -0,0 +1,388 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT
package high
import (
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/v3"
"gopkg.in/yaml.v3"
"reflect"
"sort"
"strconv"
"strings"
"unicode"
)
// NodeEntry represents a single node used by NodeBuilder.
type NodeEntry struct {
Tag string
Key string
Value any
Line int
}
// NodeBuilder is a structure used by libopenapi high-level objects, to render themselves back to YAML.
// this allows high-level objects to be 'mutable' because all changes will be rendered out.
type NodeBuilder struct {
Nodes []*NodeEntry
High any
Low any
}
// NewNodeBuilder will create a new NodeBuilder instance, this is the only way to create a NodeBuilder.
// The function accepts a high level object and a low level object (need to be siblings/same type).
//
// Using reflection, a map of every field in the high level object is created, ready to be rendered.
func NewNodeBuilder(high any, low any) *NodeBuilder {
// create a new node builder
nb := &NodeBuilder{High: high, Low: low}
// extract fields from the high level object and add them into our node builder.
// this will allow us to extract the line numbers from the low level object as well.
v := reflect.ValueOf(high).Elem()
num := v.NumField()
for i := 0; i < num; i++ {
nb.add(v.Type().Field(i).Name)
}
return nb
}
func (n *NodeBuilder) add(key string) {
// only operate on exported fields.
if unicode.IsLower(rune(key[0])) {
return
}
// if the key is 'Extensions' then we need to extract the keys from the map
// and add them to the node builder.
if key == "Extensions" {
extensions := reflect.ValueOf(n.High).Elem().FieldByName(key)
for _, e := range extensions.MapKeys() {
v := extensions.MapIndex(e)
extKey := e.String()
extValue := v.Interface()
nodeEntry := &NodeEntry{Tag: extKey, Key: extKey, Value: extValue}
if !reflect.ValueOf(n.Low).IsZero() {
fieldValue := reflect.ValueOf(n.Low).Elem().FieldByName("Extensions")
f := fieldValue.Interface()
value := reflect.ValueOf(f)
switch value.Kind() {
case reflect.Map:
if j, ok := n.Low.(low.HasExtensionsUntyped); ok {
originalExtensions := j.GetExtensions()
for k := range originalExtensions {
if k.Value == extKey {
nodeEntry.Line = originalExtensions[k].ValueNode.Line
}
}
}
default:
panic("not supported yet")
}
}
n.Nodes = append(n.Nodes, nodeEntry)
}
// done, extensions are handled separately.
return
}
// find the field with the tag supplied.
field, _ := reflect.TypeOf(n.High).Elem().FieldByName(key)
tag := string(field.Tag.Get("yaml"))
tagName := strings.Split(tag, ",")[0]
if tag == "-" {
return
}
// extract the value of the field
fieldValue := reflect.ValueOf(n.High).Elem().FieldByName(key)
f := fieldValue.Interface()
value := reflect.ValueOf(f)
if f == nil || value.IsZero() {
return
}
// create a new node entry
nodeEntry := &NodeEntry{Tag: tagName, Key: key}
switch value.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
nodeEntry.Value = strconv.FormatInt(value.Int(), 10)
case reflect.String:
nodeEntry.Value = value.String()
case reflect.Bool:
nodeEntry.Value = value.Bool()
case reflect.Slice:
if tagName == v3.TypeLabel {
if value.Len() == 1 {
nodeEntry.Value = value.Index(0).String()
}
} else {
if !value.IsNil() {
nodeEntry.Value = f
}
}
case reflect.Ptr:
if !value.IsNil() {
nodeEntry.Value = f
}
case reflect.Map:
if !value.IsNil() {
nodeEntry.Value = f
}
default:
nodeEntry.Value = f
}
// if there is no low level object, then we cannot extract line numbers,
// so skip and default to 0, which means a new entry to the spec.
// this will place new content and the top of the rendered object.
if !reflect.ValueOf(n.Low).IsZero() {
lowFieldValue := reflect.ValueOf(n.Low).Elem().FieldByName(key)
fLow := lowFieldValue.Interface()
value = reflect.ValueOf(fLow)
switch value.Kind() {
case reflect.Struct:
nb := value.Interface().(low.HasValueNodeUntyped).GetValueNode()
if nb != nil {
nodeEntry.Line = nb.Line
}
default:
// everything else, weight it to the bottom of the rendered object.
// this is things that we have no way of knowing where they should be placed.
nodeEntry.Line = 9999
}
}
if nodeEntry.Value != nil {
n.Nodes = append(n.Nodes, nodeEntry)
}
}
// Render will render the NodeBuilder back to a YAML node, iterating over every NodeEntry defined
func (n *NodeBuilder) Render() *yaml.Node {
// order nodes by line number, retain original order
sort.Slice(n.Nodes, func(i, j int) bool {
return n.Nodes[i].Line < n.Nodes[j].Line
})
m := CreateEmptyMapNode()
for i := range n.Nodes {
node := n.Nodes[i]
n.AddYAMLNode(m, node.Tag, node.Key, node.Value)
}
return m
}
// AddYAMLNode will add a new *yaml.Node to the parent node, using the tag, key and value provided.
// If the value is nil, then the node will not be added. This method is recursive, so it will dig down
// into any non-scalar types.
func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, tag, key string, value any) *yaml.Node {
if value == nil {
return parent
}
// check the type
t := reflect.TypeOf(value)
var l *yaml.Node
if tag != "" {
l = CreateStringNode(tag)
}
var valueNode *yaml.Node
vo := reflect.ValueOf(value)
switch t.Kind() {
case reflect.String:
val := value.(string)
if val == "" {
return parent
}
valueNode = CreateStringNode(val)
break
case reflect.Bool:
val := value.(bool)
if !val {
return parent
}
valueNode = CreateBoolNode("true")
break
case reflect.Map:
// the keys will be rendered randomly, if we don't find out the original line
// number of the tag.
var orderedCollection []*NodeEntry
m := reflect.ValueOf(value)
for _, k := range m.MapKeys() {
var x string
// extract key
if o, ok := k.Interface().(low.HasKeyNode); ok {
x = o.GetKeyNode().Value
} else {
x = k.String()
}
// go low and pull out the line number.
lowProps := reflect.ValueOf(n.Low)
if !lowProps.IsZero() && !lowProps.IsNil() {
gu := lowProps.Elem()
gi := gu.FieldByName(key)
jl := reflect.ValueOf(gi)
if !jl.IsZero() && gi.Interface() != nil {
gh := gi.Interface()
// extract low level key line number
if pr, ok := gh.(low.HasValueUnTyped); ok {
fg := reflect.ValueOf(pr.GetValueUntyped())
for _, ky := range fg.MapKeys() {
er := ky.Interface().(low.HasKeyNode).GetKeyNode().Value
if er == x {
orderedCollection = append(orderedCollection, &NodeEntry{
Tag: x,
Key: x,
Line: ky.Interface().(low.HasKeyNode).GetKeyNode().Line,
Value: m.MapIndex(k).Interface(),
})
}
}
} else {
// this is a map, without an enclosing struct
bj := reflect.ValueOf(gh)
for _, ky := range bj.MapKeys() {
er := ky.Interface().(low.HasKeyNode).GetKeyNode().Value
if er == x {
orderedCollection = append(orderedCollection, &NodeEntry{
Tag: x,
Key: x,
Line: ky.Interface().(low.HasKeyNode).GetKeyNode().Line,
Value: m.MapIndex(k).Interface(),
})
}
}
}
} else {
// this is a map, without any low level details available (probably an extension map).
orderedCollection = append(orderedCollection, &NodeEntry{
Tag: x,
Key: x,
Line: 9999,
Value: m.MapIndex(k).Interface(),
})
}
} else {
// this is a map, without any low level details available (probably an extension map).
orderedCollection = append(orderedCollection, &NodeEntry{
Tag: x,
Key: x,
Line: 9999,
Value: m.MapIndex(k).Interface(),
})
}
}
// sort the slice by line number to ensure everything is rendered in order.
sort.Slice(orderedCollection, func(i, j int) bool {
return orderedCollection[i].Line < orderedCollection[j].Line
})
// create an empty map.
p := CreateEmptyMapNode()
// build out each map node in original order.
for _, cv := range orderedCollection {
n.AddYAMLNode(p, cv.Tag, cv.Key, cv.Value)
}
valueNode = p
case reflect.Slice:
if vo.IsNil() {
return parent
}
var rawNode yaml.Node
err := rawNode.Encode(value)
if err != nil {
return parent
} else {
valueNode = &rawNode
}
case reflect.Struct:
if r, ok := value.(low.ValueReference[any]); ok {
valueNode = r.GetValueNode()
} else {
panic("not supported yet")
}
case reflect.Ptr:
if r, ok := value.(Renderable); ok {
rawRender, _ := r.MarshalYAML()
if rawRender != nil {
valueNode = rawRender.(*yaml.Node)
} else {
return parent
}
} else {
var rawNode yaml.Node
err := rawNode.Encode(value)
if err != nil {
return parent
} else {
valueNode = &rawNode
}
}
default:
if vo.IsNil() {
return parent
}
var rawNode yaml.Node
err := rawNode.Encode(value)
if err != nil {
return parent
} else {
valueNode = &rawNode
}
}
if l != nil {
parent.Content = append(parent.Content, l, valueNode)
} else {
parent.Content = valueNode.Content
}
return parent
}
func CreateEmptyMapNode() *yaml.Node {
n := &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
}
return n
}
func CreateStringNode(str string) *yaml.Node {
n := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: str,
}
return n
}
func CreateBoolNode(str string) *yaml.Node {
n := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: str,
}
return n
}
type Renderable interface {
MarshalYAML() (interface{}, error)
}

View File

@@ -14,15 +14,7 @@
package high package high
import ( import (
"fmt"
"github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low"
v3 "github.com/pb33f/libopenapi/datamodel/low/v3"
"gopkg.in/yaml.v3"
"reflect"
"sort"
"strconv"
"strings"
"unicode"
) )
// GoesLow is used to represent any high-level model. All high level models meet this interface and can be used to // GoesLow is used to represent any high-level model. All high level models meet this interface and can be used to
@@ -75,303 +67,3 @@ func UnpackExtensions[T any, R low.HasExtensions[T]](low GoesLow[R]) (map[string
return m, nil return m, nil
} }
// MarshalExtensions is a convenience function that makes it easy and simple to marshal an objects extensions into a
// map that can then correctly rendered back down in to YAML.
func MarshalExtensions(parent *yaml.Node, extensions map[string]any) {
for k := range extensions {
AddYAMLNode(parent, k, extensions[k])
}
}
type NodeEntry struct {
Key string
Value any
Line int
}
type NodeBuilder struct {
Nodes []*NodeEntry
High any
Low any
}
func NewNodeBuilder(high any, low any) *NodeBuilder {
// create a new node builder
nb := &NodeBuilder{High: high, Low: low}
// extract fields from the high level object and add them into our node builder.
// this will allow us to extract the line numbers from the low level object as well.
v := reflect.ValueOf(high).Elem()
num := v.NumField()
for i := 0; i < num; i++ {
nb.add(v.Type().Field(i).Name)
}
return nb
}
func (n *NodeBuilder) add(key string) {
// only operate on exported fields.
if unicode.IsLower(rune(key[0])) {
return
}
// if the key is 'Extensions' then we need to extract the keys from the map
// and add them to the node builder.
if key == "Extensions" {
extensions := reflect.ValueOf(n.High).Elem().FieldByName(key)
for _, e := range extensions.MapKeys() {
v := extensions.MapIndex(e)
extKey := e.String()
extValue := v.Interface()
nodeEntry := &NodeEntry{Key: extKey, Value: extValue}
if !reflect.ValueOf(n.Low).IsZero() {
fieldValue := reflect.ValueOf(n.Low).Elem().FieldByName("Extensions")
f := fieldValue.Interface()
value := reflect.ValueOf(f)
switch value.Kind() {
case reflect.Map:
if j, ok := n.Low.(low.HasExtensionsUntyped); ok {
originalExtensions := j.GetExtensions()
for k := range originalExtensions {
if k.Value == extKey {
nodeEntry.Line = originalExtensions[k].ValueNode.Line
}
}
}
default:
panic("not supported yet")
}
}
n.Nodes = append(n.Nodes, nodeEntry)
}
// done, extensions are handled separately.
return
}
// find the field with the tag supplied.
field, _ := reflect.TypeOf(n.High).Elem().FieldByName(key)
tag := string(field.Tag.Get("yaml"))
tagName := strings.Split(tag, ",")[0]
if tag == "-" {
return
}
// extract the value of the field
fieldValue := reflect.ValueOf(n.High).Elem().FieldByName(key)
f := fieldValue.Interface()
value := reflect.ValueOf(f)
if tag == "additionalProperties" {
fmt.Printf("woo")
}
if f == nil || value.IsZero() {
return
}
// create a new node entry
nodeEntry := &NodeEntry{Key: tagName}
switch value.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
nodeEntry.Value = strconv.FormatInt(value.Int(), 10)
case reflect.String:
nodeEntry.Value = value.String()
case reflect.Bool:
nodeEntry.Value = value.Bool()
case reflect.Slice:
if tagName == v3.TypeLabel {
if value.Len() == 1 {
nodeEntry.Value = value.Index(0).String()
}
} else {
if !value.IsNil() {
nodeEntry.Value = f
}
}
case reflect.Ptr:
if !value.IsNil() {
nodeEntry.Value = f
}
default:
nodeEntry.Value = f
}
// if there is no low level object, then we cannot extract line numbers,
// so skip and default to 0, which means a new entry to the spec.
// this will place new content and the top of the rendered object.
if !reflect.ValueOf(n.Low).IsZero() {
lowFieldValue := reflect.ValueOf(n.Low).Elem().FieldByName(key)
fLow := lowFieldValue.Interface()
value = reflect.ValueOf(fLow)
switch value.Kind() {
case reflect.Struct:
nb := value.Interface().(low.HasValueNodeUntyped).GetValueNode()
if nb != nil {
nodeEntry.Line = nb.Line
}
default:
// everything else, weight it to the bottom of the rendered object.
// this is things that we have no way of knowing where they should be placed.
nodeEntry.Line = 9999
}
}
if nodeEntry.Value != nil {
n.Nodes = append(n.Nodes, nodeEntry)
}
}
func (n *NodeBuilder) Render() *yaml.Node {
// order nodes by line number, retain original order
sort.Slice(n.Nodes, func(i, j int) bool {
return n.Nodes[i].Line < n.Nodes[j].Line
})
m := CreateEmptyMapNode()
for i := range n.Nodes {
node := n.Nodes[i]
AddYAMLNode(m, node.Key, node.Value)
}
return m
}
func AddYAMLNode(parent *yaml.Node, key string, value any) *yaml.Node {
if value == nil {
return parent
}
// check the type
t := reflect.TypeOf(value)
var l *yaml.Node
if key != "" {
l = CreateStringNode(key)
}
var valueNode *yaml.Node
vo := reflect.ValueOf(value)
switch t.Kind() {
case reflect.String:
val := value.(string)
if val == "" {
return parent
}
valueNode = CreateStringNode(val)
break
case reflect.Bool:
val := value.(bool)
if !val {
return parent
}
valueNode = CreateBoolNode("true")
break
case reflect.Slice:
if vo.IsNil() {
return parent
}
// type is a case where it can be a single value, or a slice.
// so, if the key is 'type', then check if the slice contains a sigle value
// and if so, render it as a string, otherwise, proceed as normal.
skip := false
if key == v3.TypeLabel {
//if vo.Len() == 1 {
// valueNode = CreateStringNode(value.([]string)[0])
// skip = true
//}
}
if !skip {
var rawNode yaml.Node
err := rawNode.Encode(value)
if err != nil {
return parent
} else {
valueNode = &rawNode
}
}
case reflect.Struct:
panic("no way dude, why?")
case reflect.Ptr:
if r, ok := value.(Renderable); ok {
rawRender, _ := r.MarshalYAML()
if rawRender != nil {
valueNode = rawRender.(*yaml.Node)
} else {
return parent
}
} else {
var rawNode yaml.Node
err := rawNode.Encode(value)
if err != nil {
return parent
} else {
valueNode = &rawNode
}
}
default:
if vo.IsNil() {
return parent
}
var rawNode yaml.Node
err := rawNode.Encode(value)
if err != nil {
return parent
} else {
valueNode = &rawNode
}
}
if l != nil {
parent.Content = append(parent.Content, l, valueNode)
} else {
parent.Content = valueNode.Content
}
return parent
}
func CreateEmptyMapNode() *yaml.Node {
n := &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
}
return n
}
func CreateStringNode(str string) *yaml.Node {
n := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: str,
}
return n
}
func CreateBoolNode(str string) *yaml.Node {
n := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: str,
}
return n
}
func CreateIntNode(val int) *yaml.Node {
i := strconv.Itoa(val)
n := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!int",
Value: i,
}
return n
}
type Renderable interface {
MarshalYAML() (interface{}, error)
}

View File

@@ -155,9 +155,9 @@ func (d *Document) Render() ([]byte, error) {
// MarshalYAML will create a ready to render YAML representation of the Document object. // MarshalYAML will create a ready to render YAML representation of the Document object.
func (d *Document) MarshalYAML() (interface{}, error) { func (d *Document) MarshalYAML() (interface{}, error) {
n := high.CreateEmptyMapNode() n := high.CreateEmptyMapNode()
high.AddYAMLNode(n, low.SchemaDialectLabel, d.JsonSchemaDialect) //high.AddYAMLNode(n, low.SchemaDialectLabel, d.JsonSchemaDialect)
high.AddYAMLNode(n, low.OpenAPILabel, d.Version) //high.AddYAMLNode(n, low.OpenAPILabel, d.Version)
high.AddYAMLNode(n, low.InfoLabel, d.Info) //high.AddYAMLNode(n, low.InfoLabel, d.Info)
//high.AddYAMLNode(n, low.TagsLabel, d.Tags) //high.AddYAMLNode(n, low.TagsLabel, d.Tags)
//high.AddYAMLNode(n, low.ServersLabel, d.Servers) //high.AddYAMLNode(n, low.ServersLabel, d.Servers)
//high.AddYAMLNode(n, low.SecurityLabel, d.Security) //high.AddYAMLNode(n, low.SecurityLabel, d.Security)
@@ -166,6 +166,6 @@ func (d *Document) MarshalYAML() (interface{}, error) {
//high.AddYAMLNode(n, low.PathsLabel, d.Paths) //high.AddYAMLNode(n, low.PathsLabel, d.Paths)
//high.AddYAMLNode(n, low.ComponentsLabel, d.Components) //high.AddYAMLNode(n, low.ComponentsLabel, d.Components)
//high.AddYAMLNode(n, low.WebhooksLabel, d.Webhooks) //high.AddYAMLNode(n, low.WebhooksLabel, d.Webhooks)
high.MarshalExtensions(n, d.Extensions) //high.MarshalExtensions(n, d.Extensions)
return n, nil return n, nil
} }

View File

@@ -3,6 +3,7 @@ package base
import ( import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"reflect"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -174,7 +175,43 @@ func (s *Schema) Hash() [32]byte {
d = append(d, fmt.Sprint(s.MinProperties.Value)) d = append(d, fmt.Sprint(s.MinProperties.Value))
} }
if !s.AdditionalProperties.IsEmpty() { if !s.AdditionalProperties.IsEmpty() {
d = append(d, low.GenerateHashString(s.AdditionalProperties.Value))
// check type of properties, if we have a low level map, we need to hash the values in a repeatable
// order.
to := reflect.TypeOf(s.AdditionalProperties.Value)
vo := reflect.ValueOf(s.AdditionalProperties.Value)
var values []string
switch to.Kind() {
case reflect.Slice:
for i := 0; i < vo.Len(); i++ {
vn := vo.Index(i).Interface()
values = append(values, fmt.Sprintf("%d:%s", i, low.GenerateHashString(vn)))
}
sort.Strings(values)
d = append(d, strings.Join(values, "||"))
case reflect.Map:
for _, k := range vo.MapKeys() {
var x string
var l int
var v any
// extract key
if o, ok := k.Interface().(low.HasKeyNode); ok {
x = o.GetKeyNode().Value
l = o.GetKeyNode().Line
v = vo.MapIndex(k).Interface().(low.HasValueNodeUntyped).GetValueNode().Value
} else {
x = k.String()
l = 999
v = vo.MapIndex(k).Interface()
}
values = append(values, fmt.Sprintf("%d:%s:%s", l, x, low.GenerateHashString(v)))
}
sort.Strings(values)
d = append(d, strings.Join(values, "||"))
default:
d = append(d, low.GenerateHashString(s.AdditionalProperties.Value))
}
} }
if !s.Description.IsEmpty() { if !s.Description.IsEmpty() {
d = append(d, fmt.Sprint(s.Description.Value)) d = append(d, fmt.Sprint(s.Description.Value))
@@ -593,17 +630,60 @@ func (s *Schema) Build(root *yaml.Node, idx *index.SpecIndex) error {
if utils.IsNodeMap(addPNode) { if utils.IsNodeMap(addPNode) {
// check if this is a reference, or an inline schema. // check if this is a reference, or an inline schema.
isRef, _, _ := utils.IsNodeRefValue(addPNode) isRef, _, _ := utils.IsNodeRefValue(addPNode)
sp := &SchemaProxy{ var sp *SchemaProxy
kn: addPLabel, // now check if this object has a 'type' if so, it's a schema, if not... it's a random
vn: addPNode, // object, and we should treat it as a raw map.
idx: idx, if _, v := utils.FindKeyNodeTop(TypeLabel, addPNode.Content); v != nil {
sp = &SchemaProxy{
kn: addPLabel,
vn: addPNode,
idx: idx,
}
} }
if isRef { if isRef {
sp.isReference = true
_, vn := utils.FindKeyNodeTop("$ref", addPNode.Content) _, vn := utils.FindKeyNodeTop("$ref", addPNode.Content)
sp.referenceLookup = vn.Value sp = &SchemaProxy{
kn: addPLabel,
vn: addPNode,
idx: idx,
isReference: true,
referenceLookup: vn.Value,
}
}
// if this is a reference, or a schema, we're done.
if sp != nil {
s.AdditionalProperties = low.NodeReference[any]{Value: sp, KeyNode: addPLabel, ValueNode: addPNode}
} else {
// if this is a map, collect all the keys and values.
if utils.IsNodeMap(addPNode) {
addProps := make(map[low.KeyReference[string]]low.ValueReference[any])
var label string
for g := range addPNode.Content {
if g%2 == 0 {
label = addPNode.Content[g].Value
continue
} else {
addProps[low.KeyReference[string]{Value: label, KeyNode: addPNode.Content[g-1]}] =
low.ValueReference[any]{Value: addPNode.Content[g].Value, ValueNode: addPNode.Content[g]}
}
}
s.AdditionalProperties = low.NodeReference[any]{Value: addProps, KeyNode: addPLabel, ValueNode: addPNode}
}
// if the node is an array, extract everything into a trackable structure
if utils.IsNodeArray(addPNode) {
var addProps []low.ValueReference[any]
for i := range addPNode.Content {
addProps = append(addProps,
low.ValueReference[any]{Value: addPNode.Content[i].Value, ValueNode: addPNode.Content[i]})
}
s.AdditionalProperties =
low.NodeReference[any]{Value: addProps, KeyNode: addPLabel, ValueNode: addPNode}
}
} }
s.AdditionalProperties = low.NodeReference[any]{Value: sp, KeyNode: addPLabel, ValueNode: addPNode}
} }
if utils.IsNodeBoolValue(addPNode) { if utils.IsNodeBoolValue(addPNode) {
b, _ := strconv.ParseBool(addPNode.Value) b, _ := strconv.ParseBool(addPNode.Value)

View File

@@ -59,6 +59,17 @@ type HasValue[T any] interface {
*T *T
} }
// HasValueUnTyped is implemented by NodeReference and ValueReference to return the yaml.Node backing the value.
type HasValueUnTyped interface {
GetValueUntyped() any
GetValueNode() *yaml.Node
}
// HasKeyNode is implemented by KeyReference to return the yaml.Node backing the key.
type HasKeyNode interface {
GetKeyNode() *yaml.Node
}
// NodeReference is a low-level container for holding a Value of type T, as well as references to // NodeReference is a low-level container for holding a Value of type T, as well as references to
// a key yaml.Node that points to the key node that contains the value node, and the value node that contains // a key yaml.Node that points to the key node that contains the value node, and the value node that contains
// the actual value. // the actual value.
@@ -158,6 +169,11 @@ func (n NodeReference[T]) GetValue() T {
return n.Value return n.Value
} }
// GetValueUntyped will return the raw value of the node with no type
func (n NodeReference[T]) GetValueUntyped() any {
return n.Value
}
// IsEmpty will return true if this reference has no key or value nodes assigned (it's been ignored) // IsEmpty will return true if this reference has no key or value nodes assigned (it's been ignored)
func (n ValueReference[T]) IsEmpty() bool { func (n ValueReference[T]) IsEmpty() bool {
return n.ValueNode == nil return n.ValueNode == nil
@@ -187,11 +203,26 @@ func (n ValueReference[T]) GetValue() T {
return n.Value return n.Value
} }
// GetValueUntyped will return the raw value of the node with no type
func (n ValueReference[T]) GetValueUntyped() any {
return n.Value
}
// IsEmpty will return true if this reference has no key or value nodes assigned (it's been ignored) // IsEmpty will return true if this reference has no key or value nodes assigned (it's been ignored)
func (n KeyReference[T]) IsEmpty() bool { func (n KeyReference[T]) IsEmpty() bool {
return n.KeyNode == nil return n.KeyNode == nil
} }
// GetValueUntyped will return the raw value of the node with no type
func (n KeyReference[T]) GetValueUntyped() any {
return n.Value
}
// GetKeyNode will return the yaml.Node containing the reference key node.
func (n KeyReference[T]) GetKeyNode() *yaml.Node {
return n.KeyNode
}
// GenerateMapKey will return a string based on the line and column number of the node, e.g. 33:56 for line 33, col 56. // GenerateMapKey will return a string based on the line and column number of the node, e.g. 33:56 for line 33, col 56.
func (n KeyReference[T]) GenerateMapKey() string { func (n KeyReference[T]) GenerateMapKey() string {
return fmt.Sprintf("%d:%d", n.KeyNode.Line, n.KeyNode.Column) return fmt.Sprintf("%d:%d", n.KeyNode.Line, n.KeyNode.Column)

View File

@@ -23,7 +23,7 @@ func (e *ExtensionChanges) TotalBreakingChanges() int {
return 0 return 0
} }
// CompareExtensions will compare a left and right map of Key/ValueReference models for any changes to // CompareExtensions will compare a left and right map of Tag/ValueReference models for any changes to
// anything. This function does not try and cast the value of an extension to perform checks, it // anything. This function does not try and cast the value of an extension to perform checks, it
// will perform a basic value check. // will perform a basic value check.
// //