From 9031106300cabf560fecacde6f562ea09974c8ed Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Sat, 26 Apr 2025 15:34:28 -0400 Subject: [PATCH] jsonschema: implement JSON Pointers for schemas Implement enough of the JSON Pointer spec to suffice for JSON Schema. For details, see the comment at the top of json_pointer.go. Change-Id: Ic297f1a1a08903b4b3c6666fe57ac1f45c96c0f7 Reviewed-on: https://go-review.googlesource.com/c/tools/+/670535 Reviewed-by: Alan Donovan LUCI-TryBot-Result: Go LUCI --- jsonschema/json_pointer.go | 150 ++++++++++++++++++++++++++++++++ jsonschema/json_pointer_test.go | 78 +++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 jsonschema/json_pointer.go create mode 100644 jsonschema/json_pointer_test.go diff --git a/jsonschema/json_pointer.go b/jsonschema/json_pointer.go new file mode 100644 index 0000000..c174c1b --- /dev/null +++ b/jsonschema/json_pointer.go @@ -0,0 +1,150 @@ +// Copyright 2025 The Go MCP SDK 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 implements JSON Pointers. +// A JSON Pointer is a path that refers to one JSON value within another. +// If the path is empty, it refers to the root value. +// Otherwise, it is a sequence of slash-prefixed strings, like "/points/1/x", +// selecting successive properties (for JSON objects) or items (for JSON arrays). +// For example, when applied to this JSON value: +// { +// "points": [ +// {"x": 1, "y": 2}, +// {"x": 3, "y": 4} +// ] +// } +// +// the JSON Pointer "/points/1/x" refers to the number 3. +// See the spec at https://datatracker.ietf.org/doc/html/rfc6901. + +package jsonschema + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" +) + +var jsonPointerReplacer = strings.NewReplacer("~0", "~", "~1", "/") + +// parseJSONPointer splits a JSON Pointer into a sequence of segments. It doesn't +// convert strings to numbers, because that depends on the traversal: a segment +// is treated as a number when applied to an array, but a string when applied to +// an object. See section 4 of the spec. +func parseJSONPointer(ptr string) (segments []string, err error) { + if ptr == "" { + return nil, nil + } + if ptr[0] != '/' { + return nil, fmt.Errorf("JSON Pointer %q does not begin with '/'", ptr) + } + // Unlike file paths, consecutive slashes are not coalesced. + // Split is nicer than Cut here, because it gets a final "/" right. + segments = strings.Split(ptr[1:], "/") + if strings.Contains(ptr, "~") { + // Undo the simple escaping rules that allow one to include a slash in a segment. + for i := range segments { + segments[i] = jsonPointerReplacer.Replace(segments[i]) + } + } + return segments, nil +} + +// dereferenceJSONPointer returns the Schema that sptr points to within s, +// or an error if none. +// This implementation suffices for JSON Schema: pointers are applied only to Schemas, +// and refer only to Schemas. +func dereferenceJSONPointer(s *Schema, sptr string) (_ *Schema, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("JSON Pointer %q: %w", sptr, err) + } + }() + + segments, err := parseJSONPointer(sptr) + if err != nil { + return nil, err + } + v := reflect.ValueOf(s) + for _, seg := range segments { + switch v.Kind() { + case reflect.Pointer: + v = v.Elem() + if !v.IsValid() { + return nil, errors.New("navigated to nil reference") + } + fallthrough // if valid, can only be a pointer to a Schema + + case reflect.Struct: + // The segment must refer to a field in a Schema. + if v.Type() != reflect.TypeFor[Schema]() { + return nil, fmt.Errorf("navigated to non-Schema %s", v.Type()) + } + v = lookupSchemaField(v, seg) + if !v.IsValid() { + return nil, fmt.Errorf("no schema field %q", seg) + } + case reflect.Slice, reflect.Array: + // The segment must be an integer without leading zeroes that refers to an item in the + // slice or array. + if seg == "-" { + return nil, errors.New("the JSON Pointer array segment '-' is not supported") + } + if len(seg) > 1 && seg[0] == '0' { + return nil, fmt.Errorf("segment %q has leading zeroes", seg) + } + n, err := strconv.Atoi(seg) + if err != nil { + return nil, fmt.Errorf("invalid int: %q", seg) + } + if n < 0 || n >= v.Len() { + return nil, fmt.Errorf("index %d is out of bounds for array of length %d", n, v.Len()) + } + v = v.Index(n) + // Cannot be invalid. + case reflect.Map: + // The segment must be a key in the map. + v = v.MapIndex(reflect.ValueOf(seg)) + if !v.IsValid() { + return nil, fmt.Errorf("no key %q in map", seg) + } + default: + return nil, fmt.Errorf("value %s (%s) is not a schema, slice or map", v, v.Type()) + } + } + if s, ok := v.Interface().(*Schema); ok { + return s, nil + } + return nil, fmt.Errorf("does not refer to a schema, but to a %s", v.Type()) +} + +// map from JSON names for fields in a Schema to their indexes in the struct. +var schemaFields = map[string][]int{} + +func init() { + for _, f := range reflect.VisibleFields(reflect.TypeFor[Schema]()) { + if name, ok := jsonName(f); ok { + schemaFields[name] = f.Index + } + } +} + +// lookupSchemaField returns the value of the field with the given name in v, +// or the zero value if there is no such field or it is not of type Schema or *Schema. +func lookupSchemaField(v reflect.Value, name string) reflect.Value { + if name == "type" { + // The "type" keyword may refer to Type or Types. + // At most one will be non-zero. + if t := v.FieldByName("Type"); !t.IsZero() { + return t + } + return v.FieldByName("Types") + } + if index := schemaFields[name]; index != nil { + return v.FieldByIndex(index) + } + return reflect.Value{} +} diff --git a/jsonschema/json_pointer_test.go b/jsonschema/json_pointer_test.go new file mode 100644 index 0000000..54b84be --- /dev/null +++ b/jsonschema/json_pointer_test.go @@ -0,0 +1,78 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import ( + "strings" + "testing" +) + +func TestDereferenceJSONPointer(t *testing.T) { + s := &Schema{ + AllOf: []*Schema{{}, {}}, + Defs: map[string]*Schema{ + "": {Properties: map[string]*Schema{"": {}}}, + "A": {}, + "B": { + Defs: map[string]*Schema{ + "X": {}, + "Y": {}, + }, + }, + "/~": {}, + "~1": {}, + }, + } + + for _, tt := range []struct { + ptr string + want any + }{ + {"", s}, + {"/$defs/A", s.Defs["A"]}, + {"/$defs/B", s.Defs["B"]}, + {"/$defs/B/$defs/X", s.Defs["B"].Defs["X"]}, + {"/$defs//properties/", s.Defs[""].Properties[""]}, + {"/allOf/1", s.AllOf[1]}, + {"/$defs/~1~0", s.Defs["/~"]}, + {"/$defs/~01", s.Defs["~1"]}, + } { + got, err := dereferenceJSONPointer(s, tt.ptr) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("%s:\ngot %+v\nwant %+v", tt.ptr, got, tt.want) + } + } +} + +func TestDerefernceJSONPointerErrors(t *testing.T) { + s := &Schema{ + Type: "t", + Items: &Schema{}, + Required: []string{"a"}, + } + for _, tt := range []struct { + ptr string + want string // error must contain this string + }{ + {"x", "does not begin"}, // parse error: no initial '/' + {"/minItems", "does not refer to a schema"}, + {"/minItems/x", "navigated to nil"}, + {"/required/-", "not supported"}, + {"/required/01", "leading zeroes"}, + {"/required/x", "invalid int"}, + {"/required/1", "out of bounds"}, + {"/properties/x", "no key"}, + } { + _, err := dereferenceJSONPointer(s, tt.ptr) + if err == nil { + t.Errorf("%q: succeeded, want failure", tt.ptr) + } else if !strings.Contains(err.Error(), tt.want) { + t.Errorf("%q: error is %q, which does not contain %q", tt.ptr, err, tt.want) + } + } +}