Skip to content

Commit 723eeeb

Browse files
committed
Attempting to fix daveshanley/vacuum#850
trying to not incorrectly mutate the bundling output.
1 parent 7bbbf8e commit 723eeeb

3 files changed

Lines changed: 499 additions & 2 deletions

File tree

bundler/bundler_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,93 @@ func isEmptyRef(line string) bool {
9494
return ref == "{}" || ref == ""
9595
}
9696

97+
func TestBundleDocument_PreservesInvalidComponentMapRefsAndWarns(t *testing.T) {
98+
tmpDir := t.TempDir()
99+
100+
spec := `openapi: 3.0.3
101+
info:
102+
title: Test API
103+
version: 1.0.0
104+
components:
105+
parameters:
106+
$ref: "./params.yaml"
107+
LocalParam:
108+
name: local
109+
in: query
110+
schema:
111+
type: string
112+
schemas:
113+
$ref: "./schemas.yaml"
114+
LocalSchema:
115+
type: object
116+
properties:
117+
local:
118+
type: string
119+
paths:
120+
/test:
121+
get:
122+
parameters:
123+
- $ref: "#/components/parameters/LocalParam"
124+
responses:
125+
"200":
126+
description: OK
127+
content:
128+
application/json:
129+
schema:
130+
$ref: "#/components/schemas/LocalSchema"
131+
`
132+
133+
params := `RemoteParam:
134+
name: remote
135+
in: query
136+
schema:
137+
type: string
138+
`
139+
140+
schemas := `RemoteSchema:
141+
type: object
142+
properties:
143+
id:
144+
type: string
145+
`
146+
147+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(spec), 0644))
148+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "params.yaml"), []byte(params), 0644))
149+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "schemas.yaml"), []byte(schemas), 0644))
150+
151+
var logBuf bytes.Buffer
152+
cfg := &datamodel.DocumentConfiguration{
153+
BasePath: tmpDir,
154+
AllowFileReferences: true,
155+
Logger: slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{
156+
Level: slog.LevelWarn,
157+
})),
158+
}
159+
160+
specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml"))
161+
require.NoError(t, err)
162+
163+
doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, cfg)
164+
require.NoError(t, err)
165+
166+
v3Doc, errs := doc.BuildV3Model()
167+
require.NoError(t, errs)
168+
require.NotNil(t, v3Doc)
169+
170+
bundledBytes, err := BundleDocument(&v3Doc.Model)
171+
require.NoError(t, err)
172+
173+
bundledStr := string(bundledBytes)
174+
assert.Contains(t, bundledStr, "$ref: \"./params.yaml\"")
175+
assert.Contains(t, bundledStr, "$ref: \"./schemas.yaml\"")
176+
assert.NotContains(t, bundledStr, "$ref: {}")
177+
178+
logOutput := logBuf.String()
179+
assert.Contains(t, logOutput, "preserving invalid component map $ref entry during render")
180+
assert.Contains(t, logOutput, "\"section\":\"parameters\"")
181+
assert.Contains(t, logOutput, "\"section\":\"schemas\"")
182+
}
183+
97184
func writeIssue831Fixture(t *testing.T) string {
98185
t.Helper()
99186

datamodel/high/v3/components.go

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/pb33f/libopenapi/datamodel/low/base"
1414
low "github.com/pb33f/libopenapi/datamodel/low/v3"
1515
"github.com/pb33f/libopenapi/orderedmap"
16+
"github.com/pb33f/libopenapi/utils"
1617
"go.yaml.in/yaml/v4"
1718
)
1819

@@ -173,8 +174,11 @@ func (c *Components) Render() ([]byte, error) {
173174

174175
// MarshalYAML will create a ready to render YAML representation of the Response object.
175176
func (c *Components) MarshalYAML() (interface{}, error) {
177+
c.warnPreservedComponentMapRefs()
176178
nb := high.NewNodeBuilder(c, c.low)
177-
return nb.Render(), nil
179+
rendered := nb.Render()
180+
c.preserveInvalidComponentMapRefs(rendered)
181+
return rendered, nil
178182
}
179183

180184
// RenderInline will return a YAML representation of the Components object as a byte slice with references resolved.
@@ -185,7 +189,177 @@ func (c *Components) RenderInline() ([]byte, error) {
185189

186190
// MarshalYAMLInline will create a ready to render YAML representation of the Components object with references resolved.
187191
func (c *Components) MarshalYAMLInline() (interface{}, error) {
192+
c.warnPreservedComponentMapRefs()
188193
nb := high.NewNodeBuilder(c, c.low)
189194
nb.Resolve = true
190-
return nb.Render(), nil
195+
rendered := nb.Render()
196+
c.preserveInvalidComponentMapRefs(rendered)
197+
return rendered, nil
198+
}
199+
200+
func (c *Components) warnPreservedComponentMapRefs() {
201+
if c == nil || c.low == nil {
202+
return
203+
}
204+
idx := c.low.GetIndex()
205+
if idx == nil {
206+
return
207+
}
208+
logger := idx.GetLogger()
209+
if logger == nil {
210+
return
211+
}
212+
213+
warnComponentRefEntries(logger, low.SchemasLabel, c.low.Schemas.Value)
214+
warnComponentRefEntries(logger, low.ResponsesLabel, c.low.Responses.Value)
215+
warnComponentRefEntries(logger, low.ParametersLabel, c.low.Parameters.Value)
216+
warnComponentRefEntries(logger, base.ExamplesLabel, c.low.Examples.Value)
217+
warnComponentRefEntries(logger, low.RequestBodiesLabel, c.low.RequestBodies.Value)
218+
warnComponentRefEntries(logger, low.HeadersLabel, c.low.Headers.Value)
219+
warnComponentRefEntries(logger, low.SecuritySchemesLabel, c.low.SecuritySchemes.Value)
220+
warnComponentRefEntries(logger, low.LinksLabel, c.low.Links.Value)
221+
warnComponentRefEntries(logger, low.CallbacksLabel, c.low.Callbacks.Value)
222+
warnComponentRefEntries(logger, low.PathItemsLabel, c.low.PathItems.Value)
223+
warnComponentRefEntries(logger, low.MediaTypesLabel, c.low.MediaTypes.Value)
224+
}
225+
226+
func warnComponentRefEntries[T any](
227+
logger interface {
228+
Warn(msg string, args ...any)
229+
},
230+
section string,
231+
m *orderedmap.Map[lowmodel.KeyReference[string], lowmodel.ValueReference[T]],
232+
) {
233+
if m == nil {
234+
return
235+
}
236+
237+
for pair := m.First(); pair != nil; pair = pair.Next() {
238+
if pair.Key().Value != "$ref" {
239+
continue
240+
}
241+
valueNode := pair.Value().ValueNode
242+
if valueNode == nil || valueNode.Kind != yaml.ScalarNode {
243+
continue
244+
}
245+
logger.Warn(
246+
"preserving invalid component map $ref entry during render",
247+
"section", section,
248+
"ref", valueNode.Value,
249+
"line", valueNode.Line,
250+
"column", valueNode.Column,
251+
)
252+
}
253+
}
254+
255+
// preserveInvalidComponentMapRefs patches the rendered Components YAML tree so that invalid
256+
// map-level "$ref" entries under component sections survive a render cycle unchanged.
257+
//
258+
// Inputs like:
259+
//
260+
// components:
261+
// parameters:
262+
// $ref: "./params.yaml"
263+
//
264+
// are not valid OpenAPI component maps, but they do appear in the wild. The normal high-level
265+
// render path treats "$ref" as a literal component name and can otherwise collapse the scalar
266+
// value into an empty object. For these cases we preserve the original raw YAML nodes and pair
267+
// the behavior with a warning log, rather than silently rewriting the input.
268+
func (c *Components) preserveInvalidComponentMapRefs(rendered *yaml.Node) {
269+
if c == nil || c.low == nil || rendered == nil || rendered.Kind != yaml.MappingNode {
270+
return
271+
}
272+
273+
preserveComponentRefEntries(rendered, low.SchemasLabel, c.low.Schemas.Value)
274+
preserveComponentRefEntries(rendered, low.ResponsesLabel, c.low.Responses.Value)
275+
preserveComponentRefEntries(rendered, low.ParametersLabel, c.low.Parameters.Value)
276+
preserveComponentRefEntries(rendered, base.ExamplesLabel, c.low.Examples.Value)
277+
preserveComponentRefEntries(rendered, low.RequestBodiesLabel, c.low.RequestBodies.Value)
278+
preserveComponentRefEntries(rendered, low.HeadersLabel, c.low.Headers.Value)
279+
preserveComponentRefEntries(rendered, low.SecuritySchemesLabel, c.low.SecuritySchemes.Value)
280+
preserveComponentRefEntries(rendered, low.LinksLabel, c.low.Links.Value)
281+
preserveComponentRefEntries(rendered, low.CallbacksLabel, c.low.Callbacks.Value)
282+
preserveComponentRefEntries(rendered, low.PathItemsLabel, c.low.PathItems.Value)
283+
preserveComponentRefEntries(rendered, low.MediaTypesLabel, c.low.MediaTypes.Value)
284+
}
285+
286+
// preserveComponentRefEntries re-inserts a scalar "$ref" entry into the rendered YAML for a
287+
// specific component section. Only literal "$ref" keys backed by scalar low-level value nodes
288+
// are preserved; real component entries and malformed non-scalar values are ignored.
289+
func preserveComponentRefEntries[T any](
290+
rendered *yaml.Node,
291+
section string,
292+
m *orderedmap.Map[lowmodel.KeyReference[string], lowmodel.ValueReference[T]],
293+
) {
294+
if m == nil {
295+
return
296+
}
297+
298+
sectionNode := findMapValueNode(rendered, section)
299+
for pair := m.First(); pair != nil; pair = pair.Next() {
300+
if pair.Key().Value != "$ref" {
301+
continue
302+
}
303+
304+
valueNode := pair.Value().ValueNode
305+
keyNode := pair.Key().KeyNode
306+
if keyNode == nil || valueNode == nil || valueNode.Kind != yaml.ScalarNode {
307+
continue
308+
}
309+
310+
if sectionNode == nil {
311+
sectionNode = utils.CreateEmptyMapNode()
312+
rendered.Content = append(
313+
rendered.Content,
314+
utils.CreateStringNode(section),
315+
sectionNode,
316+
)
317+
}
318+
upsertMapNodeEntry(sectionNode, cloneYAMLNode(keyNode), cloneYAMLNode(valueNode))
319+
}
320+
}
321+
322+
// findMapValueNode returns the mapping value node for key from a YAML mapping node.
323+
func findMapValueNode(m *yaml.Node, key string) *yaml.Node {
324+
if m == nil || m.Kind != yaml.MappingNode {
325+
return nil
326+
}
327+
for i := 0; i < len(m.Content); i += 2 {
328+
if m.Content[i].Value == key {
329+
return m.Content[i+1]
330+
}
331+
}
332+
return nil
333+
}
334+
335+
// upsertMapNodeEntry replaces or appends a key/value pair in a YAML mapping node.
336+
func upsertMapNodeEntry(m *yaml.Node, keyNode, valueNode *yaml.Node) {
337+
if m == nil || m.Kind != yaml.MappingNode || keyNode == nil || valueNode == nil {
338+
return
339+
}
340+
for i := 0; i < len(m.Content); i += 2 {
341+
if m.Content[i].Value == keyNode.Value {
342+
m.Content[i] = keyNode
343+
m.Content[i+1] = valueNode
344+
return
345+
}
346+
}
347+
m.Content = append(m.Content, keyNode, valueNode)
348+
}
349+
350+
// cloneYAMLNode deep-copies a YAML node tree so preserved low-level nodes can be spliced into
351+
// rendered output without mutating the original parsed model.
352+
func cloneYAMLNode(node *yaml.Node) *yaml.Node {
353+
if node == nil {
354+
return nil
355+
}
356+
357+
cloned := *node
358+
if len(node.Content) > 0 {
359+
cloned.Content = make([]*yaml.Node, len(node.Content))
360+
for i, child := range node.Content {
361+
cloned.Content[i] = cloneYAMLNode(child)
362+
}
363+
}
364+
return &cloned
191365
}

0 commit comments

Comments
 (0)