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
40 changes: 18 additions & 22 deletions bundler/bundler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"bytes"
"crypto/sha256"
"fmt"
"log"
"log/slog"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -35,6 +34,21 @@ import (

// Test helper functions to reduce duplication across DigitalOcean tests

const digitalOceanCommitID = "ed0958267922794ec8cf540e19131a2d9664bfc7"

func checkoutDigitalOceanRepo(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
cmd := exec.Command("git", "clone", "https://github.com/digitalocean/openapi.git", tmp)
if err := cmd.Run(); err != nil {
t.Fatalf("git clone failed: %s", err)
}
if err := exec.Command("git", "-C", tmp, "reset", "--hard", digitalOceanCommitID).Run(); err != nil {
t.Fatalf("git reset failed: %s", err)
}
return tmp
}

// collectAllDiscriminatorRefs gathers all refs that are allowed to be preserved (discriminator mappings).
func collectAllDiscriminatorRefs(model *v3high.Document) map[string]struct{} {
preservedRefs := make(map[string]struct{})
Expand Down Expand Up @@ -82,13 +96,7 @@ func isEmptyRef(line string) bool {

func TestBundleDocument_DigitalOcean(t *testing.T) {
// test the mother of all exploded specs.
tmp := t.TempDir()
cmd := exec.Command("git", "clone", "-b", "asb/dedup-key-model", "https://github.com/digitalocean/openapi.git", tmp)

err := cmd.Run()
if err != nil {
log.Fatalf("cmd.Run() failed with %s\n", err)
}
tmp := checkoutDigitalOceanRepo(t)

spec, _ := filepath.Abs(filepath.Join(tmp, "specification", "DigitalOcean-public.v2.yaml"))
digi, _ := os.ReadFile(spec)
Expand Down Expand Up @@ -129,13 +137,7 @@ func TestBundleDocument_DigitalOcean(t *testing.T) {

func TestBundleDocument_DigitalOceanAsync(t *testing.T) {
// test the mother of all exploded specs.
tmp := t.TempDir()
cmd := exec.Command("git", "clone", "-b", "asb/dedup-key-model", "https://github.com/digitalocean/openapi.git", tmp)

err := cmd.Run()
if err != nil {
log.Fatalf("cmd.Run() failed with %s\n", err)
}
tmp := checkoutDigitalOceanRepo(t)

spec, _ := filepath.Abs(filepath.Join(tmp, "specification", "DigitalOcean-public.v2.yaml"))
digi, _ := os.ReadFile(spec)
Expand Down Expand Up @@ -1399,13 +1401,7 @@ someData: "test"`
// for resolving refs in async mode. This is Option C from the investigation.
func TestRenderInline_DigitalOceanAsync(t *testing.T) {
// test the mother of all exploded specs.
tmp := t.TempDir()
cmd := exec.Command("git", "clone", "-b", "asb/dedup-key-model", "https://github.com/digitalocean/openapi.git", tmp)

err := cmd.Run()
if err != nil {
log.Fatalf("cmd.Run() failed with %s\n", err)
}
tmp := checkoutDigitalOceanRepo(t)

spec, _ := filepath.Abs(filepath.Join(tmp, "specification", "DigitalOcean-public.v2.yaml"))
digi, _ := os.ReadFile(spec)
Expand Down
19 changes: 15 additions & 4 deletions datamodel/high/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,11 +387,22 @@ func (sp *SchemaProxy) getInlineRenderKey() string {
// For inline schemas, use the node position
if sp.schema.ValueNode != nil {
node := sp.schema.ValueNode
if sp.schema.Value != nil && sp.schema.Value.GetIndex() != nil {
idx := sp.schema.Value.GetIndex()
return fmt.Sprintf("%s:%d:%d", idx.GetSpecAbsolutePath(), node.Line, node.Column)
var idx *index.SpecIndex
if sp.schema.Value != nil {
idx = sp.schema.Value.GetIndex()
}
if node.Line > 0 && node.Column > 0 {
if idx != nil {
return fmt.Sprintf("%s:%d:%d", idx.GetSpecAbsolutePath(), node.Line, node.Column)
}
return fmt.Sprintf("inline:%d:%d", node.Line, node.Column)
}
// Nodes created via yaml.Node.Encode() don't include line/column info.
// Fall back to a pointer-based key to avoid false cycle detection.
if idx != nil {
return fmt.Sprintf("%s:inline:%p", idx.GetSpecAbsolutePath(), node)
}
return fmt.Sprintf("inline:%d:%d", node.Line, node.Column)
return fmt.Sprintf("inline:%p", node)
}
return ""
}
Expand Down
60 changes: 60 additions & 0 deletions datamodel/high/base/schema_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,66 @@ func TestGetInlineRenderKey_NilSchemaValueReturnsRefStr(t *testing.T) {
assert.Equal(t, "#/components/schemas/AnotherEarlyReturn", renderKey)
}

func TestGetInlineRenderKey_InlineNodeWithoutPosition_NoIndex(t *testing.T) {
// Nodes created via yaml.Node.Encode() will have line/column set to zero.
// The render key should fall back to a pointer-based key to avoid collisions.

valueNode1 := &yaml.Node{Kind: yaml.MappingNode}
valueNode2 := &yaml.Node{Kind: yaml.MappingNode}

lowProxy1 := &lowbase.SchemaProxy{}
lowProxy2 := &lowbase.SchemaProxy{}

lowRef1 := low.NodeReference[*lowbase.SchemaProxy]{
Value: lowProxy1,
ValueNode: valueNode1,
}
lowRef2 := low.NodeReference[*lowbase.SchemaProxy]{
Value: lowProxy2,
ValueNode: valueNode2,
}

proxy1 := NewSchemaProxy(&lowRef1)
proxy2 := NewSchemaProxy(&lowRef2)

renderKey1 := proxy1.getInlineRenderKey()
renderKey2 := proxy2.getInlineRenderKey()

require.NotEmpty(t, renderKey1)
require.NotEmpty(t, renderKey2)
assert.Contains(t, renderKey1, "inline:")
assert.Contains(t, renderKey2, "inline:")
assert.NotEqual(t, "inline:0:0", renderKey1)
assert.NotEqual(t, "inline:0:0", renderKey2)
assert.NotEqual(t, renderKey1, renderKey2)
}

func TestGetInlineRenderKey_InlineNodeWithoutPosition_WithIndex(t *testing.T) {
// When an index is present and line/column are missing, include the index path
// and use a pointer-based key for uniqueness.

valueNode := &yaml.Node{Kind: yaml.MappingNode}
lowProxy := &lowbase.SchemaProxy{}

idx := &index.SpecIndex{}
idx.SetAbsolutePath("/tmp/spec.yaml")

err := lowProxy.Build(context.Background(), nil, valueNode, idx)
require.NoError(t, err)

lowRef := low.NodeReference[*lowbase.SchemaProxy]{
Value: lowProxy,
ValueNode: valueNode,
}

proxy := NewSchemaProxy(&lowRef)
renderKey := proxy.getInlineRenderKey()

require.NotEmpty(t, renderKey)
assert.True(t, strings.HasPrefix(renderKey, idx.GetSpecAbsolutePath()+":inline:"))
assert.NotEqual(t, idx.GetSpecAbsolutePath()+":0:0", renderKey)
}

func TestMarshalYAMLInlineWithContext_PreserveReference_ViaLowLevel(t *testing.T) {
// test context-based ref preservation when reference is set via low-level proxy
// (refStr is empty, so GetReferenceNode uses low-level path)
Expand Down
31 changes: 31 additions & 0 deletions datamodel/high/base/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/pb33f/libopenapi/index"
"github.com/pb33f/libopenapi/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)

Expand Down Expand Up @@ -1215,6 +1216,36 @@ components:
assert.Equal(t, "properties:\n bigBank:\n type: object\n properties:\n failure_balance_transaction:\n allOf:\n - type: object\n properties:\n name:\n type: string\n price:\n type: number\n anyOf:\n - description: A balance transaction\n anyOf:\n - maxLength: 5000\n type: string\n - description: A balance transaction\n", string(schemaBytes))
}

func TestSchema_RenderInline_MapEncodedNestedProperties_NoCircularDetection(t *testing.T) {
// Ensure inline rendering doesn't falsely detect circular refs when schema
// nodes are built from yaml.Node.Encode (no line/column metadata).
schemaMap := map[string]any{
"type": "array",
"contains": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"const": "X-Required-Version",
},
"in": map[string]any{
"const": "header",
},
},
"required": []any{"name", "in"},
},
}

var node yaml.Node
require.NoError(t, node.Encode(schemaMap))

lowSchema := new(lowbase.Schema)
require.NoError(t, lowSchema.Build(context.Background(), &node, nil))

highSchema := NewSchema(lowSchema)
_, err := highSchema.RenderInline()
assert.NoError(t, err)
}

func TestUnevaluatedPropertiesBoolean_True(t *testing.T) {
yml := `
type: number
Expand Down
19 changes: 9 additions & 10 deletions datamodel/high/v3/document_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,10 @@ func TestDigitalOceanAsDocViaCheckout(t *testing.T) {
log.Fatalf("cmd.Run() failed with %s\n", err)
}

if err := exec.Command("git", "-C", tmp, "reset", "--hard", "ed0958267922794ec8cf540e19131a2d9664bfc7").Run(); err != nil {
log.Fatalf("git reset failed with %s\n", err)
}

spec, _ := filepath.Abs(filepath.Join(tmp, "specification", "DigitalOcean-public.v2.yaml"))
doLocal, _ := os.ReadFile(spec)

Expand Down Expand Up @@ -513,7 +517,7 @@ func TestDigitalOceanAsDocFromSHA(t *testing.T) {
info, _ := datamodel.ExtractSpecInfo(data)
var err error

baseURL, _ := url.Parse("https://raw.githubusercontent.com/digitalocean/openapi/82e1d558e15a59edc1d47d2c5544e7138f5b3cbf/specification")
baseURL, _ := url.Parse("https://raw.githubusercontent.com/digitalocean/openapi/ed0958267922794ec8cf540e19131a2d9664bfc7/specification")
config := datamodel.DocumentConfiguration{
AllowFileReferences: true,
AllowRemoteReferences: true,
Expand All @@ -532,7 +536,7 @@ func TestDigitalOceanAsDocFromSHA(t *testing.T) {
}

lowDoc, err = lowv3.CreateDocumentFromConfig(info, &config)
assert.Len(t, utils.UnwrapErrors(err), 3) // there are 3 404's in this release of the API.
assert.Len(t, utils.UnwrapErrors(err), 0)
d := NewDocument(lowDoc)
assert.NotNil(t, d)
assert.Equal(t, 183, d.Paths.PathItems.Len())
Expand All @@ -543,7 +547,7 @@ func TestDigitalOceanAsDocFromMain(t *testing.T) {
info, _ := datamodel.ExtractSpecInfo(data)
var err error

baseURL, _ := url.Parse("https://raw.githubusercontent.com/digitalocean/openapi/main/specification")
baseURL, _ := url.Parse("https://raw.githubusercontent.com/digitalocean/openapi/ed0958267922794ec8cf540e19131a2d9664bfc7/specification")
config := datamodel.DocumentConfiguration{
AllowFileReferences: true,
AllowRemoteReferences: true,
Expand All @@ -560,18 +564,13 @@ func TestDigitalOceanAsDocFromMain(t *testing.T) {
}
config.RemoteURLHandler = func(url string) (*http.Response, error) {
request, _ := http.NewRequest(http.MethodGet, url, nil)
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("GITHUB_TOKEN")))
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("GH_PAT")))
return client.Do(request)
}
}

lowDoc, err = lowv3.CreateDocumentFromConfig(info, &config)
if err != nil {
er := utils.UnwrapErrors(err)
for e := range er {
fmt.Printf("Reported Error: %s\n", er[e])
}
}
assert.Len(t, utils.UnwrapErrors(err), 0)
d := NewDocument(lowDoc)
assert.NotNil(t, d)
assert.Equal(t, 183, orderedmap.Len(d.Paths.PathItems))
Expand Down
29 changes: 28 additions & 1 deletion datamodel/low/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ type SchemaProxy struct {
func (sp *SchemaProxy) Build(ctx context.Context, key, value *yaml.Node, idx *index.SpecIndex) error {
sp.kn = key
sp.idx = idx
sp.ctx = ctx

// transform sibling refs to allOf structure if enabled and applicable
// this ensures sp.vn contains the pre-transformed YAML as the source of truth
Expand All @@ -87,6 +86,7 @@ func (sp *SchemaProxy) Build(ctx context.Context, key, value *yaml.Node, idx *in
}

sp.vn = transformedValue
sp.ctx = applySchemaIdScope(ctx, value, idx)

// handle reference detection
if !wasTransformed {
Expand All @@ -102,6 +102,33 @@ func (sp *SchemaProxy) Build(ctx context.Context, key, value *yaml.Node, idx *in
return nil
}

func applySchemaIdScope(ctx context.Context, node *yaml.Node, idx *index.SpecIndex) context.Context {
if node == nil {
return ctx
}
scope := index.GetSchemaIdScope(ctx)
idValue := index.FindSchemaIdInNode(node)
if idValue == "" {
return ctx
}
if scope == nil {
base := ""
if idx != nil {
base = idx.GetSpecAbsolutePath()
}
scope = index.NewSchemaIdScope(base)
ctx = index.WithSchemaIdScope(ctx, scope)
}
parentBase := scope.BaseUri
resolved, err := index.ResolveSchemaId(idValue, parentBase)
if err != nil || resolved == "" {
resolved = idValue
}
updated := scope.Copy()
updated.PushId(resolved)
return index.WithSchemaIdScope(ctx, updated)
}

// Schema will first check if this SchemaProxy has already rendered the schema, and return the pre-rendered version
// first.
//
Expand Down
43 changes: 43 additions & 0 deletions datamodel/low/base/schema_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,49 @@ func TestSchemaProxy_Build_CheckRef(t *testing.T) {
assert.NotEmpty(t, low.GenerateHashString(&sch))
}

func TestSchemaProxy_Build_SetsSchemaIdScope(t *testing.T) {
yml := `$id: "https://example.com/schemas/base"
type: string`

var sch SchemaProxy
var idxNode yaml.Node
_ = yaml.Unmarshal([]byte(yml), &idxNode)

cfg := index.CreateClosedAPIIndexConfig()
cfg.SpecAbsolutePath = "https://example.com/openapi.yaml"
idx := index.NewSpecIndexWithConfig(&idxNode, cfg)

err := sch.Build(context.Background(), nil, idxNode.Content[0], idx)
assert.NoError(t, err)

scope := index.GetSchemaIdScope(sch.GetContext())
if assert.NotNil(t, scope) {
assert.Equal(t, "https://example.com/schemas/base", scope.BaseUri)
}
}

func TestApplySchemaIdScope_NilNode(t *testing.T) {
ctx := applySchemaIdScope(context.Background(), nil, nil)
assert.Nil(t, index.GetSchemaIdScope(ctx))
}

func TestApplySchemaIdScope_InvalidIdFallsBack(t *testing.T) {
yml := `$id: "http://[::1"`

var node yaml.Node
_ = yaml.Unmarshal([]byte(yml), &node)

scope := index.NewSchemaIdScope("https://example.com/base")
ctx := index.WithSchemaIdScope(context.Background(), scope)
updated := applySchemaIdScope(ctx, node.Content[0], nil)

updatedScope := index.GetSchemaIdScope(updated)
if assert.NotNil(t, updatedScope) {
assert.Equal(t, "http://[::1", updatedScope.BaseUri)
assert.Contains(t, updatedScope.Chain, "http://[::1")
}
}

func TestSchemaProxy_Build_HashInline(t *testing.T) {
yml := `type: int`

Expand Down
Loading