Skip to content

Commit 983ea45

Browse files
author
Ryan Lee
committed
feat(id-handling) support for $id syntax
1 parent 96f5f72 commit 983ea45

19 files changed

Lines changed: 679 additions & 41 deletions

document.go

Lines changed: 184 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"path/filepath"
1010
"regexp"
11+
"strings"
1112

1213
"github.com/ryanolee/go-chaff/internal/util"
1314
"github.com/thoas/go-funk"
@@ -25,6 +26,19 @@ type (
2526

2627
// A map of protocols to their associated document fetchers
2728
documentFetchers map[string]*documentFetcherInterface
29+
30+
// Maps resolved $id URIs to the real document + JSON pointer path
31+
// where the sub-schema lives, avoiding document duplication.
32+
idAliases map[string]idAlias
33+
}
34+
35+
// idAlias maps a resolved $id URI back to the parent document and the
36+
// JSON pointer path within that document. For example, a $defs entry
37+
// with "$id": "color" in https://example.com/base.json produces:
38+
// idAlias{documentId: "https://example.com/base.json", path: "#/$defs/color"}
39+
idAlias struct {
40+
documentId string // The real document containing this sub-schema
41+
path string // JSON pointer within documentId, e.g. "#/$defs/color"
2842
}
2943

3044
documentFetcherInterface interface {
@@ -76,7 +90,7 @@ func newDocumentResolver(opts ParserOptions, rootDocument *schemaNode) (*documen
7690
resolvedRootDocumentId = opts.RelativeTo
7791
}
7892

79-
return &documentResolver{
93+
resolver := &documentResolver{
8094
// Setup the root document
8195
documentCurrentlyBeingParsedId: opts.RelativeTo,
8296
parsedExternalDocuments: map[string]bool{
@@ -86,7 +100,22 @@ func newDocumentResolver(opts ParserOptions, rootDocument *schemaNode) (*documen
86100
resolvedRootDocumentId: rootDocument,
87101
},
88102
documentFetchers: documentFetchers,
89-
}, nil
103+
idAliases: make(map[string]idAlias),
104+
}
105+
106+
// Collect $id aliases from the root document tree so that relative
107+
// $ref values (e.g. $ref: "color") can be resolved without I/O.
108+
baseURI := resolvedRootDocumentId
109+
if rootDocument.Id != nil && isAbsoluteURI(*rootDocument.Id) {
110+
baseURI = *rootDocument.Id
111+
}
112+
113+
// Merge into existing idAliases
114+
for resolvedId, alias := range collectSubSchemaIds(baseURI, resolvedRootDocumentId, rootDocument) {
115+
resolver.idAliases[resolvedId] = alias
116+
}
117+
118+
return resolver, nil
90119
}
91120

92121
func (r *documentResolver) GetDocumentIdCurrentlyBeingParsed() string {
@@ -115,21 +144,11 @@ func (r *documentResolver) ResolveDocumentIdAndPath(ref string) (string, string,
115144
}
116145

117146
// If we have no document, it is a local or relative reference and can be handled as such
118-
if !hasDocument || documentID == "" || r.HasNoFetchers() {
147+
if !hasDocument || documentID == "" {
119148
return r.GetCurrentScope(), path, nil
120149
}
121150

122-
fetcher, err := r.getFetcherForRef(ref)
123-
if err != nil {
124-
return "", "", fmt.Errorf("failed to get document fetcher for document '%s': %w", ref, err)
125-
}
126-
127-
resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID)
128-
if err != nil {
129-
return "", "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", ref, err)
130-
}
131-
132-
return resolvedDocumentId, path, nil
151+
return r.resolveDocumentRef(documentID, path)
133152
}
134153

135154
// Traverses a schema node and rewrites any $ref fields to be relative to the given document ID
@@ -223,26 +242,21 @@ func (r *documentResolver) HandleDeferredReferenceResolution(ref string, metadat
223242
}
224243

225244
// If we have no document, it is a local or relative reference and can be handled as such
226-
if !hasDocument || documentID == "" || r.HasNoFetchers() {
245+
if !hasDocument || documentID == "" {
227246
return r.GetCurrentScope(), path, nil
228247
}
229248

230-
fetcher, err := r.getFetcherForRef(documentID)
231-
if err != nil {
232-
return "", "", fmt.Errorf("failed to get document fetcher for document '%s': %w", ref, err)
233-
}
234-
235-
resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID)
249+
resolvedDocId, resolvedPath, err := r.resolveDocumentRef(documentID, path)
236250
if err != nil {
237-
return "", "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", ref, err)
251+
return "", "", err
238252
}
239253

240254
// Queue document for parsing if it hasn't already been parsed
241-
if !funk.ContainsString(r.externalDocumentsThatNeedParsing, resolvedDocumentId) && r.pathWouldRequireNewDocumentParsed(resolvedDocumentId) {
242-
r.externalDocumentsThatNeedParsing = append(r.externalDocumentsThatNeedParsing, resolvedDocumentId)
255+
if !funk.ContainsString(r.externalDocumentsThatNeedParsing, resolvedDocId) && r.pathWouldRequireNewDocumentParsed(resolvedDocId) {
256+
r.externalDocumentsThatNeedParsing = append(r.externalDocumentsThatNeedParsing, resolvedDocId)
243257
}
244258

245-
return resolvedDocumentId, path, nil
259+
return resolvedDocId, resolvedPath, nil
246260
}
247261

248262
/**
@@ -269,42 +283,37 @@ func (r *documentResolver) ResolvePath(metadata *parserMetadata, ref string) (*s
269283
}
270284

271285
// If we have no document, it is a local or relative reference and can be handled as such
272-
if !hasDocument || documentID == "" || r.HasNoFetchers() {
286+
if !hasDocument || documentID == "" {
273287
subNode, err := resolveSubReferencePath(r.documents[r.GetCurrentScope()], path, "")
274288
if err != nil {
275289
return nil, "", fmt.Errorf("failed to resolve sub-reference path '%s' in document '%s': %w", path, r.GetCurrentScope(), err)
276290
}
277291
return subNode, fmt.Sprintf("%s%s", r.GetCurrentScope(), path), nil
278292
}
279293

280-
fetcher, err := r.getFetcherForRef(documentID)
294+
resolvedDocId, resolvedPath, err := r.resolveDocumentRef(documentID, path)
281295
if err != nil {
282-
return nil, "", fmt.Errorf("failed to get document fetcher for document '%s': %w", ref, err)
296+
return nil, "", err
283297
}
284298

285-
resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID)
286-
if resolvedDocumentId == "" {
287-
return nil, "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", ref, err)
288-
}
289-
290-
document, documentLoaded := r.documents[resolvedDocumentId]
299+
document, documentLoaded := r.documents[resolvedDocId]
291300

292301
if !documentLoaded {
293-
document, err = r.resolveDocument(resolvedDocumentId)
302+
document, err = r.resolveDocument(resolvedDocId)
294303
if err != nil {
295304
return nil, "", fmt.Errorf("failed to resolve document '%s': %w", documentID, err)
296305
}
297306
}
298307

299-
subNode, err := resolveSubReferencePath(document, path, "")
308+
subNode, err := resolveSubReferencePath(document, resolvedPath, "")
300309
if err != nil {
301-
return nil, "", fmt.Errorf("failed to resolve sub-reference path '%s' in document '%s': %w", path, documentID, err)
310+
return nil, "", fmt.Errorf("failed to resolve sub-reference path '%s' in document '%s': %w", resolvedPath, resolvedDocId, err)
302311
}
303312

304313
// Shift the document scope so base url resolution works correctly
305-
r.SetDocumentBeingResolved(resolvedDocumentId)
314+
r.SetDocumentBeingResolved(resolvedDocId)
306315

307-
return subNode, fmt.Sprintf("%s%s", resolvedDocumentId, path), nil
316+
return subNode, fmt.Sprintf("%s%s", resolvedDocId, resolvedPath), nil
308317
}
309318

310319
func (r *documentResolver) addDocument(id string, node *schemaNode) {
@@ -318,6 +327,95 @@ func (r *documentResolver) addDocument(id string, node *schemaNode) {
318327
// points into said document
319328
r.documents[id] = node
320329
r.parsedExternalDocuments[id] = false
330+
331+
// Collect $id aliases from the new document tree.
332+
baseURI := id
333+
if node.Id != nil && isAbsoluteURI(*node.Id) {
334+
baseURI = *node.Id
335+
}
336+
for resolvedId, alias := range collectSubSchemaIds(baseURI, id, node) {
337+
if _, exists := r.idAliases[resolvedId]; !exists {
338+
r.idAliases[resolvedId] = alias
339+
}
340+
}
341+
}
342+
343+
// collectSubSchemaIds marshals a schema node to a generic map and recursively
344+
// walks the entire structure looking for "$id" entries. Each found $id is
345+
// resolved against its nearest ancestor base URI and recorded as an idAlias.
346+
func collectSubSchemaIds(baseURI string, documentId string, node *schemaNode) map[string]idAlias {
347+
data, err := json.Marshal(node)
348+
if err != nil {
349+
return nil
350+
}
351+
var raw map[string]interface{}
352+
if err := json.Unmarshal(data, &raw); err != nil {
353+
return nil
354+
}
355+
356+
result := make(map[string]idAlias)
357+
baseAtPath := map[string]string{"": baseURI}
358+
359+
WalkSchema(raw, "", func(n map[string]interface{}, path string) {
360+
idVal, ok := n["$id"]
361+
if !ok {
362+
return
363+
}
364+
idStr, ok := idVal.(string)
365+
if !ok || idStr == "" {
366+
return
367+
}
368+
resolved := resolveRelativeURI(nearestBaseURI(baseAtPath, path), idStr)
369+
if resolved == "" {
370+
return
371+
}
372+
baseAtPath[path] = resolved
373+
if path == "" {
374+
return // root $id is the document identity, not an alias
375+
}
376+
if _, exists := result[resolved]; exists {
377+
return
378+
}
379+
result[resolved] = idAlias{documentId: documentId, path: "#" + path}
380+
})
381+
382+
return result
383+
}
384+
385+
// nearestBaseURI walks up the JSON pointer path to find the closest ancestor
386+
// that established a base URI via $id.
387+
func nearestBaseURI(baseAtPath map[string]string, path string) string {
388+
for p := path; ; {
389+
if base, ok := baseAtPath[p]; ok {
390+
return base
391+
}
392+
i := strings.LastIndex(p, "/")
393+
if i < 0 {
394+
break
395+
}
396+
p = p[:i]
397+
}
398+
return baseAtPath[""]
399+
}
400+
401+
// resolveRelativeURI resolves rel against base using standard URL resolution.
402+
// Returns "" if parsing fails.
403+
func resolveRelativeURI(base, rel string) string {
404+
baseURL, err := url.Parse(base)
405+
if err != nil {
406+
return ""
407+
}
408+
relURL, err := url.Parse(rel)
409+
if err != nil {
410+
return ""
411+
}
412+
return baseURL.ResolveReference(relURL).String()
413+
}
414+
415+
// isAbsoluteURI returns true when s looks like an absolute URI (has a scheme).
416+
func isAbsoluteURI(s string) bool {
417+
u, err := url.Parse(s)
418+
return err == nil && u.Scheme != ""
321419
}
322420

323421
// Checks if there are more documents that need to be parsed
@@ -339,10 +437,12 @@ func (r *documentResolver) ParseNextDocument(metadata *parserMetadata) (string,
339437
return nextDocumentId, fmt.Errorf("failed to resolve document '%s': %w", nextDocumentId, err)
340438
}
341439

342-
_, err = parseRoot(*documentNode, metadata)
343-
// Mark document as parsed
440+
// Mark as parsed before parseRoot so that intra-document $id-based
441+
// refs resolved during parsing don't re-queue this document.
344442
r.parsedExternalDocuments[nextDocumentId] = true
345443

444+
_, err = parseRoot(*documentNode, metadata)
445+
346446
if err != nil {
347447
return nextDocumentId, fmt.Errorf("failed to parse document '%s': %w", nextDocumentId, err)
348448
}
@@ -375,6 +475,49 @@ func (r *documentResolver) resolveDocument(ref string) (*schemaNode, error) {
375475
return document, nil
376476
}
377477

478+
// resolveDocumentRef resolves a document reference to its canonical document
479+
// ID and path. It checks the $id alias table first (a lightweight map of
480+
// resolved $id URI → document + JSON pointer), falling back to I/O-based
481+
// fetchers only if no alias matches.
482+
func (r *documentResolver) resolveDocumentRef(documentID string, refPath string) (string, string, error) {
483+
// Check $id alias table first — resolves bare-name refs like "color"
484+
// to the real document + JSON pointer path with no I/O.
485+
resolved := resolveRelativeURI(r.GetCurrentScope(), documentID)
486+
if resolved != "" {
487+
if alias, exists := r.idAliases[resolved]; exists {
488+
return alias.documentId, composeJsonPointerPaths(alias.path, refPath), nil
489+
}
490+
}
491+
492+
// No alias found — fall back to I/O-based fetchers.
493+
if r.HasNoFetchers() {
494+
return r.GetCurrentScope(), refPath, nil
495+
}
496+
497+
fetcher, err := r.getFetcherForRef(documentID)
498+
if err != nil {
499+
return "", "", fmt.Errorf("failed to get document fetcher for document '%s': %w", documentID, err)
500+
}
501+
502+
resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID)
503+
if err != nil {
504+
return "", "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", documentID, err)
505+
}
506+
507+
return resolvedDocumentId, refPath, nil
508+
}
509+
510+
// composeJsonPointerPaths appends the sub-path from a $ref onto an alias's
511+
// base path. For example, alias "#/$defs/color" + ref "#/type" = "#/$defs/color/type".
512+
// If refPath is "#" (root of the aliased resource), the alias path is returned as-is.
513+
func composeJsonPointerPaths(aliasPath string, refPath string) string {
514+
if refPath == "#" || refPath == "" {
515+
return aliasPath
516+
}
517+
// refPath is "#/something" — strip the "#" to get "/something"
518+
return aliasPath + refPath[1:]
519+
}
520+
378521
func (r *documentResolver) getFetcherForRef(documentId string) (documentFetcherInterface, error) {
379522
url, err := url.Parse(documentId)
380523
if err != nil {

document_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ func TestDeeplyNestedDocumentCluster(t *testing.T) {
5050
test.TestJsonSchemaDirWithConfig(t, "test_data/document/file/cluster4_deep_nested", 100, getDocumentChaffConfig(), nil)
5151
}
5252

53+
func TestDefinitionIdDocumentCluster(t *testing.T) {
54+
t.Parallel()
55+
test.TestJsonSchemaDirWithConfig(t, "test_data/document/file/cluster5_definition_ids", 100, getDocumentChaffConfig(), nil)
56+
}
57+
58+
func TestEveryIdPositionCluster(t *testing.T) {
59+
t.Parallel()
60+
test.TestJsonSchemaDirWithConfig(t, "test_data/document/file/cluster6_every_id_position", 100, getDocumentChaffConfig(), func() *chaff.GeneratorOptions {
61+
return &chaff.GeneratorOptions{
62+
MaximumOneOfAttempts: 10000,
63+
}
64+
})
65+
}
66+
5367
func TestHttpDocuments(t *testing.T) {
5468
server, err := http_schema_server.StartTestServer()
5569
if err != nil {

0 commit comments

Comments
 (0)