Skip to content

Commit 5fd51c8

Browse files
authored
feat: add contentEncoding, contentMediaType, and contentSchema support (#180)
Implement the JSON Schema Draft 2020-12 content vocabulary keywords (`contentEncoding`, `contentMediaType`, `contentSchema`) for the OAS 3.1 and later schema object. These were previously falling silently into extensions despite the dialect already declaring the content vocabulary. References: - https://json-schema.org/draft/2020-12/meta/content - https://spec.openapis.org/oas/3.1/dialect/2024-11-10.html - https://spec.openapis.org/oas/3.2/dialect/2025-09-17.html
1 parent dc7a4af commit 5fd51c8

6 files changed

Lines changed: 252 additions & 0 deletions

File tree

jsonschema/oas3/core/jsonschema.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ type Schema struct {
4646
MaxLength marshaller.Node[*int64] `key:"maxLength"`
4747
MinLength marshaller.Node[*int64] `key:"minLength"`
4848
Pattern marshaller.Node[*string] `key:"pattern"`
49+
ContentEncoding marshaller.Node[*string] `key:"contentEncoding"`
50+
ContentMediaType marshaller.Node[*string] `key:"contentMediaType"`
51+
ContentSchema marshaller.Node[JSONSchema] `key:"contentSchema"`
4952
Format marshaller.Node[*string] `key:"format"`
5053
MaxItems marshaller.Node[*int64] `key:"maxItems"`
5154
MinItems marshaller.Node[*int64] `key:"minItems"`

jsonschema/oas3/inline.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ func analyzeReferences(ctx context.Context, schema *JSONSchema[Referenceable], o
379379
}
380380
}
381381

382+
if err := analyzeReferences(ctx, js.ContentSchema, opts, refTracker, visited, counter); err != nil {
383+
return err
384+
}
385+
382386
if err := analyzeReferences(ctx, js.Not, opts, refTracker, visited, counter); err != nil {
383387
return err
384388
}
@@ -576,6 +580,13 @@ func inlineRecursive(ctx context.Context, schema *JSONSchema[Referenceable], opt
576580
}
577581
js.Items = s
578582

583+
// Visit contentSchema
584+
s, err = inlineRecursive(ctx, js.ContentSchema, opts, refTracker, visited, counter)
585+
if err != nil {
586+
return nil, err
587+
}
588+
js.ContentSchema = s
589+
579590
// Visit not schema
580591
s, err = inlineRecursive(ctx, js.Not, opts, refTracker, visited, counter)
581592
if err != nil {

jsonschema/oas3/inline_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,48 @@ func TestInline_Success(t *testing.T) {
224224
}
225225
}`,
226226
},
227+
{
228+
name: "contentSchema reference",
229+
input: `{
230+
"type": "string",
231+
"contentEncoding": "base64",
232+
"contentMediaType": "application/json",
233+
"contentSchema": {
234+
"$ref": "#/$defs/Payload"
235+
},
236+
"$defs": {
237+
"Payload": {
238+
"type": "object",
239+
"properties": {
240+
"id": {
241+
"type": "integer"
242+
},
243+
"name": {
244+
"type": "string"
245+
}
246+
},
247+
"required": ["id", "name"]
248+
}
249+
}
250+
}`,
251+
expected: `{
252+
"type": "string",
253+
"contentEncoding": "base64",
254+
"contentMediaType": "application/json",
255+
"contentSchema": {
256+
"type": "object",
257+
"properties": {
258+
"id": {
259+
"type": "integer"
260+
},
261+
"name": {
262+
"type": "string"
263+
}
264+
},
265+
"required": ["id", "name"]
266+
}
267+
}`,
268+
},
227269
{
228270
name: "no reference",
229271
input: `{

jsonschema/oas3/schema.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ type Schema struct {
5252
MaxLength *int64
5353
MinLength *int64
5454
Pattern *string
55+
ContentEncoding *string
56+
ContentMediaType *string
57+
ContentSchema *JSONSchema[Referenceable]
5558
Format *string
5659
MaxItems *int64
5760
MinItems *int64
@@ -124,6 +127,9 @@ func (s *Schema) ShallowCopy() *Schema {
124127
MaxLength: s.MaxLength,
125128
MinLength: s.MinLength,
126129
Pattern: s.Pattern,
130+
ContentEncoding: s.ContentEncoding,
131+
ContentMediaType: s.ContentMediaType,
132+
ContentSchema: s.ContentSchema,
127133
Format: s.Format,
128134
MaxItems: s.MaxItems,
129135
MinItems: s.MinItems,
@@ -485,6 +491,30 @@ func (s *Schema) GetPattern() string {
485491
return *s.Pattern
486492
}
487493

494+
// GetContentEncoding returns the value of the ContentEncoding field. Returns empty string if not set.
495+
func (s *Schema) GetContentEncoding() string {
496+
if s == nil || s.ContentEncoding == nil {
497+
return ""
498+
}
499+
return *s.ContentEncoding
500+
}
501+
502+
// GetContentMediaType returns the value of the ContentMediaType field. Returns empty string if not set.
503+
func (s *Schema) GetContentMediaType() string {
504+
if s == nil || s.ContentMediaType == nil {
505+
return ""
506+
}
507+
return *s.ContentMediaType
508+
}
509+
510+
// GetContentSchema returns the value of the ContentSchema field. Returns nil if not set.
511+
func (s *Schema) GetContentSchema() *JSONSchema[Referenceable] {
512+
if s == nil {
513+
return nil
514+
}
515+
return s.ContentSchema
516+
}
517+
488518
// GetFormat returns the value of the Format field. Returns empty string if not set.
489519
func (s *Schema) GetFormat() string {
490520
if s == nil || s.Format == nil {
@@ -532,6 +562,9 @@ func (s *Schema) IsReferenceOnly() bool {
532562
s.MaxLength == nil &&
533563
s.MinLength == nil &&
534564
s.Pattern == nil &&
565+
s.ContentEncoding == nil &&
566+
s.ContentMediaType == nil &&
567+
s.ContentSchema == nil &&
535568
s.Format == nil &&
536569
s.MaxItems == nil &&
537570
s.MinItems == nil &&
@@ -856,6 +889,15 @@ func (s *Schema) IsEqual(other *Schema) bool {
856889
if !equalPtrs(s.Pattern, other.Pattern) {
857890
return false
858891
}
892+
if !equalPtrs(s.ContentEncoding, other.ContentEncoding) {
893+
return false
894+
}
895+
if !equalPtrs(s.ContentMediaType, other.ContentMediaType) {
896+
return false
897+
}
898+
if !equalJSONSchemas(s.ContentSchema, other.ContentSchema) {
899+
return false
900+
}
859901
if !equalPtrs(s.Format, other.Format) {
860902
return false
861903
}

jsonschema/oas3/schema_unmarshal_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ $id: "https://example.com/schemas/user"
2323
$schema: "https://json-schema.org/draft/2020-12/schema"
2424
format: object
2525
pattern: "^user_"
26+
contentEncoding: base64
27+
contentMediaType: application/octet-stream
28+
contentSchema:
29+
type: string
2630
multipleOf: 1.0
2731
minimum: 0.0
2832
maximum: 1000.0
@@ -174,6 +178,15 @@ x-metadata:
174178
require.Equal(t, "object", schema.GetFormat())
175179
require.Equal(t, "^user_", schema.GetPattern())
176180

181+
// Test content keywords
182+
require.Equal(t, "base64", schema.GetContentEncoding())
183+
require.Equal(t, "application/octet-stream", schema.GetContentMediaType())
184+
require.NotNil(t, schema.GetContentSchema())
185+
require.True(t, schema.GetContentSchema().IsSchema())
186+
contentSchemaTypes := schema.GetContentSchema().GetSchema().GetType()
187+
require.Len(t, contentSchemaTypes, 1)
188+
require.Equal(t, oas3.SchemaTypeString, contentSchemaTypes[0])
189+
177190
// Test anchor, $id, and schema
178191
require.NotNil(t, schema.Anchor)
179192
require.Equal(t, "user-schema", *schema.Anchor)
@@ -575,3 +588,139 @@ properties:
575588
_, ok = schema.Properties.Get("name")
576589
assert.True(t, ok, "property 'name' should exist")
577590
}
591+
592+
func TestSchema_Unmarshal_ContentKeywords_Success(t *testing.T) {
593+
t.Parallel()
594+
595+
tests := []struct {
596+
name string
597+
yml string
598+
expectedContentEncoding string
599+
expectedContentMediaType string
600+
checkContentSchema bool
601+
contentSchemaType oas3.SchemaType
602+
contentSchemaRequired []string
603+
contentSchemaProperties []string
604+
}{
605+
{
606+
name: "contentEncoding only",
607+
yml: `
608+
type: string
609+
contentEncoding: base64
610+
`,
611+
expectedContentEncoding: "base64",
612+
},
613+
{
614+
name: "contentMediaType only",
615+
yml: `
616+
type: string
617+
contentMediaType: application/octet-stream
618+
`,
619+
expectedContentMediaType: "application/octet-stream",
620+
},
621+
{
622+
name: "contentEncoding with contentMediaType",
623+
yml: `
624+
type: string
625+
contentEncoding: base64
626+
contentMediaType: image/png
627+
`,
628+
expectedContentEncoding: "base64",
629+
expectedContentMediaType: "image/png",
630+
},
631+
{
632+
name: "contentMediaType with contentSchema object",
633+
yml: `
634+
type: string
635+
contentMediaType: application/json
636+
contentSchema:
637+
type: object
638+
required:
639+
- subStringProperty
640+
properties:
641+
subStringProperty:
642+
type: string
643+
`,
644+
expectedContentMediaType: "application/json",
645+
checkContentSchema: true,
646+
contentSchemaType: oas3.SchemaTypeObject,
647+
contentSchemaRequired: []string{"subStringProperty"},
648+
contentSchemaProperties: []string{"subStringProperty"},
649+
},
650+
{
651+
name: "all content keywords together",
652+
yml: `
653+
type: string
654+
contentEncoding: base64
655+
contentMediaType: application/json
656+
contentSchema:
657+
type: object
658+
required:
659+
- id
660+
- name
661+
properties:
662+
id:
663+
type: integer
664+
name:
665+
type: string
666+
tags:
667+
type: array
668+
items:
669+
type: string
670+
`,
671+
expectedContentEncoding: "base64",
672+
expectedContentMediaType: "application/json",
673+
checkContentSchema: true,
674+
contentSchemaType: oas3.SchemaTypeObject,
675+
contentSchemaRequired: []string{"id", "name"},
676+
contentSchemaProperties: []string{"id", "name", "tags"},
677+
},
678+
}
679+
680+
for _, tt := range tests {
681+
t.Run(tt.name, func(t *testing.T) {
682+
t.Parallel()
683+
684+
var schema oas3.Schema
685+
686+
validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &schema)
687+
require.NoError(t, err, "unmarshal should succeed")
688+
require.Empty(t, validationErrs, "should have no validation errors")
689+
690+
if tt.expectedContentEncoding != "" {
691+
assert.Equal(t, tt.expectedContentEncoding, schema.GetContentEncoding())
692+
} else {
693+
assert.Empty(t, schema.GetContentEncoding())
694+
}
695+
696+
if tt.expectedContentMediaType != "" {
697+
assert.Equal(t, tt.expectedContentMediaType, schema.GetContentMediaType())
698+
} else {
699+
assert.Empty(t, schema.GetContentMediaType())
700+
}
701+
702+
if tt.checkContentSchema {
703+
require.NotNil(t, schema.GetContentSchema(), "contentSchema should not be nil")
704+
require.True(t, schema.GetContentSchema().IsSchema(), "contentSchema should be a schema")
705+
706+
cs := schema.GetContentSchema().GetSchema()
707+
708+
types := cs.GetType()
709+
require.Len(t, types, 1)
710+
assert.Equal(t, tt.contentSchemaType, types[0])
711+
712+
assert.Equal(t, tt.contentSchemaRequired, cs.GetRequired())
713+
714+
require.NotNil(t, cs.GetProperties())
715+
for _, prop := range tt.contentSchemaProperties {
716+
propSchema, ok := cs.GetProperties().Get(prop)
717+
assert.True(t, ok, "property %q should exist in contentSchema", prop)
718+
assert.NotNil(t, propSchema, "property %q schema should not be nil", prop)
719+
}
720+
assert.Equal(t, len(tt.contentSchemaProperties), cs.GetProperties().Len())
721+
} else {
722+
assert.Nil(t, schema.GetContentSchema(), "contentSchema should be nil")
723+
}
724+
})
725+
}
726+
}

jsonschema/oas3/walk.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ func walkSchema(ctx context.Context, schema *JSONSchema[Referenceable], loc walk
166166
return false
167167
}
168168

169+
// Visit contentSchema
170+
if !walkSchema(ctx, js.ContentSchema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "contentSchema"}), rootSchema, yield) {
171+
return false
172+
}
173+
169174
// Visit not schema
170175
if !walkSchema(ctx, js.Not, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "not"}), rootSchema, yield) {
171176
return false

0 commit comments

Comments
 (0)