Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions jsonschema/infer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package jsonschema
import (
"fmt"
"log/slog"
"maps"
"math/big"
"reflect"
"regexp"
Expand All @@ -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".
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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]*=")
37 changes: 36 additions & 1 deletion jsonschema/infer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package jsonschema_test
import (
"log/slog"
"math/big"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down