diff --git a/document.go b/document.go index 9a3c813..b06b6ee 100644 --- a/document.go +++ b/document.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "github.com/ryanolee/go-chaff/internal/util" "github.com/thoas/go-funk" @@ -25,6 +26,19 @@ type ( // A map of protocols to their associated document fetchers documentFetchers map[string]*documentFetcherInterface + + // Maps resolved $id URIs to the real document + JSON pointer path + // where the sub-schema lives, avoiding document duplication. + idAliases map[string]idAlias + } + + // idAlias maps a resolved $id URI back to the parent document and the + // JSON pointer path within that document. For example, a $defs entry + // with "$id": "color" in https://example.com/base.json produces: + // idAlias{documentId: "https://example.com/base.json", path: "#/$defs/color"} + idAlias struct { + documentId string // The real document containing this sub-schema + path string // JSON pointer within documentId, e.g. "#/$defs/color" } documentFetcherInterface interface { @@ -76,7 +90,7 @@ func newDocumentResolver(opts ParserOptions, rootDocument *schemaNode) (*documen resolvedRootDocumentId = opts.RelativeTo } - return &documentResolver{ + resolver := &documentResolver{ // Setup the root document documentCurrentlyBeingParsedId: opts.RelativeTo, parsedExternalDocuments: map[string]bool{ @@ -86,7 +100,22 @@ func newDocumentResolver(opts ParserOptions, rootDocument *schemaNode) (*documen resolvedRootDocumentId: rootDocument, }, documentFetchers: documentFetchers, - }, nil + idAliases: make(map[string]idAlias), + } + + // Collect $id aliases from the root document tree so that relative + // $ref values (e.g. $ref: "color") can be resolved without I/O. + baseURI := resolvedRootDocumentId + if rootDocument.Id != nil && isAbsoluteURI(*rootDocument.Id) { + baseURI = *rootDocument.Id + } + + // Merge into existing idAliases + for resolvedId, alias := range collectSubSchemaIds(baseURI, resolvedRootDocumentId, rootDocument) { + resolver.idAliases[resolvedId] = alias + } + + return resolver, nil } func (r *documentResolver) GetDocumentIdCurrentlyBeingParsed() string { @@ -115,21 +144,11 @@ func (r *documentResolver) ResolveDocumentIdAndPath(ref string) (string, string, } // If we have no document, it is a local or relative reference and can be handled as such - if !hasDocument || documentID == "" || r.HasNoFetchers() { + if !hasDocument || documentID == "" { return r.GetCurrentScope(), path, nil } - fetcher, err := r.getFetcherForRef(ref) - if err != nil { - return "", "", fmt.Errorf("failed to get document fetcher for document '%s': %w", ref, err) - } - - resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID) - if err != nil { - return "", "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", ref, err) - } - - return resolvedDocumentId, path, nil + return r.resolveDocumentRef(documentID, path) } // 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 } // If we have no document, it is a local or relative reference and can be handled as such - if !hasDocument || documentID == "" || r.HasNoFetchers() { + if !hasDocument || documentID == "" { return r.GetCurrentScope(), path, nil } - fetcher, err := r.getFetcherForRef(documentID) - if err != nil { - return "", "", fmt.Errorf("failed to get document fetcher for document '%s': %w", ref, err) - } - - resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID) + resolvedDocId, resolvedPath, err := r.resolveDocumentRef(documentID, path) if err != nil { - return "", "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", ref, err) + return "", "", err } // Queue document for parsing if it hasn't already been parsed - if !funk.ContainsString(r.externalDocumentsThatNeedParsing, resolvedDocumentId) && r.pathWouldRequireNewDocumentParsed(resolvedDocumentId) { - r.externalDocumentsThatNeedParsing = append(r.externalDocumentsThatNeedParsing, resolvedDocumentId) + if !funk.ContainsString(r.externalDocumentsThatNeedParsing, resolvedDocId) && r.pathWouldRequireNewDocumentParsed(resolvedDocId) { + r.externalDocumentsThatNeedParsing = append(r.externalDocumentsThatNeedParsing, resolvedDocId) } - return resolvedDocumentId, path, nil + return resolvedDocId, resolvedPath, nil } /** @@ -269,7 +283,7 @@ func (r *documentResolver) ResolvePath(metadata *parserMetadata, ref string) (*s } // If we have no document, it is a local or relative reference and can be handled as such - if !hasDocument || documentID == "" || r.HasNoFetchers() { + if !hasDocument || documentID == "" { subNode, err := resolveSubReferencePath(r.documents[r.GetCurrentScope()], path, "") if err != nil { return nil, "", fmt.Errorf("failed to resolve sub-reference path '%s' in document '%s': %w", path, r.GetCurrentScope(), err) @@ -277,34 +291,29 @@ func (r *documentResolver) ResolvePath(metadata *parserMetadata, ref string) (*s return subNode, fmt.Sprintf("%s%s", r.GetCurrentScope(), path), nil } - fetcher, err := r.getFetcherForRef(documentID) + resolvedDocId, resolvedPath, err := r.resolveDocumentRef(documentID, path) if err != nil { - return nil, "", fmt.Errorf("failed to get document fetcher for document '%s': %w", ref, err) + return nil, "", err } - resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID) - if resolvedDocumentId == "" { - return nil, "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", ref, err) - } - - document, documentLoaded := r.documents[resolvedDocumentId] + document, documentLoaded := r.documents[resolvedDocId] if !documentLoaded { - document, err = r.resolveDocument(resolvedDocumentId) + document, err = r.resolveDocument(resolvedDocId) if err != nil { return nil, "", fmt.Errorf("failed to resolve document '%s': %w", documentID, err) } } - subNode, err := resolveSubReferencePath(document, path, "") + subNode, err := resolveSubReferencePath(document, resolvedPath, "") if err != nil { - return nil, "", fmt.Errorf("failed to resolve sub-reference path '%s' in document '%s': %w", path, documentID, err) + return nil, "", fmt.Errorf("failed to resolve sub-reference path '%s' in document '%s': %w", resolvedPath, resolvedDocId, err) } // Shift the document scope so base url resolution works correctly - r.SetDocumentBeingResolved(resolvedDocumentId) + r.SetDocumentBeingResolved(resolvedDocId) - return subNode, fmt.Sprintf("%s%s", resolvedDocumentId, path), nil + return subNode, fmt.Sprintf("%s%s", resolvedDocId, resolvedPath), nil } func (r *documentResolver) addDocument(id string, node *schemaNode) { @@ -318,6 +327,95 @@ func (r *documentResolver) addDocument(id string, node *schemaNode) { // points into said document r.documents[id] = node r.parsedExternalDocuments[id] = false + + // Collect $id aliases from the new document tree. + baseURI := id + if node.Id != nil && isAbsoluteURI(*node.Id) { + baseURI = *node.Id + } + for resolvedId, alias := range collectSubSchemaIds(baseURI, id, node) { + if _, exists := r.idAliases[resolvedId]; !exists { + r.idAliases[resolvedId] = alias + } + } +} + +// collectSubSchemaIds marshals a schema node to a generic map and recursively +// walks the entire structure looking for "$id" entries. Each found $id is +// resolved against its nearest ancestor base URI and recorded as an idAlias. +func collectSubSchemaIds(baseURI string, documentId string, node *schemaNode) map[string]idAlias { + data, err := json.Marshal(node) + if err != nil { + return nil + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil + } + + result := make(map[string]idAlias) + baseAtPath := map[string]string{"": baseURI} + + WalkSchema(raw, "", func(n map[string]interface{}, path string) { + idVal, ok := n["$id"] + if !ok { + return + } + idStr, ok := idVal.(string) + if !ok || idStr == "" { + return + } + resolved := resolveRelativeURI(nearestBaseURI(baseAtPath, path), idStr) + if resolved == "" { + return + } + baseAtPath[path] = resolved + if path == "" { + return // root $id is the document identity, not an alias + } + if _, exists := result[resolved]; exists { + return + } + result[resolved] = idAlias{documentId: documentId, path: "#" + path} + }) + + return result +} + +// nearestBaseURI walks up the JSON pointer path to find the closest ancestor +// that established a base URI via $id. +func nearestBaseURI(baseAtPath map[string]string, path string) string { + for p := path; ; { + if base, ok := baseAtPath[p]; ok { + return base + } + i := strings.LastIndex(p, "/") + if i < 0 { + break + } + p = p[:i] + } + return baseAtPath[""] +} + +// resolveRelativeURI resolves rel against base using standard URL resolution. +// Returns "" if parsing fails. +func resolveRelativeURI(base, rel string) string { + baseURL, err := url.Parse(base) + if err != nil { + return "" + } + relURL, err := url.Parse(rel) + if err != nil { + return "" + } + return baseURL.ResolveReference(relURL).String() +} + +// isAbsoluteURI returns true when s looks like an absolute URI (has a scheme). +func isAbsoluteURI(s string) bool { + u, err := url.Parse(s) + return err == nil && u.Scheme != "" } // Checks if there are more documents that need to be parsed @@ -339,10 +437,12 @@ func (r *documentResolver) ParseNextDocument(metadata *parserMetadata) (string, return nextDocumentId, fmt.Errorf("failed to resolve document '%s': %w", nextDocumentId, err) } - _, err = parseRoot(*documentNode, metadata) - // Mark document as parsed + // Mark as parsed before parseRoot so that intra-document $id-based + // refs resolved during parsing don't re-queue this document. r.parsedExternalDocuments[nextDocumentId] = true + _, err = parseRoot(*documentNode, metadata) + if err != nil { return nextDocumentId, fmt.Errorf("failed to parse document '%s': %w", nextDocumentId, err) } @@ -375,6 +475,49 @@ func (r *documentResolver) resolveDocument(ref string) (*schemaNode, error) { return document, nil } +// resolveDocumentRef resolves a document reference to its canonical document +// ID and path. It checks the $id alias table first (a lightweight map of +// resolved $id URI → document + JSON pointer), falling back to I/O-based +// fetchers only if no alias matches. +func (r *documentResolver) resolveDocumentRef(documentID string, refPath string) (string, string, error) { + // Check $id alias table first — resolves bare-name refs like "color" + // to the real document + JSON pointer path with no I/O. + resolved := resolveRelativeURI(r.GetCurrentScope(), documentID) + if resolved != "" { + if alias, exists := r.idAliases[resolved]; exists { + return alias.documentId, composeJsonPointerPaths(alias.path, refPath), nil + } + } + + // No alias found — fall back to I/O-based fetchers. + if r.HasNoFetchers() { + return r.GetCurrentScope(), refPath, nil + } + + fetcher, err := r.getFetcherForRef(documentID) + if err != nil { + return "", "", fmt.Errorf("failed to get document fetcher for document '%s': %w", documentID, err) + } + + resolvedDocumentId, err := fetcher.resolveDocumentId(r.GetCurrentScope(), documentID) + if err != nil { + return "", "", fmt.Errorf("failed to resolve document ID for reference '%s': %w", documentID, err) + } + + return resolvedDocumentId, refPath, nil +} + +// composeJsonPointerPaths appends the sub-path from a $ref onto an alias's +// base path. For example, alias "#/$defs/color" + ref "#/type" = "#/$defs/color/type". +// If refPath is "#" (root of the aliased resource), the alias path is returned as-is. +func composeJsonPointerPaths(aliasPath string, refPath string) string { + if refPath == "#" || refPath == "" { + return aliasPath + } + // refPath is "#/something" — strip the "#" to get "/something" + return aliasPath + refPath[1:] +} + func (r *documentResolver) getFetcherForRef(documentId string) (documentFetcherInterface, error) { url, err := url.Parse(documentId) if err != nil { diff --git a/document_test.go b/document_test.go index 5ad6dd2..699bd16 100644 --- a/document_test.go +++ b/document_test.go @@ -50,6 +50,20 @@ func TestDeeplyNestedDocumentCluster(t *testing.T) { test.TestJsonSchemaDirWithConfig(t, "test_data/document/file/cluster4_deep_nested", 100, getDocumentChaffConfig(), nil) } +func TestDefinitionIdDocumentCluster(t *testing.T) { + t.Parallel() + test.TestJsonSchemaDirWithConfig(t, "test_data/document/file/cluster5_definition_ids", 100, getDocumentChaffConfig(), nil) +} + +func TestEveryIdPositionCluster(t *testing.T) { + t.Parallel() + test.TestJsonSchemaDirWithConfig(t, "test_data/document/file/cluster6_every_id_position", 100, getDocumentChaffConfig(), func() *chaff.GeneratorOptions { + return &chaff.GeneratorOptions{ + MaximumOneOfAttempts: 10000, + } + }) +} + func TestHttpDocuments(t *testing.T) { server, err := http_schema_server.StartTestServer() if err != nil { diff --git a/test_data/document/file/cluster5_definition_ids/base_definitions.json b/test_data/document/file/cluster5_definition_ids/base_definitions.json new file mode 100644 index 0000000..22098ec --- /dev/null +++ b/test_data/document/file/cluster5_definition_ids/base_definitions.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "base_definitions.json", + "title": "Base definitions with $id-addressable sub-schemas", + "type": "object", + "properties": { + "inlineStatus": { + "$id": "status", + "type": "string", + "enum": ["active", "inactive", "pending"] + }, + "statusCopy": { + "$comment": "Intra-document $ref to a $id on a sibling property", + "$ref": "status" + } + }, + "$defs": { + "color": { + "$id": "color", + "type": "string", + "enum": ["red", "green", "blue", "yellow", "purple"] + }, + "percentage": { + "$id": "percentage", + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "label": { + "$id": "label", + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "nullable-color": { + "$id": "nullable-color", + "oneOf": [ + { "$ref": "color" }, + { "type": "null" } + ] + }, + "nullable-label": { + "$id": "nullable-label", + "oneOf": [ + { "$ref": "label" }, + { "type": "null" } + ] + }, + "color-with-opacity": { + "$id": "color-with-opacity", + "type": "object", + "properties": { + "color": { "$ref": "color" }, + "opacity": { "$ref": "percentage" } + }, + "required": ["color", "opacity"] + } + } +} diff --git a/test_data/document/file/cluster5_definition_ids/main.json b/test_data/document/file/cluster5_definition_ids/main.json new file mode 100644 index 0000000..35edb27 --- /dev/null +++ b/test_data/document/file/cluster5_definition_ids/main.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "main.json", + "title": "Main schema referencing definitions with $id-based $ref", + "type": "object", + "properties": { + "primaryColor": { + "$ref": "base_definitions.json#/$defs/color" + }, + "secondaryColor": { + "$ref": "base_definitions.json#/$defs/nullable-color" + }, + "title": { + "$ref": "base_definitions.json#/$defs/label" + }, + "subtitle": { + "$ref": "base_definitions.json#/$defs/nullable-label" + }, + "brightness": { + "$ref": "base_definitions.json#/$defs/percentage" + }, + "highlight": { + "$ref": "base_definitions.json#/$defs/color-with-opacity" + }, + "currentStatus": { + "$comment": "Cross-document ref to a $id-declared property via explicit path", + "$ref": "base_definitions.json#/properties/inlineStatus" + } + }, + "required": ["primaryColor", "title", "brightness", "highlight", "currentStatus"] +} diff --git a/test_data/document/file/cluster5_definition_ids/single_doc_ids.json b/test_data/document/file/cluster5_definition_ids/single_doc_ids.json new file mode 100644 index 0000000..da0b807 --- /dev/null +++ b/test_data/document/file/cluster5_definition_ids/single_doc_ids.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "single_doc_ids.json", + "title": "Single document with $id on arbitrary schema positions", + "type": "object", + "properties": { + "name": { + "$id": "name-schema", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "age": { + "$id": "age-schema", + "type": "integer", + "minimum": 0, + "maximum": 150 + }, + "nameRef": { + "$comment": "$ref to $id on a sibling property", + "$ref": "name-schema" + }, + "ageRef": { + "$comment": "$ref to $id on a sibling property", + "$ref": "age-schema" + }, + "tag": { + "$ref": "#/$defs/tag" + }, + "tagById": { + "$comment": "$ref to $id declared in $defs", + "$ref": "tag-schema" + }, + "metadata": { + "$comment": "Nested object whose children $ref $id-declared schemas", + "$ref": "#/$defs/metadata" + } + }, + "required": ["name", "age", "nameRef", "ageRef", "tag", "tagById", "metadata"], + "$defs": { + "tag": { + "$id": "tag-schema", + "type": "string", + "enum": ["alpha", "beta", "release"] + }, + "metadata": { + "type": "object", + "properties": { + "createdBy": { "$ref": "name-schema" }, + "version": { "$ref": "tag-schema" } + }, + "required": ["createdBy", "version"] + } + } +} diff --git a/test_data/document/file/cluster6_every_id_position/cross_doc_refs.json b/test_data/document/file/cluster6_every_id_position/cross_doc_refs.json new file mode 100644 index 0000000..e90eafc --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/cross_doc_refs.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cross_doc_refs.json", + "type": "object", + "properties": { + "fromProperty": { + "$comment": "Cross-doc ref to a property with $id in id_in_properties.json", + "$ref": "id_in_properties.json#/properties/original" + }, + "fromDef": { + "$comment": "Cross-doc ref to a $defs entry with $id in id_in_defs.json", + "$ref": "id_in_defs.json#/$defs/myDef" + }, + "fromItems": { + "$comment": "Cross-doc ref to items with $id in id_in_items.json", + "$ref": "id_in_items.json#/properties/list/items" + }, + "fromContains": { + "$comment": "Cross-doc ref to contains with $id in id_in_contains.json", + "$ref": "id_in_contains.json#/properties/list/contains" + }, + "fromNot": { + "$comment": "Cross-doc ref to not schema with $id in id_in_not.json", + "$ref": "id_in_not.json#/properties/nonNull/not" + } + }, + "required": ["fromProperty", "fromDef", "fromItems", "fromContains", "fromNot"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_additional_properties.json b/test_data/document/file/cluster6_every_id_position/id_in_additional_properties.json new file mode 100644 index 0000000..6d383ee --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_additional_properties.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_additional_properties.json", + "type": "object", + "properties": { + "container": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 20 } + }, + "required": ["name"], + "additionalProperties": { + "$id": "addl-prop-schema", + "type": "integer", + "minimum": 0, + "maximum": 50 + } + }, + "intRef": { "$ref": "addl-prop-schema" } + }, + "required": ["container", "intRef"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_all_of.json b/test_data/document/file/cluster6_every_id_position/id_in_all_of.json new file mode 100644 index 0000000..3eead83 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_all_of.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_all_of.json", + "type": "object", + "properties": { + "merged": { + "allOf": [ + { + "$id": "allof-base", + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 10 } + }, + "required": ["name"] + }, + { + "type": "object", + "properties": { + "age": { "type": "integer", "minimum": 0, "maximum": 150 } + }, + "required": ["age"] + } + ] + }, + "baseCopy": { "$ref": "allof-base" } + }, + "required": ["merged", "baseCopy"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_any_of.json b/test_data/document/file/cluster6_every_id_position/id_in_any_of.json new file mode 100644 index 0000000..b792162 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_any_of.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_any_of.json", + "type": "object", + "properties": { + "flexible": { + "anyOf": [ + { + "$id": "anyof-str", + "type": "string", + "enum": ["hello", "world"] + }, + { + "$id": "anyof-num", + "type": "integer", + "minimum": 1, + "maximum": 5 + } + ] + }, + "strOnly": { "$ref": "anyof-str" }, + "numOnly": { "$ref": "anyof-num" } + }, + "required": ["flexible", "strOnly", "numOnly"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_contains.json b/test_data/document/file/cluster6_every_id_position/id_in_contains.json new file mode 100644 index 0000000..406dc0d --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_contains.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_contains.json", + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "string", + "const": "special" + }, + "contains": { + "$id": "contains-item-schema", + "type": "string", + "const": "special" + }, + "minItems": 1, + "maxItems": 1 + }, + "special": { "$ref": "contains-item-schema" } + }, + "required": ["list", "special"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_defs.json b/test_data/document/file/cluster6_every_id_position/id_in_defs.json new file mode 100644 index 0000000..5a62ed8 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_defs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_defs.json", + "type": "object", + "properties": { + "value": { "$ref": "def-schema" } + }, + "required": ["value"], + "$defs": { + "myDef": { + "$id": "def-schema", + "type": "integer", + "minimum": 1, + "maximum": 10 + } + } +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_if_then_else.json b/test_data/document/file/cluster6_every_id_position/id_in_if_then_else.json new file mode 100644 index 0000000..1f25461 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_if_then_else.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_if_then_else.json", + "type": "object", + "properties": { + "guarded": { + "type": "string", + "if": { + "$id": "if-short-str", + "type": "string", + "maxLength": 5 + }, + "then": { + "$id": "then-min-str", + "type": "string", + "minLength": 2 + }, + "else": { + "$id": "else-pattern-str", + "type": "string", + "pattern": "^[a-z]+$" + } + }, + "shortStr": { "$ref": "if-short-str" }, + "minStr": { "$ref": "then-min-str" }, + "patternStr": { "$ref": "else-pattern-str" } + }, + "required": ["guarded", "shortStr", "minStr", "patternStr"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_items.json b/test_data/document/file/cluster6_every_id_position/id_in_items.json new file mode 100644 index 0000000..bf868aa --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_items.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_items.json", + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$id": "item-schema", + "type": "string", + "enum": ["x", "y", "z"] + }, + "minItems": 1, + "maxItems": 3 + }, + "single": { "$ref": "item-schema" } + }, + "required": ["list", "single"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_not.json b/test_data/document/file/cluster6_every_id_position/id_in_not.json new file mode 100644 index 0000000..ede44c1 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_not.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_not.json", + "type": "object", + "properties": { + "nonNull": { + "type": "string", + "minLength": 1, + "not": { + "$id": "null-type-schema", + "type": "null" + } + }, + "actualNull": { "$ref": "null-type-schema" } + }, + "required": ["nonNull", "actualNull"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_one_of.json b/test_data/document/file/cluster6_every_id_position/id_in_one_of.json new file mode 100644 index 0000000..582b2b7 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_one_of.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_one_of.json", + "type": "object", + "properties": { + "choice": { + "oneOf": [ + { + "$id": "circle-schema", + "type": "object", + "properties": { + "kind": { "const": "circle" }, + "radius": { "type": "number", "minimum": 1, "maximum": 100 } + }, + "required": ["kind", "radius"], + "additionalProperties": false + }, + { + "$id": "rect-schema", + "type": "object", + "properties": { + "kind": { "const": "rect" }, + "width": { "type": "number", "minimum": 1, "maximum": 100 }, + "height": { "type": "number", "minimum": 1, "maximum": 100 } + }, + "required": ["kind", "width", "height"], + "additionalProperties": false + } + ] + }, + "alwaysCircle": { "$ref": "circle-schema" }, + "alwaysRect": { "$ref": "rect-schema" } + }, + "required": ["choice", "alwaysCircle", "alwaysRect"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_pattern_properties.json b/test_data/document/file/cluster6_every_id_position/id_in_pattern_properties.json new file mode 100644 index 0000000..b6e5f4f --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_pattern_properties.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_pattern_properties.json", + "type": "object", + "properties": { + "container": { + "type": "object", + "patternProperties": { + "^s_": { + "$id": "str-pattern-schema", + "type": "string", + "enum": ["x", "y", "z"] + } + } + }, + "patRef": { "$ref": "str-pattern-schema" } + }, + "required": ["container", "patRef"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_prefix_items.json b/test_data/document/file/cluster6_every_id_position/id_in_prefix_items.json new file mode 100644 index 0000000..79b60e8 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_prefix_items.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_prefix_items.json", + "type": "object", + "properties": { + "tuple": { + "type": "array", + "prefixItems": [ + { + "$id": "first-element", + "type": "string", + "const": "fixed" + }, + { + "$id": "second-element", + "type": "integer", + "minimum": 0, + "maximum": 99 + } + ], + "minItems": 2, + "maxItems": 2 + }, + "firstCopy": { "$ref": "first-element" }, + "secondCopy": { "$ref": "second-element" } + }, + "required": ["tuple", "firstCopy", "secondCopy"] +} diff --git a/test_data/document/file/cluster6_every_id_position/id_in_properties.json b/test_data/document/file/cluster6_every_id_position/id_in_properties.json new file mode 100644 index 0000000..212b2a9 --- /dev/null +++ b/test_data/document/file/cluster6_every_id_position/id_in_properties.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "id_in_properties.json", + "type": "object", + "properties": { + "original": { + "$id": "prop-schema", + "type": "string", + "enum": ["alpha", "beta"] + }, + "copy": { "$ref": "prop-schema" } + }, + "required": ["original", "copy"] +} diff --git a/walk.go b/walk.go new file mode 100644 index 0000000..3b74f58 --- /dev/null +++ b/walk.go @@ -0,0 +1,32 @@ +package chaff + +import "fmt" + +// WalkSchema recursively visits every JSON object node in a schema tree. +// The visitor is called for each node, parents before children. +// path tracks the JSON pointer path (e.g. "/properties/name"). +func WalkSchema(node map[string]interface{}, path string, visit func(node map[string]interface{}, path string)) { + visit(node, path) + + for key, value := range node { + childPath := path + "/" + key + + if obj, ok := value.(map[string]interface{}); ok { + WalkSchema(obj, childPath, visit) + continue + } + + arr, ok := value.([]interface{}) + if !ok { + continue + } + + for i, item := range arr { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + WalkSchema(obj, fmt.Sprintf("%s/%d", childPath, i), visit) + } + } +}