Skip to content

Commit 5181f5d

Browse files
fix: handle empty targetLocation in reference resolution for in-memory documents (#42)
1 parent 1417fb7 commit 5181f5d

File tree

5 files changed

+225
-37
lines changed

5 files changed

+225
-37
lines changed

jsonschema/oas3/resolution_test.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -670,27 +670,6 @@ $ref: "circular1.yaml"
670670
func TestJSONSchema_Resolve_Errors(t *testing.T) {
671671
t.Parallel()
672672

673-
t.Run("missing root location", func(t *testing.T) {
674-
t.Parallel()
675-
ref := "external.yaml#/test" // Use external reference to trigger location validation
676-
schema := createSchemaWithRef(ref)
677-
678-
opts := ResolveOptions{
679-
RootDocument: NewMockResolutionTarget(),
680-
// TargetLocation deliberately omitted to trigger the error
681-
}
682-
683-
validationErrs, err := schema.Resolve(t.Context(), opts)
684-
685-
require.Error(t, err)
686-
assert.Nil(t, validationErrs)
687-
// The error can be either "target location is required" or "empty reference" depending on the implementation
688-
assert.True(t,
689-
strings.Contains(err.Error(), "target location is required") ||
690-
strings.Contains(err.Error(), "empty reference"),
691-
"Expected error about target location or empty reference, got: %s", err.Error())
692-
})
693-
694673
t.Run("missing root document", func(t *testing.T) {
695674
t.Parallel()
696675
ref := "#/test"

references/resolution.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func Resolve[T any](ctx context.Context, ref Reference, unmarshaler Unmarshal[T]
8080
return nil, nil, errors.New("root document is required")
8181
}
8282
if opts.TargetLocation == "" {
83-
return nil, nil, errors.New("target location is required")
83+
opts.TargetLocation = "."
8484
}
8585
if opts.TargetDocument == nil {
8686
return nil, nil, errors.New("target document is required")

references/resolution_cache.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,25 @@ func (c *RefCache) Resolve(ref Reference, targetLocation string) (*AbsoluteRefer
6464
func resolveAbsoluteReferenceUncached(ref Reference, targetLocation string) (*AbsoluteReferenceResult, error) {
6565
uri := ref.GetURI()
6666

67+
// Handle empty targetLocation by treating it as current directory
68+
if targetLocation == "" {
69+
targetLocation = "."
70+
}
71+
72+
// Classify the target location once
73+
classification, err := utils.ClassifyReference(targetLocation)
74+
if err != nil {
75+
return nil, err
76+
}
77+
6778
// If the reference is empty, it's relative to the target document
6879
if uri == "" {
69-
classification, err := utils.ClassifyReference(targetLocation)
70-
if err != nil {
71-
return nil, err
72-
}
7380
return &AbsoluteReferenceResult{
7481
AbsoluteReference: targetLocation,
7582
Classification: classification,
7683
}, nil
7784
}
7885

79-
classification, err := utils.ClassifyReference(targetLocation)
80-
if err != nil {
81-
return nil, err
82-
}
83-
8486
// Check if the URI is already absolute - if so, use it as-is instead of joining
8587
var absRef string
8688
var finalClassification *utils.ReferenceClassification

references/resolution_cache_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,206 @@ func BenchmarkRefCache_vs_Uncached(b *testing.B) {
287287
}
288288
})
289289
}
290+
291+
// Tests for empty targetLocation bug fix
292+
func TestResolveAbsoluteReferenceUncached_EmptyTargetLocation_Success(t *testing.T) {
293+
t.Parallel()
294+
295+
tests := []struct {
296+
name string
297+
targetLocation string
298+
reference string
299+
expectedAbsoluteRef string
300+
description string
301+
}{
302+
{
303+
name: "empty_target_with_empty_reference",
304+
targetLocation: "",
305+
reference: "",
306+
expectedAbsoluteRef: ".",
307+
description: "Empty target location should default to current directory",
308+
},
309+
{
310+
name: "empty_target_with_relative_reference",
311+
targetLocation: "",
312+
reference: "schemas/user.yaml",
313+
expectedAbsoluteRef: "schemas/user.yaml",
314+
description: "Empty target location with relative reference should resolve relative to current directory",
315+
},
316+
{
317+
name: "empty_target_with_absolute_file_reference",
318+
targetLocation: "",
319+
reference: "/absolute/path/schema.yaml",
320+
expectedAbsoluteRef: "/absolute/path/schema.yaml",
321+
description: "Empty target location with absolute file reference should use absolute path as-is",
322+
},
323+
{
324+
name: "empty_target_with_absolute_url_reference",
325+
targetLocation: "",
326+
reference: "https://example.com/schema.yaml",
327+
expectedAbsoluteRef: "https://example.com/schema.yaml",
328+
description: "Empty target location with absolute URL reference should use URL as-is",
329+
},
330+
{
331+
name: "empty_target_with_fragment_reference",
332+
targetLocation: "",
333+
reference: "#/components/schemas/User",
334+
expectedAbsoluteRef: ".",
335+
description: "Empty target location with fragment reference should resolve to current directory",
336+
},
337+
{
338+
name: "empty_target_with_uri_and_fragment_reference",
339+
targetLocation: "",
340+
reference: "schemas/user.yaml#/User",
341+
expectedAbsoluteRef: "schemas/user.yaml",
342+
description: "Empty target location with URI and fragment should resolve URI relative to current directory",
343+
},
344+
}
345+
346+
for _, tt := range tests {
347+
t.Run(tt.name, func(t *testing.T) {
348+
t.Parallel()
349+
350+
ref := Reference(tt.reference)
351+
result, err := resolveAbsoluteReferenceUncached(ref, tt.targetLocation)
352+
353+
require.NoError(t, err, tt.description)
354+
require.NotNil(t, result)
355+
assert.Equal(t, tt.expectedAbsoluteRef, result.AbsoluteReference, tt.description)
356+
assert.NotNil(t, result.Classification, "Classification should not be nil")
357+
})
358+
}
359+
}
360+
361+
func TestRefCache_Resolve_EmptyTargetLocation_Success(t *testing.T) {
362+
t.Parallel()
363+
cache := &RefCache{}
364+
365+
t.Run("empty_target_location_cached_correctly", func(t *testing.T) {
366+
t.Parallel()
367+
ref := Reference("schemas/user.yaml")
368+
369+
// First call with empty target location
370+
result1, err1 := cache.Resolve(ref, "")
371+
require.NoError(t, err1)
372+
require.NotNil(t, result1)
373+
assert.Equal(t, "schemas/user.yaml", result1.AbsoluteReference)
374+
375+
// Second call with same parameters should return cached result
376+
result2, err2 := cache.Resolve(ref, "")
377+
require.NoError(t, err2)
378+
require.NotNil(t, result2)
379+
assert.Equal(t, result1.AbsoluteReference, result2.AbsoluteReference)
380+
assert.Equal(t, result1.Classification.Type, result2.Classification.Type)
381+
382+
// Results should be copies (not the same instance) to prevent mutation
383+
assert.NotSame(t, result1, result2, "Cached results should be copies to prevent mutation")
384+
assert.Equal(t, result1.AbsoluteReference, result2.AbsoluteReference, "But content should be identical")
385+
})
386+
387+
t.Run("empty_vs_dot_target_location_different_cache_keys", func(t *testing.T) {
388+
t.Parallel()
389+
ref := Reference("test.yaml")
390+
391+
// Call with empty target location (internally becomes ".")
392+
result1, err1 := cache.Resolve(ref, "")
393+
require.NoError(t, err1)
394+
require.NotNil(t, result1)
395+
396+
// Call with explicit "." target location
397+
result2, err2 := cache.Resolve(ref, ".")
398+
require.NoError(t, err2)
399+
require.NotNil(t, result2)
400+
401+
// Both should have the same absolute reference result
402+
assert.Equal(t, result1.AbsoluteReference, result2.AbsoluteReference)
403+
assert.Equal(t, result1.Classification.Type, result2.Classification.Type)
404+
405+
// But they should be cached separately (different cache keys)
406+
// This is expected behavior since the cache key includes the original targetLocation
407+
assert.NotSame(t, result1, result2, "Different target locations should have separate cache entries")
408+
})
409+
}
410+
411+
//nolint:paralleltest // This test uses global cache and cannot be parallel
412+
func TestResolveAbsoluteReferenceCached_EmptyTargetLocation_Global(t *testing.T) {
413+
// Clear global cache before test
414+
ClearGlobalRefCache()
415+
416+
ref := Reference("schemas/user.yaml")
417+
418+
// Resolve using global function with empty target location
419+
result1, err := ResolveAbsoluteReferenceCached(ref, "")
420+
require.NoError(t, err)
421+
assert.Equal(t, "schemas/user.yaml", result1.AbsoluteReference)
422+
423+
// Verify it's cached globally
424+
stats := GetRefCacheStats()
425+
assert.Equal(t, int64(1), stats.Size)
426+
427+
// Resolve again - should use cache
428+
result2, err := ResolveAbsoluteReferenceCached(ref, "")
429+
require.NoError(t, err)
430+
assert.Equal(t, result1.AbsoluteReference, result2.AbsoluteReference)
431+
assert.NotSame(t, result1, result2, "should return copies")
432+
433+
// Clean up
434+
ClearGlobalRefCache()
435+
}
436+
437+
func TestResolveAbsoluteReferenceUncached_EmptyTargetLocation_InMemoryDocuments(t *testing.T) {
438+
t.Parallel()
439+
440+
// Test scenarios that represent in-memory documents (uploaded files, database content, etc.)
441+
tests := []struct {
442+
name string
443+
reference string
444+
expected string
445+
description string
446+
}{
447+
{
448+
name: "in_memory_document_self_reference",
449+
reference: "",
450+
expected: ".",
451+
description: "In-memory document referencing itself should resolve to current directory",
452+
},
453+
{
454+
name: "in_memory_document_relative_schema",
455+
reference: "components/schemas/User.yaml",
456+
expected: "components/schemas/User.yaml",
457+
description: "In-memory document with relative schema reference",
458+
},
459+
{
460+
name: "in_memory_document_json_pointer",
461+
reference: "#/components/schemas/User",
462+
expected: ".",
463+
description: "In-memory document with JSON pointer reference",
464+
},
465+
{
466+
name: "in_memory_document_external_url",
467+
reference: "https://schemas.example.com/common.yaml",
468+
expected: "https://schemas.example.com/common.yaml",
469+
description: "In-memory document referencing external URL should preserve absolute URL",
470+
},
471+
{
472+
name: "in_memory_document_uri_with_fragment",
473+
reference: "components/schemas/User.yaml#/properties/name",
474+
expected: "components/schemas/User.yaml",
475+
description: "In-memory document with URI and fragment should resolve to the URI part only",
476+
},
477+
}
478+
479+
for _, tt := range tests {
480+
t.Run(tt.name, func(t *testing.T) {
481+
t.Parallel()
482+
483+
ref := Reference(tt.reference)
484+
result, err := resolveAbsoluteReferenceUncached(ref, "")
485+
486+
require.NoError(t, err, tt.description)
487+
require.NotNil(t, result)
488+
assert.Equal(t, tt.expected, result.AbsoluteReference, tt.description)
489+
assert.NotNil(t, result.Classification, "Classification should not be nil")
490+
})
491+
}
492+
}

references/resolution_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -377,18 +377,22 @@ func TestResolve_Caching(t *testing.T) {
377377
func TestResolve_Errors(t *testing.T) {
378378
t.Parallel()
379379

380-
t.Run("missing root location", func(t *testing.T) {
380+
t.Run("empty target location defaults to current directory", func(t *testing.T) {
381381
t.Parallel()
382+
root := NewMockResolutionTarget()
382383
opts := ResolveOptions{
383-
RootDocument: NewMockResolutionTarget(),
384+
RootDocument: root,
385+
TargetDocument: root,
384386
}
385387

386-
result, validationErrs, err := Resolve(t.Context(), Reference("#/test"), testPrimitiveUnmarshaler, opts)
388+
result, validationErrs, err := Resolve(t.Context(), Reference(""), func(ctx context.Context, node *yaml.Node, skipValidation bool) (*MockResolutionTarget, []error, error) {
389+
return root, nil, nil
390+
}, opts)
387391

388-
require.Error(t, err)
392+
require.NoError(t, err)
389393
assert.Nil(t, validationErrs)
390-
assert.Nil(t, result)
391-
assert.Contains(t, err.Error(), "target location is required")
394+
require.NotNil(t, result)
395+
assert.Equal(t, ".", result.AbsoluteReference)
392396
})
393397

394398
t.Run("missing root document", func(t *testing.T) {

0 commit comments

Comments
 (0)