// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.// Use of this source code is governed by an MIT-style// license that can be found in the LICENSE file.// This file deals with preparing a schema for validation, including various checks,// optimizations, and the resolution of cross-schema references.package jsonschemaimport ()// A Resolved consists of a [Schema] along with associated information needed to// validate documents against it.// A Resolved has been validated against its meta-schema, and all its references// (the $ref and $dynamicRef keywords) have been resolved to their referenced Schemas.// Call [Schema.Resolve] to obtain a Resolved from a Schema.typeResolvedstruct { root *Schema draft draft// map from $ids to their schemas resolvedURIs map[string]*Schema// map from schemas to additional info computed during resolution resolvedInfos map[*Schema]*resolvedInfo}type draft intconst ( draft7 = iota draft2020)func newResolved( *Schema) *Resolved {return &Resolved{root: ,draft: detectDraft(),resolvedURIs: map[string]*Schema{},resolvedInfos: map[*Schema]*resolvedInfo{}, }}// detectDraft inspects the raw JSON to determine the schema version.func detectDraft( *Schema) draft {// Check explicit $schema declarationswitch .Schema {casedraft7SchemaVersion, draft7SecSchemaVersion:returndraft7casedraft202012SchemaVersion:returndraft2020default:// If nothing matches default to the latest supported version.returndraft2020 }}// resolvedInfo holds information specific to a schema that is computed by [Schema.Resolve].type resolvedInfo struct { s *Schema// The JSON Pointer path from the root schema to here. // Used in errors. path string// The schema's base schema. // If the schema is the root or has an ID, its base is itself. // Otherwise, its base is the innermost enclosing schema whose base // is itself. // Intuitively, a base schema is one that can be referred to with a // fragmentless URI. base *Schema// The URI for the schema, if it is the root or has an ID. // Otherwise nil. // Invariants: // s.base.uri != nil. // s.base == s <=> s.uri != nil uri *url.URL// The schema to which Ref refers. resolvedRef *Schema// If the schema has a dynamic ref, exactly one of the next two fields // will be non-zero after successful resolution. // The schema to which the dynamic ref refers when it acts lexically. resolvedDynamicRef *Schema// The anchor to look up on the stack when the dynamic ref acts dynamically. dynamicRefAnchor string// The following fields are independent of arguments to Schema.Resolved, // so they could live on the Schema. We put them here for simplicity.// The set of required properties. isRequired map[string]bool// Compiled regexps. pattern *regexp.Regexp patternProperties map[*regexp.Regexp]*Schema// Map from anchors to subschemas. anchors map[string]anchorInfo}// Schema returns the schema that was resolved.// It must not be modified.func ( *Resolved) () *Schema { return .root }// schemaString returns a short string describing the schema.func ( *Resolved) ( *Schema) string {if .ID != "" {return .ID } := .resolvedInfos[]if .path != "" {return .path }return"<anonymous schema>"}// A Loader reads and unmarshals the schema at uri, if any.typeLoaderfunc(uri *url.URL) (*Schema, error)// ResolveOptions are options for [Schema.Resolve].typeResolveOptionsstruct {// BaseURI is the URI relative to which the root schema should be resolved. // If non-empty, must be an absolute URI (one that starts with a scheme). // It is resolved (in the URI sense; see [url.ResolveReference]) with root's // $id property. // If the resulting URI is not absolute, then the schema cannot contain // relative URI references. BaseURI string// Loader loads schemas that are referred to by a $ref but are not under the // root schema (remote references). // If nil, resolving a remote reference will return an error. Loader Loader// ValidateDefaults determines whether to validate values of "default" keywords // against their schemas. // The [JSON Schema specification] does not require this, but it is recommended // if defaults will be used. // // [JSON Schema specification]: https://json-schema.org/understanding-json-schema/reference/annotations ValidateDefaults bool}// Resolve resolves all references within the schema and performs other tasks that// prepare the schema for validation.// If opts is nil, the default values are used.// The schema must not be changed after Resolve is called.// The same schema may be resolved multiple times.func ( *Schema) ( *ResolveOptions) (*Resolved, error) {// There are up to five steps required to prepare a schema to validate. // 1. Load: read the schema from somewhere and unmarshal it. // This schema (root) may have been loaded or created in memory, but other schemas that // come into the picture in step 4 will be loaded by the given loader. // 2. Check: validate the schema against a meta-schema, and perform other well-formedness checks. // Precompute some values along the way. // 3. Resolve URIs: determine the base URI of the root and all its subschemas, and // resolve (in the URI sense) all identifiers and anchors with their bases. This step results // in a map from URIs to schemas within root. // 4. Resolve references: all refs in the schemas are replaced with the schema they refer to. // 5. (Optional.) If opts.ValidateDefaults is true, validate the defaults. := &resolver{loaded: map[string]*Resolved{}}if != nil { .opts = * }var *url.URLif .opts.BaseURI == "" { = &url.URL{} // so we can call ResolveReference on it } else {varerror , = url.Parse(.opts.BaseURI)if != nil {returnnil, fmt.Errorf("parsing base URI: %w", ) } }if .opts.Loader == nil { .opts.Loader = func( *url.URL) (*Schema, error) {returnnil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve") } } , := .resolve(, )if != nil {returnnil, }if .opts.ValidateDefaults {if := .validateDefaults(); != nil {returnnil, } }// TODO: before we return, throw away anything we don't need for validation.return , nil}// A resolver holds the state for resolution.type resolver struct { opts ResolveOptions// A cache of loaded and partly resolved schemas. (They may not have had their // refs resolved.) The cache ensures that the loader will never be called more // than once with the same URI, and that reference cycles are handled properly. loaded map[string]*Resolved}func ( *resolver) ( *Schema, *url.URL) (*Resolved, error) {if .Fragment != "" {returnnil, fmt.Errorf("base URI %s must not have a fragment", ) } := newResolved()if := .check(.resolvedInfos); != nil {returnnil, }if := resolveURIs(, ); != nil {returnnil, }// Remember the schema by both the URI we loaded it from and its canonical name, // which may differ if the schema has an $id. // We must set the map before calling resolveRefs, or ref cycles will cause unbounded recursion. .loaded[.String()] = .loaded[.resolvedInfos[].uri.String()] = if := .resolveRefs(); != nil {returnnil, }return , nil}func ( *Schema) ( map[*Schema]*resolvedInfo) error {// Check for structural validity. Do this first and fail fast: // bad structure will cause other code to panic.if := .checkStructure(); != nil {return }var []error := func( error) { = append(, ) }for := range .all() { .checkLocal(, ) }returnerrors.Join(...)}// checkStructure verifies that root and its subschemas form a tree.// It also assigns each schema a unique path, to improve error messages.func ( *Schema) ( map[*Schema]*resolvedInfo) error {assert(len() == 0, "non-empty infos")varfunc(reflect.Value, []byte) error = func( reflect.Value, []byte) error {// For the purpose of error messages, the root schema has path "root" // and other schemas' paths are their JSON Pointer from the root. := "root"iflen() > 0 { = string() } := .Interface().(*Schema)if == nil {returnfmt.Errorf("jsonschema: schema at %s is nil", ) }if , := []; {// We've seen s before. // The schema graph at root is not a tree, but it needs to // be because a schema's base must be unique. // A cycle would also put Schema.all into an infinite recursion.returnfmt.Errorf("jsonschema: schemas at %s do not form a tree; %s appears more than once (also at %s)", , .path, ) } [] = &resolvedInfo{s: , path: }for , := rangeschemaFieldInfos { := .Elem().FieldByIndex(.sf.Index)switch .sf.Type {caseschemaType:// A field that contains an individual schema. // A nil is valid: it just means the field isn't present.if !.IsNil() {if := (, fmt.Appendf(, "/%s", .jsonName)); != nil {return } }caseschemaSliceType:for := range .Len() {if := (.Index(), fmt.Appendf(, "/%s/%d", .jsonName, )); != nil {return } }caseschemaMapType: := .MapRange()for .Next() { := escapeJSONPointerSegment(.Key().String())if := (.Value(), fmt.Appendf(, "/%s/%s", .jsonName, )); != nil {return } } } }returnnil }return (reflect.ValueOf(), make([]byte, 0, 256))}// checkLocal checks s for validity, independently of other schemas it may refer to.// Since checking a regexp involves compiling it, checkLocal saves those compiled regexps// in the schema for later use.// It appends the errors it finds to errs.func ( *Schema) ( func(error), map[*Schema]*resolvedInfo) { := func( string, ...any) { := fmt.Sprintf(, ...) (fmt.Errorf("jsonschema.Schema: %s: %s", , )) }if == nil { ("nil subschema")return }if := .basicChecks(); != nil { ()return }// TODO: validate the schema's properties, // ideally by jsonschema-validating it against the meta-schema.// Some properties are present so that Schemas can round-trip, but we do not // validate them. // Currently, it's just the $vocabulary property. // As a special case, we can validate the 2020-12 meta-schema.if .Vocabulary != nil && .Schema != draft202012SchemaVersion { ("cannot validate a schema with $vocabulary") } := []// Check and compile regexps.if .Pattern != "" { , := regexp.Compile(.Pattern)if != nil { ("pattern: %v", ) } else { .pattern = } }iflen(.PatternProperties) > 0 { .patternProperties = map[*regexp.Regexp]*Schema{}for , := range .PatternProperties { , := regexp.Compile()if != nil { ("patternProperties[%q]: %v", , )continue } .patternProperties[] = } }// Build a set of required properties, to avoid quadratic behavior when validating // a struct.iflen(.Required) > 0 { .isRequired = map[string]bool{}for , := range .Required { .isRequired[] = true } }}// resolveURIs resolves the ids and anchors in all the schemas of root, relative// to baseURI.// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2, section// 8.2.1.//// Every schema has a base URI and a parent base URI.//// The parent base URI is the base URI of the lexically enclosing schema, or for// a root schema, the URI it was loaded from or the one supplied to [Schema.Resolve].//// If the schema has no $id property, the base URI of a schema is that of its parent.// If the schema does have an $id, it must be a URI, possibly relative. The schema's// base URI is the $id resolved (in the sense of [url.URL.ResolveReference]) against// the parent base.//// As an example, consider this schema loaded from http://a.com/root.json (quotes omitted)://// {// allOf: [// {$id: "sub1.json", minLength: 5},// {$id: "http://b.com", minimum: 10},// {not: {maximum: 20}}// ]// }//// The base URIs are as follows. Schema locations are expressed in the JSON Pointer notation.//// schema base URI// root http://a.com/root.json// allOf/0 http://a.com/sub1.json// allOf/1 http://b.com (absolute $id; doesn't matter that it's not under the loaded URI)// allOf/2 http://a.com/root.json (inherited from parent)// allOf/2/not http://a.com/root.json (inherited from parent)func resolveURIs( *Resolved, *url.URL) error {// Anchors and dynamic anchors are URI fragments that are scoped to their base. // We treat them as keys in a map stored within the schema. := func( *Schema, *resolvedInfo, string, bool) error {if != "" {if , := .anchors[]; {returnfmt.Errorf("duplicate anchor %q in %s", , .uri) }if .anchors == nil { .anchors = map[string]anchorInfo{} } .anchors[] = anchorInfo{, } }returnnil }varfunc(, *Schema) error = func(, *Schema) error { := .resolvedInfos[] := .resolvedInfos[]// ids are scoped to the root.if .ID != "" {// draft-7 specific // https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3 // "All other properties in a "$ref" object MUST be ignored." := .draft == draft7 && .Ref != ""if ! {// A non-empty ID establishes a new base. , := url.Parse(.ID)if != nil {return }if .draft == draft2020 && .Fragment != "" {returnfmt.Errorf("$id %s must not have a fragment", .ID) }if .draft == draft7 && .Fragment != "" {// anchor did not exist in draft 7, id was used for base uri and document navigation // https://json-schema.org/draft-07/draft-handrews-json-schema-01#id-keyword := strings.TrimPrefix(.ID, "#") (, , , false) } else {// The base URI for this schema is its $id resolved against the parent base. .uri = .uri.ResolveReference()if !.uri.IsAbs() {returnfmt.Errorf("$id %s does not resolve to an absolute URI (base is %q)", .ID, .uri) } .resolvedURIs[.uri.String()] = = // needed for anchors = .resolvedInfos[] } } } .base = if .draft == draft2020 { (, , .Anchor, false) (, , .DynamicAnchor, true) }for := range .children() {if := (, ); != nil {return } }returnnil }// Set the root URI to the base for now. If the root has an $id, this will change. .resolvedInfos[.root].uri = // The original base, even if changed, is still a valid way to refer to the root. .resolvedURIs[.String()] = .rootreturn (.root, .root)}// resolveRefs replaces every ref in the schemas with the schema it refers to.// A reference that doesn't resolve within the schema may refer to some other schema// that needs to be loaded.func ( *resolver) ( *Resolved) error {for := range .root.all() { := .resolvedInfos[]if .Ref != "" { , , := .resolveRef(, , .Ref)if != nil {return }// Whether or not the anchor referred to by $ref fragment is dynamic, // the ref still treats it lexically. .resolvedRef = }if .DynamicRef != "" { , , := .resolveRef(, , .DynamicRef)if != nil {return }if != "" {// The dynamic ref's fragment points to a dynamic anchor. // We must resolve the fragment at validation time. .dynamicRefAnchor = } else {// There is no dynamic anchor in the lexically referenced schema, // so the dynamic ref behaves like a lexical ref. .resolvedDynamicRef = } } }returnnil}// resolveRef resolves the reference ref, which is either s.Ref or s.DynamicRef.func ( *resolver) ( *Resolved, *Schema, string) ( *Schema, string, error) { , := url.Parse()if != nil {returnnil, "", }// URI-resolve the ref against the current base URI to get a complete URI. := .resolvedInfos[].base = .resolvedInfos[].uri.ResolveReference()// The non-fragment part of a ref URI refers to the base URI of some schema. // This part is the same for dynamic refs too: their non-fragment part resolves // lexically. := * .Fragment = "" := &// Look it up locally. := .resolvedURIs[.String()]if == nil {// The schema is remote. Maybe we've already loaded it. // We assume that the non-fragment part of refURI refers to a top-level schema // document. That is, we don't support the case exemplified by // http://foo.com/bar.json/baz, where the document is in bar.json and // the reference points to a subschema within it. // TODO: support that case.if := .loaded[.String()]; != nil { = .root } else {// Try to load the schema. , := .opts.Loader()if != nil {returnnil, "", fmt.Errorf("loading %s: %w", , ) }// Check if referenced schema has $schema defined. If not it should inherit the resolvedif .Schema == "" { .Schema = .Schema } , := .resolve(, )if != nil {returnnil, "", } = .rootassert( != nil, "nil referenced schema")// Copy the resolvedInfos from lrs into rs, without overwriting // (hence we can't use maps.Insert).for , := range .resolvedInfos {if .resolvedInfos[] == nil { .resolvedInfos[] = } } } } := .Fragment// Look up frag in refSchema. // frag is either a JSON Pointer or the name of an anchor. // A JSON Pointer is either the empty string or begins with a '/', // whereas anchors are always non-empty strings that don't contain slashes.if != "" && !strings.HasPrefix(, "/") { := .resolvedInfos[] , := .anchors[]if ! {returnnil, "", fmt.Errorf("no anchor %q in %s", , ) }if .dynamic { = }return .schema, , nil }// frag is a JSON Pointer. , = dereferenceJSONPointer(, )return , "", }
The pages are generated with Goldsv0.8.4. (GOOS=linux GOARCH=amd64)
Golds is a Go 101 project developed by Tapir Liu.
PR and bug reports are welcome and can be submitted to the issue list.
Please follow @zigo_101 (reachable from the left QR code) to get the latest news of Golds.