Skip to content

Commit 11a1c83

Browse files
authored
jsonschema: handle embedding of custom schemas (#32)
When a type with a custom schema is embedded, honor its customization by taking (a clone of) its custom properties. For now, disallow any keyword on the embedded struct other than "type" and "properties". It's unclear how other keywords should be treated. Fixes #17.
1 parent 22c5e54 commit 11a1c83

File tree

2 files changed

+138
-7
lines changed

2 files changed

+138
-7
lines changed

jsonschema/infer.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,63 @@ func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas ma
221221
s.Type = "object"
222222
// no additional properties are allowed
223223
s.AdditionalProperties = falseSchema()
224+
225+
// If skipPath is non-nil, it is path to an anonymous field whose
226+
// schema has been replaced by a known schema.
227+
var skipPath []int
224228
for _, field := range reflect.VisibleFields(t) {
225229
if field.Anonymous {
230+
override := schemas[field.Type]
231+
if override != nil {
232+
// Type must be object, and only properties can be set.
233+
if override.Type != "object" {
234+
return nil, fmt.Errorf(`custom schema for embedded struct must have type "object", got %q`,
235+
override.Type)
236+
}
237+
// Check that all keywords relevant for objects are absent, except properties.
238+
ov := reflect.ValueOf(override).Elem()
239+
for _, sfi := range schemaFieldInfos {
240+
if sfi.sf.Name == "Type" || sfi.sf.Name == "Properties" {
241+
continue
242+
}
243+
fv := ov.FieldByIndex(sfi.sf.Index)
244+
if !fv.IsZero() {
245+
return nil, fmt.Errorf(`overrides for embedded fields can have only "Type" and "Properties"; this has %q`, sfi.sf.Name)
246+
}
247+
}
248+
249+
skipPath = field.Index
250+
for name, prop := range override.Properties {
251+
s.Properties[name] = prop.CloneSchemas()
252+
}
253+
}
226254
continue
227255
}
228256

257+
// Check to see if this field has been promoted from a replaced anonymous
258+
// type.
259+
if skipPath != nil {
260+
skip := false
261+
if len(field.Index) >= len(skipPath) {
262+
skip = true
263+
for i, index := range skipPath {
264+
if field.Index[i] != index {
265+
// If we're no longer in a subfield.
266+
skip = false
267+
break
268+
}
269+
}
270+
}
271+
if skip {
272+
continue
273+
} else {
274+
// Anonymous fields are followed immediately by their promoted fields.
275+
// Once we encounter a field that *isn't* promoted, we can stop
276+
// checking.
277+
skipPath = nil
278+
}
279+
}
280+
229281
info := fieldJSONInfo(field)
230282
if info.omit {
231283
continue

jsonschema/infer_test.go

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,20 +212,34 @@ func TestFor(t *testing.T) {
212212
}
213213

214214
func TestForType(t *testing.T) {
215+
// This tests embedded structs with a custom schema in addition to ForType.
215216
type schema = jsonschema.Schema
216217

217-
// ForType is virtually identical to For. Just test that options are handled properly.
218-
opts := &jsonschema.ForOptions{
219-
IgnoreInvalidTypes: true,
220-
TypeSchemas: map[reflect.Type]*jsonschema.Schema{
221-
reflect.TypeFor[custom](): {Type: "custom"},
222-
},
218+
type E struct {
219+
G float64 // promoted into S
220+
B int // hidden by S.B
223221
}
224222

225223
type S struct {
226224
I int
227225
F func()
228226
C custom
227+
E
228+
B bool
229+
}
230+
231+
opts := &jsonschema.ForOptions{
232+
IgnoreInvalidTypes: true,
233+
TypeSchemas: map[reflect.Type]*schema{
234+
reflect.TypeFor[custom](): {Type: "custom"},
235+
reflect.TypeFor[E](): {
236+
Type: "object",
237+
Properties: map[string]*schema{
238+
"G": {Type: "integer"},
239+
"B": {Type: "integer"},
240+
},
241+
},
242+
},
229243
}
230244
got, err := jsonschema.ForType(reflect.TypeOf(S{}), opts)
231245
if err != nil {
@@ -236,15 +250,80 @@ func TestForType(t *testing.T) {
236250
Properties: map[string]*schema{
237251
"I": {Type: "integer"},
238252
"C": {Type: "custom"},
253+
"G": {Type: "integer"},
254+
"B": {Type: "boolean"},
239255
},
240-
Required: []string{"I", "C"},
256+
Required: []string{"I", "C", "B"},
241257
AdditionalProperties: falseSchema(),
242258
}
243259
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(schema{})); diff != "" {
244260
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
245261
}
246262
}
247263

264+
func TestCustomEmbeddedError(t *testing.T) {
265+
// Disallow anything but "type" and "properties".
266+
type schema = jsonschema.Schema
267+
268+
type (
269+
E struct{ G int }
270+
S struct{ E }
271+
)
272+
273+
for _, tt := range []struct {
274+
name string
275+
override *schema
276+
}{
277+
{
278+
"missing type",
279+
&schema{},
280+
},
281+
{
282+
"wrong type",
283+
&schema{Type: "number"},
284+
},
285+
{
286+
"extra string field",
287+
&schema{
288+
Type: "object",
289+
Title: "t",
290+
},
291+
},
292+
{
293+
"extra pointer field",
294+
&schema{
295+
Type: "object",
296+
MinProperties: jsonschema.Ptr(1),
297+
},
298+
},
299+
{
300+
"extra array field",
301+
&schema{
302+
Type: "object",
303+
Required: []string{"G"},
304+
},
305+
},
306+
{
307+
"extra schema field",
308+
&schema{
309+
Type: "object",
310+
AdditionalProperties: falseSchema(),
311+
},
312+
},
313+
} {
314+
t.Run(tt.name, func(t *testing.T) {
315+
opts := &jsonschema.ForOptions{
316+
TypeSchemas: map[reflect.Type]*schema{
317+
reflect.TypeFor[E](): tt.override,
318+
},
319+
}
320+
if _, err := jsonschema.ForType(reflect.TypeOf(S{}), opts); err == nil {
321+
t.Error("got nil, want error")
322+
}
323+
})
324+
}
325+
}
326+
248327
func forErr[T any]() error {
249328
_, err := jsonschema.For[T](nil)
250329
return err

0 commit comments

Comments
 (0)