jsonschema: resolve URIs for $id

Assign a base URI to every schema.

See the comment on resolveURIs for details.

Change-Id: I2332d825c486766832503cabdd0bf9d819171d53
Reviewed-on: https://go-review.googlesource.com/c/tools/+/669997
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
This commit is contained in:
Jonathan Amsterdam 2025-04-26 11:13:17 -04:00
commit 0a79e67c62

View file

@ -10,6 +10,7 @@ package jsonschema
import (
"errors"
"fmt"
"net/url"
"regexp"
)
@ -20,20 +21,44 @@ import (
// Call [Schema.Resolve] to obtain a Resolved from a Schema.
type Resolved struct {
root *Schema
// map from $ids to their schemas
resolvedURIs map[string]*Schema
}
// Resolve resolves all references within the schema and performs other tasks that
// prepare the schema for validation.
func (root *Schema) Resolve() (*Resolved, error) {
// baseURI can be empty, or 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 not contain relative URI references.
func (root *Schema) Resolve(baseURI string) (*Resolved, error) {
// There are three steps involved in preparing a schema to validate.
// 1. Check: validate the schema against a meta-schema, and perform other well-formedness
// checks. Precompute some values along the way.
// 2. Resolve URIs: TODO.
// 2. 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.
// 3. Resolve references: TODO.
if err := root.check(); err != nil {
return nil, err
}
return &Resolved{root: root}, nil
var base *url.URL
if baseURI == "" {
base = &url.URL{} // so we can call ResolveReference on it
} else {
var err error
base, err = url.Parse(baseURI)
if err != nil {
return nil, fmt.Errorf("parsing base URI: %w", err)
}
}
m, err := resolveURIs(root, base)
if err != nil {
return nil, err
}
return &Resolved{
root: root,
resolvedURIs: m,
}, nil
}
func (s *Schema) check() error {
@ -91,3 +116,84 @@ func (s *Schema) checkLocal(report func(error)) {
}
}
}
// 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.
// TODO(jba): anchors (section 8.2.2)
// TODO(jba): dynamicAnchors (ditto)
//
// 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(root *Schema, baseURI *url.URL) (map[string]*Schema, error) {
resolvedURIs := map[string]*Schema{}
var resolve func(s, base *Schema) error
resolve = func(s, base *Schema) error {
// ids are scoped to the root.
if s.ID == "" {
// If a schema doesn't have an $id, its base is the parent base.
s.baseURI = base.baseURI
} else {
// A non-empty ID establishes a new base.
idURI, err := url.Parse(s.ID)
if err != nil {
return err
}
if idURI.Fragment != "" {
return fmt.Errorf("$id %s must not have a fragment", s.ID)
}
// The base URI for this schema is its $id resolved against the parent base.
s.baseURI = base.baseURI.ResolveReference(idURI)
if !s.baseURI.IsAbs() {
return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %s)", s.ID, s.baseURI)
}
resolvedURIs[s.baseURI.String()] = s
base = s // needed for anchors
}
for c := range s.children() {
if err := resolve(c, base); err != nil {
return err
}
}
return nil
}
// Set the root URI to the base for now. If the root has an $id, the base will change.
root.baseURI = baseURI
// The original base, even if changed, is still a valid way to refer to the root.
resolvedURIs[baseURI.String()] = root
if err := resolve(root, root); err != nil {
return nil, err
}
return resolvedURIs, nil
}