diff --git a/bundler/bundler_composer.go b/bundler/bundler_composer.go index 077c969d..5e1d11d3 100644 --- a/bundler/bundler_composer.go +++ b/bundler/bundler_composer.go @@ -12,6 +12,7 @@ import ( "sync" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + lowbase "github.com/pb33f/libopenapi/datamodel/low/base" v3low "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" @@ -217,6 +218,23 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) } if len(location) > 0 { + if isJSONSchemaDefsLocation(location) { + defName := decodeJSONSchemaDefsSegment(location[len(location)-1]) + componentName := jsonSchemaDefsComponentName(pr, defName, compositionDelimiter(cf)) + pr.originalName = defName + pr.name = componentName + pr.location = []string{v3low.ComponentsLabel, v3low.SchemasLabel, componentName} + handled, err := composeReferenceAs(v3low.SchemasLabel, componentName, components, pr, idx, cf) + if err != nil { + return err + } + if handled { + return nil + } + unknown(pr, cf) + return nil + } + pr.location = location if location[0] == v3low.ComponentsLabel { if len(location) > 2 { @@ -269,6 +287,56 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) return nil } +func isJSONSchemaDefsLocation(location []string) bool { + return len(location) >= 2 && location[0] == lowbase.DefsLabel +} + +func jsonSchemaDefsComponentName(pr *processRef, defName, delimiter string) string { + sourceName := "" + if pr != nil && pr.ref != nil { + sourceName = deriveNameFromFullDefinition(pr.ref.FullDefinition) + } + if sourceName == "." || sourceName == "" || sourceName == defName { + return sanitizeComponentName(defName) + } + return sanitizeComponentName(sourceName + delimiter + defName) +} + +func decodeJSONSchemaDefsSegment(segment string) string { + if decoded, err := url.PathUnescape(segment); err == nil { + segment = decoded + } + return decodeSingleSegmentPointer(segment) +} + +func sanitizeComponentName(name string) string { + if name == "" { + return fallbackSchemaComponentName + } + var b strings.Builder + b.Grow(len(name)) + lastReplacement := false + for _, r := range name { + if isOpenAPIComponentNameChar(r) { + b.WriteRune(r) + lastReplacement = false + continue + } + if !lastReplacement { + b.WriteByte('_') + lastReplacement = true + } + } + return b.String() +} + +func isOpenAPIComponentNameChar(r rune) bool { + return r >= 'a' && r <= 'z' || + r >= 'A' && r <= 'Z' || + r >= '0' && r <= '9' || + r == '.' || r == '_' || r == '-' +} + // enqueueDiscriminatorMappingTargets ensures mapping targets are composed into components. // This handles cases where a schema is ONLY referenced via discriminator mapping. func enqueueDiscriminatorMappingTargets( diff --git a/bundler/bundler_composer_test.go b/bundler/bundler_composer_test.go index 5479be2c..370ed673 100644 --- a/bundler/bundler_composer_test.go +++ b/bundler/bundler_composer_test.go @@ -16,6 +16,7 @@ import ( "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" @@ -87,6 +88,57 @@ func TestProcessReference_ContextualSingleSegmentRejectsUnsafeNode(t *testing.T) assert.Same(t, pr, cf.inlineRequired[0]) } +func TestProcessReference_JSONSchemaDefsReturnsComposeError(t *testing.T) { + model := &v3.Document{ + Components: &v3.Components{ + Schemas: orderedmap.New[string, *highbase.SchemaProxy](), + }, + } + idx := newVersionedIndex(3.2) + cf := &handleIndexConfig{ + idx: idx, + rootIdx: idx, + compositionConfig: &BundleCompositionConfig{Delimiter: "__"}, + } + pr := &processRef{ + idx: idx, + ref: &index.Reference{ + FullDefinition: "/tmp/widget.json#/$defs/company", + }, + seqRef: &index.Reference{}, + } + + err := processReference(model, pr, cf) + require.Error(t, err) + assert.Equal(t, "node is nil", err.Error()) +} + +func TestProcessReference_JSONSchemaDefsFallsBackWhenSchemasMissing(t *testing.T) { + model := &v3.Document{ + Components: &v3.Components{}, + } + idx := newVersionedIndex(3.2) + cf := &handleIndexConfig{ + idx: idx, + rootIdx: idx, + compositionConfig: &BundleCompositionConfig{Delimiter: "__"}, + } + pr := &processRef{ + idx: idx, + ref: &index.Reference{ + FullDefinition: "/tmp/widget.json#/$defs/company", + }, + seqRef: &index.Reference{}, + } + + require.NoError(t, processReference(model, pr, cf)) + require.Len(t, cf.inlineRequired, 1) + assert.Same(t, pr, cf.inlineRequired[0]) + assert.Equal(t, "company", pr.originalName) + assert.Equal(t, "widget__company", pr.name) + assert.Equal(t, []string{"components", "schemas", "widget__company"}, pr.location) +} + func TestCheckFileIteration(t *testing.T) { name := calculateCollisionName("bundled", "/test/specs/bundled.yaml", "__", 1) assert.Equal(t, "bundled__specs", name) diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index 610556cc..07755abe 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -23,7 +23,16 @@ import ( "go.yaml.in/yaml/v4" ) -const contextualRefKeySeparator = "\x00" +const ( + contextualRefKeySeparator = "\x00" + defaultCompositionDelimiter = "__" + fallbackSchemaComponentName = "schema" +) + +type rewriteVisitKey struct { + node *yaml.Node + inExtension bool +} // extractFragment returns the JSON pointer fragment from a full definition. // e.g., "file.yaml#/components/schemas/Pet" -> "#/components/schemas/Pet" @@ -221,7 +230,7 @@ func composeReferenceAs( idx *index.SpecIndex, cf *handleIndexConfig, ) (bool, error) { - delimiter := cf.compositionConfig.Delimiter + delimiter := compositionDelimiter(cf) switch componentType { case v3low.SchemasLabel: @@ -294,7 +303,7 @@ func fileImportLocationForType( pr *processRef, cf *handleIndexConfig, ) (bool, []string) { - delimiter := cf.compositionConfig.Delimiter + delimiter := compositionDelimiter(cf) switch componentType { case v3low.SchemasLabel: @@ -492,6 +501,10 @@ func renameRefWithSource( processedNodes *orderedmap.Map[string, *processRef], ) string { if strings.Contains(def, "#/") { + if pr := processedRefFor(processedNodes, def, source); pr != nil && len(pr.location) > 0 && pr.location[0] == v3low.ComponentsLabel { + return "#/" + joinLocationAsJSONPointer(pr.location) + } + defSplit := strings.Split(def, "#/") if len(defSplit) != 2 { return def @@ -699,7 +712,7 @@ func rewriteAllRefs( processedNodes *orderedmap.Map[string, *processRef], rolodex *index.Rolodex, ) { - walkAndRewriteRefs(idx.GetRootNode(), idx, processedNodes, rolodex, false) + walkAndRewriteRefs(idx.GetRootNode(), idx, processedNodes, rolodex, false, make(map[rewriteVisitKey]struct{})) } func walkAndRewriteRefs( @@ -708,46 +721,63 @@ func walkAndRewriteRefs( processedNodes *orderedmap.Map[string, *processRef], rolodex *index.Rolodex, inExtension bool, // Tracks if we're under an x-* key + visited map[rewriteVisitKey]struct{}, ) { if node == nil { return } + // Filter leaves before touching the active-path map; scalar and alias nodes + // cannot recurse, and they dominate large documents. + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode, yaml.MappingNode: + default: + return + } + if visited == nil { + visited = make(map[rewriteVisitKey]struct{}) + } + key := rewriteVisitKey{node: node, inExtension: inExtension} + if _, ok := visited[key]; ok { + return + } + visited[key] = struct{}{} switch node.Kind { case yaml.DocumentNode: if len(node.Content) > 0 { - walkAndRewriteRefs(node.Content[0], sourceIdx, processedNodes, rolodex, inExtension) + walkAndRewriteRefs(node.Content[0], sourceIdx, processedNodes, rolodex, inExtension, visited) } - return case yaml.SequenceNode: for _, child := range node.Content { - walkAndRewriteRefs(child, sourceIdx, processedNodes, rolodex, inExtension) + walkAndRewriteRefs(child, sourceIdx, processedNodes, rolodex, inExtension, visited) } - return case yaml.MappingNode: - // Continue below - default: - return - } - - for i := 0; i < len(node.Content); i += 2 { - keyNode := node.Content[i] - valueNode := node.Content[i+1] + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] - // Track extension scope - childInExtension := inExtension || strings.HasPrefix(keyNode.Value, "x-") + // Track extension scope + childInExtension := inExtension || strings.HasPrefix(keyNode.Value, "x-") - if keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode && !inExtension { - newRef := resolveRefToComposed(valueNode.Value, sourceIdx, processedNodes, rolodex) - if newRef != valueNode.Value { - valueNode.Value = newRef + if keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode && !inExtension { + newRef := resolveRefToComposed(valueNode.Value, sourceIdx, processedNodes, rolodex) + if newRef != valueNode.Value { + valueNode.Value = newRef + } + } else { + walkAndRewriteRefs(valueNode, sourceIdx, processedNodes, rolodex, childInExtension, visited) } - } else { - walkAndRewriteRefs(valueNode, sourceIdx, processedNodes, rolodex, childInExtension) } } } +func compositionDelimiter(cf *handleIndexConfig) string { + if cf == nil || cf.compositionConfig == nil || cf.compositionConfig.Delimiter == "" { + return defaultCompositionDelimiter + } + return cf.compositionConfig.Delimiter +} + func resolveRefToComposed( refValue string, sourceIdx *index.SpecIndex, diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index 4fd2fa90..628c154a 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -547,10 +547,123 @@ func newProcessRefForTest(t *testing.T, name, source string) *processRef { func TestWalkAndRewriteRefs_NilNode(t *testing.T) { require.NotPanics(t, func() { - walkAndRewriteRefs(nil, nil, nil, nil, false) + walkAndRewriteRefs(nil, nil, nil, nil, false, nil) }) } +func TestWalkAndRewriteRefs_SkipsCycles(t *testing.T) { + root := &yaml.Node{Kind: yaml.MappingNode} + root.Content = []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "child"}, + root, + } + + require.NotPanics(t, func() { + walkAndRewriteRefs(root, nil, nil, nil, false, nil) + }) +} + +func TestWalkAndRewriteRefs_RevisitsSharedNodesOnDifferentPaths(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {}`), &root)) + + cfg := index.CreateClosedAPIIndexConfig() + cfg.SpecAbsolutePath = "/tmp/root.yaml" + idx := index.NewSpecIndexWithConfig(&root, cfg) + + sharedRefNode := testYAMLContentNode(t, `$ref: "#/components/schemas/Old"`) + doc := &yaml.Node{Kind: yaml.MappingNode} + doc.Content = []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "x-extension"}, + sharedRefNode, + {Kind: yaml.ScalarNode, Value: "schema"}, + sharedRefNode, + } + processedNodes := orderedmap.New[string, *processRef]() + processedNodes.Set("/tmp/root.yaml#/components/schemas/Old", &processRef{ + location: []string{"components", "schemas", "New"}, + }) + + walkAndRewriteRefs(doc, idx, processedNodes, nil, false, nil) + assert.Equal(t, "#/components/schemas/New", sharedRefNode.Content[1].Value) +} + +func TestWalkAndRewriteRefs_ScalarNode(t *testing.T) { + require.NotPanics(t, func() { + walkAndRewriteRefs(&yaml.Node{Kind: yaml.ScalarNode, Value: "plain"}, nil, nil, nil, false, nil) + }) +} + +func TestWalkAndRewriteRefs_RewritesProcessedLocalRef(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {}`), &root)) + + cfg := index.CreateClosedAPIIndexConfig() + cfg.SpecAbsolutePath = "/tmp/root.yaml" + idx := index.NewSpecIndexWithConfig(&root, cfg) + + refNode := testYAMLContentNode(t, `$ref: "#/components/schemas/Old"`) + processedNodes := orderedmap.New[string, *processRef]() + processedNodes.Set("/tmp/root.yaml#/components/schemas/Old", &processRef{ + location: []string{"components", "schemas", "New"}, + }) + + walkAndRewriteRefs(refNode, idx, processedNodes, nil, false, nil) + assert.Equal(t, "#/components/schemas/New", refNode.Content[1].Value) +} + +func TestJSONSchemaDefsComponentName(t *testing.T) { + location := []string{"$defs", "company"} + assert.False(t, isJSONSchemaDefsLocation(nil)) + assert.False(t, isJSONSchemaDefsLocation([]string{"components", "schemas", "company"})) + assert.True(t, isJSONSchemaDefsLocation(location)) + + assert.Equal(t, "widget__company", jsonSchemaDefsComponentName(&processRef{ + ref: &index.Reference{FullDefinition: "/tmp/schemas/widget.json#/$defs/company"}, + }, "company", "__")) + assert.Equal(t, "company", jsonSchemaDefsComponentName(nil, "company", "__")) + assert.Equal(t, "company", jsonSchemaDefsComponentName(&processRef{ + ref: &index.Reference{FullDefinition: "#/$defs/company"}, + }, "company", "__")) + assert.Equal(t, "company", jsonSchemaDefsComponentName(&processRef{ + ref: &index.Reference{FullDefinition: "/tmp/schemas/company.json#/$defs/company"}, + }, "company", "__")) + assert.Equal(t, "widget__company_profile", jsonSchemaDefsComponentName(&processRef{ + ref: &index.Reference{FullDefinition: "/tmp/schemas/widget.json#/$defs/company~1profile"}, + }, "company/profile", "__")) + assert.Equal(t, "widget__company_profile", jsonSchemaDefsComponentName(&processRef{ + ref: &index.Reference{FullDefinition: "/tmp/schemas/widget.json#/$defs/company%20profile"}, + }, "company profile", "__")) + assert.Equal(t, "schema", sanitizeComponentName("")) + assert.Equal(t, "_", sanitizeComponentName("///")) + assert.Equal(t, "company_profile", sanitizeComponentName("company/profile")) + assert.Equal(t, "company profile", decodeJSONSchemaDefsSegment("company%20profile")) + assert.Equal(t, "company/profile", decodeJSONSchemaDefsSegment("company%7E1profile")) + assert.Equal(t, defaultCompositionDelimiter, compositionDelimiter(nil)) + assert.Equal(t, defaultCompositionDelimiter, compositionDelimiter(&handleIndexConfig{})) + assert.Equal(t, defaultCompositionDelimiter, compositionDelimiter(&handleIndexConfig{compositionConfig: &BundleCompositionConfig{}})) + assert.Equal(t, "@@", compositionDelimiter(&handleIndexConfig{compositionConfig: &BundleCompositionConfig{Delimiter: "@@"}})) +} + +func TestRenameRefWithSourceUsesComposedLocation(t *testing.T) { + fullDefinition := "/tmp/widget.json#/$defs/company" + processedNodes := orderedmap.New[string, *processRef]() + processedNodes.Set(fullDefinition, &processRef{ + name: "ignored", + location: []string{"components", "schemas", "widget__company"}, + }) + + assert.Equal(t, "#/components/schemas/widget__company", renameRef(nil, fullDefinition, processedNodes)) +} + func TestRemapIndexSkipsMappedExtensionRefs(t *testing.T) { var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`openapi: 3.1.0`), &root)) diff --git a/bundler/json_schema_defs_test.go b/bundler/json_schema_defs_test.go new file mode 100644 index 00000000..ff154d42 --- /dev/null +++ b/bundler/json_schema_defs_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: MIT + +package bundler + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestBundleBytesComposed_JSONSchemaDefsLiftedAndRewritten(t *testing.T) { + tmp := t.TempDir() + + spec := `openapi: "3.2.0" +info: + title: JSON Schema $defs bundle + version: 0.0.1 +paths: + /widgets/{id}: + get: + responses: + "200": + description: A widget. + content: + application/json: + schema: + $ref: "./widget.json" +` + widget := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.com/schemas/widget.json", + "title": "Widget", + "type": "object", + "additionalProperties": false, + "required": ["id", "manufacturer"], + "properties": { + "id": { "type": "string", "title": "ID" }, + "manufacturer": { "$ref": "#/$defs/company~1profile", "title": "Manufacturer" }, + "distributor": { "$ref": "#/$defs/company~1profile", "title": "Distributor" }, + "subsidiaries": { + "type": "array", + "items": { "$ref": "#/$defs/company~1profile" }, + "title": "Subsidiaries" + }, + "reseller": { "$ref": "#/$defs/company_profile", "title": "Reseller" } + }, + "$defs": { + "company/profile": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "title": "Name" }, + "country": { "type": "string", "title": "Country" } + } + }, + "company_profile": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string", "title": "Name" } + } + } + } +} +` + + require.NoError(t, os.WriteFile(filepath.Join(tmp, "spec.yaml"), []byte(spec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "widget.json"), []byte(widget), 0644)) + + specBytes, err := os.ReadFile(filepath.Join(tmp, "spec.yaml")) + require.NoError(t, err) + + bundled, err := BundleBytesComposed(specBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + SpecFilePath: filepath.Join(tmp, "spec.yaml"), + AllowFileReferences: true, + }, nil) + require.NoError(t, err) + + bundledText := string(bundled) + assert.NotContains(t, bundledText, "#/$defs/company~1profile") + assert.NotContains(t, bundledText, "#/$defs/company_profile") + assert.Equal(t, 1, strings.Count(bundledText, "#/components/schemas/widget__company_profile__1")) + + var doc map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &doc)) + + components, ok := doc["components"].(map[string]any) + require.True(t, ok) + schemas, ok := components["schemas"].(map[string]any) + require.True(t, ok) + require.Contains(t, schemas, "widget") + require.Contains(t, schemas, "widget__company_profile") + require.Contains(t, schemas, "widget__company_profile__1") + for name := range schemas { + assert.Regexp(t, `^[a-zA-Z0-9._-]+$`, name) + } + + widgetSchema, ok := schemas["widget"].(map[string]any) + require.True(t, ok) + assert.Contains(t, widgetSchema, "$defs", "$defs should remain valid JSON Schema data on the schema model") + properties, ok := widgetSchema["properties"].(map[string]any) + require.True(t, ok) + manufacturer, ok := properties["manufacturer"].(map[string]any) + require.True(t, ok) + reseller, ok := properties["reseller"].(map[string]any) + require.True(t, ok) + + manufacturerRef, ok := manufacturer["$ref"].(string) + require.True(t, ok) + resellerRef, ok := reseller["$ref"].(string) + require.True(t, ok) + assert.NotEqual(t, manufacturerRef, resellerRef) + for _, ref := range []string{manufacturerRef, resellerRef} { + componentName := strings.TrimPrefix(ref, "#/components/schemas/") + require.Contains(t, schemas, componentName) + assert.Regexp(t, `^[a-zA-Z0-9._-]+$`, componentName) + } +} diff --git a/datamodel/high/base/schema.go b/datamodel/high/base/schema.go index 6a720808..58dec858 100644 --- a/datamodel/high/base/schema.go +++ b/datamodel/high/base/schema.go @@ -64,8 +64,11 @@ type Schema struct { DependentSchemas *orderedmap.Map[string, *SchemaProxy] `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` DependentRequired *orderedmap.Map[string, []string] `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` PatternProperties *orderedmap.Map[string, *SchemaProxy] `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` - PropertyNames *SchemaProxy `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` - UnevaluatedItems *SchemaProxy `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"` + + // Defs holds reusable JSON Schema definitions declared under the $defs keyword. + Defs *orderedmap.Map[string, *SchemaProxy] `json:"$defs,omitempty" yaml:"$defs,omitempty"` + PropertyNames *SchemaProxy `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` + UnevaluatedItems *SchemaProxy `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"` // in 3.1 UnevaluatedProperties can be a Schema or a boolean // https://github.com/pb33f/libopenapi/issues/118 @@ -398,6 +401,8 @@ func NewSchema(schema *base.Schema) *Schema { s.DependentSchemas = props case 2: s.PatternProperties = props + case 3: + s.Defs = props } } @@ -434,6 +439,13 @@ func NewSchema(schema *base.Schema) *Schema { buildProps(name, schemaProxy, patternProps, 2) } + if !schema.Defs.IsEmpty() { + defs := orderedmap.New[string, *SchemaProxy]() + for name, schemaProxy := range schema.Defs.Value.FromOldest() { + buildProps(name, schemaProxy, defs, 3) + } + } + var allOf []*SchemaProxy var oneOf []*SchemaProxy var anyOf []*SchemaProxy diff --git a/datamodel/high/base/schema_test.go b/datamodel/high/base/schema_test.go index 78b290d0..3bf381fa 100644 --- a/datamodel/high/base/schema_test.go +++ b/datamodel/high/base/schema_test.go @@ -26,6 +26,37 @@ func TestDynamicValue_IsA(t *testing.T) { assert.False(t, dv.IsB()) } +func TestNewSchemaProxy_PreservesDefs(t *testing.T) { + yml := `type: object +$defs: + company: + type: object + properties: + name: + type: string` + + var compNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &compNode)) + + sp := new(lowbase.SchemaProxy) + require.NoError(t, sp.Build(context.Background(), nil, compNode.Content[0], nil)) + + schemaProxy := NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: compNode.Content[0], + }) + compiled := schemaProxy.Schema() + require.NotNil(t, compiled) + require.NotNil(t, compiled.Defs) + assert.Equal(t, "object", compiled.Defs.GetOrZero("company").Schema().Type[0]) + + rendered, err := compiled.Render() + require.NoError(t, err) + renderedText := string(rendered) + assert.Contains(t, renderedText, "$defs:") + assert.Contains(t, renderedText, "company:") +} + func TestNewSchemaProxy(t *testing.T) { // check proxy yml := `components: diff --git a/datamodel/low/base/constants.go b/datamodel/low/base/constants.go index 078ac9a9..ed20339d 100644 --- a/datamodel/low/base/constants.go +++ b/datamodel/low/base/constants.go @@ -31,6 +31,7 @@ const ( DependentSchemasLabel = "dependentSchemas" DependentRequiredLabel = "dependentRequired" PatternPropertiesLabel = "patternProperties" + DefsLabel = "$defs" IfLabel = "if" ElseLabel = "else" ThenLabel = "then" diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go index 325cde4a..4b18f668 100644 --- a/datamodel/low/base/schema.go +++ b/datamodel/low/base/schema.go @@ -88,7 +88,9 @@ type Schema struct { DependentSchemas low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]] DependentRequired low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[[]string]]] - PatternProperties low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]] + PatternProperties low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]] + // Defs holds reusable JSON Schema definitions declared under the $defs keyword. + Defs low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]] PropertyNames low.NodeReference[*SchemaProxy] UnevaluatedItems low.NodeReference[*SchemaProxy] UnevaluatedProperties low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]] diff --git a/datamodel/low/base/schema_build.go b/datamodel/low/base/schema_build.go index 8068ddb0..597763a7 100644 --- a/datamodel/low/base/schema_build.go +++ b/datamodel/low/base/schema_build.go @@ -380,6 +380,14 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde s.PatternProperties = *props } + props, err = buildPropertyMap(ctx, s, root, idx, DefsLabel) + if err != nil { + return err + } + if props != nil { + s.Defs = *props + } + itemsIsBool := false itemsBoolValue := false _, itemsLabel, itemsValue := utils.FindKeyNodeFullTop(ItemsLabel, root.Content) diff --git a/datamodel/low/base/schema_hash.go b/datamodel/low/base/schema_hash.go index f091e54c..34064b2c 100644 --- a/datamodel/low/base/schema_hash.go +++ b/datamodel/low/base/schema_hash.go @@ -324,6 +324,8 @@ func (s *Schema) hash(quick bool) uint64 { writeSchemaMapHashes(sb, s.PatternProperties.Value) + writeSchemaMapHashes(sb, s.Defs.Value) + if len(s.PrefixItems.Value) > 0 { scratch = resizeSchemaHashScratch(scratch, len(s.PrefixItems.Value)) for i := range s.PrefixItems.Value { diff --git a/datamodel/low/base/schema_hash_coverage_test.go b/datamodel/low/base/schema_hash_coverage_test.go index be4acdd2..e02f64a4 100644 --- a/datamodel/low/base/schema_hash_coverage_test.go +++ b/datamodel/low/base/schema_hash_coverage_test.go @@ -4,12 +4,15 @@ package base import ( + "context" "strings" "testing" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" ) func TestWriteSchemaBoolMap(t *testing.T) { @@ -57,3 +60,26 @@ func TestWriteSortedSchemaStrings(t *testing.T) { writeSortedSchemaStrings(&sb, []string{"zeta", "alpha"}, false) assert.Equal(t, "alphazeta|", sb.String()) } + +func TestSchemaHashIncludesDefs(t *testing.T) { + build := func(source string) *Schema { + t.Helper() + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(source), &root)) + + var schema Schema + require.NoError(t, schema.Build(context.Background(), root.Content[0], nil)) + return &schema + } + + a := build(`type: object +$defs: + shared: + type: string`) + b := build(`type: object +$defs: + shared: + type: integer`) + + assert.NotEqual(t, a.Hash(), b.Hash()) +} diff --git a/datamodel/low/base/schema_test.go b/datamodel/low/base/schema_test.go index 331333f8..74b75ece 100644 --- a/datamodel/low/base/schema_test.go +++ b/datamodel/low/base/schema_test.go @@ -354,6 +354,27 @@ func Test_Schema(t *testing.T) { assert.Equal(t, "#dynamicRefTarget", sch.DynamicRef.Value) } +func TestSchema_BuildDefs(t *testing.T) { + yml := `type: object +$defs: + company: + type: object + properties: + name: + type: string + tag: + type: string` + + var rootNode yaml.Node + assert.NoError(t, yaml.Unmarshal([]byte(yml), &rootNode)) + + var sch Schema + assert.NoError(t, sch.Build(context.Background(), rootNode.Content[0], nil)) + assert.Equal(t, 2, orderedmap.Len(sch.Defs.Value)) + assert.Equal(t, "object", low.FindItemInOrderedMap("company", sch.Defs.Value).Value.Schema().Type.Value.A) + assert.Equal(t, "string", low.FindItemInOrderedMap("tag", sch.Defs.Value).Value.Schema().Type.Value.A) +} + func TestSchemaAllOfSequenceOrder(t *testing.T) { testSpec := test_get_allOf_schema_blob() @@ -3255,6 +3276,23 @@ func TestBuildSchemaList_RefNotFound(t *testing.T) { assert.Contains(t, err.Error(), "reference cannot be found") } +func TestBuildSchema_DefsRefNotFound(t *testing.T) { + yml := `$defs: + missing: + $ref: '#/components/schemas/Missing'` + + var schemaNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &schemaNode) + cfg := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&schemaNode, cfg) + + var schema Schema + _ = low.BuildModel(schemaNode.Content[0], &schema) + err := schema.Build(context.Background(), schemaNode.Content[0], idx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "schema properties build failed") +} + func TestBuildSchema_SkipExternalRef(t *testing.T) { schemaYml := `additionalProperties: $ref: './models/Pet.yaml#/Pet'` diff --git a/generator/golang/metadata_test.go b/generator/golang/metadata_test.go index 7a275981..9ecf50a6 100644 --- a/generator/golang/metadata_test.go +++ b/generator/golang/metadata_test.go @@ -543,6 +543,8 @@ func TestSchemaMetadataSidecarTypedDataCoversJSONSchemaKeywords(t *testing.T) { properties.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}, Format: "uuid"})) patternProperties := orderedmap.New[string, *highbase.SchemaProxy]() patternProperties.Set("^x-", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + defs := orderedmap.New[string, *highbase.SchemaProxy]() + defs.Set("shared", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}})) dependentSchemas := orderedmap.New[string, *highbase.SchemaProxy]() dependentSchemas.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Required: []string{"kind"}})) dependentRequired := orderedmap.New[string, []string]() @@ -582,6 +584,7 @@ func TestSchemaMetadataSidecarTypedDataCoversJSONSchemaKeywords(t *testing.T) { DependentSchemas: dependentSchemas, DependentRequired: dependentRequired, PatternProperties: patternProperties, + Defs: defs, PropertyNames: highbase.CreateSchemaProxy(&highbase.Schema{Pattern: "^[a-z]+$"}), UnevaluatedItems: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"boolean"}}), UnevaluatedProperties: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{N: 1, B: false}, @@ -630,7 +633,7 @@ func TestSchemaMetadataSidecarTypedDataCoversJSONSchemaKeywords(t *testing.T) { if sidecar == "" { t.Fatal("expected metadata sidecar declaration") } - for _, want := range []string{"SchemaTypeRef", "ExclusiveMaximum", "DependentRequired", "UnevaluatedProperties", "ContentMediaType", "Extensions"} { + for _, want := range []string{"SchemaTypeRef", "ExclusiveMaximum", "DependentRequired", "Defs", "UnevaluatedProperties", "ContentMediaType", "Extensions"} { assertContains(t, sidecar, want) } @@ -655,6 +658,7 @@ func TestSchemaMetadataSidecarTypedDataCoversJSONSchemaKeywords(t *testing.T) { Values: []string{"kind"}, }}, PatternProperties: []providerNamedSchemaMetadata{{Name: "^x-", Schema: &providerSchemaMetadata{Type: []string{"string"}}}}, + Defs: []providerNamedSchemaMetadata{{Name: "shared", Schema: &providerSchemaMetadata{Type: []string{"object"}}}}, PropertyNames: &providerSchemaMetadata{Pattern: "^[a-z]+$"}, UnevaluatedItems: &providerSchemaMetadata{Type: []string{"boolean"}}, Properties: []providerNamedSchemaMetadata{{ @@ -717,7 +721,10 @@ func TestSchemaMetadataSidecarTypedDataCoversJSONSchemaKeywords(t *testing.T) { t.Fatal(err) } roundTrip := proxy.Schema() - if roundTrip.Properties.GetOrZero("id").Schema().Format != "uuid" || roundTrip.AdditionalProperties == nil || !roundTrip.AdditionalProperties.IsB() || roundTrip.MinLength == nil || *roundTrip.MinLength != 0 { + if roundTrip.Properties.GetOrZero("id").Schema().Format != "uuid" || + roundTrip.Defs.GetOrZero("shared").Schema().Type[0] != "object" || + roundTrip.AdditionalProperties == nil || !roundTrip.AdditionalProperties.IsB() || + roundTrip.MinLength == nil || *roundTrip.MinLength != 0 { t.Fatalf("typed provider metadata did not convert: %#v", roundTrip) } refProxy := schemaProxyFromMetadata(&providerSchemaMetadata{ diff --git a/generator/golang/provider_methods.go b/generator/golang/provider_methods.go index 044a4c14..bdae03ae 100644 --- a/generator/golang/provider_methods.go +++ b/generator/golang/provider_methods.go @@ -70,6 +70,7 @@ func writeSchemaMetadataTypes(b *strings.Builder) { DependentSchemas []openAPINamedSchemaMetadata DependentRequired []openAPIStringList PatternProperties []openAPINamedSchemaMetadata + Defs []openAPINamedSchemaMetadata PropertyNames *openAPISchemaMetadata UnevaluatedItems *openAPISchemaMetadata UnevaluatedProperties *openAPIDynamicSchemaBool @@ -207,6 +208,7 @@ func (g *Generator) schemaMetadataLiteralWithRef(ref string, schema *highbase.Sc writeMetadataField(&b, depth+1, "DependentSchemas", g.schemaMapMetadataLiteral(schema.DependentSchemas, depth+1)) writeMetadataField(&b, depth+1, "DependentRequired", metadataStringListMapLiteral(schema.DependentRequired, depth+1)) writeMetadataField(&b, depth+1, "PatternProperties", g.schemaMapMetadataLiteral(schema.PatternProperties, depth+1)) + writeMetadataField(&b, depth+1, "Defs", g.schemaMapMetadataLiteral(schema.Defs, depth+1)) writeMetadataField(&b, depth+1, "PropertyNames", g.optionalSchemaProxyMetadataLiteral(schema.PropertyNames, depth+1)) writeMetadataField(&b, depth+1, "UnevaluatedItems", g.optionalSchemaProxyMetadataLiteral(schema.UnevaluatedItems, depth+1)) writeMetadataField(&b, depth+1, "UnevaluatedProperties", g.metadataDynamicSchemaBoolLiteral(schema.UnevaluatedProperties, depth+1)) diff --git a/generator/golang/schema_metadata.go b/generator/golang/schema_metadata.go index f7c9278c..1f865f7a 100644 --- a/generator/golang/schema_metadata.go +++ b/generator/golang/schema_metadata.go @@ -36,6 +36,7 @@ type providerSchemaMetadata struct { DependentSchemas []providerNamedSchemaMetadata DependentRequired []providerStringList PatternProperties []providerNamedSchemaMetadata + Defs []providerNamedSchemaMetadata PropertyNames *providerSchemaMetadata UnevaluatedItems *providerSchemaMetadata UnevaluatedProperties *providerDynamicSchemaBool @@ -193,6 +194,7 @@ func schemaFromMetadata(metadata *providerSchemaMetadata) *highbase.Schema { DependentSchemas: schemaMapFromMetadata(metadata.DependentSchemas), DependentRequired: stringListMapFromMetadata(metadata.DependentRequired), PatternProperties: schemaMapFromMetadata(metadata.PatternProperties), + Defs: schemaMapFromMetadata(metadata.Defs), PropertyNames: schemaProxyFromMetadata(metadata.PropertyNames), UnevaluatedItems: schemaProxyFromMetadata(metadata.UnevaluatedItems), UnevaluatedProperties: dynamicSchemaBoolFromMetadata(metadata.UnevaluatedProperties), @@ -252,8 +254,8 @@ func schemaMetadataEmpty(metadata *providerSchemaMetadata) bool { metadata.Discriminator == nil && len(metadata.Examples) == 0 && len(metadata.PrefixItems) == 0 && metadata.Contains == nil && metadata.MinContains == nil && metadata.MaxContains == nil && metadata.If == nil && metadata.Else == nil && metadata.Then == nil && len(metadata.DependentSchemas) == 0 && - len(metadata.DependentRequired) == 0 && len(metadata.PatternProperties) == 0 && metadata.PropertyNames == nil && - metadata.UnevaluatedItems == nil && metadata.UnevaluatedProperties == nil && metadata.Items == nil && + len(metadata.DependentRequired) == 0 && len(metadata.PatternProperties) == 0 && len(metadata.Defs) == 0 && + metadata.PropertyNames == nil && metadata.UnevaluatedItems == nil && metadata.UnevaluatedProperties == nil && metadata.Items == nil && metadata.ID == "" && metadata.Anchor == "" && metadata.DynamicAnchor == "" && metadata.DynamicRef == "" && metadata.Comment == "" && metadata.ContentSchema == nil && len(metadata.Vocabulary) == 0 && metadata.Not == nil && len(metadata.Properties) == 0 && metadata.Title == "" && metadata.MultipleOf == nil && metadata.Maximum == nil && diff --git a/generator/golang/to_openapi.go b/generator/golang/to_openapi.go index e4c4e728..0c42c1cc 100644 --- a/generator/golang/to_openapi.go +++ b/generator/golang/to_openapi.go @@ -247,6 +247,7 @@ func applySchemaFidelity(schema *highbase.Schema, ir *SchemaIR) { schema.Then = src.Then schema.DependentSchemas = src.DependentSchemas schema.DependentRequired = src.DependentRequired + schema.Defs = src.Defs schema.PropertyNames = src.PropertyNames schema.UnevaluatedItems = src.UnevaluatedItems schema.UnevaluatedProperties = src.UnevaluatedProperties diff --git a/go.mod b/go.mod index ef0c1ee1..50b1ff16 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/pb33f/jsonpath v0.8.2 github.com/pb33f/ordered-map/v2 v2.3.1 github.com/pb33f/testify v0.1.0 - go.yaml.in/yaml/v4 v4.0.0-rc.5 + go.yaml.in/yaml/v4 v4.0.0-rc.6 golang.org/x/sync v0.21.0 ) diff --git a/go.sum b/go.sum index 9e9e3014..2d0c5da5 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c= -go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/index/extract_refs.go b/index/extract_refs.go index e0176ee4..da55e843 100644 --- a/index/extract_refs.go +++ b/index/extract_refs.go @@ -20,7 +20,7 @@ func isSchemaContainingNode(v string) bool { func isMapOfSchemaContainingNode(v string) bool { switch v { - case "properties", "patternProperties": + case "properties", "patternProperties", "$defs": return true } return false diff --git a/index/extract_refs_test.go b/index/extract_refs_test.go index c2339e96..6c3b6ccb 100644 --- a/index/extract_refs_test.go +++ b/index/extract_refs_test.go @@ -562,6 +562,46 @@ components: assert.Equal(t, []string{"paths", "~1test", "get", "responses", "200"}, responseRef.SourcePath) } +func TestSpecIndex_ExtractRefs_CollectsJSONSchemaDefs(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Defs + version: "1.0" +paths: {} +components: + schemas: + Widget: + type: object + properties: + manufacturer: + $ref: "#/components/schemas/Widget/$defs/company" + $defs: + company: + type: object + properties: + name: + type: string` + + var rootNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &rootNode)) + + cfg := CreateOpenAPIIndexConfig() + cfg.AvoidCircularReferenceCheck = true + idx := NewSpecIndexWithConfig(&rootNode, cfg) + + var found *Reference + for _, ref := range idx.GetAllInlineSchemas() { + if ref.FullDefinition == "#/components/schemas/Widget/$defs/company" || + strings.HasSuffix(ref.FullDefinition, "#/components/schemas/Widget/$defs/company") { + found = ref + break + } + } + require.NotNil(t, found) + assert.Equal(t, "#/components/schemas/Widget/$defs/company", found.Definition) + assert.Equal(t, "object", found.Node.Content[1].Value) +} + func TestSpecIndex_GetExtensionRefsSequenced(t *testing.T) { yml := `openapi: 3.1.0 info: