jsonschema: add ForType function (#242)

Add ForType, which is like For but takes a reflect.Type.

Fixes #233.
This commit is contained in:
Jonathan Amsterdam 2025-08-05 16:32:06 -04:00 • committed by GitHub
commit 032f03bbea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 66 additions and 12 deletions

View file

@ -9,6 +9,7 @@ package jsonschema
import (
"fmt"
"log/slog"
"maps"
"math/big"
"reflect"
"regexp"
@ -19,8 +20,8 @@ import (
// ForOptions are options for the [For] function.
type ForOptions struct {
// If IgnoreInvalidTypes is true, fields that can't be represented as a JSON Schema
// are ignored instead of causing an error.
// If IgnoreInvalidTypes is true, fields that can't be represented as a JSON
// Schema are ignored instead of causing an error.
// This allows callers to adjust the resulting schema using custom knowledge.
// For example, an interface type where all the possible implementations are
// known can be described with "oneof".
@ -77,15 +78,7 @@ func For[T any](opts *ForOptions) (*Schema, error) {
if opts == nil {
opts = &ForOptions{}
}
schemas := make(map[reflect.Type]*Schema)
// Add types from the standard library that have MarshalJSON methods.
ss := &Schema{Type: "string"}
schemas[reflect.TypeFor[time.Time]()] = ss
schemas[reflect.TypeFor[slog.Level]()] = ss
schemas[reflect.TypeFor[big.Int]()] = &Schema{Types: []string{"null", "string"}}
schemas[reflect.TypeFor[big.Rat]()] = ss
schemas[reflect.TypeFor[big.Float]()] = ss
schemas := maps.Clone(initialSchemaMap)
// Add types from the options. They override the default ones.
for v, s := range opts.TypeSchemas {
schemas[reflect.TypeOf(v)] = s
@ -98,6 +91,20 @@ func For[T any](opts *ForOptions) (*Schema, error) {
return s, nil
}
// ForType is like [For], but takes a [reflect.Type]
func ForType(t reflect.Type, opts *ForOptions) (*Schema, error) {
schemas := maps.Clone(initialSchemaMap)
// Add types from the options. They override the default ones.
for v, s := range opts.TypeSchemas {
schemas[reflect.TypeOf(v)] = s
}
s, err := forType(t, map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
if err != nil {
return nil, fmt.Errorf("ForType(%s): %w", t, err)
}
return s, nil
}
func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas map[reflect.Type]*Schema) (*Schema, error) {
// Follow pointers: the schema for *T is almost the same as for T, except that
// an explicit JSON "null" is allowed for the pointer.
@ -230,5 +237,17 @@ func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas ma
return s, nil
}
// initialSchemaMap holds types from the standard library that have MarshalJSON methods.
var initialSchemaMap = make(map[reflect.Type]*Schema)
func init() {
ss := &Schema{Type: "string"}
initialSchemaMap[reflect.TypeFor[time.Time]()] = ss
initialSchemaMap[reflect.TypeFor[slog.Level]()] = ss
initialSchemaMap[reflect.TypeFor[big.Int]()] = &Schema{Types: []string{"null", "string"}}
initialSchemaMap[reflect.TypeFor[big.Rat]()] = ss
initialSchemaMap[reflect.TypeFor[big.Float]()] = ss
}
// Disallow jsonschema tag values beginning "WORD=", for future expansion.
var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=")

View file

@ -7,6 +7,7 @@ package jsonschema_test
import (
"log/slog"
"math/big"
"reflect"
"strings"
"testing"
"time"
@ -139,7 +140,7 @@ func TestFor(t *testing.T) {
run := func(t *testing.T, tt test) {
if diff := cmp.Diff(tt.want, tt.got, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
t.Fatalf("For mismatch (-want +got):\n%s", diff)
}
// These schemas should all resolve.
if _, err := tt.got.Resolve(nil); err != nil {
@ -176,6 +177,40 @@ func TestFor(t *testing.T) {
})
}
func TestForType(t *testing.T) {
type schema = jsonschema.Schema
// ForType is virtually identical to For. Just test that options are handled properly.
opts := &jsonschema.ForOptions{
IgnoreInvalidTypes: true,
TypeSchemas: map[any]*jsonschema.Schema{
custom(0): {Type: "custom"},
},
}
type S struct {
I int
F func()
C custom
}
got, err := jsonschema.ForType(reflect.TypeOf(S{}), opts)
if err != nil {
t.Fatal(err)
}
want := &schema{
Type: "object",
Properties: map[string]*schema{
"I": {Type: "integer"},
"C": {Type: "custom"},
},
Required: []string{"I", "C"},
AdditionalProperties: falseSchema(),
}
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}
}
func forErr[T any]() error {
_, err := jsonschema.For[T](nil)
return err