diff --git a/jsonschema/resolve.go b/jsonschema/resolve.go index cd46b28..e3cd3da 100644 --- a/jsonschema/resolve.go +++ b/jsonschema/resolve.go @@ -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 +} diff --git a/jsonschema/resolve_test.go b/jsonschema/resolve_test.go index 73ce3fe..b790799 100644 --- a/jsonschema/resolve_test.go +++ b/jsonschema/resolve_test.go @@ -5,7 +5,10 @@ package jsonschema import ( + "maps" + "net/url" "regexp" + "slices" "testing" ) @@ -24,7 +27,7 @@ func TestCheckLocal(t *testing.T) { "regexp", }, } { - _, err := tt.s.Resolve() + _, err := tt.s.Resolve("") if err == nil { t.Errorf("%s: unexpectedly passed", tt.s.json()) continue @@ -35,3 +38,56 @@ func TestCheckLocal(t *testing.T) { } } } + +func TestResolveURIs(t *testing.T) { + for _, baseURI := range []string{"", "http://a.com"} { + t.Run(baseURI, func(t *testing.T) { + root := &Schema{ + ID: "http://b.com", + Items: &Schema{ + ID: "/foo.json", + }, + Contains: &Schema{ + ID: "/bar.json", + Anchor: "a", + Items: &Schema{ + Anchor: "b", + Items: &Schema{ + // An ID shouldn't be a query param, but this tests + // resolving an ID with its parent. + ID: "?items", + Anchor: "c", + }, + }, + }, + } + base, err := url.Parse(baseURI) + if err != nil { + t.Fatal(err) + } + got, err := resolveURIs(root, base) + if err != nil { + t.Fatal(err) + } + + want := map[string]*Schema{ + baseURI: root, + "http://b.com/foo.json": root.Items, + "http://b.com/bar.json": root.Contains, + "http://b.com/bar.json?items": root.Contains.Items.Items, + } + if baseURI != root.ID { + want[root.ID] = root + } + + gotKeys := slices.Sorted(maps.Keys(got)) + wantKeys := slices.Sorted(maps.Keys(want)) + if !slices.Equal(gotKeys, wantKeys) { + t.Errorf("ID keys:\ngot %q\nwant %q", gotKeys, wantKeys) + } + if !maps.Equal(got, want) { + t.Errorf("IDs:\ngot %+v\n\nwant %+v", got, want) + } + }) + } +} diff --git a/jsonschema/schema.go b/jsonschema/schema.go index f0c4475..c07dd68 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -13,6 +13,7 @@ import ( "fmt" "iter" "math" + "net/url" "regexp" ) @@ -109,6 +110,11 @@ type Schema struct { DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"` // computed fields + // If the schema doesn't have an ID, the base URI is that of its parent. + // Otherwise, the base URI is the ID resolved against the parent's baseURI. + // The parent base URI at top level is where the schema was loaded from, or + // if not loaded, then it should be provided to Schema.Resolve. + baseURI *url.URL pattern *regexp.Regexp patternProperties map[*regexp.Regexp]*Schema } @@ -283,11 +289,11 @@ func (s *Schema) every(f func(*Schema) bool) bool { f(s) && s.everyChild(f) } -// everyChild returns an iterator over the immediate child schemas of s. +// everyChild reports whether f is true for every immediate child schema of s. // -// It does not yield nils from fields holding individual schemas, like Contains, +// It does not call f on nil-valued fields holding individual schemas, like Contains, // because a nil value indicates that the field is absent. -// It does yield nils when they occur in slices and maps, so those invalid values +// It does call f on nils when they occur in slices and maps, so those invalid values // can be detected when the schema is validated. func (s *Schema) everyChild(f func(*Schema) bool) bool { // Fields that contain individual schemas. A nil is valid: it just means the field isn't present. @@ -324,3 +330,8 @@ func (s *Schema) everyChild(f func(*Schema) bool) bool { func (s *Schema) all() iter.Seq[*Schema] { return func(yield func(*Schema) bool) { s.every(yield) } } + +// children wraps everyChild in an iterator. +func (s *Schema) children() iter.Seq[*Schema] { + return func(yield func(*Schema) bool) { s.everyChild(yield) } +} diff --git a/jsonschema/validate_test.go b/jsonschema/validate_test.go index 54b3959..2b0ab22 100644 --- a/jsonschema/validate_test.go +++ b/jsonschema/validate_test.go @@ -51,7 +51,7 @@ func TestValidate(t *testing.T) { } for _, g := range groups { t.Run(g.Description, func(t *testing.T) { - rs, err := g.Schema.Resolve() + rs, err := g.Schema.Resolve("") if err != nil { t.Fatal(err) }