From b3b2ca55e510bd8fc0ea7d30b24f9c7f0225639c Mon Sep 17 00:00:00 2001 From: quobix Date: Thu, 21 May 2026 16:23:30 -0700 Subject: [PATCH 1/3] feat(bundler): classify ambiguous refs by source slot during composition Track the OpenAPI path of every $ref through the index as `Reference.SourcePath`, then use it in the composed bundler to infer a component bucket when the target node's shape is ambiguous (sparse responses, description-only objects, bare-file refs). Refs that previously fell back to inlining can now be lifted into the correct `components` slot, with contextual map keys preventing collisions when the same target is referenced from different slots. --- bundler/bundler.go | 220 ++++++++------- bundler/bundler_composer.go | 224 ++++----------- bundler/bundler_composer_test.go | 319 +++++++++++++++++++++ bundler/composer_functions.go | 249 ++++++++++++++++- bundler/composer_functions_test.go | 433 +++++++++++++++++++++++++++++ bundler/source_context.go | 196 +++++++++++++ bundler/source_context_test.go | 273 ++++++++++++++++++ bundler/test/specs/bundled.yaml | 4 +- bundler/test/specs/main.yaml | 3 +- index/extract_refs_ref.go | 2 + index/extract_refs_test.go | 37 +++ index/find_component_build.go | 1 + index/index_model.go | 1 + index/resolver_relatives.go | 1 + 14 files changed, 1684 insertions(+), 279 deletions(-) create mode 100644 bundler/source_context.go create mode 100644 bundler/source_context_test.go diff --git a/bundler/bundler.go b/bundler/bundler.go index db8dccd16..ff3933246 100644 --- a/bundler/bundler.go +++ b/bundler/bundler.go @@ -508,7 +508,7 @@ func composeWithOrigins(model *v3.Document, compositionConfig *BundleComposition return nil, err } - // Step 1: Collect discriminator mappings WITH context (early) + // Collect discriminator mappings before ref processing so mapping-only targets can be composed. discriminatorMappings := collectDiscriminatorMappingNodesWithContext(rolodex) cf := &handleIndexConfig{ @@ -523,10 +523,9 @@ func composeWithOrigins(model *v3.Document, compositionConfig *BundleComposition origins: make(ComponentOriginMap), } - // Step 2: Enqueue mapping targets for composition (AFTER cf is created, BEFORE handleIndex) - // Pass rootIndex so we can skip root-local #/ refs + // Enqueue mapping targets after cf exists; root-local #/ refs stay in place. enqueueDiscriminatorMappingTargets(discriminatorMappings, cf, rootIndex) - // Refresh indexes in case mapping resolution loaded new ones + // Refresh indexes in case mapping resolution loaded new ones. cf.indexes = rolodex.GetIndexes() if err := validateDiscriminatorMappings(rolodex); err != nil { return nil, err @@ -545,7 +544,10 @@ func composeWithOrigins(model *v3.Document, compositionConfig *BundleComposition for _, ref := range cf.refMap.FromOldest() { err := processReference(model, ref, cf) errs = append(errs, err) - processedNodes.Set(ref.ref.FullDefinition, ref) + processedNodes.Set(ref.mapKey, ref) + if ref.ref != nil && ref.mapKey != ref.ref.FullDefinition { + processedNodes.Set(ref.ref.FullDefinition, ref) + } } slices.SortFunc(indexes, func(i, j *index.SpecIndex) int { @@ -555,56 +557,20 @@ func composeWithOrigins(model *v3.Document, compositionConfig *BundleComposition return 0 }) - // Step 3: Remap indexed refs + // Remap indexed refs. remapIndex(rootIndex, processedNodes) for _, idx := range indexes { remapIndex(idx, processedNodes) } - // Step 4: Update discriminator mappings (uses renameRef for collision handling) + // Update discriminator mapping values after component names are final. updateDiscriminatorMappingsComposed(discriminatorMappings, processedNodes, rolodex) - // Step 5: Inline handling with guard for synthetic discriminator refs - // anything that could not be recomposed and needs inlining - inlinedPaths := make(map[string]*yaml.Node) - for _, pr := range cf.inlineRequired { - // Skip synthetic refs from discriminator mappings - their seqRef.Node is a - // scalar value node, not a $ref node with Content array - if pr.fromDiscriminator { - continue - } - - if pr.refPointer != "" { - - // if the ref is a pointer to an external pointer, then we need to stitch it. - uri := strings.Split(pr.refPointer, "#/") - if len(uri) == 2 { - if uri[0] != "" { - if !filepath.IsAbs(uri[0]) && !strings.HasPrefix(uri[0], "http") { - // if the uri is not absolute, then we need to make it absolute. - uri[0] = utils.CheckPathOverlap(filepath.Dir(pr.idx.GetSpecAbsolutePath()), uri[0], string(os.PathSeparator)) - } - pointerRef := pr.idx.FindComponent(context.Background(), strings.Join(uri, "#/")) - pr.seqRef.Node.Content = pointerRef.Node.Content - // Track this inlined content for reuse - if pr.ref != nil { - inlinedPaths[pr.ref.FullDefinition] = pointerRef.Node - } - continue - } - } - } - pr.seqRef.Node.Content = pr.ref.Node.Content - // Track this inlined content for reuse - if pr.ref != nil { - inlinedPaths[pr.ref.FullDefinition] = pr.ref.Node - } - } + // Inline anything that could not be recomposed. + inlinedPaths := inlineRequiredRefs(cf.inlineRequired, rolodex) - // Step 6: Tree walk for any remaining unindexed refs - // Re-fetch indexes since new ones may have been loaded during composition - // (e.g., discriminator mapping targets that weren't indexed initially) + // Rewrite any remaining unindexed refs after mapping resolution loads new indexes. allLoadedIndexes := rolodex.GetIndexes() rewriteAllRefs(rootIndex, processedNodes, rolodex) for _, idx := range allLoadedIndexes { @@ -672,7 +638,7 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] return nil, err } - // Collect discriminator mappings WITH context (early) + // Collect discriminator mappings before ref processing so mapping-only targets can be composed. discriminatorMappings := collectDiscriminatorMappingNodesWithContext(rolodex) cf := &handleIndexConfig{ @@ -687,10 +653,9 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] origins: make(ComponentOriginMap), } - // Enqueue mapping targets for composition (AFTER cf is created, BEFORE handleIndex) - // Pass rootIndex so we can skip root-local #/ refs + // Enqueue mapping targets after cf exists; root-local #/ refs stay in place. enqueueDiscriminatorMappingTargets(discriminatorMappings, cf, rootIndex) - // Refresh indexes in case mapping resolution loaded new ones + // Refresh indexes in case mapping resolution loaded new ones. cf.indexes = rolodex.GetIndexes() if err := validateDiscriminatorMappings(rolodex); err != nil { return nil, err @@ -709,7 +674,10 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] for _, ref := range cf.refMap.FromOldest() { err := processReference(model, ref, cf) errs = append(errs, err) - processedNodes.Set(ref.ref.FullDefinition, ref) + processedNodes.Set(ref.mapKey, ref) + if ref.ref != nil && ref.mapKey != ref.ref.FullDefinition { + processedNodes.Set(ref.ref.FullDefinition, ref) + } } slices.SortFunc(indexes, func(i, j *index.SpecIndex) int { @@ -719,56 +687,20 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] return 0 }) - // Remap indexed refs + // Remap indexed refs. remapIndex(rootIndex, processedNodes) for _, idx := range indexes { remapIndex(idx, processedNodes) } - // Update discriminator mappings (uses renameRef for collision handling) + // Update discriminator mapping values after component names are final. updateDiscriminatorMappingsComposed(discriminatorMappings, processedNodes, rolodex) - // Inline handling with guard for synthetic discriminator refs - // anything that could not be recomposed and needs inlining - inlinedPaths := make(map[string]*yaml.Node) - for _, pr := range cf.inlineRequired { - // Skip synthetic refs from discriminator mappings - their seqRef.Node is a - // scalar value node, not a $ref node with a Content array - if pr.fromDiscriminator { - continue - } - - if pr.refPointer != "" { + // Inline anything that could not be recomposed. + inlinedPaths := inlineRequiredRefs(cf.inlineRequired, rolodex) - // if the ref is a pointer to an external pointer, then we need to stitch it. - uri := strings.Split(pr.refPointer, "#/") - if len(uri) == 2 { - if uri[0] != "" { - if !filepath.IsAbs(uri[0]) && !strings.HasPrefix(uri[0], "http") { - // if the uri is not absolute, then we need to make it absolute. - uri[0] = utils.CheckPathOverlap(filepath.Dir(pr.idx.GetSpecAbsolutePath()), uri[0], string(os.PathSeparator)) - } - pointerRef := pr.idx.FindComponent(context.Background(), strings.Join(uri, "#/")) - pr.seqRef.Node.Content = pointerRef.Node.Content - // Track this inlined content for reuse - if pr.ref != nil { - inlinedPaths[pr.ref.FullDefinition] = pointerRef.Node - } - continue - } - } - } - pr.seqRef.Node.Content = pr.ref.Node.Content - // Track this inlined content for reuse - if pr.ref != nil { - inlinedPaths[pr.ref.FullDefinition] = pr.ref.Node - } - } - - // Tree walk for any remaining unindexed refs - // Re-fetch indexes since new ones may have been loaded during composition - // (e.g., discriminator mapping targets that weren't indexed initially) + // Rewrite any remaining unindexed refs after mapping resolution loads new indexes. allLoadedIndexes := rolodex.GetIndexes() rewriteAllRefs(rootIndex, processedNodes, rolodex) for _, idx := range allLoadedIndexes { @@ -802,6 +734,103 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] return b, errors.Join(errs...) } +// inlineRequiredRefs inlines refs that cannot be represented as root components. +func inlineRequiredRefs(required []*processRef, rolodex *index.Rolodex) map[string]*yaml.Node { + inlinedPaths := make(map[string]*yaml.Node) + if len(required) == 0 { + return inlinedPaths + } + + refsByDefinition := sequencedRefsByFullDefinition(rolodex) + for _, pr := range required { + inlinedNode := inlineProcessRef(pr) + if inlinedNode == nil { + continue + } + if pr.ref != nil { + inlinedPaths[pr.ref.FullDefinition] = inlinedNode + } + inlineMatchingRefs(pr, inlinedNode, refsByDefinition) + } + return inlinedPaths +} + +// sequencedRefsByFullDefinition buckets refs once for inlineRequiredRefs. +func sequencedRefsByFullDefinition(rolodex *index.Rolodex) map[string][]*index.Reference { + refsByDefinition := make(map[string][]*index.Reference) + if rolodex == nil { + return refsByDefinition + } + + indexes := append([]*index.SpecIndex{}, rolodex.GetIndexes()...) + indexes = append(indexes, rolodex.GetRootIndex()) + seen := make(map[*index.SpecIndex]struct{}, len(indexes)) + + for _, idx := range indexes { + if idx == nil { + continue + } + if _, ok := seen[idx]; ok { + continue + } + seen[idx] = struct{}{} + + for _, seqRef := range idx.GetRawReferencesSequenced() { + if seqRef == nil || seqRef.IsExtensionRef || seqRef.Node == nil || seqRef.FullDefinition == "" { + continue + } + refsByDefinition[seqRef.FullDefinition] = append(refsByDefinition[seqRef.FullDefinition], seqRef) + } + } + return refsByDefinition +} + +// inlineProcessRef replaces the source ref node with its resolved target node. +func inlineProcessRef(pr *processRef) *yaml.Node { + if pr == nil || pr.fromDiscriminator || pr.seqRef == nil || pr.seqRef.Node == nil || pr.ref == nil { + return nil + } + + if pr.refPointer != "" { + uri := strings.Split(pr.refPointer, "#/") + if len(uri) == 2 && uri[0] != "" { + if !filepath.IsAbs(uri[0]) && !strings.HasPrefix(uri[0], "http") { + uri[0] = utils.CheckPathOverlap(filepath.Dir(pr.idx.GetSpecAbsolutePath()), uri[0], string(os.PathSeparator)) + } + pointerRef := pr.idx.FindComponent(context.Background(), strings.Join(uri, "#/")) + if pointerRef == nil || pointerRef.Node == nil { + return nil + } + pr.seqRef.Node.Content = pointerRef.Node.Content + return pointerRef.Node + } + } + + if pr.ref.Node == nil { + return nil + } + pr.seqRef.Node.Content = pr.ref.Node.Content + return pr.ref.Node +} + +// inlineMatchingRefs applies the same inline replacement to repeated matching refs. +func inlineMatchingRefs(pr *processRef, inlinedNode *yaml.Node, refsByDefinition map[string][]*index.Reference) { + if pr == nil || pr.ref == nil || inlinedNode == nil || refsByDefinition == nil { + return + } + key := pr.mapKey + if key == "" { + key = processRefMapKey(pr.ref, pr.seqRef) + } + + for _, seqRef := range refsByDefinition[pr.ref.FullDefinition] { + if contextualProcessRefKey(pr.ref.FullDefinition, seqRef) != key { + continue + } + seqRef.Node.Content = inlinedNode.Content + } +} + // resolveBundleInlineConfig resolves the inlineLocalRefs setting from the fallback chain: // 1. BundleInlineConfig.InlineLocalRefs (explicit per-call) // 2. DocumentConfiguration.BundleInlineRefs (document-wide default) @@ -1341,9 +1370,8 @@ func updateDiscriminatorMappingsComposed(mappings []*discriminatorMappingWithCon continue } - // Use the cached canonicalKey and targetIdx from enqueue time. - // At enqueue time, we captured ref.FullDefinition BEFORE any mutation. - // Using SearchIndexForReference again here would return a potentially mutated + // Use the canonicalKey and targetIdx captured before bundling mutates refs. + // Calling SearchIndexForReference again here could return a mutated // ref.FullDefinition that won't match processedNodes keys. canonicalKey := mapping.canonicalKey targetIdx := mapping.targetIdx @@ -1359,7 +1387,7 @@ func updateDiscriminatorMappingsComposed(mappings []*discriminatorMappingWithCon continue } canonicalKey = ref.FullDefinition - targetIdx = refIdx // Use the resolved index, NOT mapping.sourceIdx + targetIdx = refIdx // Use the resolved index, not mapping.sourceIdx. } // Gate rewrites on processedNodes presence. diff --git a/bundler/bundler_composer.go b/bundler/bundler_composer.go index 2bc7045c5..077c969df 100644 --- a/bundler/bundler_composer.go +++ b/bundler/bundler_composer.go @@ -23,20 +23,21 @@ type processRef struct { idx *index.SpecIndex ref *index.Reference seqRef *index.Reference + mapKey string refPointer string name string location []string wasRenamed bool // true when component was renamed due to collision originalName string // original name before collision renaming - fromDiscriminator bool // true if created from discriminator mapping (skip inline handling) + fromDiscriminator bool // created from discriminator mapping; do not inline } // discriminatorMappingWithContext stores a mapping node with its source index -// and the pre-computed canonical key for processedNodes lookup +// and the canonical key used for processedNodes lookup. type discriminatorMappingWithContext struct { node *yaml.Node // The YAML node containing the mapping value sourceIdx *index.SpecIndex // The index where the mapping was found - canonicalKey string // Pre-computed key: ref.FullDefinition at enqueue time (before any mutation) + canonicalKey string // ref.FullDefinition captured before bundling mutates refs targetIdx *index.SpecIndex // The index where the resolved ref actually lives (may differ from sourceIdx) } @@ -93,28 +94,26 @@ func handleIndex(c *handleIndexConfig) error { lookup := sequenced.FullDefinition mr := i.FindComponent(context.Background(), lookup) if mr != nil { - // found the component; this is the one we want to use. + // Use the component from the matching index. mappedReference = mr break } } } } - // check if we have seen this index before, if so - skip it, otherwise we will be going around forever. - if _, ok := c.seen.Load(sequenced.FullDefinition); ok { - continue - } + refMapKey := processRefMapKey(mappedReference, sequenced) if foundIndex != nil && mappedReference != nil { // Avoid recomposing components that resolve back to the root document. if c.rootIdx != nil && foundIndex.GetSpecAbsolutePath() == c.rootIdx.GetSpecAbsolutePath() { continue } - // store the reference to be composed in the root. - if kk := c.refMap.GetOrZero(mappedReference.FullDefinition); kk == nil { - c.refMap.Set(mappedReference.FullDefinition, &processRef{ + // Store the reference to be composed in the root. + if kk := c.refMap.GetOrZero(refMapKey); kk == nil { + c.refMap.Set(refMapKey, &processRef{ idx: foundIndex, ref: mappedReference, seqRef: sequenced, + mapKey: refMapKey, name: mappedReference.Name, }) } @@ -165,6 +164,13 @@ func rootSupportsPathItemComponents(rootIdx *index.SpecIndex) bool { return rootIdx.GetConfig().SpecInfo.VersionNumeric >= 3.1 } +func rootSupportsMediaTypeComponents(rootIdx *index.SpecIndex) bool { + if rootIdx == nil || rootIdx.GetConfig() == nil || rootIdx.GetConfig().SpecInfo == nil { + return true + } + return rootIdx.GetConfig().SpecInfo.VersionNumeric >= 3.2 +} + // processReference will extract a reference from the current index, and transform it into a first class // top-level component in the root OpenAPI document. func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) error { @@ -172,9 +178,6 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) var components *v3.Components var err error - delim := cf.compositionConfig.Delimiter - supportsPathItemComponents := rootSupportsPathItemComponents(cf.rootIdx) - if model.Components != nil { components = model.Components } else { @@ -188,40 +191,17 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) var location []string if strings.Contains(pr.ref.FullDefinition, "#/") { - // extract fragment from the full definition. segs := strings.Split(pr.ref.FullDefinition, "#/") location = strings.Split(segs[1], "/") } else { - // make sure the sequence ref and pr ref have the same full definition. + // Bare-file imports need the sequenced absolute definition so composition + // keys and later rewrites point at the same target. pr.ref.FullDefinition = pr.seqRef.FullDefinition - // this is a root document reference, there is no way to get the location from the fragment. - // first, lets try to determine the type of the import, if we can. - if importType, ok := DetectOpenAPIComponentType(pr.ref.Node); ok { - // cool, using the filename as the reference name, check if we have any collisions. - switch importType { - case v3low.SchemasLabel: - location = handleFileImport(pr, v3low.SchemasLabel, delim, components.Schemas) - case v3low.ResponsesLabel: - location = handleFileImport(pr, v3low.ResponsesLabel, delim, components.Responses) - case v3low.ParametersLabel: - location = handleFileImport(pr, v3low.ParametersLabel, delim, components.Parameters) - case v3low.HeadersLabel: - location = handleFileImport(pr, v3low.HeadersLabel, delim, components.Headers) - case v3low.RequestBodiesLabel: - location = handleFileImport(pr, v3low.RequestBodiesLabel, delim, components.RequestBodies) - case v3low.ExamplesLabel: - location = handleFileImport(pr, v3low.ExamplesLabel, delim, components.Examples) - case v3low.LinksLabel: - location = handleFileImport(pr, v3low.LinksLabel, delim, components.Links) - case v3low.CallbacksLabel: - location = handleFileImport(pr, v3low.CallbacksLabel, delim, components.Callbacks) - case v3low.PathItemsLabel: - if supportsPathItemComponents { - location = handleFileImport(pr, v3low.PathItemsLabel, delim, components.PathItems) - } else { - cf.inlineRequired = append(cf.inlineRequired, pr) - } - } + if importType, ok := inferComponentTypeFromSourcePath(pr.seqRef.SourcePath); ok && + canComposeContextualReference(importType, pr.ref.Node, true) { + _, location = fileImportLocationForType(importType, components, pr, cf) + } else if importType, ok := DetectOpenAPIComponentType(pr.ref.Node); ok { + _, location = fileImportLocationForType(importType, components, pr, cf) } else { // the only choice we can make here to be accurate is to inline instead of recompose. cf.inlineRequired = append(cf.inlineRequired, pr) @@ -239,63 +219,9 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) if len(location) > 0 { pr.location = location if location[0] == v3low.ComponentsLabel { - if len(location) > 1 { - switch location[1] { - case v3low.SchemasLabel: - if len(location) > 2 && components.Schemas != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.SchemasLabel, pr, idx, components.Schemas, buildSchema, cf.origins) - } - - case v3low.ResponsesLabel: - if len(location) > 2 && components.Responses != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.ResponsesLabel, pr, idx, components.Responses, buildResponse, cf.origins) - } - - case v3low.ParametersLabel: - if len(location) > 2 && components.Parameters != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.ParametersLabel, pr, idx, components.Parameters, buildParameter, cf.origins) - } - - case v3low.HeadersLabel: - if len(location) > 2 && components.Headers != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.HeadersLabel, pr, idx, components.Headers, buildHeader, cf.origins) - } - - case v3low.RequestBodiesLabel: - if len(location) > 2 && components.RequestBodies != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.RequestBodiesLabel, pr, idx, components.RequestBodies, buildRequestBody, cf.origins) - } - - case v3low.ExamplesLabel: - if len(location) > 2 && components.Examples != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.ExamplesLabel, pr, idx, components.Examples, buildExample, cf.origins) - } - - case v3low.LinksLabel: - if len(location) > 2 && components.Links != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.LinksLabel, pr, idx, components.Links, buildLink, cf.origins) - } - - case v3low.CallbacksLabel: - if len(location) > 2 && components.Callbacks != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.CallbacksLabel, pr, idx, components.Callbacks, buildCallback, cf.origins) - } - - case v3low.PathItemsLabel: - if supportsPathItemComponents && len(location) > 2 && components.PathItems != nil { - return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter, - v3low.PathItemsLabel, pr, idx, components.PathItems, buildPathItem, cf.origins) - } - cf.inlineRequired = append(cf.inlineRequired, pr) - return nil + if len(location) > 2 { + if handled, err := composeReferenceAs(location[1], location[2], components, pr, idx, cf); handled || err != nil { + return err } } } else { @@ -308,10 +234,7 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) componentName = decoded } // process JSON Pointer escapes per RFC 6901 (~1 before ~0 to avoid mangling "~0") - if strings.Contains(componentName, "~") { - componentName = strings.ReplaceAll(componentName, "~1", "/") - componentName = strings.ReplaceAll(componentName, "~0", "~") - } + componentName = decodeSingleSegmentPointer(componentName) // skip known OpenAPI root-level keys that are not reusable components if isOpenAPIRootKey(componentName) { @@ -322,64 +245,18 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) // preserve original name before collision handling pr.originalName = componentName - if importType, ok := DetectOpenAPIComponentType(pr.ref.Node); ok { - switch importType { - case v3low.SchemasLabel: - if components.Schemas != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Schemas) - pr.location = []string{v3low.ComponentsLabel, v3low.SchemasLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.SchemasLabel, pr, idx, components.Schemas, buildSchema, cf.origins) - } - case v3low.ResponsesLabel: - if components.Responses != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Responses) - pr.location = []string{v3low.ComponentsLabel, v3low.ResponsesLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.ResponsesLabel, pr, idx, components.Responses, buildResponse, cf.origins) - } - case v3low.ParametersLabel: - if components.Parameters != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Parameters) - pr.location = []string{v3low.ComponentsLabel, v3low.ParametersLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.ParametersLabel, pr, idx, components.Parameters, buildParameter, cf.origins) - } - case v3low.HeadersLabel: - if components.Headers != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Headers) - pr.location = []string{v3low.ComponentsLabel, v3low.HeadersLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.HeadersLabel, pr, idx, components.Headers, buildHeader, cf.origins) - } - case v3low.RequestBodiesLabel: - if components.RequestBodies != nil { - pr.name = checkForCollision(componentName, delim, pr, components.RequestBodies) - pr.location = []string{v3low.ComponentsLabel, v3low.RequestBodiesLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.RequestBodiesLabel, pr, idx, components.RequestBodies, buildRequestBody, cf.origins) - } - case v3low.ExamplesLabel: - if components.Examples != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Examples) - pr.location = []string{v3low.ComponentsLabel, v3low.ExamplesLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.ExamplesLabel, pr, idx, components.Examples, buildExample, cf.origins) - } - case v3low.LinksLabel: - if components.Links != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Links) - pr.location = []string{v3low.ComponentsLabel, v3low.LinksLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.LinksLabel, pr, idx, components.Links, buildLink, cf.origins) - } - case v3low.CallbacksLabel: - if components.Callbacks != nil { - pr.name = checkForCollision(componentName, delim, pr, components.Callbacks) - pr.location = []string{v3low.ComponentsLabel, v3low.CallbacksLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.CallbacksLabel, pr, idx, components.Callbacks, buildCallback, cf.origins) - } - case v3low.PathItemsLabel: - if supportsPathItemComponents && components.PathItems != nil { - pr.name = checkForCollision(componentName, delim, pr, components.PathItems) - pr.location = []string{v3low.ComponentsLabel, v3low.PathItemsLabel, pr.name} - return checkReferenceAndCapture(pr.name, delim, v3low.PathItemsLabel, pr, idx, components.PathItems, buildPathItem, cf.origins) - } - cf.inlineRequired = append(cf.inlineRequired, pr) - return nil + importType, ok := inferComponentTypeFromSourcePath(pr.seqRef.SourcePath) + if ok && !canComposeContextualReference(importType, pr.ref.Node, false) { + ok = false + } + if !ok { + importType, ok = DetectOpenAPIComponentType(pr.ref.Node) + } + if ok { + pr.name = componentName + pr.location = []string{v3low.ComponentsLabel, importType, pr.name} + if handled, err := composeReferenceAs(importType, componentName, components, pr, idx, cf); handled || err != nil { + return err } } } @@ -427,22 +304,22 @@ func enqueueDiscriminatorMappingTargets( ref, foundIdx = resolveDiscriminatorMappingTarget(mapping.sourceIdx, refValue) } if ref == nil { - continue // Can't resolve - will be caught later + // Unresolved mappings are validated later. + continue } - // Cache the canonical key and target index NOW, before any mutation can occur. - // This key will be used later in updateDiscriminatorMappingsComposed - // to correctly look up the processedNodes entry. - // The targetIdx may differ from sourceIdx if the ref resolves to a different file. + // Cache the canonical key and target index before bundling mutates refs. mapping.canonicalKey = ref.FullDefinition mapping.targetIdx = foundIdx - // Skip if already in refMap (avoid duplicates) - if cf.refMap.GetOrZero(ref.FullDefinition) != nil { + mapKey := processRefMapKeyForComponent(ref, v3low.SchemasLabel) + + // Skip targets already queued for composition. + if cf.refMap.GetOrZero(mapKey) != nil { continue } - // Derive name from reference - use ref.Name if set, otherwise extract from FullDefinition + // Use ref.Name when available; otherwise derive it from FullDefinition. name := ref.Name if name == "" { name = deriveNameFromFullDefinition(ref.FullDefinition) @@ -452,10 +329,11 @@ func enqueueDiscriminatorMappingTargets( ref: ref, seqRef: ref, idx: foundIdx, + mapKey: mapKey, name: name, - fromDiscriminator: true, // NEW FLAG: prevents inline handling + fromDiscriminator: true, } - cf.refMap.Set(ref.FullDefinition, pr) + cf.refMap.Set(mapKey, pr) } } diff --git a/bundler/bundler_composer_test.go b/bundler/bundler_composer_test.go index 2be6c6441..330aac5f8 100644 --- a/bundler/bundler_composer_test.go +++ b/bundler/bundler_composer_test.go @@ -4,6 +4,7 @@ package bundler import ( + "bytes" "errors" "log/slog" "os" @@ -1852,6 +1853,324 @@ paths: assert.True(t, foundOkResponse, "OkResponse should be added to components") } +func TestBundleBytesComposed_RepeatedDescriptionOnlyExternalResponses(t *testing.T) { + rootSpec := `openapi: 3.1.0 +info: + title: Repeated description-only responses + version: 1.0.0 +paths: + /bookmarks: + post: + operationId: createBookmark + responses: + "201": + $ref: 'responses.yaml#/RecordCreated' + "401": + $ref: 'responses.yaml#/AuthenticationFailed' + "404": + $ref: 'responses.yaml#/NotFound' + /bookmarks/{id}: + get: + operationId: getBookmark + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + "401": + $ref: 'responses.yaml#/AuthenticationFailed' + "404": + $ref: 'responses.yaml#/NotFound' +` + responsesFile := `AuthenticationFailed: + description: Authentication Failure (401) +NotFound: + description: The record does not exist (404) +RecordCreated: + description: Successful creation of a record (201) +` + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "main.yaml"), []byte(rootSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "responses.yaml"), []byte(responsesFile), 0644)) + mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.yaml")) + require.NoError(t, err) + + var logBuf bytes.Buffer + bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + AllowFileReferences: true, + Logger: slog.New(slog.NewTextHandler(&logBuf, nil)), + }, nil) + require.NoError(t, err) + + bundledText := string(bundled) + assert.NotContains(t, logBuf.String(), "unable to compose reference") + assert.NotContains(t, bundledText, "#/AuthenticationFailed") + assert.NotContains(t, bundledText, "#/NotFound") + assert.NotContains(t, bundledText, "responses.yaml") + assert.Contains(t, bundledText, "$ref: '#/components/responses/AuthenticationFailed'") + assert.Contains(t, bundledText, "$ref: '#/components/responses/NotFound'") + assert.Contains(t, bundledText, "$ref: '#/components/responses/RecordCreated'") + + doc, err := libopenapi.NewDocument(bundled) + require.NoError(t, err) + _, buildErr := doc.BuildV3Model() + require.NoError(t, buildErr) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &parsed)) + components := parsed["components"].(map[string]any) + responses := components["responses"].(map[string]any) + assert.Contains(t, responses, "AuthenticationFailed") + assert.Contains(t, responses, "NotFound") + assert.Contains(t, responses, "RecordCreated") +} + +func TestBundleBytesComposed_ContextClassifiesDescriptionOnlySchema(t *testing.T) { + rootSpec := `openapi: 3.1.0 +info: + title: Sparse schema + version: 1.0.0 +paths: + /pets: + get: + operationId: getPets + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: 'schemas.yaml#/SparsePet' +` + schemasFile := `SparsePet: + description: A schema with no structural keywords +` + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "main.yaml"), []byte(rootSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "schemas.yaml"), []byte(schemasFile), 0644)) + mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.yaml")) + require.NoError(t, err) + + bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + AllowFileReferences: true, + }, nil) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &parsed)) + components := parsed["components"].(map[string]any) + assert.Contains(t, components["schemas"].(map[string]any), "SparsePet") + assert.NotContains(t, components, "responses") + assert.Contains(t, string(bundled), "$ref: '#/components/schemas/SparsePet'") +} + +func TestBundleBytesComposed_ContextClassifiesSparseExample(t *testing.T) { + rootSpec := `openapi: 3.1.0 +info: + title: Sparse example + version: 1.0.0 +paths: + /pets: + get: + operationId: getPets + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + examples: + sparse: + $ref: 'examples.yaml#/SparseExample' +` + examplesFile := `SparseExample: + summary: Sparse reusable example +` + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "main.yaml"), []byte(rootSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "examples.yaml"), []byte(examplesFile), 0644)) + mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.yaml")) + require.NoError(t, err) + + bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + AllowFileReferences: true, + }, nil) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &parsed)) + components := parsed["components"].(map[string]any) + assert.Contains(t, components["examples"].(map[string]any), "SparseExample") + assert.Contains(t, string(bundled), "$ref: '#/components/examples/SparseExample'") +} + +func TestBundleBytesComposed_ContextualRefsSameTargetDifferentBuckets(t *testing.T) { + rootSpec := `openapi: 3.1.0 +info: + title: Shared sparse target + version: 1.0.0 +paths: + /as-response: + get: + operationId: getAsResponse + responses: + "200": + $ref: 'common.yaml#/Thing' + /as-schema: + get: + operationId: getAsSchema + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: 'common.yaml#/Thing' +` + commonFile := `Thing: + description: Shared description-only target +` + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "main.yaml"), []byte(rootSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "common.yaml"), []byte(commonFile), 0644)) + mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.yaml")) + require.NoError(t, err) + + bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + AllowFileReferences: true, + }, nil) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &parsed)) + components := parsed["components"].(map[string]any) + require.Contains(t, components["responses"].(map[string]any), "Thing") + require.Contains(t, components["schemas"].(map[string]any), "Thing") + + paths := parsed["paths"].(map[string]any) + getOp := paths["/as-response"].(map[string]any)["get"].(map[string]any) + responseRef := getOp["responses"].(map[string]any)["200"].(map[string]any)["$ref"] + assert.Equal(t, "#/components/responses/Thing", responseRef) + + schemaOp := paths["/as-schema"].(map[string]any)["get"].(map[string]any) + schemaResponse := schemaOp["responses"].(map[string]any)["200"].(map[string]any) + content := schemaResponse["content"].(map[string]any)["application/json"].(map[string]any) + thingRef := content["schema"].(map[string]any)["$ref"] + assert.Equal(t, "#/components/schemas/Thing", thingRef) +} + +func TestBundleBytesComposed_SingularExampleRefPreservesExampleComponent(t *testing.T) { + rootSpec := `openapi: 3.1.0 +info: + title: Singular example ref + version: 1.0.0 +paths: {} +components: + schemas: + Pet: + type: object + example: + $ref: 'examples.yaml#/PetExample' +` + examplesFile := `PetExample: + value: + id: 123 + name: Buster +` + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "main.yaml"), []byte(rootSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "examples.yaml"), []byte(examplesFile), 0644)) + mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.yaml")) + require.NoError(t, err) + + bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + AllowFileReferences: true, + }, nil) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &parsed)) + components := parsed["components"].(map[string]any) + examples := components["examples"].(map[string]any) + require.Contains(t, examples, "PetExample") + assert.NotContains(t, components["schemas"].(map[string]any), "PetExample") + + petExample := examples["PetExample"].(map[string]any) + value := petExample["value"].(map[string]any) + assert.Equal(t, 123, value["id"]) + + pet := components["schemas"].(map[string]any)["Pet"].(map[string]any) + exampleRef := pet["example"].(map[string]any)["$ref"] + assert.Equal(t, "#/components/examples/PetExample", exampleRef) +} + +func TestBundleBytesComposed_UnsupportedRepeatedMediaTypeRefsInlineAll(t *testing.T) { + rootSpec := `openapi: 3.1.0 +info: + title: Repeated media type refs + version: 1.0.0 +paths: + /pets: + get: + operationId: getPets + responses: + "200": + description: OK + content: + application/json: + $ref: 'media.yaml#/Json' + application/problem+json: + $ref: 'media.yaml#/Json' +` + mediaFile := `Json: + schema: + type: object + properties: + id: + type: string +` + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "main.yaml"), []byte(rootSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "media.yaml"), []byte(mediaFile), 0644)) + mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.yaml")) + require.NoError(t, err) + + bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{ + BasePath: tmp, + AllowFileReferences: true, + }, nil) + require.NoError(t, err) + + assert.NotContains(t, string(bundled), "components/mediaTypes") + assert.NotContains(t, string(bundled), "media.yaml") + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(bundled, &parsed)) + paths := parsed["paths"].(map[string]any) + content := paths["/pets"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any) + for _, mediaType := range []string{"application/json", "application/problem+json"} { + entry := content[mediaType].(map[string]any) + assert.NotContains(t, entry, "$ref") + require.Contains(t, entry, "schema") + } +} + // TestBundleBytesComposed_SingleSegmentParameter tests that single-segment JSON pointer // references to parameter objects are properly recomposed to component references. func TestBundleBytesComposed_SingleSegmentParameter(t *testing.T) { diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index f52432fc9..610556cc9 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -23,8 +23,10 @@ import ( "go.yaml.in/yaml/v4" ) +const contextualRefKeySeparator = "\x00" + // extractFragment returns the JSON pointer fragment from a full definition. -// e.g., "file.yaml#/components/schemas/Pet" → "#/components/schemas/Pet" +// e.g., "file.yaml#/components/schemas/Pet" -> "#/components/schemas/Pet" func extractFragment(fullDef string) string { if idx := strings.Index(fullDef, "#/"); idx != -1 { return fullDef[idx:] @@ -32,6 +34,72 @@ func extractFragment(fullDef string) string { return "#/" } +// processRefMapKey scopes ambiguous target refs by the source component bucket. +func processRefMapKey(target, source *index.Reference) string { + fullDefinition := "" + if target != nil { + fullDefinition = target.FullDefinition + } + if fullDefinition == "" && source != nil { + fullDefinition = source.FullDefinition + } + return contextualProcessRefKey(fullDefinition, source) +} + +// processRefMapKeyForComponent scopes a target ref by an already-known component bucket. +func processRefMapKeyForComponent(target *index.Reference, componentType string) string { + if target == nil { + return "" + } + if target.FullDefinition == "" || componentType == "" { + return target.FullDefinition + } + if isExplicitComponentDefinition(target.FullDefinition) { + return target.FullDefinition + } + return target.FullDefinition + contextualRefKeySeparator + componentType +} + +// contextualProcessRefKey scopes ambiguous target refs by source path inference. +func contextualProcessRefKey(fullDefinition string, source *index.Reference) string { + if fullDefinition == "" || source == nil { + return fullDefinition + } + if isExplicitComponentDefinition(fullDefinition) { + return fullDefinition + } + if componentType, ok := inferComponentTypeFromSourcePath(source.SourcePath); ok { + return fullDefinition + contextualRefKeySeparator + componentType + } + return fullDefinition +} + +// isExplicitComponentDefinition reports whether a full definition already names +// an OpenAPI component bucket, such as #/components/schemas/Pet. +func isExplicitComponentDefinition(fullDefinition string) bool { + fragment := extractFragment(fullDefinition) + segments := strings.Split(strings.TrimPrefix(fragment, "#/"), "/") + return len(segments) >= 3 && segments[0] == v3low.ComponentsLabel +} + +// processedRefFor prefers a source-contextual processed ref and falls back to +// the canonical full definition for refs that do not need source scoping. +func processedRefFor( + processedNodes *orderedmap.Map[string, *processRef], + fullDefinition string, + source *index.Reference, +) *processRef { + if processedNodes == nil { + return nil + } + if key := contextualProcessRefKey(fullDefinition, source); key != fullDefinition { + if pr := processedNodes.GetOrZero(key); pr != nil { + return pr + } + } + return processedNodes.GetOrZero(fullDefinition) +} + func calculateCollisionName(name, pointer, delimiter string, iteration int) string { jsonPointer := strings.Split(pointer, "#/") if len(jsonPointer) == 2 { @@ -137,12 +205,162 @@ func checkReferenceAndCapture[T any]( origins ComponentOriginMap, ) error { err := checkReferenceAndBubbleUp(name, delimiter, pr, idx, componentMap, buildFunc) + if err == nil && pr != nil { + pr.location = []string{v3low.ComponentsLabel, componentType, pr.name} + } if err == nil && origins != nil { captureOrigin(pr, componentType, origins) } return err } +func composeReferenceAs( + componentType, name string, + components *v3.Components, + pr *processRef, + idx *index.SpecIndex, + cf *handleIndexConfig, +) (bool, error) { + delimiter := cf.compositionConfig.Delimiter + + switch componentType { + case v3low.SchemasLabel: + if components.Schemas == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.SchemasLabel, pr, idx, components.Schemas, buildSchema, cf.origins) + case v3low.ResponsesLabel: + if components.Responses == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.ResponsesLabel, pr, idx, components.Responses, buildResponse, cf.origins) + case v3low.ParametersLabel: + if components.Parameters == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.ParametersLabel, pr, idx, components.Parameters, buildParameter, cf.origins) + case v3low.HeadersLabel: + if components.Headers == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.HeadersLabel, pr, idx, components.Headers, buildHeader, cf.origins) + case v3low.RequestBodiesLabel: + if components.RequestBodies == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.RequestBodiesLabel, pr, idx, components.RequestBodies, buildRequestBody, cf.origins) + case v3low.ExamplesLabel: + if components.Examples == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.ExamplesLabel, pr, idx, components.Examples, buildExample, cf.origins) + case v3low.LinksLabel: + if components.Links == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.LinksLabel, pr, idx, components.Links, buildLink, cf.origins) + case v3low.CallbacksLabel: + if components.Callbacks == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.CallbacksLabel, pr, idx, components.Callbacks, buildCallback, cf.origins) + case v3low.PathItemsLabel: + if !rootSupportsPathItemComponents(cf.rootIdx) { + cf.inlineRequired = append(cf.inlineRequired, pr) + return true, nil + } + if components.PathItems == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.PathItemsLabel, pr, idx, components.PathItems, buildPathItem, cf.origins) + case v3low.MediaTypesLabel: + if !rootSupportsMediaTypeComponents(cf.rootIdx) { + pr.location = nil + cf.inlineRequired = append(cf.inlineRequired, pr) + return true, nil + } + if components.MediaTypes == nil { + return false, nil + } + return true, checkReferenceAndCapture(name, delimiter, v3low.MediaTypesLabel, pr, idx, components.MediaTypes, buildMediaType, cf.origins) + default: + return false, nil + } +} + +func fileImportLocationForType( + componentType string, + components *v3.Components, + pr *processRef, + cf *handleIndexConfig, +) (bool, []string) { + delimiter := cf.compositionConfig.Delimiter + + switch componentType { + case v3low.SchemasLabel: + if components.Schemas == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.SchemasLabel, delimiter, components.Schemas) + case v3low.ResponsesLabel: + if components.Responses == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.ResponsesLabel, delimiter, components.Responses) + case v3low.ParametersLabel: + if components.Parameters == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.ParametersLabel, delimiter, components.Parameters) + case v3low.HeadersLabel: + if components.Headers == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.HeadersLabel, delimiter, components.Headers) + case v3low.RequestBodiesLabel: + if components.RequestBodies == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.RequestBodiesLabel, delimiter, components.RequestBodies) + case v3low.ExamplesLabel: + if components.Examples == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.ExamplesLabel, delimiter, components.Examples) + case v3low.LinksLabel: + if components.Links == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.LinksLabel, delimiter, components.Links) + case v3low.CallbacksLabel: + if components.Callbacks == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.CallbacksLabel, delimiter, components.Callbacks) + case v3low.PathItemsLabel: + if !rootSupportsPathItemComponents(cf.rootIdx) { + cf.inlineRequired = append(cf.inlineRequired, pr) + return true, nil + } + if components.PathItems == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.PathItemsLabel, delimiter, components.PathItems) + case v3low.MediaTypesLabel: + if !rootSupportsMediaTypeComponents(cf.rootIdx) { + pr.location = nil + cf.inlineRequired = append(cf.inlineRequired, pr) + return true, nil + } + if components.MediaTypes == nil { + return false, nil + } + return true, handleFileImport(pr, v3low.MediaTypesLabel, delimiter, components.MediaTypes) + default: + return false, nil + } +} + func isZeroOfType[T any](v T) bool { isZero := reflect.ValueOf(v).IsZero() return isZero @@ -243,7 +461,7 @@ func remapIndex(idx *index.SpecIndex, processedNodes *orderedmap.Map[string, *pr } // encodeJSONPointerSegment encodes a string for use in a JSON Pointer per RFC 6901. -// The escape sequence is: ~ → ~0, / → ~1 (order matters: ~ must be escaped first). +// The escape sequence is: ~ -> ~0, / -> ~1 (order matters: ~ must be escaped first). func encodeJSONPointerSegment(s string) string { if !strings.ContainsAny(s, "~/") { return s @@ -264,6 +482,15 @@ func joinLocationAsJSONPointer(location []string) string { } func renameRef(idx *index.SpecIndex, def string, processedNodes *orderedmap.Map[string, *processRef]) string { + return renameRefWithSource(idx, def, nil, processedNodes) +} + +func renameRefWithSource( + idx *index.SpecIndex, + def string, + source *index.Reference, + processedNodes *orderedmap.Map[string, *processRef], +) string { if strings.Contains(def, "#/") { defSplit := strings.Split(def, "#/") if len(defSplit) != 2 { @@ -273,7 +500,7 @@ func renameRef(idx *index.SpecIndex, def string, processedNodes *orderedmap.Map[ segs := strings.Split(ptr, "/") if len(segs) < 2 { // check if this single-segment pointer was processed and has a location - if pr := processedNodes.GetOrZero(def); pr != nil && len(pr.location) > 0 { + if pr := processedRefFor(processedNodes, def, source); pr != nil && len(pr.location) > 0 { return "#/" + joinLocationAsJSONPointer(pr.location) } return def @@ -281,7 +508,7 @@ func renameRef(idx *index.SpecIndex, def string, processedNodes *orderedmap.Map[ prefix := strings.Join(segs[:len(segs)-1], "/") // reference already renamed during composition - if pr := processedNodes.GetOrZero(def); pr != nil { + if pr := processedRefFor(processedNodes, def, source); pr != nil { return fmt.Sprintf("#/%s/%s", prefix, encodeJSONPointerSegment(pr.name)) } @@ -296,7 +523,7 @@ func renameRef(idx *index.SpecIndex, def string, processedNodes *orderedmap.Map[ } // root-file import lifted into components - if pn := processedNodes.GetOrZero(def); pn != nil && len(pn.location) > 0 { + if pn := processedRefFor(processedNodes, def, source); pn != nil && len(pn.location) > 0 { return "#/" + joinLocationAsJSONPointer(pn.location) } @@ -307,7 +534,7 @@ func rewireRef(idx *index.SpecIndex, ref *index.Reference, fullDef string, proce isRef, _, _ := utils.IsNodeRefValue(ref.Node) // extract the pr from the processed nodes. - if pr := processedNodes.GetOrZero(fullDef); pr != nil { + if pr := processedRefFor(processedNodes, fullDef, ref); pr != nil { if kk, _, _ := utils.IsNodeRefValue(pr.ref.Node); kk { if pr.refPointer == "" { // Use GetRefValueNode to handle OA 3.1 sibling properties correctly @@ -318,7 +545,7 @@ func rewireRef(idx *index.SpecIndex, ref *index.Reference, fullDef string, proce } } - rename := renameRef(idx, fullDef, processedNodes) + rename := renameRefWithSource(idx, fullDef, ref, processedNodes) if isRef { // Use GetRefValueNode to find the correct $ref value node // This handles OA 3.1 sibling properties where $ref may not be at index 0 @@ -426,6 +653,14 @@ func buildPathItem(node *yaml.Node, idx *index.SpecIndex) (*v3.PathItem, error) return v3.NewPathItem(&pathItem), err } +func buildMediaType(node *yaml.Node, idx *index.SpecIndex) (*v3.MediaType, error) { + mediaType := v3low.MediaType{} + _ = low.BuildModel(node, &mediaType) + ctx := context.Background() + err := mediaType.Build(ctx, &yaml.Node{}, node, idx) + return v3.NewMediaType(&mediaType), err +} + // captureOrigin records origin information for a processed reference. // enables navigation from bundled components back to their source files. func captureOrigin(pr *processRef, componentType string, origins ComponentOriginMap) { diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index acf967660..4b114401a 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -3,11 +3,13 @@ package bundler import ( "os" "path/filepath" + "strings" "testing" "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel" highbase "github.com/pb33f/libopenapi/datamodel/high/base" + highv3 "github.com/pb33f/libopenapi/datamodel/high/v3" v3low "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" @@ -31,6 +33,437 @@ func TestHandleFileImport_StripsFragment(t *testing.T) { assert.Equal(t, "Cat", pr.seqRef.Name) } +func TestComposeReferenceAs_MediaType(t *testing.T) { + components := &highv3.Components{ + MediaTypes: orderedmap.New[string, *highv3.MediaType](), + } + idx := newVersionedIndex(3.2) + cf := &handleIndexConfig{ + rootIdx: idx, + compositionConfig: &BundleCompositionConfig{ + Delimiter: "__", + }, + } + pr := newProcessRefForTest(t, "media", "schema:\n type: object") + + handled, err := composeReferenceAs(v3low.MediaTypesLabel, "json", components, pr, idx, cf) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, []string{v3low.ComponentsLabel, v3low.MediaTypesLabel, "json"}, pr.location) + assert.NotNil(t, components.MediaTypes.GetOrZero("json")) +} + +func TestComposeReferenceAs_UnsupportedMediaTypeComponentInlines(t *testing.T) { + components := &highv3.Components{ + MediaTypes: orderedmap.New[string, *highv3.MediaType](), + } + idx := newVersionedIndex(3.1) + cf := &handleIndexConfig{ + rootIdx: idx, + compositionConfig: &BundleCompositionConfig{ + Delimiter: "__", + }, + } + pr := newProcessRefForTest(t, "media", "schema:\n type: object") + + handled, err := composeReferenceAs(v3low.MediaTypesLabel, "json", components, pr, idx, cf) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, pr.location) + assert.Len(t, cf.inlineRequired, 1) +} + +func TestComposeReferenceAs_MissingComponentMap(t *testing.T) { + idx := newVersionedIndex(3.2) + cf := &handleIndexConfig{ + rootIdx: idx, + compositionConfig: &BundleCompositionConfig{ + Delimiter: "__", + }, + } + pr := newProcessRefForTest(t, "schema", "type: object") + + for _, componentType := range []string{ + v3low.SchemasLabel, + v3low.ResponsesLabel, + v3low.ParametersLabel, + v3low.HeadersLabel, + v3low.RequestBodiesLabel, + v3low.ExamplesLabel, + v3low.LinksLabel, + v3low.CallbacksLabel, + v3low.PathItemsLabel, + v3low.MediaTypesLabel, + "unknown", + } { + t.Run(componentType, func(t *testing.T) { + handled, err := composeReferenceAs(componentType, "missing", &highv3.Components{}, pr, idx, cf) + require.NoError(t, err) + assert.False(t, handled) + }) + } +} + +func TestFileImportLocationForType_MediaType(t *testing.T) { + components := &highv3.Components{ + MediaTypes: orderedmap.New[string, *highv3.MediaType](), + } + cf := &handleIndexConfig{ + rootIdx: newVersionedIndex(3.2), + compositionConfig: &BundleCompositionConfig{ + Delimiter: "__", + }, + } + pr := &processRef{ + ref: &index.Reference{FullDefinition: "/tmp/application-json.yaml"}, + seqRef: &index.Reference{}, + } + + handled, location := fileImportLocationForType(v3low.MediaTypesLabel, components, pr, cf) + assert.True(t, handled) + assert.Equal(t, []string{v3low.ComponentsLabel, v3low.MediaTypesLabel, "application-json"}, location) +} + +func TestFileImportLocationForType_MissingComponentMap(t *testing.T) { + cf := &handleIndexConfig{ + rootIdx: newVersionedIndex(3.2), + compositionConfig: &BundleCompositionConfig{ + Delimiter: "__", + }, + } + pr := &processRef{ + ref: &index.Reference{FullDefinition: "/tmp/missing.yaml"}, + seqRef: &index.Reference{}, + } + + for _, componentType := range []string{ + v3low.SchemasLabel, + v3low.ResponsesLabel, + v3low.ParametersLabel, + v3low.HeadersLabel, + v3low.RequestBodiesLabel, + v3low.ExamplesLabel, + v3low.LinksLabel, + v3low.CallbacksLabel, + v3low.PathItemsLabel, + v3low.MediaTypesLabel, + "unknown", + } { + t.Run(componentType, func(t *testing.T) { + handled, location := fileImportLocationForType(componentType, &highv3.Components{}, pr, cf) + assert.False(t, handled) + assert.Nil(t, location) + }) + } +} + +func TestFileImportLocationForType_UnsupportedComponentVersionsInline(t *testing.T) { + tests := []struct { + name string + version float32 + componentType string + components *highv3.Components + }{ + { + name: "path items before openapi 3.1", + version: 3.0, + componentType: v3low.PathItemsLabel, + components: &highv3.Components{ + PathItems: orderedmap.New[string, *highv3.PathItem](), + }, + }, + { + name: "media types before openapi 3.2", + version: 3.1, + componentType: v3low.MediaTypesLabel, + components: &highv3.Components{ + MediaTypes: orderedmap.New[string, *highv3.MediaType](), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cf := &handleIndexConfig{ + rootIdx: newVersionedIndex(tt.version), + compositionConfig: &BundleCompositionConfig{ + Delimiter: "__", + }, + } + pr := &processRef{ + ref: &index.Reference{FullDefinition: "/tmp/item.yaml"}, + seqRef: &index.Reference{}, + } + + handled, location := fileImportLocationForType(tt.componentType, tt.components, pr, cf) + assert.True(t, handled) + assert.Nil(t, location) + assert.Nil(t, pr.location) + assert.Len(t, cf.inlineRequired, 1) + }) + } +} + +func TestRootSupportsMediaTypeComponents(t *testing.T) { + assert.True(t, rootSupportsMediaTypeComponents(nil)) + assert.False(t, rootSupportsMediaTypeComponents(newVersionedIndex(3.1))) + assert.True(t, rootSupportsMediaTypeComponents(newVersionedIndex(3.2))) +} + +func TestProcessRefMapKeys(t *testing.T) { + target := &index.Reference{FullDefinition: "/tmp/common.yaml#/Thing"} + responseSource := &index.Reference{ + SourcePath: []string{"paths", "/pets", "get", "responses", "200"}, + } + schemaSource := &index.Reference{ + FullDefinition: target.FullDefinition, + SourcePath: []string{"paths", "/pets", "get", "responses", "200", "content", "application/json", "schema"}, + } + unknownSource := &index.Reference{ + SourcePath: []string{"x-private", "thing"}, + } + explicitComponentTarget := &index.Reference{ + FullDefinition: "/tmp/common.yaml#/components/schemas/Pet", + } + + assert.Empty(t, processRefMapKey(nil, nil)) + assert.Equal(t, target.FullDefinition, processRefMapKey(target, nil)) + assert.Equal(t, target.FullDefinition+contextualRefKeySeparator+v3low.ResponsesLabel, processRefMapKey(target, responseSource)) + assert.Equal(t, target.FullDefinition+contextualRefKeySeparator+v3low.SchemasLabel, processRefMapKey(&index.Reference{}, schemaSource)) + assert.Equal(t, target.FullDefinition, processRefMapKey(target, unknownSource)) + assert.Equal(t, explicitComponentTarget.FullDefinition, processRefMapKey(explicitComponentTarget, responseSource)) + + assert.Empty(t, processRefMapKeyForComponent(nil, v3low.SchemasLabel)) + assert.Empty(t, processRefMapKeyForComponent(&index.Reference{}, v3low.SchemasLabel)) + assert.Equal(t, target.FullDefinition, processRefMapKeyForComponent(target, "")) + assert.Equal(t, explicitComponentTarget.FullDefinition, processRefMapKeyForComponent(explicitComponentTarget, v3low.ResponsesLabel)) + assert.Equal(t, target.FullDefinition+contextualRefKeySeparator+v3low.SchemasLabel, processRefMapKeyForComponent(target, v3low.SchemasLabel)) + + assert.Empty(t, contextualProcessRefKey("", responseSource)) + assert.Equal(t, target.FullDefinition, contextualProcessRefKey(target.FullDefinition, nil)) + assert.Equal(t, explicitComponentTarget.FullDefinition, contextualProcessRefKey(explicitComponentTarget.FullDefinition, responseSource)) + assert.Equal(t, target.FullDefinition+contextualRefKeySeparator+v3low.ResponsesLabel, contextualProcessRefKey(target.FullDefinition, responseSource)) + assert.Equal(t, target.FullDefinition, contextualProcessRefKey(target.FullDefinition, unknownSource)) +} + +func TestProcessedRefFor(t *testing.T) { + fullDefinition := "/tmp/common.yaml#/Thing" + responseSource := &index.Reference{ + SourcePath: []string{"paths", "/pets", "get", "responses", "200"}, + } + contextualKey := fullDefinition + contextualRefKeySeparator + v3low.ResponsesLabel + + assert.Nil(t, processedRefFor(nil, fullDefinition, responseSource)) + + processedNodes := orderedmap.New[string, *processRef]() + fallbackRef := &processRef{name: "fallback"} + contextualRef := &processRef{name: "contextual"} + + processedNodes.Set(fullDefinition, fallbackRef) + assert.Same(t, fallbackRef, processedRefFor(processedNodes, fullDefinition, responseSource)) + + processedNodes.Set(contextualKey, contextualRef) + assert.Same(t, contextualRef, processedRefFor(processedNodes, fullDefinition, responseSource)) + assert.Nil(t, processedRefFor(processedNodes, "/tmp/missing.yaml#/Thing", nil)) +} + +func TestInlineProcessRef(t *testing.T) { + assert.Nil(t, inlineProcessRef(nil)) + + seqNode := testYAMLContentNode(t, "$ref: old.yaml\n") + assert.Nil(t, inlineProcessRef(&processRef{ + ref: &index.Reference{FullDefinition: "/tmp/missing.yaml"}, + seqRef: &index.Reference{Node: seqNode}, + })) + + replacement := testYAMLContentNode(t, "description: inlined\n") + directNode := testYAMLContentNode(t, "$ref: direct.yaml\n") + directRef := &processRef{ + ref: &index.Reference{ + FullDefinition: "/tmp/direct.yaml", + Node: replacement, + }, + seqRef: &index.Reference{Node: directNode}, + } + assert.Same(t, replacement, inlineProcessRef(directRef)) + assert.Equal(t, replacement.Content, directNode.Content) + + missingPointerNode := testYAMLContentNode(t, "$ref: pointer.yaml\n") + assert.Nil(t, inlineProcessRef(&processRef{ + idx: newVersionedIndex(3.1), + refPointer: "missing.yaml#/components/schemas/Missing", + ref: &index.Reference{FullDefinition: "/tmp/pointer.yaml", Node: replacement}, + seqRef: &index.Reference{Node: missingPointerNode}, + })) + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, "root.yaml") + rootSource := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {} +components: + schemas: + Pet: + type: object +`) + require.NoError(t, os.WriteFile(rootPath, rootSource, 0644)) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal(rootSource, &root)) + + cfg := index.CreateOpenAPIIndexConfig() + cfg.BasePath = tmpDir + cfg.SpecAbsolutePath = rootPath + idx := index.NewSpecIndexWithConfig(&root, cfg) + + pointerNode := testYAMLContentNode(t, "$ref: pointer.yaml\n") + pointerRef := &processRef{ + idx: idx, + refPointer: rootPath + "#/components/schemas/Pet", + ref: &index.Reference{FullDefinition: "/tmp/pointer.yaml", Node: replacement}, + seqRef: &index.Reference{Node: pointerNode}, + } + inlined := inlineProcessRef(pointerRef) + require.NotNil(t, inlined) + assert.Equal(t, inlined.Content, pointerNode.Content) +} + +func TestInlineRequiredRefsCopiesMatchingOccurrences(t *testing.T) { + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, "root.yaml") + rootSource := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /as-response: + get: + responses: + '200': + $ref: 'target.yaml#/Thing' + /as-schema: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'target.yaml#/Thing' + /other: + get: + responses: + '200': + $ref: 'other.yaml#/Thing' +x-extension: + $ref: 'target.yaml#/Thing' +`) + require.NoError(t, os.WriteFile(rootPath, rootSource, 0644)) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal(rootSource, &root)) + + cfg := index.CreateOpenAPIIndexConfig() + cfg.BasePath = tmpDir + cfg.SpecAbsolutePath = rootPath + idx := index.NewSpecIndexWithConfig(&root, cfg) + + responseRef := findRawRefByComponentType(t, idx, v3low.ResponsesLabel, "target.yaml#/Thing") + schemaRef := findRawRefByComponentType(t, idx, v3low.SchemasLabel, "target.yaml#/Thing") + require.Equal(t, responseRef.FullDefinition, schemaRef.FullDefinition) + + rolodex := index.NewRolodex(cfg) + rolodex.SetRootIndex(idx) + rolodex.AddIndex(idx) + assert.Empty(t, inlineRequiredRefs(nil, rolodex)) + assert.Empty(t, sequencedRefsByFullDefinition(nil)) + + refsByDefinition := sequencedRefsByFullDefinition(rolodex) + assert.Len(t, refsByDefinition[responseRef.FullDefinition], 2) + + replacement := testYAMLContentNode(t, "description: inlined\n") + inlineMatchingRefs(nil, nil, nil) + inlineMatchingRefs(&processRef{ + ref: &index.Reference{FullDefinition: responseRef.FullDefinition}, + }, replacement, nil) + inlineMatchingRefs(&processRef{ + ref: &index.Reference{FullDefinition: responseRef.FullDefinition}, + seqRef: responseRef, + }, replacement, sequencedRefsByFullDefinition(index.NewRolodex(cfg))) + + inlinedPaths := inlineRequiredRefs([]*processRef{ + nil, + { + ref: &index.Reference{ + FullDefinition: responseRef.FullDefinition, + Node: replacement, + }, + seqRef: responseRef, + mapKey: contextualProcessRefKey(responseRef.FullDefinition, responseRef), + }, + }, rolodex) + + assert.Same(t, replacement, inlinedPaths[responseRef.FullDefinition]) + assert.Equal(t, replacement.Content, responseRef.Node.Content) + assert.NotEqual(t, replacement.Content, schemaRef.Node.Content) +} + +func newVersionedIndex(version float32) *index.SpecIndex { + var root yaml.Node + _ = yaml.Unmarshal([]byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {}`), &root) + + cfg := index.CreateClosedAPIIndexConfig() + cfg.SpecInfo = &datamodel.SpecInfo{VersionNumeric: version} + idx := index.NewSpecIndexWithConfig(&root, cfg) + idx.GetConfig().SpecInfo.VersionNumeric = version + return idx +} + +func testYAMLContentNode(t *testing.T, source string) *yaml.Node { + t.Helper() + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(source), &root)) + return unwrapDocumentNode(&root) +} + +func findRawRefByComponentType(t *testing.T, idx *index.SpecIndex, componentType, refSuffix string) *index.Reference { + t.Helper() + + for _, ref := range idx.GetRawReferencesSequenced() { + if ref == nil || !strings.HasSuffix(ref.FullDefinition, refSuffix) { + continue + } + if inferred, ok := inferComponentTypeFromSourcePath(ref.SourcePath); ok && inferred == componentType { + return ref + } + } + t.Fatalf("expected raw %s ref ending in %q", componentType, refSuffix) + return nil +} + +func newProcessRefForTest(t *testing.T, name, source string) *processRef { + t.Helper() + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(source), &root)) + node := unwrapDocumentNode(&root) + return &processRef{ + ref: &index.Reference{ + Name: name, + FullDefinition: "/tmp/" + name + ".yaml", + Node: node, + }, + seqRef: &index.Reference{}, + } +} + func TestWalkAndRewriteRefs_NilNode(t *testing.T) { require.NotPanics(t, func() { walkAndRewriteRefs(nil, nil, nil, nil, false) diff --git a/bundler/source_context.go b/bundler/source_context.go new file mode 100644 index 000000000..a6b00c15d --- /dev/null +++ b/bundler/source_context.go @@ -0,0 +1,196 @@ +// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package bundler + +import ( + "strings" + + v3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "go.yaml.in/yaml/v4" +) + +// inferComponentTypeFromSourcePath returns the component bucket implied by the +// OpenAPI slot that contains a $ref. It is deliberately context-based: sparse +// but valid targets, such as description-only responses or empty schemas, cannot +// always be classified from their own shape. +func inferComponentTypeFromSourcePath(sourcePath []string) (string, bool) { + if len(sourcePath) == 0 { + return "", false + } + + for i := len(sourcePath) - 1; i >= 0; i-- { + segment := sourcePath[i] + previous := "" + if i > 0 { + previous = sourcePath[i-1] + } + + if isSingularExampleSourceSegment(sourcePath, i) { + return v3.ExamplesLabel, true + } + + if isSchemaSourceSegment(sourcePath, i) { + return v3.SchemasLabel, true + } + + switch previous { + case v3.ResponsesLabel: + return v3.ResponsesLabel, true + case v3.ParametersLabel: + return v3.ParametersLabel, true + case v3.HeadersLabel: + return v3.HeadersLabel, true + case v3.ExamplesLabel: + return v3.ExamplesLabel, true + case v3.LinksLabel: + return v3.LinksLabel, true + case v3.CallbacksLabel: + if i == len(sourcePath)-1 { + return v3.CallbacksLabel, true + } + case v3.PathItemsLabel: + return v3.PathItemsLabel, true + case v3.MediaTypesLabel: + return v3.MediaTypesLabel, true + case v3.ContentLabel: + return v3.MediaTypesLabel, true + } + + if segment == v3.RequestBodyLabel { + return v3.RequestBodiesLabel, true + } + } + + if pathContains(sourcePath, v3.CallbacksLabel) { + return v3.PathItemsLabel, true + } + if len(sourcePath) == 2 && (sourcePath[0] == v3.PathsLabel || sourcePath[0] == v3.WebhooksLabel) { + return v3.PathItemsLabel, true + } + if len(sourcePath) > 1 && sourcePath[0] == v3.ComponentsLabel && sourcePath[1] == v3.SchemasLabel { + return v3.SchemasLabel, true + } + return "", false +} + +// canComposeContextualReference reports whether a source-slot inference is safe +// for the referenced node. JSON Pointer refs already identify a specific node, +// so source context can classify sparse but valid targets. Bare-file refs need a +// stronger guard because the file may be a wrapper map or full OpenAPI document. +func canComposeContextualReference(componentType string, node *yaml.Node, bareFile bool) bool { + node = unwrapDocumentNode(node) + if node == nil || isOpenAPIDocumentNode(node) { + return false + } + if !bareFile { + return true + } + + if detectedType, ok := DetectOpenAPIComponentType(node); ok { + if detectedType == componentType { + return true + } + // Media Type and Header objects both use schema/content-shaped fields. + // In a media type slot, the source path breaks that tie. + if componentType != v3.MediaTypesLabel { + return false + } + } + + keys := getNodeKeys(node) + if len(keys) == 0 { + return componentType == v3.SchemasLabel || componentType == v3.MediaTypesLabel + } + + switch componentType { + case v3.ResponsesLabel: + return containsKey(keys, v3.DescriptionLabel) + case v3.SchemasLabel: + return containsKey(keys, v3.DescriptionLabel) || + containsKey(keys, v3.TitleLabel) || + containsKey(keys, v3.JSONSchemaLabel) || + containsKey(keys, v3.SchemaDialectLabel) + case v3.ExamplesLabel: + return containsKey(keys, v3.SummaryLabel) || containsKey(keys, v3.DescriptionLabel) + case v3.HeadersLabel, v3.LinksLabel, v3.PathItemsLabel: + return containsKey(keys, v3.SummaryLabel) || containsKey(keys, v3.DescriptionLabel) + case v3.MediaTypesLabel: + return containsKey(keys, v3.SchemaLabel) || + containsKey(keys, v3.ExampleLabel) || + containsKey(keys, v3.ExamplesLabel) || + containsKey(keys, v3.EncodingLabel) + default: + return false + } +} + +func unwrapDocumentNode(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func isOpenAPIDocumentNode(node *yaml.Node) bool { + keys := getNodeKeys(node) + return containsKey(keys, v3.OpenAPILabel) || + containsKey(keys, v3.SwaggerLabel) || + (containsKey(keys, v3.InfoLabel) && containsKey(keys, v3.PathsLabel)) +} + +// isSingularExampleSourceSegment reports whether sourcePath[index] is the +// OpenAPI example keyword, excluding schema properties named "example". +func isSingularExampleSourceSegment(sourcePath []string, index int) bool { + if index < 0 || index >= len(sourcePath) || sourcePath[index] != v3.ExampleLabel { + return false + } + if index == 0 { + return true + } + switch sourcePath[index-1] { + case "properties", "patternProperties": + return false + default: + return true + } +} + +func isSchemaSourceSegment(sourcePath []string, index int) bool { + segment := sourcePath[index] + previous := "" + if index > 0 { + previous = sourcePath[index-1] + } + + switch segment { + case "schema", "items", "additionalProperties", "unevaluatedItems", "unevaluatedProperties", + "contains", "not", "if", "then", "else", "propertyNames": + return true + } + + switch previous { + case v3.SchemasLabel, "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", + "allOf", "anyOf", "oneOf", "prefixItems": + return true + } + + return false +} + +func pathContains(path []string, needle string) bool { + for _, segment := range path { + if segment == needle { + return true + } + } + return false +} + +func decodeSingleSegmentPointer(segment string) string { + if strings.Contains(segment, "~") { + segment = strings.ReplaceAll(segment, "~1", "/") + segment = strings.ReplaceAll(segment, "~0", "~") + } + return segment +} diff --git a/bundler/source_context_test.go b/bundler/source_context_test.go new file mode 100644 index 000000000..6bde245b5 --- /dev/null +++ b/bundler/source_context_test.go @@ -0,0 +1,273 @@ +// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package bundler + +import ( + "testing" + + v3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestInferComponentTypeFromSourcePath(t *testing.T) { + tests := []struct { + name string + sourcePath []string + wantType string + wantOK bool + }{ + { + name: "empty path", + sourcePath: nil, + wantOK: false, + }, + { + name: "operation response", + sourcePath: []string{"paths", "/pets", "get", "responses", "200"}, + wantType: v3.ResponsesLabel, + wantOK: true, + }, + { + name: "response content schema", + sourcePath: []string{"paths", "/pets", "get", "responses", "200", "content", "application/json", "schema"}, + wantType: v3.SchemasLabel, + wantOK: true, + }, + { + name: "response media type", + sourcePath: []string{"paths", "/pets", "get", "responses", "200", "content", "application/json"}, + wantType: v3.MediaTypesLabel, + wantOK: true, + }, + { + name: "operation parameter", + sourcePath: []string{"paths", "/pets", "get", "parameters", "0"}, + wantType: v3.ParametersLabel, + wantOK: true, + }, + { + name: "operation request body", + sourcePath: []string{"paths", "/pets", "post", "requestBody"}, + wantType: v3.RequestBodiesLabel, + wantOK: true, + }, + { + name: "response header", + sourcePath: []string{"paths", "/pets", "get", "responses", "200", "headers", "X-Rate-Limit"}, + wantType: v3.HeadersLabel, + wantOK: true, + }, + { + name: "media type example", + sourcePath: []string{"paths", "/pets", "get", "responses", "200", "content", "application/json", "examples", "sample"}, + wantType: v3.ExamplesLabel, + wantOK: true, + }, + { + name: "singular example wrapper under schema", + sourcePath: []string{"components", "schemas", "Pet", "example"}, + wantType: v3.ExamplesLabel, + wantOK: true, + }, + { + name: "schema property named example", + sourcePath: []string{"components", "schemas", "Pet", "properties", "example"}, + wantType: v3.SchemasLabel, + wantOK: true, + }, + { + name: "response link", + sourcePath: []string{"paths", "/pets", "get", "responses", "200", "links", "next"}, + wantType: v3.LinksLabel, + wantOK: true, + }, + { + name: "operation callback", + sourcePath: []string{"paths", "/pets", "post", "callbacks", "created"}, + wantType: v3.CallbacksLabel, + wantOK: true, + }, + { + name: "callback path item", + sourcePath: []string{"paths", "/pets", "post", "callbacks", "created", "{$request.body#/url}"}, + wantType: v3.PathItemsLabel, + wantOK: true, + }, + { + name: "path item", + sourcePath: []string{"paths", "/pets"}, + wantType: v3.PathItemsLabel, + wantOK: true, + }, + { + name: "path item component", + sourcePath: []string{"components", "pathItems", "Pet"}, + wantType: v3.PathItemsLabel, + wantOK: true, + }, + { + name: "webhook path item", + sourcePath: []string{"webhooks", "petCreated"}, + wantType: v3.PathItemsLabel, + wantOK: true, + }, + { + name: "schema property", + sourcePath: []string{"components", "schemas", "Pet", "properties", "owner"}, + wantType: v3.SchemasLabel, + wantOK: true, + }, + { + name: "components schema bucket", + sourcePath: []string{"components", "schemas"}, + wantType: v3.SchemasLabel, + wantOK: true, + }, + { + name: "media type component", + sourcePath: []string{"components", "mediaTypes", "json"}, + wantType: v3.MediaTypesLabel, + wantOK: true, + }, + { + name: "unknown path", + sourcePath: []string{"x-private", "thing"}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotType, gotOK := inferComponentTypeFromSourcePath(tt.sourcePath) + assert.Equal(t, tt.wantOK, gotOK) + assert.Equal(t, tt.wantType, gotType) + }) + } +} + +func TestIsSingularExampleSourceSegment(t *testing.T) { + sourcePath := []string{"components", "schemas", "Pet", "example"} + + assert.False(t, isSingularExampleSourceSegment(sourcePath, -1)) + assert.False(t, isSingularExampleSourceSegment(sourcePath, len(sourcePath))) + assert.False(t, isSingularExampleSourceSegment(sourcePath, 2)) + assert.False(t, isSingularExampleSourceSegment([]string{"components", "schemas", "Pet", "properties", "example"}, 4)) + assert.True(t, isSingularExampleSourceSegment([]string{"example"}, 0)) + assert.True(t, isSingularExampleSourceSegment(sourcePath, 3)) +} + +func TestDecodeSingleSegmentPointer(t *testing.T) { + assert.Equal(t, "plain", decodeSingleSegmentPointer("plain")) + assert.Equal(t, "one/two~three", decodeSingleSegmentPointer("one~1two~0three")) +} + +func TestCanComposeContextualReference(t *testing.T) { + tests := []struct { + name string + componentType string + source string + bareFile bool + want bool + }{ + { + name: "pointer response can be sparse", + componentType: v3.ResponsesLabel, + source: "description: Authentication failed", + want: true, + }, + { + name: "bare file response can be description only", + componentType: v3.ResponsesLabel, + source: "description: Authentication failed", + bareFile: true, + want: true, + }, + { + name: "bare file detected schema must match requested type", + componentType: v3.ResponsesLabel, + source: "type: object", + bareFile: true, + want: false, + }, + { + name: "bare file schema rejects wrapper map", + componentType: v3.SchemasLabel, + source: "NonRequired:\n type: object\n", + bareFile: true, + want: false, + }, + { + name: "bare file schema rejects OpenAPI document", + componentType: v3.SchemasLabel, + source: "openapi: 3.1.0\ninfo:\n title: External\n version: 1.0.0\npaths: {}\n", + bareFile: true, + want: false, + }, + { + name: "bare file schema accepts description annotation", + componentType: v3.SchemasLabel, + source: "description: Sparse schema", + bareFile: true, + want: true, + }, + { + name: "bare file example accepts summary only", + componentType: v3.ExamplesLabel, + source: "summary: Small example", + bareFile: true, + want: true, + }, + { + name: "bare file header accepts description only", + componentType: v3.HeadersLabel, + source: "description: Header context", + bareFile: true, + want: true, + }, + { + name: "bare file media type accepts empty map", + componentType: v3.MediaTypesLabel, + source: "{}", + bareFile: true, + want: true, + }, + { + name: "bare file media type accepts schema key", + componentType: v3.MediaTypesLabel, + source: "schema:\n type: string\n", + bareFile: true, + want: true, + }, + { + name: "bare file empty response is not enough", + componentType: v3.ResponsesLabel, + source: "{}", + bareFile: true, + want: false, + }, + { + name: "unknown component type is not composed", + componentType: "securitySchemes", + source: "description: Sparse security", + bareFile: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(tt.source), &node)) + + got := canComposeContextualReference(tt.componentType, &node, tt.bareFile) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCanComposeContextualReference_NilNode(t *testing.T) { + assert.False(t, canComposeContextualReference(v3.ResponsesLabel, nil, true)) +} diff --git a/bundler/test/specs/bundled.yaml b/bundler/test/specs/bundled.yaml index b42cc0671..f51e59aa9 100644 --- a/bundler/test/specs/bundled.yaml +++ b/bundler/test/specs/bundled.yaml @@ -18,7 +18,7 @@ paths: $ref: "#/components/requestBodies/testBody" responses: "403": - description: could be meat, could be cake. only option is to inline. + $ref: "#/components/responses/unknown" "404": description: another test content: @@ -202,6 +202,8 @@ components: errorCode: ErrOperationForbidden requestId: "x837ant-000007" message: Forbidden + unknown: + description: could be meat, could be cake. only option is to inline. parameters: query: description: Query param diff --git a/bundler/test/specs/main.yaml b/bundler/test/specs/main.yaml index f9cd2c9ea..967b49d8d 100644 --- a/bundler/test/specs/main.yaml +++ b/bundler/test/specs/main.yaml @@ -20,7 +20,7 @@ paths: $ref: "common.yaml#/components/requestBodies/testBody" responses: 403: - # this can only be inlined, there is no way to know what type of object this is. + # sparse response target classified from its source slot. $ref: "clash/unknown.yaml" 404: description: another test @@ -83,4 +83,3 @@ paths: application/json: schema: $ref: "paging.yaml#/components/schemas/paging" - diff --git a/index/extract_refs_ref.go b/index/extract_refs_ref.go index 850c120e4..2aeef7f0d 100644 --- a/index/extract_refs_ref.go +++ b/index/extract_refs_ref.go @@ -80,6 +80,7 @@ func (index *SpecIndex) extractReferenceAt( Node: node, KeyNode: node.Content[keyIndex+1], Path: path, + SourcePath: append([]string(nil), seenPath...), Index: index, IsExtensionRef: isExtensionPath, HasSiblingProperties: len(siblingProps) > 0, @@ -334,6 +335,7 @@ func (index *SpecIndex) storeReferenceWithSiblings( Node: &copiedNode, KeyNode: node.Content[keyIndex], Path: path, + SourcePath: append([]string(nil), ref.SourcePath...), Index: index, IsExtensionRef: isExtensionPath, HasSiblingProperties: len(siblingProps) > 0, diff --git a/index/extract_refs_test.go b/index/extract_refs_test.go index 3df0df9e0..9c9e7b97b 100644 --- a/index/extract_refs_test.go +++ b/index/extract_refs_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" ) @@ -525,6 +526,42 @@ components: assert.False(t, normalRef.IsExtensionRef, "Normal ref should NOT be marked as IsExtensionRef") } +func TestSpecIndex_ExtractRefs_CapturesSourcePath(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test + version: "1.0" +paths: + /test: + get: + responses: + "200": + $ref: '#/components/responses/OK' +components: + responses: + OK: + description: OK` + + var rootNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &rootNode)) + + c := CreateClosedAPIIndexConfig() + c.AvoidCircularReferenceCheck = true + + idx := NewSpecIndexWithConfig(&rootNode, c) + + var responseRef *Reference + for _, ref := range idx.GetRawReferencesSequenced() { + if ref.RawRef == "#/components/responses/OK" { + responseRef = ref + break + } + } + + require.NotNil(t, responseRef) + assert.Equal(t, []string{"paths", "~1test", "get", "responses", "200"}, responseRef.SourcePath) +} + func TestSpecIndex_GetExtensionRefsSequenced(t *testing.T) { yml := `openapi: 3.1.0 info: diff --git a/index/find_component_build.go b/index/find_component_build.go index 5c027c28f..7c537d2a1 100644 --- a/index/find_component_build.go +++ b/index/find_component_build.go @@ -47,6 +47,7 @@ func buildResolvedComponentReference( HasSiblingProperties: source.HasSiblingProperties, In: source.In, } + ref.SourcePath = append([]string(nil), source.SourcePath...) ref.ParentNodeTypes = append([]string(nil), source.ParentNodeTypes...) ref.SiblingKeys = append([]*yaml.Node(nil), source.SiblingKeys...) ref.SiblingProperties = cloneSiblingProperties(source.SiblingProperties) diff --git a/index/index_model.go b/index/index_model.go index 46a5c386f..32eb02905 100644 --- a/index/index_model.go +++ b/index/index_model.go @@ -40,6 +40,7 @@ type Reference struct { Index *SpecIndex `json:"-"` // index that contains this reference. RemoteLocation string `json:"remoteLocation,omitempty"` Path string `json:"path,omitempty"` // this won't always be available. + SourcePath []string `json:"-"` // OpenAPI path to the source $ref location. RequiredRefProperties map[string][]string `json:"requiredProperties,omitempty"` // definition names (eg, #/definitions/One) to a list of required properties on this definition which reference that definition HasSiblingProperties bool `json:"-"` // indicates if ref has sibling properties SiblingProperties map[string]*yaml.Node `json:"-"` // stores sibling property nodes diff --git a/index/resolver_relatives.go b/index/resolver_relatives.go index ca33a60d9..4fee92f3a 100644 --- a/index/resolver_relatives.go +++ b/index/resolver_relatives.go @@ -144,6 +144,7 @@ func (resolver *Resolver) extractRelativeReference( RemoteLocation: ref.RemoteLocation, IsRemote: true, Index: ref.Index, + SourcePath: append([]string(nil), ref.SourcePath...), } locatedRef, _, _ := resolver.searchReferenceWithContext(ref, searchRef) From 0683fefa765bd9f69afbfc535ca1747e43b1dc15 Mon Sep 17 00:00:00 2001 From: quobix Date: Thu, 21 May 2026 17:58:22 -0700 Subject: [PATCH 2/3] coverage updates --- bundler/bundler_composer_test.go | 26 ++++++++++++++++++++++++++ bundler/composer_functions_test.go | 6 ++++++ 2 files changed, 32 insertions(+) diff --git a/bundler/bundler_composer_test.go b/bundler/bundler_composer_test.go index 330aac5f8..3b10d81db 100644 --- a/bundler/bundler_composer_test.go +++ b/bundler/bundler_composer_test.go @@ -61,6 +61,32 @@ func TestBundlerComposed(t *testing.T) { assert.Error(t, err) } +func TestProcessReference_ContextualSingleSegmentRejectsUnsafeNode(t *testing.T) { + model := &v3.Document{ + Components: &v3.Components{}, + } + idx := newVersionedIndex(3.1) + cf := &handleIndexConfig{ + idx: idx, + rootIdx: idx, + inlineRequired: nil, + compositionConfig: &BundleCompositionConfig{Delimiter: "__"}, + } + pr := &processRef{ + idx: idx, + ref: &index.Reference{ + FullDefinition: "/tmp/common.yaml#/Thing", + }, + seqRef: &index.Reference{ + SourcePath: []string{"paths", "/pets", "get", "responses", "200"}, + }, + } + + require.NoError(t, processReference(model, pr, cf)) + require.Len(t, cf.inlineRequired, 1) + assert.Same(t, pr, cf.inlineRequired[0]) +} + 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_test.go b/bundler/composer_functions_test.go index 4b114401a..cc450c8a7 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -210,6 +210,12 @@ func TestRootSupportsMediaTypeComponents(t *testing.T) { assert.True(t, rootSupportsMediaTypeComponents(newVersionedIndex(3.2))) } +func TestRootSupportsPathItemComponents(t *testing.T) { + assert.True(t, rootSupportsPathItemComponents(nil)) + assert.False(t, rootSupportsPathItemComponents(newVersionedIndex(3.0))) + assert.True(t, rootSupportsPathItemComponents(newVersionedIndex(3.1))) +} + func TestProcessRefMapKeys(t *testing.T) { target := &index.Reference{FullDefinition: "/tmp/common.yaml#/Thing"} responseSource := &index.Reference{ From 04d3abc48302a71e2c8ce8320247199b560ba101 Mon Sep 17 00:00:00 2001 From: quobix Date: Thu, 21 May 2026 18:20:24 -0700 Subject: [PATCH 3/3] test(bundler): cover absolute inline ref rewrite --- bundler/bundler.go | 76 ++++++++++++++---------------- bundler/composer_functions_test.go | 75 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 40 deletions(-) diff --git a/bundler/bundler.go b/bundler/bundler.go index ff3933246..6000c46bb 100644 --- a/bundler/bundler.go +++ b/bundler/bundler.go @@ -577,26 +577,7 @@ func composeWithOrigins(model *v3.Document, compositionConfig *BundleComposition rewriteAllRefs(idx, processedNodes, rolodex) } - // Fix any remaining absolute path references that match inlined content - // Also check the root index - allIndexes := append(allLoadedIndexes, rolodex.GetRootIndex()) - for _, idx := range allIndexes { - for _, seqRef := range idx.GetRawReferencesSequenced() { - if isRef, _, refVal := utils.IsNodeRefValue(seqRef.Node); isRef { - // Check if this is an absolute path that should have been inlined - if filepath.IsAbs(refVal) { - // Try to find matching inlined content - for inlinedPath, inlinedNode := range inlinedPaths { - // Match if paths are the same or if they refer to the same file - if refVal == inlinedPath { - seqRef.Node.Content = inlinedNode.Content - break - } - } - } - } - } - } + rewriteInlinedAbsoluteRefs(rolodex, allLoadedIndexes, inlinedPaths) b, err := renderBundledModel(model, rootIndex) errs = append(errs, err) @@ -707,26 +688,7 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] rewriteAllRefs(idx, processedNodes, rolodex) } - // Fix any remaining absolute path references that match inlined content - // Also check the root index - allIndexes := append(allLoadedIndexes, rolodex.GetRootIndex()) - for _, idx := range allIndexes { - for _, seqRef := range idx.GetRawReferencesSequenced() { - if isRef, _, refVal := utils.IsNodeRefValue(seqRef.Node); isRef { - // Check if this is an absolute path that should have been inlined - if filepath.IsAbs(refVal) { - // Try to find matching inlined content - for inlinedPath, inlinedNode := range inlinedPaths { - // Match if paths are the same or if they refer to the same file - if refVal == inlinedPath { - seqRef.Node.Content = inlinedNode.Content - break - } - } - } - } - } - } + rewriteInlinedAbsoluteRefs(rolodex, allLoadedIndexes, inlinedPaths) b, err := renderBundledModel(model, rootIndex) errs = append(errs, err) @@ -734,6 +696,40 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([] return b, errors.Join(errs...) } +// rewriteInlinedAbsoluteRefs updates absolute $ref values that were resolved by +// the inline fallback after the index's normal rewrite pass has already run. +func rewriteInlinedAbsoluteRefs(rolodex *index.Rolodex, indexes []*index.SpecIndex, inlinedPaths map[string]*yaml.Node) { + if rolodex == nil || len(inlinedPaths) == 0 { + return + } + + allIndexes := append([]*index.SpecIndex{}, indexes...) + allIndexes = append(allIndexes, rolodex.GetRootIndex()) + seen := make(map[*index.SpecIndex]struct{}, len(allIndexes)) + + for _, idx := range allIndexes { + if idx == nil { + continue + } + if _, ok := seen[idx]; ok { + continue + } + seen[idx] = struct{}{} + + for _, seqRef := range idx.GetRawReferencesSequenced() { + isRef, _, refVal := utils.IsNodeRefValue(seqRef.Node) + if !isRef || !filepath.IsAbs(refVal) { + continue + } + inlinedNode := inlinedPaths[refVal] + if inlinedNode == nil { + continue + } + seqRef.Node.Content = inlinedNode.Content + } + } +} + // inlineRequiredRefs inlines refs that cannot be represented as root components. func inlineRequiredRefs(required []*processRef, rolodex *index.Rolodex) map[string]*yaml.Node { inlinedPaths := make(map[string]*yaml.Node) diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index cc450c8a7..18771e79a 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -13,6 +13,7 @@ import ( v3low "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" @@ -416,6 +417,65 @@ x-extension: assert.NotEqual(t, replacement.Content, schemaRef.Node.Content) } +func TestRewriteInlinedAbsoluteRefs(t *testing.T) { + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, "root.yaml") + matchedPath := filepath.Join(tmpDir, "matched.yaml") + + rootSource := strings.ReplaceAll(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /matched: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: 'MATCHED_PATH' + /relative: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: './relative.yaml' +`, "MATCHED_PATH", matchedPath) + require.NoError(t, os.WriteFile(rootPath, []byte(rootSource), 0644)) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(rootSource), &root)) + + cfg := index.CreateOpenAPIIndexConfig() + cfg.BasePath = tmpDir + cfg.SpecAbsolutePath = rootPath + idx := index.NewSpecIndexWithConfig(&root, cfg) + + matchedRef := findRawRefByValue(t, idx, matchedPath) + relativeRef := findRawRefByValue(t, idx, "./relative.yaml") + replacement := testYAMLContentNode(t, "type: object\nproperties:\n id:\n type: string\n") + + rolodex := index.NewRolodex(cfg) + rolodex.SetRootIndex(idx) + rolodex.AddIndex(idx) + + rewriteInlinedAbsoluteRefs(nil, []*index.SpecIndex{idx}, map[string]*yaml.Node{matchedPath: replacement}) + assert.NotEqual(t, replacement.Content, matchedRef.Node.Content) + + rewriteInlinedAbsoluteRefs(rolodex, []*index.SpecIndex{nil, idx}, nil) + rewriteInlinedAbsoluteRefs(rolodex, []*index.SpecIndex{nil, idx}, map[string]*yaml.Node{matchedPath: nil}) + assert.NotEqual(t, replacement.Content, matchedRef.Node.Content) + + rewriteInlinedAbsoluteRefs(rolodex, []*index.SpecIndex{nil, idx}, map[string]*yaml.Node{matchedPath: replacement}) + assert.Equal(t, replacement.Content, matchedRef.Node.Content) + assert.NotEqual(t, replacement.Content, relativeRef.Node.Content) +} + func newVersionedIndex(version float32) *index.SpecIndex { var root yaml.Node _ = yaml.Unmarshal([]byte(`openapi: 3.1.0 @@ -454,6 +514,21 @@ func findRawRefByComponentType(t *testing.T, idx *index.SpecIndex, componentType return nil } +func findRawRefByValue(t *testing.T, idx *index.SpecIndex, refValue string) *index.Reference { + t.Helper() + + for _, ref := range idx.GetRawReferencesSequenced() { + if ref == nil || ref.Node == nil { + continue + } + if isRef, _, value := utils.IsNodeRefValue(ref.Node); isRef && value == refValue { + return ref + } + } + t.Fatalf("expected raw ref value %q", refValue) + return nil +} + func newProcessRefForTest(t *testing.T, name, source string) *processRef { t.Helper()