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
74 changes: 74 additions & 0 deletions arazzo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@
package libopenapi

import (
"reflect"
"sync"
"testing"
"unsafe"

"github.com/pb33f/libopenapi/datamodel/low"
lowArazzo "github.com/pb33f/libopenapi/datamodel/low/arazzo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)

//go:linkname arazzoLowBuildModelFieldCache github.com/pb33f/libopenapi/datamodel/low.buildModelFieldCache
var arazzoLowBuildModelFieldCache sync.Map

func TestNewArazzoDocument_ValidFull(t *testing.T) {
yml := []byte(`arazzo: 1.0.1
info:
Expand Down Expand Up @@ -177,6 +186,67 @@ func TestNewArazzoDocument_ArrayYAML(t *testing.T) {
assert.Contains(t, err.Error(), "expected YAML mapping")
}

func TestNewArazzoDocument_BuildModelError(t *testing.T) {
yml := []byte(`arazzo: 1.0.1
`)
var root yaml.Node
require.NoError(t, yaml.Unmarshal(yml, &root))

var seed lowArazzo.Arazzo
require.NoError(t, low.BuildModel(root.Content[0], &seed))

arazzoType := reflect.TypeOf(lowArazzo.Arazzo{})
original, ok := arazzoLowBuildModelFieldCache.Load(arazzoType)
require.True(t, ok)

origType := reflect.TypeOf(original)
elemType := origType.Elem()
replacement := reflect.MakeSlice(origType, 1, 1)
elem := reflect.New(elemType).Elem()
setArazzoUnexportedField(elem.FieldByName("lookupKey"), "arazzo")
setArazzoUnexportedField(elem.FieldByName("index"), 0)
setArazzoUnexportedField(elem.FieldByName("kind"), reflect.Bool)
replacement.Index(0).Set(elem)

arazzoLowBuildModelFieldCache.Store(arazzoType, replacement.Interface())
t.Cleanup(func() {
arazzoLowBuildModelFieldCache.Store(arazzoType, original)
})

doc, err := NewArazzoDocument(yml)
assert.Error(t, err)
assert.Nil(t, doc)
assert.Contains(t, err.Error(), "failed to build low-level model")
assert.Contains(t, err.Error(), "unsupported type")
}

func TestNewArazzoDocument_BuildError(t *testing.T) {
yml := []byte(`arazzo: 1.0.1
info:
title: Build Error
version: 1.0.0
sourceDescriptions:
- name: api
url: https://example.com/openapi.yaml
workflows:
- workflowId: wf1
steps:
- stepId: s1
operationId: op1
components:
failureActions:
badRetry:
name: retry
type: retry
retryAfter: nope
`)
doc, err := NewArazzoDocument(yml)
assert.Error(t, err)
assert.Nil(t, doc)
assert.Contains(t, err.Error(), "failed to build arazzo document")
assert.Contains(t, err.Error(), "invalid retryAfter")
}

func TestNewArazzoDocument_MultipleWorkflows(t *testing.T) {
yml := []byte(`arazzo: 1.0.1
info:
Expand Down Expand Up @@ -467,3 +537,7 @@ workflows:
assert.Len(t, doc2.Workflows, len(doc1.Workflows))
assert.Equal(t, doc1.Workflows[0].WorkflowId, doc2.Workflows[0].WorkflowId)
}

func setArazzoUnexportedField(field reflect.Value, value any) {
reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
}
25 changes: 22 additions & 3 deletions datamodel/document_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,16 @@ type DocumentConfiguration struct {
// passed in and used. Only enable this when parsing non openapi documents.
BypassDocumentCheck bool

// SkipJSONConversion skips the YAML-to-JSON conversion during spec parsing.
// SpecJSON and SpecJSONBytes on SpecInfo will be nil when enabled.
// This also skips structural validation that parseJSON performs (e.g., duplicate key detection).
// SkipJSONConversion disables the JSON representation of the document entirely:
// SpecInfo.GetSpecJSON and GetSpecJSONBytes return nil when enabled (and the
// deprecated SpecJSON/SpecJSONBytes fields stay nil). This also skips the eager
// structural validation performed at parse time (e.g., duplicate key detection).
// Safe when document-level schema validation rules are not running and no custom
// functions depend on the JSON representation.
//
// Note: the JSON representation is built lazily on first accessor call, so leaving
// this disabled no longer costs anything at parse time. Enable it only to also
// skip the eager structural validation, or to guarantee accessors return nil.
SkipJSONConversion bool

// IgnorePolymorphicCircularReferences will skip over checking for circular references in polymorphic schemas.
Expand Down Expand Up @@ -139,6 +144,20 @@ type DocumentConfiguration struct {
// defaults to false (which means extensions will be included)
ExcludeExtensionRefs bool

// SkipMetadataCollection disables the collection of diagnostic metadata during indexing:
// descriptions, summaries, enums, objects-with-properties, security requirement
// references, and the JSONPath `Path` values on inline schema references. Skipping
// them significantly reduces allocations and retained memory when parsing large
// documents. Reference extraction, resolution and model building are unaffected.
//
// -- UNSAFE FOR DIAGNOSTIC, RULE, OR PATH CONSUMERS --
// When enabled, the index methods GetAllDescriptions, GetAllSummaries, GetAllEnums,
// GetAllObjectsWithProperties, GetSecurityRequirementReferences and the related
// counts are intentionally empty/zero, and inline schema Reference.Path values are
// empty strings. vacuum and any other tool that consumes index metadata or Path
// values must NOT enable this. Defaults to false (everything is collected).
SkipMetadataCollection bool

// BundleInlineRefs controls whether local component references are inlined during bundling.
// When false (default): Local refs like #/components/schemas/Pet are preserved
// When true: Local refs are also inlined (may break discriminator mappings)
Expand Down
80 changes: 15 additions & 65 deletions datamodel/high/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,50 +367,21 @@ func NewSchema(schema *base.Schema) *Schema {
}
s.Enum = enum

// async work.
// any polymorphic properties need to be handled in their own threads
// any properties each need to be processed in their own thread.
// we go as fast as we can.
polyCompletedChan := make(chan struct{})
errChan := make(chan error)

type buildResult struct {
idx int
s *SchemaProxy
}

// for every item, build schema async
buildSchema := func(sch lowmodel.ValueReference[*base.SchemaProxy], idx int, bChan chan buildResult) {
n := &lowmodel.NodeReference[*base.SchemaProxy]{
ValueNode: sch.ValueNode,
Value: sch.Value,
}
n.SetReference(sch.GetReference(), sch.GetReferenceNode())

p := NewSchemaProxy(n)

bChan <- buildResult{idx: idx, s: p}
}

// schema async
buildOutSchemas := func(schemas []lowmodel.ValueReference[*base.SchemaProxy], items *[]*SchemaProxy,
doneChan chan struct{}, e chan error,
) {
bChan := make(chan buildResult)
totalSchemas := len(schemas)
// each item is a single SchemaProxy struct construction: spinning up goroutines
// and channels per item costs far more than the work itself, so build inline.
buildOutSchemas := func(schemas []lowmodel.ValueReference[*base.SchemaProxy]) []*SchemaProxy {
items := make([]*SchemaProxy, len(schemas))
for i := range schemas {
go buildSchema(schemas[i], i, bChan)
}
j := 0
for j < totalSchemas {
r := <-bChan
j++
(*items)[r.idx] = r.s
n := &lowmodel.NodeReference[*base.SchemaProxy]{
ValueNode: schemas[i].ValueNode,
Value: schemas[i].Value,
}
n.SetReference(schemas[i].GetReference(), schemas[i].GetReferenceNode())
items[i] = NewSchemaProxy(n)
}
doneChan <- struct{}{}
return items
}

// props async
buildProps := func(k lowmodel.KeyReference[string], v lowmodel.ValueReference[*base.SchemaProxy],
props *orderedmap.Map[string, *SchemaProxy], sw int,
) {
Expand Down Expand Up @@ -470,21 +441,14 @@ func NewSchema(schema *base.Schema) *Schema {
var items *DynamicValue[*SchemaProxy, bool]
var prefixItems []*SchemaProxy

children := 0
if !schema.AllOf.IsEmpty() {
children++
allOf = make([]*SchemaProxy, len(schema.AllOf.Value))
go buildOutSchemas(schema.AllOf.Value, &allOf, polyCompletedChan, errChan)
allOf = buildOutSchemas(schema.AllOf.Value)
}
if !schema.AnyOf.IsEmpty() {
children++
anyOf = make([]*SchemaProxy, len(schema.AnyOf.Value))
go buildOutSchemas(schema.AnyOf.Value, &anyOf, polyCompletedChan, errChan)
anyOf = buildOutSchemas(schema.AnyOf.Value)
}
if !schema.OneOf.IsEmpty() {
children++
oneOf = make([]*SchemaProxy, len(schema.OneOf.Value))
go buildOutSchemas(schema.OneOf.Value, &oneOf, polyCompletedChan, errChan)
oneOf = buildOutSchemas(schema.OneOf.Value)
}
if !schema.Not.IsEmpty() {
not = NewSchemaProxy(&schema.Not)
Expand All @@ -504,21 +468,7 @@ func NewSchema(schema *base.Schema) *Schema {
}
}
if !schema.PrefixItems.IsEmpty() {
children++
prefixItems = make([]*SchemaProxy, len(schema.PrefixItems.Value))
go buildOutSchemas(schema.PrefixItems.Value, &prefixItems, polyCompletedChan, errChan)
}

completeChildren := 0
if children > 0 {
allDone:
for {
<-polyCompletedChan
completeChildren++
if children == completeChildren {
break allDone
}
}
prefixItems = buildOutSchemas(schema.PrefixItems.Value)
}
s.OneOf = oneOf
s.AnyOf = anyOf
Expand Down
18 changes: 6 additions & 12 deletions datamodel/high/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,12 @@ type SchemaProxy struct {
buildError error
rendered *Schema
refStr string
lock *sync.Mutex
lock sync.Mutex
}

// NewSchemaProxy creates a new high-level SchemaProxy from a low-level one.
func NewSchemaProxy(schema *low.NodeReference[*base.SchemaProxy]) *SchemaProxy {
return &SchemaProxy{schema: schema, lock: &sync.Mutex{}}
return &SchemaProxy{schema: schema}
}

// copySchemaWithParentProxy creates a shallow copy of a schema and sets the ParentProxy
Expand All @@ -193,13 +193,13 @@ func (sp *SchemaProxy) copySchemaWithParentProxy(schema *Schema) *Schema {
// CreateSchemaProxy will create a new high-level SchemaProxy from a high-level Schema, this acts the same
// as if the SchemaProxy is pre-rendered.
func CreateSchemaProxy(schema *Schema) *SchemaProxy {
return &SchemaProxy{rendered: schema, lock: &sync.Mutex{}}
return &SchemaProxy{rendered: schema}
}

// CreateSchemaProxyRef will create a new high-level SchemaProxy from a reference string, this is used only when
// building out new models from scratch that require a reference rather than a schema implementation.
func CreateSchemaProxyRef(ref string) *SchemaProxy {
return &SchemaProxy{refStr: ref, lock: &sync.Mutex{}}
return &SchemaProxy{refStr: ref}
}

// CreateSchemaProxyRefWithSchema creates a SchemaProxy that carries both a $ref and sibling schema
Expand All @@ -208,7 +208,7 @@ func CreateSchemaProxyRef(ref string) *SchemaProxy {
//
// If schema is nil, the result behaves identically to CreateSchemaProxyRef.
func CreateSchemaProxyRefWithSchema(ref string, schema *Schema) *SchemaProxy {
return &SchemaProxy{refStr: ref, rendered: schema, lock: &sync.Mutex{}}
return &SchemaProxy{refStr: ref, rendered: schema}
}

// GetValueNode returns the value node of the SchemaProxy.
Expand All @@ -228,7 +228,7 @@ func (sp *SchemaProxy) GetValueNode() *yaml.Node {
// instead for proxies created using NewSchemaProxy or CreateSchema* methods.
// https://github.com/pb33f/libopenapi/issues/403
func (sp *SchemaProxy) Schema() *Schema {
if sp == nil || sp.lock == nil {
if sp == nil {
return nil
}

Expand Down Expand Up @@ -376,9 +376,6 @@ func (sp *SchemaProxy) BuildSchema() (*Schema, error) {
return nil, nil
}
schema := sp.Schema()
if sp.lock == nil {
return schema, sp.buildError
}
sp.lock.Lock()
er := sp.buildError
sp.lock.Unlock()
Expand All @@ -390,9 +387,6 @@ func (sp *SchemaProxy) GetBuildError() error {
if sp == nil {
return nil
}
if sp.lock == nil {
return sp.buildError
}
sp.lock.Lock()
err := sp.buildError
sp.lock.Unlock()
Expand Down
15 changes: 6 additions & 9 deletions datamodel/high/base/schema_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,12 @@ func TestCreateSchemaProxy_Fail(t *testing.T) {
}

func TestSchemaProxy_Schema_NoLowLevel(t *testing.T) {
proxy := &SchemaProxy{
lock: &sync.Mutex{},
}
proxy := &SchemaProxy{}
assert.Nil(t, proxy.Schema())
}

func TestSchemaProxy_Schema_NilProxy(t *testing.T) {
var proxy *SchemaProxy
assert.Nil(t, proxy.Schema())
}

Expand Down Expand Up @@ -868,7 +871,6 @@ func TestMarshalYAMLInline_CircularReferenceDetection_WithReference(t *testing.T
ref := "#/components/schemas/CircularTest"
proxy := &SchemaProxy{
refStr: ref,
lock: &sync.Mutex{},
}

// Pre-load the render key to simulate being mid-render (cycle detected)
Expand Down Expand Up @@ -959,7 +961,6 @@ func TestGetInlineRenderKey_ReferenceWithoutIndex(t *testing.T) {

proxy := &SchemaProxy{
schema: &lowRef,
lock: &sync.Mutex{},
}

renderKey := proxy.getInlineRenderKey()
Expand All @@ -974,7 +975,6 @@ func TestGetInlineRenderKey_NilSchemaReturnsRefStr(t *testing.T) {

proxy := &SchemaProxy{
refStr: "#/components/schemas/EarlyReturn",
lock: &sync.Mutex{},
}

renderKey := proxy.getInlineRenderKey()
Expand All @@ -994,7 +994,6 @@ func TestGetInlineRenderKey_NilSchemaValueReturnsRefStr(t *testing.T) {
proxy := &SchemaProxy{
refStr: "#/components/schemas/AnotherEarlyReturn",
schema: &lowRef,
lock: &sync.Mutex{},
}

renderKey := proxy.getInlineRenderKey()
Expand Down Expand Up @@ -1076,7 +1075,6 @@ func TestMarshalYAMLInlineWithContext_PreserveReference_ViaLowLevel(t *testing.T

proxy := &SchemaProxy{
schema: &lowRef,
lock: &sync.Mutex{},
}

// create context and mark the ref as preserved
Expand Down Expand Up @@ -1137,7 +1135,6 @@ func TestMarshalYAMLInline_BundlingMode_ViaLowLevelRef(t *testing.T) {

proxy := &SchemaProxy{
schema: &lowRef,
lock: &sync.Mutex{},
}

// Enable bundling mode
Expand Down
Loading
Loading