Skip to content

Commit ca54d59

Browse files
committed
jsonschema: represent integer keywords as ints
Some JSON Schema keywords, like minLength, are logically integers. The JSON Schema spec allows them to be written as floating-point values with zero fractional part, which is a reasonable choice, since JSON doesn't distinguish the two. We could simply validate these fields as integers in the normal course of events (namely, validating the schema against its meta-schema), but this CL does a little better: it uses *int struct fields for these keywords, preventing them from ever being non-integers. The UnmarshalJSON method still accepts integral floating-point values. Marshaling is unaffected, and the results don't change: encoding/json has always marshaled integral floats without the decimal part. Other choices: - Leave them as float64: you could then get away with Schema{MinLength: Ptr(1.5)} and though you couldn't get far with that, it's still irksome. - json.Number: same problem as float64, plus it's even harder to write as a literal. - int64: now `Ptr(1)` doesn't work (it's a *int, not a *int64). Anyway, these keywords all compare against len(x) in Go, which is of type int. Change-Id: Ie3f08bd8a5deaf2a21344d6ab873551c011b12d5 Reviewed-on: https://go-review.googlesource.com/c/tools/+/668315 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Robert Findley <rfindley@google.com>
1 parent c75f7e8 commit ca54d59

File tree

4 files changed

+132
-25
lines changed

4 files changed

+132
-25
lines changed

internal/mcp/internal/jsonschema/infer.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
9292
return nil, fmt.Errorf("computing element schema: %v", err)
9393
}
9494
if t.Kind() == reflect.Array {
95-
s.MinItems = Ptr(float64(t.Len()))
96-
s.MaxItems = Ptr(float64(t.Len()))
95+
s.MinItems = Ptr(t.Len())
96+
s.MaxItems = Ptr(t.Len())
9797
}
9898

9999
case reflect.String:

internal/mcp/internal/jsonschema/schema.go

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package jsonschema
88

99
import (
1010
"bytes"
11+
"cmp"
1112
"encoding/json"
1213
"errors"
1314
"fmt"
@@ -67,25 +68,25 @@ type Schema struct {
6768
Maximum *float64 `json:"maximum,omitempty"`
6869
ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"`
6970
ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"`
70-
MinLength *float64 `json:"minLength,omitempty"`
71-
MaxLength *float64 `json:"maxLength,omitempty"`
71+
MinLength *int `json:"minLength,omitempty"`
72+
MaxLength *int `json:"maxLength,omitempty"`
7273
Pattern string `json:"pattern,omitempty"`
7374

7475
// arrays
7576
PrefixItems []*Schema `json:"prefixItems,omitempty"`
7677
Items *Schema `json:"items,omitempty"`
77-
MinItems *float64 `json:"minItems,omitempty"`
78-
MaxItems *float64 `json:"maxItems,omitempty"`
78+
MinItems *int `json:"minItems,omitempty"`
79+
MaxItems *int `json:"maxItems,omitempty"`
7980
AdditionalItems *Schema `json:"additionalItems,omitempty"`
8081
UniqueItems bool `json:"uniqueItems,omitempty"`
8182
Contains *Schema `json:"contains,omitempty"`
82-
MinContains *float64 `json:"minContains,omitempty"`
83-
MaxContains *float64 `json:"maxContains,omitempty"`
83+
MinContains *int `json:"minContains,omitempty"` // *int, not int: default is 1, not 0
84+
MaxContains *int `json:"maxContains,omitempty"`
8485
UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"`
8586

8687
// objects
87-
MinProperties *float64 `json:"minProperties,omitempty"`
88-
MaxProperties *float64 `json:"maxProperties,omitempty"`
88+
MinProperties *int `json:"minProperties,omitempty"`
89+
MaxProperties *int `json:"maxProperties,omitempty"`
8990
Required []string `json:"required,omitempty"`
9091
DependentRequired map[string][]string `json:"dependentRequired,omitempty"`
9192
Properties map[string]*Schema `json:"properties,omitempty"`
@@ -174,8 +175,17 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
174175
}
175176

176177
ms := struct {
177-
Type json.RawMessage `json:"type,omitempty"`
178-
Const json.RawMessage `json:"const,omitempty"`
178+
Type json.RawMessage `json:"type,omitempty"`
179+
Const json.RawMessage `json:"const,omitempty"`
180+
MinLength *float64 `json:"minLength,omitempty"`
181+
MaxLength *float64 `json:"maxLength,omitempty"`
182+
MinItems *float64 `json:"minItems,omitempty"`
183+
MaxItems *float64 `json:"maxItems,omitempty"`
184+
MinProperties *float64 `json:"minProperties,omitempty"`
185+
MaxProperties *float64 `json:"maxProperties,omitempty"`
186+
MinContains *float64 `json:"minContains,omitempty"`
187+
MaxContains *float64 `json:"maxContains,omitempty"`
188+
179189
*schemaWithoutMethods
180190
}{
181191
schemaWithoutMethods: (*schemaWithoutMethods)(s),
@@ -192,12 +202,13 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
192202
case '[':
193203
err = json.Unmarshal(ms.Type, &s.Types)
194204
default:
195-
err = fmt.Errorf("invalid type: %q", ms.Type)
205+
err = fmt.Errorf(`invalid value for "type": %q`, ms.Type)
196206
}
197207
}
198208
if err != nil {
199209
return err
200210
}
211+
201212
// Setting Const to a pointer to null will marshal properly, but won't unmarshal:
202213
// the *any is set to nil, not a pointer to nil.
203214
if len(ms.Const) > 0 {
@@ -207,6 +218,34 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
207218
return err
208219
}
209220
}
221+
222+
// Store integer properties as ints.
223+
setInt := func(name string, dst **int, src *float64) error {
224+
if src == nil {
225+
return nil
226+
}
227+
i := int(*src)
228+
if float64(i) != *src {
229+
return fmt.Errorf("%s: %f is not an int", name, *src)
230+
}
231+
*dst = &i
232+
return nil
233+
}
234+
235+
err = cmp.Or(
236+
setInt("minLength", &s.MinLength, ms.MinLength),
237+
setInt("maxLength", &s.MaxLength, ms.MaxLength),
238+
setInt("minItems", &s.MinItems, ms.MinItems),
239+
setInt("maxItems", &s.MaxItems, ms.MaxItems),
240+
setInt("minProperties", &s.MinProperties, ms.MinProperties),
241+
setInt("maxProperties", &s.MaxProperties, ms.MaxProperties),
242+
setInt("minContains", &s.MinContains, ms.MinContains),
243+
setInt("maxContains", &s.MaxContains, ms.MaxContains),
244+
)
245+
if err != nil {
246+
return err
247+
}
248+
210249
return nil
211250
}
212251

internal/mcp/internal/jsonschema/schema_test.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ package jsonschema
66

77
import (
88
"encoding/json"
9+
"regexp"
910
"testing"
1011
)
1112

12-
func TestMarshal(t *testing.T) {
13+
func TestGoRoundTrip(t *testing.T) {
14+
// Verify that Go representations round-trip.
1315
for _, s := range []*Schema{
1416
{Type: "null"},
1517
{Types: []string{"null", "number"}},
16-
{Type: "string", MinLength: Ptr(20.0)},
18+
{Type: "string", MinLength: Ptr(20)},
1719
{Minimum: Ptr(20.0)},
1820
{Items: &Schema{Type: "integer"}},
1921
{Const: Ptr(any(0))},
@@ -38,3 +40,69 @@ func TestMarshal(t *testing.T) {
3840
}
3941
}
4042
}
43+
44+
func TestJSONRoundTrip(t *testing.T) {
45+
// Verify that JSON texts for schemas marshal into equivalent forms.
46+
// We don't expect everything to round-trip perfectly. For example, "true" and "false"
47+
// will turn into their object equivalents.
48+
// But most things should.
49+
// Some of these cases test Schema.{UnM,M}arshalJSON.
50+
// Most of others follow from the behavior of encoding/json, but they are still
51+
// valuable as regression tests of this package's behavior.
52+
for _, tt := range []struct {
53+
in, want string
54+
}{
55+
{`true`, `{}`}, // boolean schemas become object schemas
56+
{`false`, `{"not":{}}`},
57+
{`{"type":"", "enum":null}`, `{}`}, // empty fields are omitted
58+
{`{"minimum":1}`, `{"minimum":1}`},
59+
{`{"minimum":1.0}`, `{"minimum":1}`}, // floating-point integers lose their fractional part
60+
{`{"minLength":1.0}`, `{"minLength":1}`}, // some floats are unmarshaled into ints, but you can't tell
61+
{
62+
// map keys are sorted
63+
`{"$vocabulary":{"b":true, "a":false}}`,
64+
`{"$vocabulary":{"a":false,"b":true}}`,
65+
},
66+
{`{"unk":0}`, `{}`}, // unknown fields are dropped, unfortunately
67+
} {
68+
var s Schema
69+
if err := json.Unmarshal([]byte(tt.in), &s); err != nil {
70+
t.Fatal(err)
71+
}
72+
data, err := json.Marshal(s)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
if got := string(data); got != tt.want {
77+
t.Errorf("%s:\ngot %s\nwant %s", tt.in, got, tt.want)
78+
}
79+
}
80+
}
81+
82+
func TestUnmarshalErrors(t *testing.T) {
83+
for _, tt := range []struct {
84+
in string
85+
want string // error must match this regexp
86+
}{
87+
{`1`, "cannot unmarshal number"},
88+
{`{"type":1}`, `invalid value for "type"`},
89+
{`{"minLength":1.5}`, `minLength:.*not an int`},
90+
{`{"maxLength":1.5}`, `maxLength:.*not an int`},
91+
{`{"minItems":1.5}`, `minItems:.*not an int`},
92+
{`{"maxItems":1.5}`, `maxItems:.*not an int`},
93+
{`{"minProperties":1.5}`, `minProperties:.*not an int`},
94+
{`{"maxProperties":1.5}`, `maxProperties:.*not an int`},
95+
{`{"minContains":1.5}`, `minContains:.*not an int`},
96+
{`{"maxContains":1.5}`, `maxContains:.*not an int`},
97+
} {
98+
var s Schema
99+
err := json.Unmarshal([]byte(tt.in), &s)
100+
if err == nil {
101+
t.Fatalf("%s: no error but expected one", tt.in)
102+
}
103+
if !regexp.MustCompile(tt.want).MatchString(err.Error()) {
104+
t.Errorf("%s: error %q does not match %q", tt.in, err, tt.want)
105+
}
106+
107+
}
108+
}

internal/mcp/internal/jsonschema/validate.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,12 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
145145
str := instance.String()
146146
n := utf8.RuneCountInString(str)
147147
if schema.MinLength != nil {
148-
if m := int(*schema.MinLength); n < m {
148+
if m := *schema.MinLength; n < m {
149149
return fmt.Errorf("minLength: %q contains %d Unicode code points, fewer than %d", str, n, m)
150150
}
151151
}
152152
if schema.MaxLength != nil {
153-
if m := int(*schema.MaxLength); n > m {
153+
if m := *schema.MaxLength; n > m {
154154
return fmt.Errorf("maxLength: %q contains %d Unicode code points, more than %d", str, n, m)
155155
}
156156
}
@@ -268,7 +268,7 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
268268
anns.noteIndex(i)
269269
}
270270
}
271-
if nContains == 0 && (schema.MinContains == nil || int(*schema.MinContains) > 0) {
271+
if nContains == 0 && (schema.MinContains == nil || *schema.MinContains > 0) {
272272
return fmt.Errorf("contains: %s does not have an item matching %s",
273273
instance, schema.Contains)
274274
}
@@ -277,23 +277,23 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
277277
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.4
278278
// TODO(jba): check that these next four keywords' values are integers.
279279
if schema.MinContains != nil && schema.Contains != nil {
280-
if m := int(*schema.MinContains); nContains < m {
280+
if m := *schema.MinContains; nContains < m {
281281
return fmt.Errorf("minContains: contains validated %d items, less than %d", nContains, m)
282282
}
283283
}
284284
if schema.MaxContains != nil && schema.Contains != nil {
285-
if m := int(*schema.MaxContains); nContains > m {
285+
if m := *schema.MaxContains; nContains > m {
286286
return fmt.Errorf("maxContains: contains validated %d items, greater than %d", nContains, m)
287287
}
288288
}
289289
if schema.MinItems != nil {
290-
if m := int(*schema.MinItems); instance.Len() < m {
290+
if m := *schema.MinItems; instance.Len() < m {
291291
return fmt.Errorf("minItems: array length %d is less than %d", instance.Len(), m)
292292
}
293293
}
294294
if schema.MaxItems != nil {
295-
if m := int(*schema.MaxItems); instance.Len() > m {
296-
return fmt.Errorf("minItems: array length %d is greater than %d", instance.Len(), m)
295+
if m := *schema.MaxItems; instance.Len() > m {
296+
return fmt.Errorf("maxItems: array length %d is greater than %d", instance.Len(), m)
297297
}
298298
}
299299
if schema.UniqueItems {
@@ -385,12 +385,12 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
385385

386386
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5
387387
if schema.MinProperties != nil {
388-
if n, m := instance.Len(), int(*schema.MinProperties); n < m {
388+
if n, m := instance.Len(), *schema.MinProperties; n < m {
389389
return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m)
390390
}
391391
}
392392
if schema.MaxProperties != nil {
393-
if n, m := instance.Len(), int(*schema.MaxProperties); n > m {
393+
if n, m := instance.Len(), *schema.MaxProperties; n > m {
394394
return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m)
395395
}
396396
}

0 commit comments

Comments
 (0)