Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions datamodel/high/v3/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,10 @@ func (d *Document) Render() ([]byte, error) {
// the rendering will use the original indention of the document.
func (d *Document) RenderWithIndention(indent int) []byte {
var buf bytes.Buffer
yamlEncoder := yaml.NewEncoder(&buf)
yamlEncoder.SetIndent(indent)
_ = yamlEncoder.Encode(d)
yamlDumper, _ := yaml.NewDumper(&buf, yaml.WithV3Defaults(), yaml.WithLineWidth(-1))
yamlDumper.SetIndent(indent)
_ = yamlDumper.Dump(d)
_ = yamlDumper.Close()
return buf.Bytes()
}

Expand Down
7 changes: 2 additions & 5 deletions datamodel/low/extraction_functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2379,13 +2379,10 @@ func TestLocateRefEnd_Empty(t *testing.T) {
}

func TestArray_NotRefNotArray(t *testing.T) {
yml := ``
var idxNode yaml.Node
mErr := yaml.Unmarshal([]byte(yml), &idxNode)
assert.NoError(t, mErr)
idxNode := yaml.Node{Kind: yaml.DocumentNode}
idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateClosedAPIIndexConfig())

yml = `limes:
yml := `limes:
not: array`

var cNode yaml.Node
Expand Down
39 changes: 17 additions & 22 deletions datamodel/spec_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ const (
// SpecInfo represents a 'ready-to-process' OpenAPI Document. The RootNode is the most important property
// used by the library, this contains the top of the document tree that every single low model is based off.
type SpecInfo struct {
SpecType string `json:"type"`
NumLines int `json:"numLines"`
Version string `json:"version"`
VersionNumeric float32 `json:"versionNumeric"`
SpecFormat string `json:"format"`
SpecFileType string `json:"fileType"`
SpecBytes *[]byte `json:"bytes"` // the original byte array
RootNode *yaml.Node `json:"-"` // reference to the root node of the spec.
SpecType string `json:"type"`
NumLines int `json:"numLines"`
Version string `json:"version"`
VersionNumeric float32 `json:"versionNumeric"`
SpecFormat string `json:"format"`
SpecFileType string `json:"fileType"`
SpecBytes *[]byte `json:"bytes"` // the original byte array
RootNode *yaml.Node `json:"-"` // reference to the root node of the spec.

// SpecJSONBytes is the original document converted to JSON. It is populated lazily.
//
Expand Down Expand Up @@ -231,6 +231,9 @@ func extractSpecInfoInternal(spec []byte, bypass bool, skipJSON bool) (*SpecInfo
if err := parsedNode.Decode(&jsonSpec); err != nil {
return fmt.Errorf("failed to decode YAML to JSON: %w", err)
}
if jsonSpec == nil {
return fmt.Errorf("failed to decode YAML to JSON: YAML document root is %v, not a mapping", root.Kind)
}
}
if err := checkDuplicateMappingKeys(parsedNode); err != nil {
return fmt.Errorf("failed to decode YAML to JSON: %w", err)
Expand Down Expand Up @@ -387,10 +390,8 @@ func extractSpecInfoInternal(spec []byte, bypass bool, skipJSON bool) (*SpecInfo
// parseJSON(spec, specInfo, &parsedSpec)
//}

if !parsed && !skipJSON {
if err := parseJSON(spec, specInfo, &parsedSpec); err != nil && !bypass {
return nil, err
}
if !parsed && !skipJSON && bypass {
_ = parseJSON(spec, specInfo, &parsedSpec)
}

// detect the original whitespace indentation
Expand All @@ -411,9 +412,9 @@ func ExtractSpecInfo(spec []byte) (*SpecInfo, error) {
// checkDuplicateMappingKeys walks a parsed node tree and reports duplicate mapping
// keys using the exact equality semantics of the go.yaml.in/yaml/v4 decoder: two keys
// in the same mapping collide when their node Kind and raw Value match (no tag or
// alias resolution). Error text matches the decoder's construct errors byte for byte,
// and children of an offending mapping are not descended into, mirroring the decoder
// halting construction of that mapping.
// alias resolution). The collected construct errors are normalized onto the
// public one-line error shape, and children of an offending mapping are not
// descended into, mirroring the decoder halting construction of that mapping.
//
// Known divergence: an anchored mapping with duplicate keys that is aliased
// elsewhere is reported ONCE here, while the decoder re-reports it on every
Expand All @@ -428,13 +429,7 @@ func checkDuplicateMappingKeys(node *yaml.Node) error {
if len(errs) == 0 {
return nil
}
var b strings.Builder
b.WriteString("yaml: construct errors:")
for _, e := range errs {
b.WriteString("\n ")
b.WriteString(e)
}
return errors.New(b.String())
return errors.New("yaml: construct errors: " + strings.Join(errs, "; "))
}

func walkDuplicateMappingKeys(node *yaml.Node, errs *[]string) {
Expand Down
8 changes: 8 additions & 0 deletions datamodel/spec_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ func TestExtractSpecInfo_InvalidYAML(t *testing.T) {
assert.Error(t, e)
}

func TestExtractSpecInfo_BareMergeRootReturnsDecodeError(t *testing.T) {
_, err := ExtractSpecInfo([]byte("<<\n"))

assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decode YAML to JSON")
assert.Contains(t, err.Error(), "YAML document root is")
}

// TestExtractSpecInfo_InvalidYAML_DuplicateKey tests issue #355
// Malformed YAML with duplicate keys should return an error when bypass=false
func TestExtractSpecInfo_InvalidYAML_DuplicateKey(t *testing.T) {
Expand Down
66 changes: 62 additions & 4 deletions generator/golang/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,70 @@ func (g *Generator) applyOpenAPIMetadata(ir *SchemaIR, meta openAPIMetadata) {
schema.MaxProperties = &meta.MaxProperties
}
if len(meta.Enum) > 0 {
ir.Enum = meta.Enum
schema.Enum = meta.Enum
if len(schema.Enum) > 0 && equivalentMetadataYAMLNodeSlices(schema.Enum, meta.Enum) {
ir.Enum = schema.Enum
} else {
ir.Enum = meta.Enum
schema.Enum = meta.Enum
}
}
if meta.Const != nil {
ir.Const = meta.Const
schema.Const = meta.Const
if schema.Const != nil && equivalentMetadataYAMLNodes(schema.Const, meta.Const) {
ir.Const = schema.Const
} else {
ir.Const = meta.Const
schema.Const = meta.Const
}
}
}

func equivalentMetadataYAMLNodeSlices(left, right []*yaml.Node) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if !equivalentMetadataYAMLNodes(left[i], right[i]) {
return false
}
}
return true
}

func equivalentMetadataYAMLNodes(left, right *yaml.Node) bool {
if left == nil || right == nil {
return left == right
}
if left.Kind != right.Kind ||
normalizeMetadataYAMLTag(left.Kind, left.Tag) != normalizeMetadataYAMLTag(right.Kind, right.Tag) ||
left.Value != right.Value ||
left.Anchor != right.Anchor ||
len(left.Content) != len(right.Content) {
return false
}
if !equivalentMetadataYAMLNodes(left.Alias, right.Alias) {
return false
}
for i := range left.Content {
if !equivalentMetadataYAMLNodes(left.Content[i], right.Content[i]) {
return false
}
}
return true
}

func normalizeMetadataYAMLTag(kind yaml.Kind, tag string) string {
if tag != "" {
return tag
}
switch kind {
case yaml.SequenceNode:
return "!!seq"
case yaml.MappingNode:
return "!!map"
case yaml.ScalarNode:
return "!!str"
default:
return tag
}
}

Expand Down
81 changes: 81 additions & 0 deletions generator/golang/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,39 @@ func TestGeneratedQuotedOpenAPITag(t *testing.T) {
}
}

func TestApplyOpenAPIMetadataKeepsEquivalentSourceYAMLNodes(t *testing.T) {
enum := []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bam"}}
constValue := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "card"}
schema := &highbase.Schema{Enum: enum, Const: constValue}
ir := &SchemaIR{Enum: enum, Const: constValue, SourceSchema: schema}

NewGenerator().applyOpenAPIMetadata(ir, openAPIMetadata{
Present: true,
Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Value: "bam"}},
Const: &yaml.Node{Kind: yaml.ScalarNode, Value: "card"},
})

if ir.Enum[0].Tag != "!!str" || schema.Enum[0].Tag != "!!str" {
t.Fatalf("equivalent enum metadata stripped source tag: %#v", ir.Enum[0])
}
if ir.Const.Tag != "!!str" || schema.Const.Tag != "!!str" {
t.Fatalf("equivalent const metadata stripped source tag: %#v", ir.Const)
}

NewGenerator().applyOpenAPIMetadata(ir, openAPIMetadata{
Present: true,
Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bgn"}},
Const: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bank_account"},
})

if ir.Enum[0].Value != "bgn" || schema.Enum[0].Value != "bgn" {
t.Fatalf("changed enum metadata was not applied: %#v", ir.Enum[0])
}
if ir.Const.Value != "bank_account" || schema.Const.Value != "bank_account" {
t.Fatalf("changed const metadata was not applied: %#v", ir.Const)
}
}

func TestFieldSchemaOverridesAndSchemaYAMLProvider(t *testing.T) {
sourceSchema := schemaProxyFromYAML(t, `
oneOf:
Expand Down Expand Up @@ -1005,6 +1038,54 @@ func TestMetadataHelpersCoverage(t *testing.T) {
if got := metadataYAMLNodeLiteral(nil, 0); got != "nil" {
t.Fatalf("nil yaml node literal mismatch: %q", got)
}
if got := metadataYAMLNodeLiteral(&yaml.Node{Kind: yaml.ScalarNode, Value: "1"}, 0); !strings.Contains(got, `Tag: "!!int"`) {
t.Fatalf("empty integer scalar tag should infer numeric metadata literal: %s", got)
}
if got := metadataYAMLNodeLiteral(&yaml.Node{Kind: yaml.ScalarNode, Value: "true"}, 0); !strings.Contains(got, `Tag: "!!bool"`) {
t.Fatalf("empty boolean scalar tag should infer boolean metadata literal: %s", got)
}
if got := metadataYAMLNodeLiteral(&yaml.Node{Kind: yaml.ScalarNode, Style: yaml.SingleQuotedStyle, Value: "1"}, 0); !strings.Contains(got, `Tag: "!!str"`) {
t.Fatalf("styled scalar tag should stay string in metadata literal: %s", got)
}
if got := metadataYAMLNodeLiteral(&yaml.Node{Kind: yaml.MappingNode}, 0); !strings.Contains(got, `Tag: "!!map"`) {
t.Fatalf("empty mapping tag should normalize to default in metadata literal: %s", got)
}
if got := metadataYAMLNodeLiteral(&yaml.Node{Kind: yaml.SequenceNode}, 0); !strings.Contains(got, `Tag: "!!seq"`) {
t.Fatalf("empty sequence tag should normalize to default in metadata literal: %s", got)
}
if got := metadataYAMLNodeLiteralTag(nil); got != "" {
t.Fatalf("nil yaml node tag should be empty: %q", got)
}
if got := inferMetadataYAMLScalarTag(&yaml.Node{Kind: yaml.ScalarNode, Value: "["}); got != "!!str" {
t.Fatalf("invalid scalar should fall back to string tag: %q", got)
}
if got := inferMetadataYAMLScalarTag(&yaml.Node{Kind: yaml.ScalarNode, Value: "[]"}); got != "!!str" {
t.Fatalf("non-scalar parsed value should fall back to string tag: %q", got)
}
if equivalentMetadataYAMLNodeSlices([]*yaml.Node{stringNode("a")}, nil) {
t.Fatal("metadata yaml node slices with different lengths should not match")
}
if equivalentMetadataYAMLNodes(
&yaml.Node{Kind: yaml.AliasNode, Alias: stringNode("a")},
&yaml.Node{Kind: yaml.AliasNode, Alias: stringNode("b")},
) {
t.Fatal("metadata yaml alias nodes with different targets should not match")
}
if equivalentMetadataYAMLNodes(
&yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{stringNode("a")}},
&yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{stringNode("b")}},
) {
t.Fatal("metadata yaml nodes with different children should not match")
}
if got := normalizeMetadataYAMLTag(yaml.SequenceNode, ""); got != "!!seq" {
t.Fatalf("empty sequence tag should normalize to !!seq: %q", got)
}
if got := normalizeMetadataYAMLTag(yaml.MappingNode, ""); got != "!!map" {
t.Fatalf("empty mapping tag should normalize to !!map: %q", got)
}
if got := normalizeMetadataYAMLTag(yaml.DocumentNode, ""); got != "" {
t.Fatalf("unknown empty yaml tag should stay empty: %q", got)
}
}

func schemaProxyFromRefDocumentYAML(t *testing.T, sampleYAML string) *highbase.SchemaProxy {
Expand Down
36 changes: 35 additions & 1 deletion generator/golang/provider_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ func metadataYAMLNodeLiteral(node *yaml.Node, depth int) string {
b.WriteString("&openAPIYAMLNode{\n")
writeMetadataField(&b, depth+1, "Kind", strconv.Quote(metadataYAMLKind(node.Kind)))
writeMetadataField(&b, depth+1, "Style", metadataPlainIntLiteral(int64(node.Style)))
writeMetadataField(&b, depth+1, "Tag", metadataStringLiteral(node.Tag))
writeMetadataField(&b, depth+1, "Tag", metadataStringLiteral(metadataYAMLNodeLiteralTag(node)))
writeMetadataField(&b, depth+1, "Value", metadataStringLiteral(node.Value))
writeMetadataField(&b, depth+1, "Anchor", metadataStringLiteral(node.Anchor))
writeMetadataField(&b, depth+1, "Content", metadataYAMLNodeContentLiteral(node.Content, depth+1))
Expand All @@ -490,6 +490,40 @@ func metadataYAMLNodeLiteral(node *yaml.Node, depth int) string {
return b.String()
}

func metadataYAMLNodeLiteralTag(node *yaml.Node) string {
if node == nil {
return ""
}
if node.Tag != "" {
return node.Tag
}
switch node.Kind {
case yaml.SequenceNode:
return "!!seq"
case yaml.MappingNode:
return "!!map"
case yaml.ScalarNode:
return inferMetadataYAMLScalarTag(node)
default:
return node.Tag
}
}

func inferMetadataYAMLScalarTag(node *yaml.Node) string {
if node.Style&(yaml.SingleQuotedStyle|yaml.DoubleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 {
return "!!str"
}
var parsed yaml.Node
if err := yaml.Unmarshal([]byte(node.Value+"\n"), &parsed); err != nil || len(parsed.Content) == 0 {
return "!!str"
}
scalar := parsed.Content[0]
if scalar.Kind != yaml.ScalarNode || scalar.Tag == "" {
return "!!str"
}
return scalar.Tag
}

func optionalMetadataYAMLNodeLiteral(node *yaml.Node, depth int) string {
if node == nil {
return ""
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
module github.com/pb33f/libopenapi

go 1.25.0
go 1.25.7

require (
github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb
github.com/pb33f/jsonpath v0.8.2
github.com/pb33f/ordered-map/v2 v2.3.1
github.com/stretchr/testify v1.11.1
go.yaml.in/yaml/v4 v4.0.0-rc.4
go.yaml.in/yaml/v4 v4.0.0-rc.5
golang.org/x/sync v0.21.0
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c=
go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
5 changes: 5 additions & 0 deletions overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package libopenapi

import (
"bytes"
gocontext "context"

"github.com/pb33f/libopenapi/datamodel"
Expand Down Expand Up @@ -32,6 +33,10 @@ type OverlayResult struct {
// NewOverlayDocument creates a new overlay document from the provided bytes.
// The overlay document can then be applied to a target OpenAPI document using ApplyOverlay.
func NewOverlayDocument(overlayBytes []byte) (*highoverlay.Overlay, error) {
if len(bytes.TrimSpace(overlayBytes)) == 0 {
return nil, overlay.ErrInvalidOverlay
}

var node yaml.Node
if err := yaml.Unmarshal(overlayBytes, &node); err != nil {
return nil, err
Expand Down
Loading
Loading