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>
149 lines
4 KiB
Go
149 lines
4 KiB
Go
// 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 contains functions that infer a schema from a Go type.
|
|
|
|
package jsonschema
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// For constructs a JSON schema object for the given type argument.
|
|
//
|
|
// It is a convenience for ForType.
|
|
func For[T any]() (*Schema, error) {
|
|
return ForType(reflect.TypeFor[T]())
|
|
}
|
|
|
|
// ForType constructs a JSON schema object for the given type.
|
|
// It translates Go types into compatible JSON schema types, as follows:
|
|
// - strings have schema type "string"
|
|
// - bools have schema type "boolean"
|
|
// - signed and unsigned integer types have schema type "integer"
|
|
// - floating point types have schema type "number"
|
|
// - slices and arrays have schema type "array", and a corresponding schema
|
|
// for items
|
|
// - maps with string key have schema type "object", and corresponding
|
|
// schema for additionalProperties
|
|
// - structs have schema type "object", and disallow additionalProperties.
|
|
// Their properties are derived from exported struct fields, using the
|
|
// struct field json name. Fields that are marked "omitempty" are
|
|
// considered optional; all other fields become required properties.
|
|
//
|
|
// It returns an error if t contains (possibly recursively) any of the following Go
|
|
// types, as they are incompatible with the JSON schema spec.
|
|
// - maps with key other than 'string'
|
|
// - function types
|
|
// - complex numbers
|
|
// - unsafe pointers
|
|
//
|
|
// TODO(rfindley): we could perhaps just skip these incompatible fields.
|
|
func ForType(t reflect.Type) (*Schema, error) {
|
|
return typeSchema(t, make(map[reflect.Type]*Schema))
|
|
}
|
|
|
|
func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error) {
|
|
if t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if s := seen[t]; s != nil {
|
|
return s, nil
|
|
}
|
|
var (
|
|
s = new(Schema)
|
|
err error
|
|
)
|
|
seen[t] = s
|
|
|
|
switch t.Kind() {
|
|
case reflect.Bool:
|
|
s.Type = "boolean"
|
|
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
|
reflect.Uintptr:
|
|
s.Type = "integer"
|
|
|
|
case reflect.Float32, reflect.Float64:
|
|
s.Type = "number"
|
|
|
|
case reflect.Interface:
|
|
// Unrestricted
|
|
|
|
case reflect.Map:
|
|
if t.Key().Kind() != reflect.String {
|
|
return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind())
|
|
}
|
|
s.Type = "object"
|
|
s.AdditionalProperties, err = typeSchema(t.Elem(), seen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("computing map value schema: %v", err)
|
|
}
|
|
|
|
case reflect.Slice, reflect.Array:
|
|
s.Type = "array"
|
|
s.Items, err = typeSchema(t.Elem(), seen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("computing element schema: %v", err)
|
|
}
|
|
if t.Kind() == reflect.Array {
|
|
s.MinItems = Ptr(t.Len())
|
|
s.MaxItems = Ptr(t.Len())
|
|
}
|
|
|
|
case reflect.String:
|
|
s.Type = "string"
|
|
|
|
case reflect.Struct:
|
|
s.Type = "object"
|
|
// no additional properties are allowed
|
|
s.AdditionalProperties = &Schema{Not: &Schema{}}
|
|
|
|
for i := range t.NumField() {
|
|
field := t.Field(i)
|
|
name, required, include := parseField(field)
|
|
if !include {
|
|
continue
|
|
}
|
|
if s.Properties == nil {
|
|
s.Properties = make(map[string]*Schema)
|
|
}
|
|
s.Properties[name], err = typeSchema(field.Type, seen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if required {
|
|
s.Required = append(s.Required, name)
|
|
}
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("type %v is unsupported by jsonschema", t)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func parseField(f reflect.StructField) (name string, required, include bool) {
|
|
if !f.IsExported() {
|
|
return "", false, false
|
|
}
|
|
name = f.Name
|
|
required = true
|
|
if tag, ok := f.Tag.Lookup("json"); ok {
|
|
props := strings.Split(tag, ",")
|
|
if props[0] != "" {
|
|
if props[0] == "-" {
|
|
return "", false, false
|
|
}
|
|
name = props[0]
|
|
}
|
|
// TODO: support 'omitzero' as well.
|
|
required = !slices.Contains(props[1:], "omitempty")
|
|
}
|
|
return name, required, true
|
|
}
|