Skip to content

Commit 2c577e5

Browse files
authored
jsonschema: support "jsonschema" struct tags (#101)
Struct fields may have the "jsonschema" struct tag, which is used as the description of the property. Fixes #47.
1 parent c037ba5 commit 2c577e5

File tree

2 files changed

+99
-10
lines changed

2 files changed

+99
-10
lines changed

jsonschema/infer.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package jsonschema
99
import (
1010
"fmt"
1111
"reflect"
12+
"regexp"
1213

1314
"github.com/modelcontextprotocol/go-sdk/internal/util"
1415
)
@@ -36,8 +37,12 @@ import (
3637
// - complex numbers
3738
// - unsafe pointers
3839
//
39-
// The types must not have cycles.
4040
// It will return an error if there is a cycle in the types.
41+
//
42+
// For recognizes struct field tags named "jsonschema".
43+
// A jsonschema tag on a field is used as the description for the corresponding property.
44+
// For future compatibility, descriptions must not start with "WORD=", where WORD is a
45+
// sequence of non-whitespace characters.
4146
func For[T any]() (*Schema, error) {
4247
// TODO: consider skipping incompatible fields, instead of failing.
4348
seen := make(map[reflect.Type]bool)
@@ -126,10 +131,20 @@ func forType(t reflect.Type, seen map[reflect.Type]bool) (*Schema, error) {
126131
if s.Properties == nil {
127132
s.Properties = make(map[string]*Schema)
128133
}
129-
s.Properties[info.Name], err = forType(field.Type, seen)
134+
fs, err := forType(field.Type, seen)
130135
if err != nil {
131136
return nil, err
132137
}
138+
if tag, ok := field.Tag.Lookup("jsonschema"); ok {
139+
if tag == "" {
140+
return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name)
141+
}
142+
if disallowedPrefixRegexp.MatchString(tag) {
143+
return nil, fmt.Errorf("tag must not begin with 'WORD=': %q", tag)
144+
}
145+
fs.Description = tag
146+
}
147+
s.Properties[info.Name] = fs
133148
if !info.Settings["omitempty"] && !info.Settings["omitzero"] {
134149
s.Required = append(s.Required, info.Name)
135150
}
@@ -144,3 +159,6 @@ func forType(t reflect.Type, seen map[reflect.Type]bool) (*Schema, error) {
144159
}
145160
return s, nil
146161
}
162+
163+
// Disallow jsonschema tag values beginning "WORD=", for future expansion.
164+
var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=")

jsonschema/infer_test.go

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package jsonschema_test
66

77
import (
8+
"strings"
89
"testing"
910

1011
"github.com/google/go-cmp/cmp"
@@ -20,8 +21,13 @@ func forType[T any]() *jsonschema.Schema {
2021
return s
2122
}
2223

23-
func TestForType(t *testing.T) {
24+
func TestFor(t *testing.T) {
2425
type schema = jsonschema.Schema
26+
27+
type S struct {
28+
B int `jsonschema:"bdesc"`
29+
}
30+
2531
tests := []struct {
2632
name string
2733
got *jsonschema.Schema
@@ -44,9 +50,9 @@ func TestForType(t *testing.T) {
4450
{
4551
"struct",
4652
forType[struct {
47-
F int `json:"f"`
53+
F int `json:"f" jsonschema:"fdesc"`
4854
G []float64
49-
P *bool
55+
P *bool `jsonschema:"pdesc"`
5056
Skip string `json:"-"`
5157
NoSkip string `json:",omitempty"`
5258
unexported float64
@@ -55,13 +61,13 @@ func TestForType(t *testing.T) {
5561
&schema{
5662
Type: "object",
5763
Properties: map[string]*schema{
58-
"f": {Type: "integer"},
64+
"f": {Type: "integer", Description: "fdesc"},
5965
"G": {Type: "array", Items: &schema{Type: "number"}},
60-
"P": {Types: []string{"null", "boolean"}},
66+
"P": {Types: []string{"null", "boolean"}, Description: "pdesc"},
6167
"NoSkip": {Type: "string"},
6268
},
6369
Required: []string{"f", "G", "P"},
64-
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
70+
AdditionalProperties: falseSchema(),
6571
},
6672
},
6773
{
@@ -74,7 +80,37 @@ func TestForType(t *testing.T) {
7480
"Y": {Type: "integer"},
7581
},
7682
Required: []string{"X", "Y"},
77-
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
83+
AdditionalProperties: falseSchema(),
84+
},
85+
},
86+
{
87+
"nested and embedded",
88+
forType[struct {
89+
A S
90+
S
91+
}](),
92+
&schema{
93+
Type: "object",
94+
Properties: map[string]*schema{
95+
"A": {
96+
Type: "object",
97+
Properties: map[string]*schema{
98+
"B": {Type: "integer", Description: "bdesc"},
99+
},
100+
Required: []string{"B"},
101+
AdditionalProperties: falseSchema(),
102+
},
103+
"S": {
104+
Type: "object",
105+
Properties: map[string]*schema{
106+
"B": {Type: "integer", Description: "bdesc"},
107+
},
108+
Required: []string{"B"},
109+
AdditionalProperties: falseSchema(),
110+
},
111+
},
112+
Required: []string{"A", "S"},
113+
AdditionalProperties: falseSchema(),
78114
},
79115
},
80116
}
@@ -92,6 +128,38 @@ func TestForType(t *testing.T) {
92128
}
93129
}
94130

131+
func forErr[T any]() error {
132+
_, err := jsonschema.For[T]()
133+
return err
134+
}
135+
136+
func TestForErrors(t *testing.T) {
137+
type (
138+
s1 struct {
139+
Empty int `jsonschema:""`
140+
}
141+
s2 struct {
142+
Bad int `jsonschema:"$foo=1,bar"`
143+
}
144+
)
145+
146+
for _, tt := range []struct {
147+
got error
148+
want string
149+
}{
150+
{forErr[map[int]int](), "unsupported map key type"},
151+
{forErr[s1](), "empty jsonschema tag"},
152+
{forErr[s2](), "must not begin with"},
153+
{forErr[func()](), "unsupported"},
154+
} {
155+
if tt.got == nil {
156+
t.Errorf("got nil, want error containing %q", tt.want)
157+
} else if !strings.Contains(tt.got.Error(), tt.want) {
158+
t.Errorf("got %q\nwant it to contain %q", tt.got, tt.want)
159+
}
160+
}
161+
}
162+
95163
func TestForWithMutation(t *testing.T) {
96164
// This test ensures that the cached schema is not mutated when the caller
97165
// mutates the returned schema.
@@ -172,7 +240,6 @@ func TestForWithCycle(t *testing.T) {
172240
}
173241

174242
for _, test := range tests {
175-
test := test // prevent loop shadowing
176243
t.Run(test.name, func(t *testing.T) {
177244
err := test.fn()
178245
if test.shouldErr && err == nil {
@@ -184,3 +251,7 @@ func TestForWithCycle(t *testing.T) {
184251
})
185252
}
186253
}
254+
255+
func falseSchema() *jsonschema.Schema {
256+
return &jsonschema.Schema{Not: &jsonschema.Schema{}}
257+
}

0 commit comments

Comments
 (0)