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:
parent
15e0c9ef02
commit
0a79e67c62
4 changed files with 181 additions and 8 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue